Compare commits
17 Commits
0e82416800
...
feat-prac
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bf4773400 | |||
| 69ed5bda1b | |||
| 5bab30d2e4 | |||
| 8b8366f30f | |||
| 8b2ea24f38 | |||
| c30aa45e5b | |||
| 5cf243d187 | |||
| bfdd40ec93 | |||
| 996754be7f | |||
| 50a2829519 | |||
| 022683aff1 | |||
| 0d5866b82a | |||
| 72518fa17e | |||
| f5497c534d | |||
| b3fc11f1b1 | |||
| 8b25a10d4c | |||
| 2a5394155a |
10
src/App.vue
10
src/App.vue
@@ -22,7 +22,8 @@
|
||||
const {
|
||||
updateUser,
|
||||
updateOnline,
|
||||
clearSessionState
|
||||
clearSessionState,
|
||||
clearDevice
|
||||
} = store;
|
||||
|
||||
watch(
|
||||
@@ -63,6 +64,11 @@
|
||||
updateOnline(data.online);
|
||||
}
|
||||
|
||||
function onDeviceBindInvalid() {
|
||||
clearDevice();
|
||||
uni.setStorageSync("calibration", false);
|
||||
}
|
||||
|
||||
function onDeviceShoot() {
|
||||
// audioManager.play("射箭声音")
|
||||
}
|
||||
@@ -78,6 +84,7 @@
|
||||
uni.$on("update-user", emitUpdateUser);
|
||||
uni.$on("update-online", emitUpdateOnline);
|
||||
uni.$on("session-kicked-out", onSessionKickedOut);
|
||||
uni.$on("device-bind-invalid", onDeviceBindInvalid);
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
@@ -91,6 +98,7 @@
|
||||
uni.$off("update-user", emitUpdateUser);
|
||||
uni.$off("update-online", emitUpdateOnline);
|
||||
uni.$off("session-kicked-out", onSessionKickedOut);
|
||||
uni.$off("device-bind-invalid", onDeviceBindInvalid);
|
||||
websocket.closeWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -70,6 +70,15 @@ function request(method, url, data = {}) {
|
||||
resolve({binded: true});
|
||||
return;
|
||||
}
|
||||
if (message === "BIND_FAILD") {
|
||||
uni.$emit("device-bind-invalid");
|
||||
uni.showToast({
|
||||
title: "设备绑定状态已失效,请重新绑定",
|
||||
icon: "none",
|
||||
});
|
||||
reject({type: "DEVICE_BIND_INVALID", message});
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_ORDER_UNPAY") {
|
||||
uni.showToast({
|
||||
title: "当前有未支付订单",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -26,12 +26,33 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 45,
|
||||
},
|
||||
sizeUnit: {
|
||||
type: String,
|
||||
default: "px",
|
||||
},
|
||||
imageMode: {
|
||||
type: String,
|
||||
default: "widthFix",
|
||||
},
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const avatarFrame = ref("");
|
||||
const sizeValue = computed(() => `${Number(props.size)}${props.sizeUnit}`);
|
||||
const frameSizeValue = computed(() => `${Number(props.size) + 10}${props.sizeUnit}`);
|
||||
const avatarImageStyle = computed(() => ({
|
||||
width: sizeValue.value,
|
||||
height: sizeValue.value,
|
||||
minHeight: sizeValue.value,
|
||||
borderColor: props.borderColor || "#fff",
|
||||
}));
|
||||
const avatarFrameStyle = computed(() => ({
|
||||
width: frameSizeValue.value,
|
||||
height: frameSizeValue.value,
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => [config.value, props.rankLvl],
|
||||
() => {
|
||||
@@ -51,10 +72,7 @@ watch(
|
||||
v-if="avatarFrame"
|
||||
:src="avatarFrame"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
width: Number(size) + 10 + 'px',
|
||||
height: Number(size) + 10 + 'px',
|
||||
}"
|
||||
:style="avatarFrameStyle"
|
||||
class="avatar-frame"
|
||||
/>
|
||||
<image
|
||||
@@ -78,13 +96,8 @@ watch(
|
||||
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||
<image
|
||||
:src="src || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
minHeight: size + 'px',
|
||||
borderColor: borderColor || '#fff',
|
||||
}"
|
||||
:mode="imageMode"
|
||||
:style="avatarImageStyle"
|
||||
class="avatar-image"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -41,6 +41,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
headerClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showBottom: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -152,6 +156,7 @@ const goCalibration = async () => {
|
||||
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||
<Header
|
||||
v-if="!isHome"
|
||||
:class="headerClass"
|
||||
:title="title"
|
||||
:onBack="onBack"
|
||||
:whiteBackArrow="whiteBackArrow"
|
||||
|
||||
@@ -15,6 +15,12 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const rowCount = new Array(6).fill(0);
|
||||
|
||||
const getRingText = (arrow) => {
|
||||
if (!arrow) return "-";
|
||||
if (arrow.ringX && arrow.ring) return "X环";
|
||||
return arrow.ring ? `${arrow.ring}环` : "-";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,23 +41,19 @@ const rowCount = new Array(6).fill(0);
|
||||
<view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{
|
||||
scores[0] && scores[0][index] ? `${scores[0][index].ring}环` : "-"
|
||||
}}</text>
|
||||
<text>{{ getRingText(scores[0]?.[index]) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{
|
||||
scores[1] && scores[1][index] ? `${scores[1][index].ring}环` : "-"
|
||||
}}</text>
|
||||
<text>{{ getRingText(scores[1]?.[index]) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text
|
||||
>{{
|
||||
scores
|
||||
.map((s) => s.reduce((last, next) => last + next.ring, 0))
|
||||
.map((s) => (s || []).reduce((last, next) => last + next.ring, 0))
|
||||
.reduce((last, next) => last + next, 0)
|
||||
}}环</text
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import useStore from "@/store";
|
||||
const store = useStore();
|
||||
const { updateUser, updateDevice, updateOnline } = store;
|
||||
const { updateUser, updateDevice, updateOnline, clearDevice } = store;
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -107,6 +107,8 @@ async function doLogin() {
|
||||
);
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
} else {
|
||||
clearDevice();
|
||||
}
|
||||
props.onClose();
|
||||
} catch (error) {
|
||||
|
||||
535
src/components/TargetCanvas.vue
Normal file
535
src/components/TargetCanvas.vue
Normal file
@@ -0,0 +1,535 @@
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
const defaultCanvasSize = 300;
|
||||
const defaultRingCount = 10;
|
||||
|
||||
const props = defineProps({
|
||||
// canvas 唯一标识;不传时组件内部自动生成,避免多个靶面 canvas-id 冲突。
|
||||
canvasId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// 业务坐标半径,例如 20 表示命中点坐标范围为 -20 到 20。
|
||||
// 当前组件主要用它参与重绘判断,外层命中点定位也应使用同一半径。
|
||||
coordinateRadius: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
// 是否显示靶心十字辅助线。
|
||||
showCrosshair: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否显示象限文字。
|
||||
showQuadrantLabels: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否显示环数文字。
|
||||
showRingLabels: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 象限文字配置,key 为 1/2/3/4。
|
||||
quadrantLabels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
}),
|
||||
},
|
||||
// 高亮区域数组。
|
||||
// quadrant: 1/2/3/4,表示第几个象限。
|
||||
// rings: "all" 或环数数组,例如 [7, 8, 9, 10]。
|
||||
// scope: "box" 表示整象限矩形,"sector" 表示环形扇区。
|
||||
// style: 可覆盖高亮填充色、描边色、线宽比例。
|
||||
highlightAreas: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 只绘制透明高亮层,不绘制完整靶纸;用于叠加在靶纸图片上。
|
||||
highlightOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 外部指定 canvas 绘制尺寸;用于让高亮层跟随靶图真实显示区域。
|
||||
canvasWidth: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
canvasHeight: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
// 靶纸样式覆盖配置,例如环数、环色、环线颜色、环数字体等。
|
||||
targetStyleConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 十字辅助线样式覆盖配置。
|
||||
crosshairStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 象限文字样式覆盖配置。
|
||||
quadrantLabelStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const localCanvasId = `target-canvas-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const currentCanvasId = computed(() => props.canvasId || localCanvasId);
|
||||
const lastDrawKey = ref("");
|
||||
const canvasSize = ref({
|
||||
width: defaultCanvasSize,
|
||||
height: defaultCanvasSize,
|
||||
});
|
||||
|
||||
// 完整靶纸默认样式,调用方可以通过 targetStyleConfig 局部覆盖。
|
||||
const defaultTargetStyleConfig = {
|
||||
ringCount: defaultRingCount,
|
||||
ringColors: {
|
||||
1: "#f8f8f3",
|
||||
2: "#f8f8f3",
|
||||
3: "#595959",
|
||||
4: "#595959",
|
||||
5: "#24aee0",
|
||||
6: "#24aee0",
|
||||
7: "#ff1f35",
|
||||
8: "#ff1f35",
|
||||
9: "#f7d34a",
|
||||
10: "#f7d34a",
|
||||
},
|
||||
ringLineColor: "rgba(150, 150, 150, 0.55)",
|
||||
ringLineWidthRatio: 0.0022,
|
||||
centerDotColor: "#ffffff",
|
||||
centerDotRadiusRatio: 0.0048,
|
||||
ringLabelFontRatio: 0.032,
|
||||
ringLabelDarkColor: "#111111",
|
||||
ringLabelLightColor: "#ffffff",
|
||||
};
|
||||
|
||||
// 十字辅助线默认样式。
|
||||
const defaultCrosshairStyle = {
|
||||
color: "rgba(20, 20, 20, 0.38)",
|
||||
lineWidthRatio: 0.0025,
|
||||
};
|
||||
|
||||
// 象限文字默认样式。
|
||||
const defaultQuadrantLabelStyle = {
|
||||
color: "#ffffff",
|
||||
fontSizeRatio: 0.045,
|
||||
offsetRatio: 0.78,
|
||||
};
|
||||
|
||||
// 高亮区域默认样式。
|
||||
const defaultHighlightStyle = {
|
||||
color: "rgba(254, 216, 71, 0.34)",
|
||||
strokeColor: "rgba(254, 216, 71, 0.82)",
|
||||
lineWidthRatio: 0.003,
|
||||
};
|
||||
|
||||
// 合并默认靶纸样式和外部传入样式,ringColors 单独深合并。
|
||||
const mergeTargetStyleConfig = () => ({
|
||||
...defaultTargetStyleConfig,
|
||||
...props.targetStyleConfig,
|
||||
ringColors: {
|
||||
...defaultTargetStyleConfig.ringColors,
|
||||
...(props.targetStyleConfig?.ringColors || {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 统一把外部传入值转成有效数字,非法值使用 fallback。
|
||||
const getNumber = (value, fallback = 0) => {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
};
|
||||
|
||||
// 获取指定环数的填充色,兼容数字 key 和字符串 key。
|
||||
const getRingColor = (ring, config) => {
|
||||
return config.ringColors?.[ring] || config.ringColors?.[String(ring)] || "#ffffff";
|
||||
};
|
||||
|
||||
// 规范化高亮环数配置;"all" 表示全部环,数组或单值会过滤非法环数。
|
||||
const normalizeRings = (rings, ringCount) => {
|
||||
if (rings === "all") {
|
||||
return "all";
|
||||
}
|
||||
|
||||
const rawRings = Array.isArray(rings) ? rings : [rings];
|
||||
return rawRings
|
||||
.map((ring) => Number(ring))
|
||||
.filter((ring) => Number.isInteger(ring) && ring >= 1 && ring <= ringCount);
|
||||
};
|
||||
|
||||
// 获取象限对应的扇形弧度范围。
|
||||
const getQuadrantAngles = (quadrant) => {
|
||||
const angleMap = {
|
||||
1: [Math.PI, Math.PI * 1.5],
|
||||
2: [Math.PI * 1.5, Math.PI * 2],
|
||||
3: [Math.PI * 0.5, Math.PI],
|
||||
4: [0, Math.PI * 0.5],
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 绘制实心圆,靶纸环区和中心点都会用到。
|
||||
const drawCircle = (ctx, centerX, centerY, radius, fillColor) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.setFillStyle(fillColor);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
// 绘制环形扇区,用于按象限高亮指定环数。
|
||||
const drawAnnularSector = (
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fillColor,
|
||||
strokeColor = "",
|
||||
lineWidth = 0
|
||||
) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, outerRadius, startAngle, endAngle);
|
||||
|
||||
if (innerRadius > 0) {
|
||||
ctx.arc(centerX, centerY, innerRadius, endAngle, startAngle, true);
|
||||
} else {
|
||||
ctx.lineTo(centerX, centerY);
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
ctx.setFillStyle(fillColor);
|
||||
ctx.fill();
|
||||
|
||||
if (strokeColor && lineWidth > 0) {
|
||||
ctx.setStrokeStyle(strokeColor);
|
||||
ctx.setLineWidth(lineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
// 从外到内绘制完整靶纸色环。
|
||||
const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
for (let ring = 1; ring <= config.ringCount; ring += 1) {
|
||||
const radius = targetRadius * ((config.ringCount + 1 - ring) / config.ringCount);
|
||||
drawCircle(ctx, centerX, centerY, radius, getRingColor(ring, config));
|
||||
}
|
||||
};
|
||||
|
||||
// 绘制所有高亮区域,支持整象限矩形高亮和指定环数扇区高亮。
|
||||
const drawHighlights = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
props.highlightAreas.forEach((area = {}) => {
|
||||
const angles = getQuadrantAngles(area.quadrant);
|
||||
|
||||
if (!angles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightStyle = {
|
||||
...defaultHighlightStyle,
|
||||
...(area.style || {}),
|
||||
};
|
||||
const highlightLineWidth = Math.max(1, targetRadius * highlightStyle.lineWidthRatio);
|
||||
const rings = normalizeRings(area.rings || "all", config.ringCount);
|
||||
const scope = area.scope || (rings === "all" ? "box" : "sector");
|
||||
|
||||
// 整象限默认画成矩形高亮,便于对应 1/2/3/4 号框训练提示。
|
||||
if (rings === "all" && scope === "box") {
|
||||
const box = getQuadrantBox(area.quadrant, centerX, centerY, targetRadius);
|
||||
if (!box) return;
|
||||
ctx.beginPath();
|
||||
ctx.rect(...box);
|
||||
ctx.setFillStyle(highlightStyle.color);
|
||||
ctx.fill();
|
||||
ctx.setStrokeStyle(highlightStyle.strokeColor);
|
||||
ctx.setLineWidth(highlightLineWidth);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRings = rings === "all"
|
||||
? Array.from({ length: config.ringCount }, (_, index) => index + 1)
|
||||
: rings;
|
||||
|
||||
targetRings.forEach((ring) => {
|
||||
const innerRadius = targetRadius * ((config.ringCount - ring) / config.ringCount);
|
||||
const outerRadius = targetRadius * ((config.ringCount + 1 - ring) / config.ringCount);
|
||||
drawAnnularSector(
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
angles[0],
|
||||
angles[1],
|
||||
highlightStyle.color,
|
||||
highlightStyle.strokeColor,
|
||||
highlightLineWidth
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 绘制各环之间的分割线。
|
||||
const drawRingLines = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
const lineWidth = Math.max(1, targetRadius * config.ringLineWidthRatio);
|
||||
|
||||
ctx.setStrokeStyle(config.ringLineColor);
|
||||
ctx.setLineWidth(lineWidth);
|
||||
|
||||
for (let index = 1; index <= config.ringCount; index += 1) {
|
||||
const radius = targetRadius * (index / config.ringCount);
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
// 绘制靶心十字辅助线。
|
||||
const drawCrosshair = (ctx, centerX, centerY, targetRadius) => {
|
||||
if (!props.showCrosshair) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = {
|
||||
...defaultCrosshairStyle,
|
||||
...props.crosshairStyle,
|
||||
};
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - targetRadius, centerY);
|
||||
ctx.lineTo(centerX + targetRadius, centerY);
|
||||
ctx.moveTo(centerX, centerY - targetRadius);
|
||||
ctx.lineTo(centerX, centerY + targetRadius);
|
||||
ctx.setStrokeStyle(style.color);
|
||||
ctx.setLineWidth(Math.max(1, targetRadius * style.lineWidthRatio));
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// 绘制环数文字。
|
||||
const drawRingLabels = (ctx, centerX, centerY, targetRadius, config) => {
|
||||
if (!props.showRingLabels) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ringWidth = targetRadius / config.ringCount;
|
||||
const fontSize = Math.max(10, targetRadius * config.ringLabelFontRatio);
|
||||
|
||||
ctx.setFontSize(fontSize);
|
||||
ctx.setTextAlign("center");
|
||||
ctx.setTextBaseline("middle");
|
||||
|
||||
for (let ring = config.ringCount; ring >= 1; ring -= 1) {
|
||||
const y = centerY + (config.ringCount - ring + 0.45) * ringWidth;
|
||||
const color = ring <= 2 ? config.ringLabelDarkColor : config.ringLabelLightColor;
|
||||
ctx.setFillStyle(color);
|
||||
ctx.fillText(String(ring), centerX, y);
|
||||
}
|
||||
};
|
||||
|
||||
// 绘制象限文字。
|
||||
const drawQuadrantLabels = (ctx, centerX, centerY, targetRadius) => {
|
||||
if (!props.showQuadrantLabels) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = {
|
||||
...defaultQuadrantLabelStyle,
|
||||
...props.quadrantLabelStyle,
|
||||
};
|
||||
const offset = targetRadius * style.offsetRatio;
|
||||
const positions = {
|
||||
1: [centerX - offset, centerY - offset],
|
||||
2: [centerX + offset, centerY - offset],
|
||||
3: [centerX - offset, centerY + offset],
|
||||
4: [centerX + offset, centerY + offset],
|
||||
};
|
||||
|
||||
ctx.setFontSize(Math.max(12, targetRadius * style.fontSizeRatio));
|
||||
ctx.setTextAlign("center");
|
||||
ctx.setTextBaseline("middle");
|
||||
ctx.setFillStyle(style.color);
|
||||
|
||||
Object.entries(positions).forEach(([key, position]) => {
|
||||
const label = props.quadrantLabels?.[key] || props.quadrantLabels?.[Number(key)];
|
||||
if (label === undefined || label === null || label === "") return;
|
||||
ctx.fillText(String(label), position[0], position[1]);
|
||||
});
|
||||
};
|
||||
|
||||
// 生成高亮区域的绘制 key,只保留真正影响画面的字段。
|
||||
const getHighlightDrawKeyAreas = () => {
|
||||
return props.highlightAreas.map((area = {}) => ({
|
||||
quadrant: area.quadrant,
|
||||
rings: area.rings,
|
||||
scope: area.scope,
|
||||
style: area.style,
|
||||
}));
|
||||
};
|
||||
|
||||
// 生成本次绘制状态的唯一 key,用于避免相同内容重复 draw。
|
||||
const getDrawKey = (width, height) => {
|
||||
return JSON.stringify({
|
||||
width,
|
||||
height,
|
||||
coordinateRadius: props.coordinateRadius,
|
||||
showCrosshair: props.showCrosshair,
|
||||
showQuadrantLabels: props.showQuadrantLabels,
|
||||
showRingLabels: props.showRingLabels,
|
||||
highlightAreas: getHighlightDrawKeyAreas(),
|
||||
targetStyleConfig: props.targetStyleConfig,
|
||||
crosshairStyle: props.crosshairStyle,
|
||||
quadrantLabelStyle: props.quadrantLabelStyle,
|
||||
highlightOnly: props.highlightOnly,
|
||||
});
|
||||
};
|
||||
|
||||
// 主绘制入口:根据 highlightOnly 决定画完整靶纸,还是只画透明高亮层。
|
||||
const drawTarget = () => {
|
||||
const width = Math.max(getNumber(canvasSize.value.width, defaultCanvasSize), 1);
|
||||
const height = Math.max(getNumber(canvasSize.value.height, defaultCanvasSize), 1);
|
||||
const drawKey = getDrawKey(width, height);
|
||||
|
||||
if (drawKey === lastDrawKey.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = Math.min(width, height);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const targetRadius = size / 2;
|
||||
const config = mergeTargetStyleConfig();
|
||||
const ctx = uni.createCanvasContext(currentCanvasId.value, instance?.proxy);
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
if (!props.highlightOnly) {
|
||||
drawTargetRings(ctx, centerX, centerY, targetRadius, config);
|
||||
}
|
||||
|
||||
drawHighlights(ctx, centerX, centerY, targetRadius, config);
|
||||
|
||||
if (!props.highlightOnly) {
|
||||
drawRingLines(ctx, centerX, centerY, targetRadius, config);
|
||||
drawCircle(
|
||||
ctx,
|
||||
centerX,
|
||||
centerY,
|
||||
Math.max(1, targetRadius * config.centerDotRadiusRatio),
|
||||
config.centerDotColor
|
||||
);
|
||||
drawCrosshair(ctx, centerX, centerY, targetRadius);
|
||||
drawRingLabels(ctx, centerX, centerY, targetRadius, config);
|
||||
drawQuadrantLabels(ctx, centerX, centerY, targetRadius);
|
||||
}
|
||||
|
||||
ctx.draw();
|
||||
lastDrawKey.value = drawKey;
|
||||
};
|
||||
|
||||
const setCanvasSizeAndDraw = async (width, height) => {
|
||||
canvasSize.value = {
|
||||
width: width > 0 ? width : defaultCanvasSize,
|
||||
height: height > 0 ? height : width || defaultCanvasSize,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
drawTarget();
|
||||
};
|
||||
|
||||
// 读取 canvas 实际渲染尺寸后再绘制,保证小程序真机尺寸和坐标一致。
|
||||
const measureAndDraw = () => {
|
||||
const propWidth = Math.round(getNumber(props.canvasWidth, 0));
|
||||
const propHeight = Math.round(getNumber(props.canvasHeight, 0));
|
||||
|
||||
if (propWidth > 0 && propHeight > 0) {
|
||||
setCanvasSizeAndDraw(propWidth, propHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = uni.createSelectorQuery().in(instance?.proxy);
|
||||
|
||||
query
|
||||
.select(`#${currentCanvasId.value}`)
|
||||
.boundingClientRect(async (rect) => {
|
||||
const width = Math.round(getNumber(rect?.width, defaultCanvasSize));
|
||||
const height = Math.round(getNumber(rect?.height, width || defaultCanvasSize));
|
||||
|
||||
await setCanvasSizeAndDraw(width, height);
|
||||
})
|
||||
.exec();
|
||||
};
|
||||
|
||||
// 等待 Vue 完成 DOM 更新后重新测量和绘制。
|
||||
const scheduleDraw = async () => {
|
||||
await nextTick();
|
||||
measureAndDraw();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.coordinateRadius,
|
||||
props.showCrosshair,
|
||||
props.showQuadrantLabels,
|
||||
props.showRingLabels,
|
||||
props.highlightAreas,
|
||||
props.highlightOnly,
|
||||
props.canvasWidth,
|
||||
props.canvasHeight,
|
||||
props.quadrantLabels,
|
||||
props.targetStyleConfig,
|
||||
props.crosshairStyle,
|
||||
props.quadrantLabelStyle,
|
||||
],
|
||||
scheduleDraw,
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(measureAndDraw, 30);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
:id="currentCanvasId"
|
||||
class="target-canvas"
|
||||
:canvas-id="currentCanvasId"
|
||||
:width="canvasSize.width"
|
||||
:height="canvasSize.height"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.target-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -305,7 +305,7 @@ function goBack() {
|
||||
<Container
|
||||
:bgType="data.mode > 3 ? -1 : 0"
|
||||
bgColor="#000000"
|
||||
:onBack="goBack"
|
||||
:onBack="exit"
|
||||
>
|
||||
|
||||
<!-- ----- Banner 区:game 胜负展示图(仅 NvN 对抗模式)----- -->
|
||||
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
updateConfig,
|
||||
updateUser,
|
||||
updateDevice,
|
||||
clearDevice,
|
||||
getLvlName,
|
||||
getLvlNameByScore,
|
||||
updateOnline,
|
||||
@@ -127,6 +128,8 @@ onShow(async () => {
|
||||
);
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
} else {
|
||||
clearDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,65 @@ const playersSorted = ref([]);
|
||||
const playersScores = ref([]);
|
||||
const halfTimeTip = ref(false);
|
||||
const halfRest = ref(false);
|
||||
const HALF_REST_SECONDS = 20;
|
||||
const halfRestRemain = ref(HALF_REST_SECONDS);
|
||||
let halfRestTimer = null;
|
||||
/** 控制设备离线提示弹窗的显示状态 */
|
||||
const showOfflineModal = ref(false);
|
||||
/** 记录每位玩家当前半场连续 X 环数,key 为 playerId,用于触发 tententen 音效 */
|
||||
/** 记录每位玩家当前半场连续 10 环及以上次数,key 为 playerId,用于触发 tententen 音效 */
|
||||
const xRingStreaks = ref({});
|
||||
|
||||
function clearHalfRestCountdown() {
|
||||
if (halfRestTimer) {
|
||||
clearInterval(halfRestTimer);
|
||||
halfRestTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getHalfRestSeconds(battleInfo) {
|
||||
const remainCandidates = [
|
||||
battleInfo?.halfRestRemain,
|
||||
battleInfo?.halfRestRemainSeconds,
|
||||
battleInfo?.restRemain,
|
||||
battleInfo?.restRemainSeconds,
|
||||
];
|
||||
|
||||
for (const item of remainCandidates) {
|
||||
const remain = Number(item);
|
||||
if (Number.isFinite(remain) && remain > 0 && remain <= HALF_REST_SECONDS) {
|
||||
return Math.ceil(remain);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Number(battleInfo?.halfRestEndTime ?? battleInfo?.restEndTime);
|
||||
if (!Number.isFinite(endTime) || endTime <= 0) return HALF_REST_SECONDS;
|
||||
|
||||
const timestamp = endTime < 1e12 ? endTime * 1000 : endTime;
|
||||
const diffSeconds = (timestamp - Date.now()) / 1000;
|
||||
if (diffSeconds > 0 && diffSeconds <= HALF_REST_SECONDS) {
|
||||
return Math.ceil(diffSeconds);
|
||||
}
|
||||
|
||||
return HALF_REST_SECONDS;
|
||||
}
|
||||
|
||||
function startHalfRestCountdown(seconds = HALF_REST_SECONDS) {
|
||||
clearHalfRestCountdown();
|
||||
halfRestRemain.value = Math.max(0, Math.ceil(Number(seconds) || HALF_REST_SECONDS));
|
||||
|
||||
if (halfRestRemain.value <= 0) return;
|
||||
|
||||
halfRestTimer = setInterval(() => {
|
||||
if (halfRestRemain.value <= 1) {
|
||||
halfRestRemain.value = 0;
|
||||
clearHalfRestCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
halfRestRemain.value -= 1;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
|
||||
*/
|
||||
@@ -91,8 +145,7 @@ function recoverData(battleInfo, { force = false } = {}) {
|
||||
halfTimeTip.value = true;
|
||||
halfRest.value = true;
|
||||
tips.value = "准备下半场";
|
||||
// 剩余休息时间
|
||||
// const remain = (Date.now() - battleInfo.timeoutTime) / 1000;
|
||||
startHalfRestCountdown(getHalfRestSeconds(battleInfo));
|
||||
setTimeout(() => {
|
||||
uni.$emit("update-remain", 0);
|
||||
}, 200);
|
||||
@@ -123,23 +176,27 @@ onLoad(async (options) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 检测指定玩家连续 X 环是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
|
||||
* 检测指定玩家连续 10 环及以上是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
|
||||
* @param {number|string} playerId - 本次射手的 ID(大乱斗中 ShootResult 保留 playerId)
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
* @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
|
||||
*/
|
||||
function checkAndPlayTententen(playerId, isXRing) {
|
||||
function isTenPlusRing(shot) {
|
||||
return !!(shot?.ringX || Number(shot?.ring) >= 10);
|
||||
}
|
||||
|
||||
function checkAndPlayTententen(playerId, isTenPlusRingShot) {
|
||||
if (!playerId) return;
|
||||
const id = parseInt(playerId);
|
||||
if (isXRing) {
|
||||
if (isTenPlusRingShot) {
|
||||
xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1;
|
||||
// 同一玩家连续 3 箭均为 X 环,追加到环数音效队列尾部播放
|
||||
// 同一玩家连续 3 箭均为 10 环及以上,追加到环数音效队列尾部播放
|
||||
if (xRingStreaks.value[id] >= 3) {
|
||||
xRingStreaks.value[id] = 0;
|
||||
// nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen,避免播放顺序颠倒
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置该玩家的连续计数
|
||||
// 低于 10 环或未上靶则重置该玩家的连续计数
|
||||
xRingStreaks.value[id] = 0;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +204,7 @@ function checkAndPlayTententen(playerId, isXRing) {
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||
clearHalfRestCountdown();
|
||||
halfTimeTip.value = false;
|
||||
halfRest.value = false;
|
||||
recoverData(msg);
|
||||
@@ -161,22 +219,23 @@ async function onReceiveMessage(msg) {
|
||||
// 对比更新后数据找出箭数增加的玩家(即本次射手),并读取其最新箭的 ring 数据
|
||||
const newRound = playersScores.value[playersScores.value.length - 1] || {};
|
||||
let shooterId = null;
|
||||
let isXRing = false;
|
||||
let isTenPlusRingShot = false;
|
||||
for (const pid of Object.keys(newRound)) {
|
||||
const newLen = (newRound[pid] || []).length;
|
||||
if (newLen > (prevCounts[pid] || 0)) {
|
||||
shooterId = parseInt(pid);
|
||||
const shot = newRound[pid][newLen - 1];
|
||||
isXRing = !!(shot?.ringX && shot?.ring);
|
||||
isTenPlusRingShot = isTenPlusRing(shot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 检测同一玩家三箭全 X 环,触发 tententen 音效
|
||||
checkAndPlayTententen(shooterId, isXRing);
|
||||
// 检测同一玩家连续三箭 10 环及以上,触发 tententen 音效
|
||||
checkAndPlayTententen(shooterId, isTenPlusRingShot);
|
||||
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||
halfTimeTip.value = true;
|
||||
halfRest.value = true;
|
||||
tips.value = "准备下半场";
|
||||
startHalfRestCountdown();
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(() => {
|
||||
// 全部跳转到新结算页
|
||||
@@ -197,6 +256,7 @@ onBeforeUnmount(() => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
clearHalfRestCountdown();
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
audioManager.stopAll();
|
||||
});
|
||||
@@ -261,7 +321,7 @@ onShow(async () => {
|
||||
>
|
||||
<view class="half-time-tip">
|
||||
<text>上半场结束,休息一下吧:)</text>
|
||||
<text>20秒后开始下半场</text>
|
||||
<text>{{ halfRestRemain }}秒后开始下半场</text>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
<!-- 设备离线提示弹窗 -->
|
||||
|
||||
@@ -16,7 +16,7 @@ const showTip = ref(false);
|
||||
const confirmBindTip = ref(false);
|
||||
const addDevice = ref();
|
||||
const store = useStore();
|
||||
const { updateDevice } = store;
|
||||
const { updateDevice, clearDevice } = store;
|
||||
const { user, device } = storeToRefs(store);
|
||||
const justBind = ref(false);
|
||||
const calibration = ref(false);
|
||||
@@ -84,13 +84,21 @@ const toFristTryPage = () => {
|
||||
};
|
||||
|
||||
const unbindDevice = async () => {
|
||||
try {
|
||||
await unbindDeviceAPI(device.value.deviceId);
|
||||
} catch (error) {
|
||||
if (error?.type === "DEVICE_BIND_INVALID") {
|
||||
uni.setStorageSync("calibration", false);
|
||||
clearDevice();
|
||||
}
|
||||
return;
|
||||
}
|
||||
uni.setStorageSync("calibration", false);
|
||||
uni.showToast({
|
||||
title: "解绑成功",
|
||||
icon: "success",
|
||||
});
|
||||
device.value = {};
|
||||
clearDevice();
|
||||
};
|
||||
|
||||
const toDeviceIntroPage = () => {
|
||||
@@ -122,8 +130,23 @@ const goCalibration = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const syncDeviceBinding = async () => {
|
||||
if (!user.value.id) return;
|
||||
try {
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (devices.bindings && devices.bindings.length) {
|
||||
updateDevice(devices.bindings[0].deviceId, devices.bindings[0].deviceName);
|
||||
} else {
|
||||
clearDevice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("sync device binding error", error);
|
||||
}
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
calibration.value = uni.getStorageSync("calibration");
|
||||
await syncDeviceBinding();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -396,7 +419,7 @@ onShow(() => {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.device-binded > view > text {
|
||||
width: 120px;
|
||||
|
||||
@@ -31,7 +31,7 @@ const { user } = storeToRefs(store);
|
||||
const start = ref(false);
|
||||
const scores = ref([]);
|
||||
const total = 12;
|
||||
/** 当前练习中连续 X 环计数,用于触发 tententen 音效 */
|
||||
/** 当前练习中连续 10 环及以上计数,用于触发 tententen 音效 */
|
||||
const xRingStreak = ref(0);
|
||||
const practiseResult = ref({});
|
||||
const practiseId = ref("");
|
||||
@@ -48,7 +48,7 @@ onLoad((options) => {
|
||||
const onReady = async () => {
|
||||
await startPractiseAPI();
|
||||
scores.value = [];
|
||||
xRingStreak.value = 0; // 新一局开始,重置 X 环连续计数
|
||||
xRingStreak.value = 0; // 新一局开始,重置 10 环及以上连续计数
|
||||
start.value = true;
|
||||
audioManager.play("练习开始");
|
||||
};
|
||||
@@ -59,19 +59,23 @@ const onOver = async () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测连续 X 环是否达到 3 箭,达到则播放 tententen 音效
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
* 检测连续 10 环及以上是否达到 3 箭,达到则播放 tententen 音效
|
||||
* @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
|
||||
*/
|
||||
function checkAndPlayTententen(isXRing) {
|
||||
if (isXRing) {
|
||||
function isTenPlusRing(shot) {
|
||||
return !!(shot?.ringX || Number(shot?.ring) >= 10);
|
||||
}
|
||||
|
||||
function checkAndPlayTententen(isTenPlusRingShot) {
|
||||
if (isTenPlusRingShot) {
|
||||
xRingStreak.value += 1;
|
||||
// 连续 3 箭均为 X 环,在环数播报入队后追加 tententen,避免播放顺序颠倒
|
||||
// 连续 3 箭均为 10 环及以上,在环数播报入队后追加 tententen,避免播放顺序颠倒
|
||||
if (xRingStreak.value >= 3) {
|
||||
xRingStreak.value = 0;
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置连续计数
|
||||
// 低于 10 环或未上靶则重置连续计数
|
||||
xRingStreak.value = 0;
|
||||
}
|
||||
}
|
||||
@@ -80,10 +84,10 @@ async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
const prevLen = scores.value.length;
|
||||
scores.value = msg.details;
|
||||
// 有新箭时取最后一箭判断是否 X 环并检测连续计数
|
||||
// 有新箭时取最后一箭判断是否 10 环及以上并检测连续计数
|
||||
if (scores.value.length > prevLen) {
|
||||
const latestArrow = scores.value[scores.value.length - 1];
|
||||
checkAndPlayTententen(!!(latestArrow?.ringX && latestArrow?.ring));
|
||||
checkAndPlayTententen(isTenPlusRing(latestArrow));
|
||||
}
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
// setTimeout(onOver, 1500);
|
||||
@@ -101,7 +105,7 @@ async function onComplete() {
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数
|
||||
xRingStreak.value = 0; // 重新开始练习,重置 10 环及以上连续计数
|
||||
const result = await createPractiseAPI(total, 120);
|
||||
if (result) practiseId.value = result.id;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const { user } = storeToRefs(store);
|
||||
const start = ref(false);
|
||||
const scores = ref([]);
|
||||
const total = 36;
|
||||
/** 当前练习中连续 X 环计数,用于触发 tententen 音效 */
|
||||
/** 当前练习中连续 10 环及以上计数,用于触发 tententen 音效 */
|
||||
const xRingStreak = ref(0);
|
||||
const practiseResult = ref({});
|
||||
const practiseId = ref("");
|
||||
@@ -47,7 +47,7 @@ onLoad((options) => {
|
||||
const onReady = async () => {
|
||||
await startPractiseAPI();
|
||||
scores.value = [];
|
||||
xRingStreak.value = 0; // 新一局开始,重置 X 环连续计数
|
||||
xRingStreak.value = 0; // 新一局开始,重置 10 环及以上连续计数
|
||||
start.value = true;
|
||||
audioManager.play("练习开始");
|
||||
};
|
||||
@@ -58,19 +58,23 @@ const onOver = async () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测连续 X 环是否达到 3 箭,达到则播放 tententen 音效
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
* 检测连续 10 环及以上是否达到 3 箭,达到则播放 tententen 音效
|
||||
* @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
|
||||
*/
|
||||
function checkAndPlayTententen(isXRing) {
|
||||
if (isXRing) {
|
||||
function isTenPlusRing(shot) {
|
||||
return !!(shot?.ringX || Number(shot?.ring) >= 10);
|
||||
}
|
||||
|
||||
function checkAndPlayTententen(isTenPlusRingShot) {
|
||||
if (isTenPlusRingShot) {
|
||||
xRingStreak.value += 1;
|
||||
// 连续 3 箭均为 X 环,在环数播报入队后追加 tententen,避免播放顺序颠倒
|
||||
// 连续 3 箭均为 10 环及以上,在环数播报入队后追加 tententen,避免播放顺序颠倒
|
||||
if (xRingStreak.value >= 3) {
|
||||
xRingStreak.value = 0;
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置连续计数
|
||||
// 低于 10 环或未上靶则重置连续计数
|
||||
xRingStreak.value = 0;
|
||||
}
|
||||
}
|
||||
@@ -79,10 +83,10 @@ async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
const prevLen = scores.value.length;
|
||||
scores.value = msg.details;
|
||||
// 有新箭时取最后一箭判断是否 X 环并检测连续计数
|
||||
// 有新箭时取最后一箭判断是否 10 环及以上并检测连续计数
|
||||
if (scores.value.length > prevLen) {
|
||||
const latestArrow = scores.value[scores.value.length - 1];
|
||||
checkAndPlayTententen(!!(latestArrow?.ringX && latestArrow?.ring));
|
||||
checkAndPlayTententen(isTenPlusRing(latestArrow));
|
||||
}
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(onOver, 1500);
|
||||
@@ -116,7 +120,7 @@ async function onComplete() {
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数
|
||||
xRingStreak.value = 0; // 重新开始练习,重置 10 环及以上连续计数
|
||||
const result = await createPractiseAPI(total, 3600);
|
||||
if (result) practiseId.value = result.id;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const battleWay = ref(0);
|
||||
const lastToSomeoneShootKey = ref("");
|
||||
/** 控制设备离线提示弹窗的显示状态 */
|
||||
const showOfflineModal = ref(false);
|
||||
/** 记录每位玩家当前轮连续 X 环数,key 为 playerId,用于触发 tententen 音效 */
|
||||
/** 记录每位玩家当前轮连续 10 环及以上次数,key 为 playerId,用于触发 tententen 音效 */
|
||||
const xRingStreaks = ref({});
|
||||
|
||||
/**
|
||||
@@ -234,22 +234,26 @@ function onNewRound(msg, prevRound) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测指定射手连续 X 环是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
|
||||
* 检测指定射手连续 10 环及以上是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
|
||||
* @param {number} shooterId - 本次射手的 ID(取自 currentShooterId.value)
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
* @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
|
||||
*/
|
||||
function checkAndPlayTententen(shooterId, isXRing) {
|
||||
function isTenPlusRing(shot) {
|
||||
return !!(shot?.ringX || Number(shot?.ring) >= 10);
|
||||
}
|
||||
|
||||
function checkAndPlayTententen(shooterId, isTenPlusRingShot) {
|
||||
if (!shooterId) return;
|
||||
if (isXRing) {
|
||||
if (isTenPlusRingShot) {
|
||||
xRingStreaks.value[shooterId] = (xRingStreaks.value[shooterId] || 0) + 1;
|
||||
// 同一玩家连续 3 箭均为 X 环,追加到环数音效队列尾部播放
|
||||
// 同一玩家连续 3 箭均为 10 环及以上,追加到环数音效队列尾部播放
|
||||
if (xRingStreaks.value[shooterId] >= 3) {
|
||||
xRingStreaks.value[shooterId] = 0;
|
||||
// nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen,避免播放顺序颠倒
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置该玩家的连续计数
|
||||
// 低于 10 环或未上靶则重置该玩家的连续计数
|
||||
xRingStreaks.value[shooterId] = 0;
|
||||
}
|
||||
}
|
||||
@@ -268,9 +272,9 @@ async function onReceiveMessage(msg) {
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
showRoundTip.value = false;
|
||||
recoverData(msg, {arrowOnly: true});
|
||||
// 检测同一玩家三箭全 X 环,触发 tententen 音效
|
||||
// 检测同一玩家连续三箭 10 环及以上,触发 tententen 音效
|
||||
// currentShooterId 在 ToSomeoneShoot 时写入,ShootResult 不会覆盖,可靠识别本次射手
|
||||
checkAndPlayTententen(currentShooterId.value, !!(msg.shootData?.ringX && msg.shootData?.ring));
|
||||
checkAndPlayTententen(currentShooterId.value, isTenPlusRing(msg.shootData));
|
||||
} else if (msg.type === MESSAGETYPESV2.NewRound) {
|
||||
// 在进入延迟前先捕获当前轮次,供 onNewRound 使用,防止 800ms 内 ToSomeoneShoot 提前更新 currentRound 造成 Tip 展示错轮
|
||||
const prevRound = currentRound.value;
|
||||
|
||||
@@ -318,6 +318,22 @@ function enqueueBattleMessage(message) {
|
||||
if (battleEnded && message.type !== MESSAGETYPESV2.BattleEnd) return;
|
||||
if (message.type === MESSAGETYPESV2.BattleEnd) battleEnded = true;
|
||||
|
||||
if (message.type === MESSAGETYPESV2.InvalidShot) {
|
||||
const receivedAt = Date.now();
|
||||
const order = ++queueOrder;
|
||||
battleQueue.value.push({
|
||||
message,
|
||||
type: message.type,
|
||||
key: `${message.type}:invalid:${receivedAt}:${order}`,
|
||||
serverTime: 0,
|
||||
receivedAt,
|
||||
order,
|
||||
});
|
||||
sortBattleQueue();
|
||||
runBattleQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
// 入队阶段只做排序、去重和时间边界判断,不直接改 UI。
|
||||
const serverTime = getServerTime(message);
|
||||
const key = getMessageKey(message);
|
||||
@@ -463,7 +479,8 @@ function updateGoldenRound(battleInfo) {
|
||||
}
|
||||
const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
|
||||
const finishedGoldCount = rounds.filter((round) => !!round?.ifGold).length;
|
||||
goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0));
|
||||
// goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0));
|
||||
goldenRound.value = Math.max(1, finishedGoldCount);
|
||||
}
|
||||
|
||||
// Restore an info snapshot whose eventType points at the NewRound phase.
|
||||
@@ -841,10 +858,14 @@ async function runToSomeoneShootTask(task, runId) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateXRingStreak(shooterId, isXRing) {
|
||||
function isTenPlusRing(shot) {
|
||||
return !!(shot?.ringX || Number(shot?.ring) >= 10);
|
||||
}
|
||||
|
||||
function updateXRingStreak(shooterId, isTenPlusRingShot) {
|
||||
if (!shooterId) return false;
|
||||
const id = String(shooterId);
|
||||
if (!isXRing) {
|
||||
if (!isTenPlusRingShot) {
|
||||
xRingStreaks.value[id] = 0;
|
||||
saveXRingStreaks();
|
||||
return false;
|
||||
@@ -889,7 +910,7 @@ async function runShootResultTask(task) {
|
||||
|
||||
const isTententen = updateXRingStreak(
|
||||
currentShooterId.value,
|
||||
!!(battleInfo.shootData?.ringX && battleInfo.shootData?.ring)
|
||||
isTenPlusRing(battleInfo.shootData)
|
||||
);
|
||||
const audioKeys = buildShootResultAudioKeys(battleInfo.shootData);
|
||||
if (isTententen) audioKeys.push("tententen");
|
||||
@@ -1233,7 +1254,7 @@ onShow(() => {
|
||||
<view class="offline-modal">
|
||||
<text class="offline-title">设备已离线</text>
|
||||
<text class="offline-desc">检测到设备已断开连接,请检查设备后继续比赛</text>
|
||||
<SButton @click="showOfflineModal = false">我知道了</SButton>
|
||||
<SButton :onClick="() => (showOfflineModal = false)">我知道了</SButton>
|
||||
</view>
|
||||
</SModal>
|
||||
</view>
|
||||
|
||||
116
src/pages/training/components/BowData.vue
Normal file
116
src/pages/training/components/BowData.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import BowTarget from "./BowTarget.vue";
|
||||
import ScorePanel from "./ScorePanel.vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
arrows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<AppBackground :type="10" />
|
||||
<view class="header">
|
||||
<view>
|
||||
<Avatar :src="user.avatar" :rankLvl="user.rankLvl" :size="45" />
|
||||
<view>
|
||||
<text>{{ user.nickName }}</text>
|
||||
<text>{{ user.lvlName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view @click="onClose">
|
||||
<image src="/static/close-white.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ width: '100%', marginBottom: '20px' }">
|
||||
<BowTarget :scores="arrows" />
|
||||
</view>
|
||||
<view class="desc">
|
||||
<text>{{ arrows.length }}</text>
|
||||
<text>支箭,共</text>
|
||||
<text>{{ arrows.reduce((a, b) => a + (b.ring || 0), 0) }}</text>
|
||||
<text>环</text>
|
||||
</view>
|
||||
<ScorePanel
|
||||
:completeEffect="false"
|
||||
:rowCount="total === 12 ? 6 : 9"
|
||||
:total="total"
|
||||
:arrows="arrows"
|
||||
:margin="total === 12 ? 4 : 1"
|
||||
:fontSize="total === 12 ? 25 : 22"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #232323;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: calc(100% - 20px);
|
||||
padding: 10px;
|
||||
}
|
||||
.header > view:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.header > view:first-child > view:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-left: 10px;
|
||||
color: #fff;
|
||||
}
|
||||
.header > view:first-child > view:last-child > text:last-child {
|
||||
font-size: 10px;
|
||||
background-color: #5f51ff;
|
||||
padding: 2px 5px;
|
||||
border-radius: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.header > view:last-child > image {
|
||||
width: 40px;
|
||||
}
|
||||
.desc {
|
||||
color: #fff;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.desc > text:nth-child(2),
|
||||
.desc > text:nth-child(4) {
|
||||
color: #fed847;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||
import TargetCanvas from "@/components/TargetCanvas.vue";
|
||||
|
||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
@@ -34,6 +41,39 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
coordinateRadius: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
hitRadiusPx: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
zoomHitRadiusPx: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
showCrosshair: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showQuadrantLabels: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
quadrantLabels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
}),
|
||||
},
|
||||
highlightAreas: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const pMode = ref(true);
|
||||
@@ -45,6 +85,100 @@ const timer = ref(null);
|
||||
const dirTimer = ref(null);
|
||||
const angle = ref(null);
|
||||
const circleColor = ref("");
|
||||
const ROUND_TIP_OFFSET_Y = -32;
|
||||
const EXPERIENCE_TIP_OFFSET_Y = -68;
|
||||
|
||||
const getNumber = (value, fallback = 0) => {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const safeTargetRadius = computed(() => {
|
||||
return Math.max(getNumber(props.coordinateRadius, 20), 1);
|
||||
});
|
||||
|
||||
const currentHitRadiusPx = computed(() => {
|
||||
const radius = Number(
|
||||
pMode.value ? props.zoomHitRadiusPx : props.hitRadiusPx
|
||||
);
|
||||
return Number.isFinite(radius) && radius >= 0 ? radius : 0;
|
||||
});
|
||||
|
||||
function getShotPoint(shot, fallbackCenter = false) {
|
||||
const x = Number(shot?.x);
|
||||
const y = Number(shot?.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
|
||||
return fallbackCenter ? { x: 0, y: 0 } : null;
|
||||
}
|
||||
|
||||
function getPointDirection(point) {
|
||||
if (!point) return null;
|
||||
const distance = Math.sqrt(point.x * point.x + point.y * point.y);
|
||||
if (distance === 0) return null;
|
||||
|
||||
return {
|
||||
x: point.x / distance,
|
||||
y: point.y / distance,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPxOffset(value) {
|
||||
if (!value) return "";
|
||||
const operator = value > 0 ? "+" : "-";
|
||||
return ` ${operator} ${Math.abs(value)}px`;
|
||||
}
|
||||
|
||||
function formatTargetPosition(percent, offset) {
|
||||
const pxOffset = formatPxOffset(offset);
|
||||
return pxOffset ? `calc(${percent}%${pxOffset})` : `${percent}%`;
|
||||
}
|
||||
|
||||
function getTargetPositionStyle(point, offsetPx = 0, extraOffset = {}) {
|
||||
if (!point) return { display: "none" };
|
||||
|
||||
const radius = safeTargetRadius.value;
|
||||
const diameter = radius * 2;
|
||||
const direction = getPointDirection(point);
|
||||
const xOffset = (direction ? direction.x * offsetPx : 0) + (extraOffset.x || 0);
|
||||
const yOffset = (direction ? -direction.y * offsetPx : 0) + (extraOffset.y || 0);
|
||||
const leftPercent = ((point.x + radius) / diameter) * 100;
|
||||
const topPercent = ((radius - point.y) / diameter) * 100;
|
||||
|
||||
return {
|
||||
left: formatTargetPosition(leftPercent, xOffset),
|
||||
top: formatTargetPosition(topPercent, yOffset),
|
||||
transform: "translate(-50%, -50%)",
|
||||
};
|
||||
}
|
||||
|
||||
function getHitStyle(shot) {
|
||||
const radius = currentHitRadiusPx.value;
|
||||
const point = getShotPoint(shot);
|
||||
|
||||
return {
|
||||
...getTargetPositionStyle(point, radius),
|
||||
width: `${radius * 2}px`,
|
||||
height: `${radius * 2}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function getRoundTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
point,
|
||||
shot?.ring ? currentHitRadiusPx.value : 0,
|
||||
{ y: ROUND_TIP_OFFSET_Y }
|
||||
);
|
||||
}
|
||||
|
||||
function getExperienceTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
point,
|
||||
shot?.ring ? currentHitRadiusPx.value : 0,
|
||||
{ y: EXPERIENCE_TIP_OFFSET_Y }
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.scores,
|
||||
@@ -80,14 +214,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
function calcRealX(num, offset = 3.4) {
|
||||
const len = 20.4 + num;
|
||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||
}
|
||||
function calcRealY(num, offset = 3.4) {
|
||||
const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num;
|
||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||
}
|
||||
const simulShoot = async () => {
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||
};
|
||||
@@ -111,6 +237,44 @@ const arrowStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const currentArrowIndex = computed(() => {
|
||||
return props.scores.length + props.blueScores.length + 1;
|
||||
});
|
||||
|
||||
const getHighlightArrowIndex = (area = {}) => {
|
||||
const arrowIndex = Number(area.arrowIndex ?? area.arrowNo ?? area.arrow);
|
||||
return Number.isInteger(arrowIndex) && arrowIndex > 0 ? arrowIndex : null;
|
||||
};
|
||||
|
||||
const currentHighlightAreas = computed(() => {
|
||||
if (!Array.isArray(props.highlightAreas) || props.highlightAreas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hasExplicitArrowIndex = props.highlightAreas.some((area = {}) => {
|
||||
return getHighlightArrowIndex(area) !== null;
|
||||
});
|
||||
|
||||
const matchedAreas = props.highlightAreas.filter((area = {}) => {
|
||||
return getHighlightArrowIndex(area) === currentArrowIndex.value;
|
||||
});
|
||||
|
||||
if (hasExplicitArrowIndex) {
|
||||
return matchedAreas;
|
||||
}
|
||||
|
||||
if (props.highlightAreas.length === 1) {
|
||||
return props.highlightAreas.slice(0, 1);
|
||||
}
|
||||
|
||||
const currentArea = props.highlightAreas[currentArrowIndex.value - 1];
|
||||
return currentArea ? [currentArea] : [];
|
||||
});
|
||||
|
||||
const showHighlightCanvas = computed(() => {
|
||||
return props.totalRound > 0 && currentHighlightAreas.value.length > 0;
|
||||
});
|
||||
|
||||
async function onReceiveMessage(message) {
|
||||
if (Array.isArray(message)) return;
|
||||
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
|
||||
@@ -160,6 +324,21 @@ onBeforeUnmount(() => {
|
||||
}}</text>
|
||||
</view> -->
|
||||
<view class="target">
|
||||
<image
|
||||
class="target-image"
|
||||
src="../../../static/bow-target.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<TargetCanvas
|
||||
v-if="showHighlightCanvas"
|
||||
class="target-highlight-layer"
|
||||
:coordinateRadius="coordinateRadius"
|
||||
:showCrosshair="false"
|
||||
:showQuadrantLabels="false"
|
||||
:showRingLabels="false"
|
||||
:highlightOnly="true"
|
||||
:highlightAreas="currentHighlightAreas"
|
||||
/>
|
||||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||
<view :style="{ background: circleColor }">
|
||||
<image src="../../../static/dot-circle.png" mode="widthFix" />
|
||||
@@ -169,20 +348,14 @@ onBeforeUnmount(() => {
|
||||
<view
|
||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||
class="e-value fade-in-out"
|
||||
:style="{
|
||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
|
||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
|
||||
}"
|
||||
:style="getExperienceTipStyle(latestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="latestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="{
|
||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
|
||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
|
||||
}"
|
||||
:style="getRoundTipStyle(latestOne)"
|
||||
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
||||
}}<text v-if="latestOne.ring">环</text>
|
||||
</view>
|
||||
@@ -193,20 +366,14 @@ onBeforeUnmount(() => {
|
||||
user.id === bluelatestOne.playerId
|
||||
"
|
||||
class="e-value fade-in-out"
|
||||
:style="{
|
||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
|
||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
|
||||
}"
|
||||
:style="getExperienceTipStyle(bluelatestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="bluelatestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="{
|
||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
|
||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
|
||||
}"
|
||||
:style="getRoundTipStyle(bluelatestOne)"
|
||||
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
||||
}}<text v-if="bluelatestOne.ring">环</text></view
|
||||
>
|
||||
@@ -217,8 +384,7 @@ onBeforeUnmount(() => {
|
||||
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
:style="{
|
||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
||||
...getHitStyle(bow),
|
||||
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
||||
}"
|
||||
><text v-if="pMode">{{ index + 1 }}</text></view
|
||||
@@ -231,15 +397,13 @@ onBeforeUnmount(() => {
|
||||
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
:style="{
|
||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
||||
...getHitStyle(bow),
|
||||
backgroundColor: '#1840FF',
|
||||
}"
|
||||
>
|
||||
<text v-if="pMode">{{ index + 1 }}</text>
|
||||
</view>
|
||||
</block>
|
||||
<image src="../../../static/bow-target.png" mode="widthFix" />
|
||||
</view>
|
||||
<view class="footer">
|
||||
<PointSwitcher
|
||||
@@ -266,7 +430,25 @@ onBeforeUnmount(() => {
|
||||
margin: 10px;
|
||||
width: calc(100% - 20px);
|
||||
height: calc(100% - 20px);
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
}
|
||||
.target-image {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.target-highlight-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.e-value {
|
||||
position: absolute;
|
||||
@@ -275,7 +457,7 @@ onBeforeUnmount(() => {
|
||||
font-size: 12px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 5px;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -284,7 +466,7 @@ onBeforeUnmount(() => {
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -292,31 +474,44 @@ onBeforeUnmount(() => {
|
||||
font-size: 24px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.target > image:last-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@keyframes target-tip-fade-in-out {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.round-tip.fade-in-out,
|
||||
.e-value.fade-in-out {
|
||||
animation: target-tip-fade-in-out 1.2s ease forwards;
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
z-index: 3;
|
||||
color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.s-point {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
min-width: 4px;
|
||||
min-height: 4px;
|
||||
}
|
||||
.b-point {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
min-width: 10px;
|
||||
min-height: 10px;
|
||||
border: 1px solid #fff;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -332,6 +527,19 @@ onBeforeUnmount(() => {
|
||||
transform: translate(-50%, -50%);*/
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
@keyframes target-pump-in {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
.hit.pump-in {
|
||||
animation: target-pump-in 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -382,7 +590,7 @@ onBeforeUnmount(() => {
|
||||
height: 60px;
|
||||
left: calc(50% - 100px);
|
||||
top: calc(50% - 30px);
|
||||
z-index: 99;
|
||||
z-index: 5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.arrow-dir {
|
||||
@@ -391,6 +599,7 @@ onBeforeUnmount(() => {
|
||||
height: 52%;
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
157
src/pages/training/components/ScorePanel.vue
Normal file
157
src/pages/training/components/ScorePanel.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
const props = defineProps({
|
||||
rowCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
arrows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
fontSize: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
completeEffect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const items = ref(new Array(props.total).fill(9));
|
||||
const bgImages = [
|
||||
"../static/complete-light1.png",
|
||||
"../static/complete-light2.png",
|
||||
];
|
||||
const bgIndex = ref(0);
|
||||
|
||||
const getDisplayText = (arrow) => {
|
||||
if (!arrow) return "-";
|
||||
if (arrow.ringX) return "X";
|
||||
return arrow.ring ?? "-";
|
||||
};
|
||||
|
||||
const isLowScore = (arrow) => {
|
||||
if (!arrow || arrow.ringX) return false;
|
||||
const ring = Number(arrow.ring);
|
||||
return Number.isFinite(ring) && ring < 6;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.total,
|
||||
(newValue) => {
|
||||
items.value = new Array(newValue).fill(9);
|
||||
}
|
||||
);
|
||||
const timer = ref(null);
|
||||
onMounted(() => {
|
||||
timer.value = setInterval(() => {
|
||||
bgIndex.value = bgIndex.value === 0 ? 1 : 0;
|
||||
}, 200);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<view class="container">
|
||||
<image
|
||||
v-if="total > 0 && arrows.length === total && completeEffect"
|
||||
:src="bgImages[bgIndex]"
|
||||
class="complete-light"
|
||||
:style="{
|
||||
width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${
|
||||
(100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24))
|
||||
}px)`,
|
||||
height: `calc(${(100 / (rowCount + 2)) * (total / rowCount)}vw + ${
|
||||
(100 / (total * 2)) *
|
||||
((total / rowCount) * 2 + (total === 12 ? 7 : 24))
|
||||
}px)`,
|
||||
top: `${total === 12 ? -2 : -3}vw`,
|
||||
}"
|
||||
/>
|
||||
<view
|
||||
v-for="(_, index) in items"
|
||||
:key="index"
|
||||
class="score-item"
|
||||
>
|
||||
<image
|
||||
class="score-item-bg"
|
||||
:src="
|
||||
isLowScore(arrows[index])
|
||||
? '/static/training-difficulty-design/block-gray.png'
|
||||
: '/static/training-difficulty-design/block-gold.png'
|
||||
"
|
||||
/>
|
||||
<text
|
||||
class="score-value"
|
||||
:class="{ 'score-value--low': isLowScore(arrows[index]) }"
|
||||
>
|
||||
{{ getDisplayText(arrows[index]) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
padding: 30rpx 40rpx 0 40rpx;
|
||||
}
|
||||
.score-item {
|
||||
position: relative;
|
||||
width: 100rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
margin-right: 14rpx;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.score-item:nth-child(6n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.score-item-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 28rpx;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
color: #f6e3b2;
|
||||
text-shadow: 0 2rpx 0 rgba(36, 36, 48, 0.5);
|
||||
margin-left: -10rpx;
|
||||
}
|
||||
|
||||
.score-value--low {
|
||||
color: #cfcfcf;
|
||||
text-shadow: 0 2rpx 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.complete-light {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import BowData from "@/components/BowData.vue";
|
||||
import ScreenHint from "./ScreenHint.vue";
|
||||
import BowData from "./BowData.vue";
|
||||
import UserUpgrade from "@/components/UserUpgrade.vue";
|
||||
import { directionAdjusts } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
|
||||
89
src/pages/training/components/ScreenHint.vue
Normal file
89
src/pages/training/components/ScreenHint.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import IconButton from "@/components/IconButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "normal",
|
||||
},
|
||||
});
|
||||
const getContentHeight = () => {
|
||||
if (props.mode === "tall") return "50vw";
|
||||
if (props.mode === "square") return "74vw";
|
||||
return "36vw";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<view class="scale-in" :style="{ height: getContentHeight() }">
|
||||
<image
|
||||
v-if="mode === 'normal'"
|
||||
src="/static/screen-hint-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'tall'"
|
||||
src="/static/coach-comment.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'square'"
|
||||
src="/static/prompt-bg-square.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'small'"
|
||||
src="/static/finish-frame.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<slot />
|
||||
</view>
|
||||
<IconButton
|
||||
v-if="!!onClose"
|
||||
src="/static/close-gold-outline.png"
|
||||
:width="30"
|
||||
:onClick="onClose"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
}
|
||||
.container > view:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 70vw;
|
||||
color: #fff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.container > view:first-child > image {
|
||||
position: absolute;
|
||||
width: 80vw;
|
||||
left: -7%;
|
||||
bottom: -18vw;
|
||||
z-index: -1;
|
||||
transform: translateY(-75px);
|
||||
}
|
||||
</style>
|
||||
@@ -206,7 +206,12 @@ onBeforeUnmount(() => {
|
||||
<view class="progress-card__header">
|
||||
<view class="progress-card__profile">
|
||||
<view class="progress-card__avatar-shell">
|
||||
<Avatar :src="user.avatar" :size="40" />
|
||||
<Avatar
|
||||
:src="avatarSrc"
|
||||
:size="80"
|
||||
size-unit="rpx"
|
||||
image-mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
<text class="progress-card__name">{{ displayName }}</text>
|
||||
</view>
|
||||
@@ -271,6 +276,9 @@ onBeforeUnmount(() => {
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 209, 153, 1), rgba(162, 119, 55, 1));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-card__avatar {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import TargetPicker from "@/components/TargetPicker.vue";
|
||||
import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue";
|
||||
import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue";
|
||||
import TrainingDifficultyStartButton from "./components/TrainingDifficultyStartButton.vue";
|
||||
@@ -40,6 +39,7 @@ const routeModeTypeMap = {
|
||||
precision: "precision",
|
||||
rhythm: "rhythm",
|
||||
};
|
||||
const defaultTargetType = 1;
|
||||
|
||||
const resolveTrainingType = (mode) => {
|
||||
const normalizedMode = String(mode || "").toLowerCase();
|
||||
@@ -232,7 +232,6 @@ const emptyDifficulty = {
|
||||
const pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
|
||||
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
|
||||
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
|
||||
const showTargetPicker = ref(false);
|
||||
const nodesScrollTop = ref(0);
|
||||
const nodesScrollWithAnimation = ref(false);
|
||||
const routeOptions = ref({});
|
||||
@@ -555,11 +554,63 @@ const initPageState = async (options = {}, refreshOptions = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTargetPaperType = (target) => {
|
||||
return Number(target) === 1 ? "20厘米全环靶" : "40厘米全环靶";
|
||||
const cleanQueryValue = (value) => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const saveTrainingContext = (target) => {
|
||||
const createPracticeQuery = (difficulty) => {
|
||||
const trainingType = pageConfig.value.key || defaultTrainingType;
|
||||
const commonQuery = {
|
||||
type: trainingType,
|
||||
difficultyId: difficulty.id,
|
||||
difficulty: difficulty.level,
|
||||
recordId: difficulty.recordId,
|
||||
arrows: toNumber(difficulty.arrows, 12),
|
||||
time: toNumber(difficulty.time_limit, 120) || 120,
|
||||
target: defaultTargetType,
|
||||
};
|
||||
const typedQueryMap = {
|
||||
base: {
|
||||
hitReq: toNumber(difficulty.hit_req),
|
||||
},
|
||||
endurance: {
|
||||
totalReq: toNumber(difficulty.total_req),
|
||||
},
|
||||
precision: {
|
||||
blocks: toNumber(difficulty.blocks),
|
||||
mode: toNumber(difficulty.mode),
|
||||
},
|
||||
rhythm: {
|
||||
hitReq: toNumber(difficulty.hit_req),
|
||||
mode: toNumber(difficulty.mode),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...commonQuery,
|
||||
...(typedQueryMap[trainingType] || {}),
|
||||
};
|
||||
};
|
||||
|
||||
const createPracticeUrl = (difficulty) => {
|
||||
const query = Object.entries(createPracticeQuery(difficulty))
|
||||
.map(([key, value]) => [key, cleanQueryValue(value)])
|
||||
.filter(([, value]) => value !== "")
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
|
||||
return `/pages/training/practise-one${query ? `?${query}` : ""}`;
|
||||
};
|
||||
|
||||
const saveTrainingContext = () => {
|
||||
const difficulty = selectedDifficulty.value;
|
||||
|
||||
if (!difficulty.id) {
|
||||
@@ -571,9 +622,8 @@ const saveTrainingContext = (target) => {
|
||||
trainingTitle: pageConfig.value.title,
|
||||
difficultyId: difficulty.id,
|
||||
difficultyLabel: difficulty.label,
|
||||
targetPaperType: target
|
||||
? resolveTargetPaperType(target)
|
||||
: difficulty.targetPaperType,
|
||||
targetType: defaultTargetType,
|
||||
targetPaperType: difficulty.targetPaperType,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -606,30 +656,8 @@ const handleStart = () => {
|
||||
}
|
||||
|
||||
saveTrainingContext();
|
||||
uni.showToast({
|
||||
title: `${selectedDifficulty.value.title} 即将开始`,
|
||||
icon: "none",
|
||||
});
|
||||
};
|
||||
|
||||
const openTargetPicker = () => {
|
||||
if (!selectedDifficulty.value.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
showTargetPicker.value = true;
|
||||
};
|
||||
|
||||
const handleTargetConfirm = (target) => {
|
||||
showTargetPicker.value = false;
|
||||
saveTrainingContext(target);
|
||||
// uni.showToast({
|
||||
// title: `${selectedDifficulty.value.title} 即将开始`,
|
||||
// icon: "none",
|
||||
// });
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/training/practise-one?target=${target}`,
|
||||
url: createPracticeUrl(selectedDifficulty.value),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -708,15 +736,10 @@ onUnload(() => {
|
||||
<view class="difficulty-page__start">
|
||||
<TrainingDifficultyStartButton
|
||||
:text="selectedDifficulty.startText"
|
||||
@click="openTargetPicker"
|
||||
@click="handleStart"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<TargetPicker
|
||||
:show="showTargetPicker"
|
||||
:onClose="() => (showTargetPicker = false)"
|
||||
:onConfirm="handleTargetConfirm"
|
||||
/>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ShootProgress from "./components/ShootProgress.vue";
|
||||
@@ -29,35 +29,192 @@ const { user } = storeToRefs(store);
|
||||
|
||||
const sound = ref(true);
|
||||
const start = ref(false);
|
||||
const pageStages = Object.freeze({
|
||||
DISTANCE: "distance",
|
||||
SHOOTING: "shooting",
|
||||
RESULT: "result",
|
||||
LOADING: "loading",
|
||||
});
|
||||
const pageStage = ref(pageStages.DISTANCE);
|
||||
const scores = ref([]);
|
||||
const total = 12;
|
||||
const defaultTotal = 12;
|
||||
const defaultShootTime = 120;
|
||||
const defaultTargetType = 1;
|
||||
const total = ref(defaultTotal);
|
||||
const shootTime = ref(defaultShootTime);
|
||||
const practiseResult = ref({});
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const tips = ref("");
|
||||
const targetType = ref(1);
|
||||
const targetType = ref(defaultTargetType);
|
||||
const trainingParams = ref({});
|
||||
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
|
||||
const useHighlightTest = ref(false);
|
||||
const highlightTestTimer = ref(null);
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.target) {
|
||||
targetType.value = Number(options.target);
|
||||
const env = computed(() => {
|
||||
try {
|
||||
return uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
} catch (error) {
|
||||
return "release";
|
||||
}
|
||||
});
|
||||
|
||||
const isDistanceStage = computed(() => pageStage.value === pageStages.DISTANCE);
|
||||
const isShootingStage = computed(() => pageStage.value === pageStages.SHOOTING);
|
||||
const hasPractiseResult = computed(() => !!practiseResult.value?.details);
|
||||
const showResult = computed(
|
||||
() => pageStage.value === pageStages.RESULT && hasPractiseResult.value
|
||||
);
|
||||
|
||||
const defaultHighlightAreas = [{ quadrant: 1, rings: [7] }];
|
||||
|
||||
// 临时高亮测试数据:第 N 项对应第 N 箭,每箭展示一个不同区域。
|
||||
const highlightTestAreas = [
|
||||
{ arrowIndex: 1, quadrant: 1, rings: [10] },
|
||||
{ arrowIndex: 2, quadrant: 2, rings: [9, 10] },
|
||||
{ arrowIndex: 3, quadrant: 3, rings: [8, 9] },
|
||||
{ arrowIndex: 4, quadrant: 4, rings: [7, 8] },
|
||||
{ arrowIndex: 5, quadrant: 1, rings: [6, 7] },
|
||||
{ arrowIndex: 6, quadrant: 2, rings: [5, 6] },
|
||||
{ arrowIndex: 7, quadrant: 3, rings: [4, 5] },
|
||||
{ arrowIndex: 8, quadrant: 4, rings: [3, 4] },
|
||||
{ arrowIndex: 9, quadrant: 1, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 10, quadrant: 2, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 11, quadrant: 3, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 12, quadrant: 4, rings: "all", scope: "sector" },
|
||||
];
|
||||
|
||||
const targetHighlightAreas = computed(() => {
|
||||
return useHighlightTest.value ? highlightTestAreas : defaultHighlightAreas;
|
||||
});
|
||||
|
||||
const toRouteNumber = (value, fallback = 0) => {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const toPositiveRouteNumber = (value, fallback) => {
|
||||
const numberValue = toRouteNumber(value, fallback);
|
||||
return numberValue > 0 ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const createPractice = async () => {
|
||||
const result = await createPractiseAPI(
|
||||
total.value,
|
||||
shootTime.value,
|
||||
targetType.value
|
||||
);
|
||||
|
||||
if (result) practiseId.value = result.id;
|
||||
};
|
||||
|
||||
const clearHighlightTestTimer = () => {
|
||||
if (highlightTestTimer.value) {
|
||||
clearInterval(highlightTestTimer.value);
|
||||
highlightTestTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildHighlightTestScore = (index) => ({
|
||||
playerId: user.value?.id,
|
||||
ring: 9,
|
||||
ringX: false,
|
||||
x: ((index % 4) - 1.5) * 2,
|
||||
y: (Math.floor(index / 4) - 1) * 2,
|
||||
angle: null,
|
||||
});
|
||||
|
||||
const setHighlightTestArrow = (arrowIndex) => {
|
||||
const completedCount = Math.max(arrowIndex - 1, 0);
|
||||
scores.value = Array.from({ length: completedCount }, (_, index) =>
|
||||
buildHighlightTestScore(index)
|
||||
);
|
||||
};
|
||||
|
||||
// 临时测试入口:自动切换第 1 到第 12 箭,让 BowTarget 按当前箭展示不同高亮。
|
||||
const runHighlightTest = () => {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = true;
|
||||
practiseResult.value = {};
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
start.value = true;
|
||||
|
||||
let arrowIndex = 1;
|
||||
setHighlightTestArrow(arrowIndex);
|
||||
|
||||
highlightTestTimer.value = setInterval(() => {
|
||||
if (arrowIndex >= highlightTestAreas.length) {
|
||||
clearHighlightTestTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
arrowIndex += 1;
|
||||
setHighlightTestArrow(arrowIndex);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const resetHighlightTest = () => {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
scores.value = [];
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
targetType.value = toPositiveRouteNumber(options.target, defaultTargetType);
|
||||
total.value = toPositiveRouteNumber(options.arrows, defaultTotal);
|
||||
shootTime.value = toPositiveRouteNumber(options.time, defaultShootTime);
|
||||
trainingParams.value = {
|
||||
type: options.type || "",
|
||||
difficultyId: options.difficultyId || "",
|
||||
difficulty: toRouteNumber(options.difficulty),
|
||||
recordId: options.recordId || "",
|
||||
hitReq: toRouteNumber(options.hitReq),
|
||||
totalReq: toRouteNumber(options.totalReq),
|
||||
blocks: toRouteNumber(options.blocks),
|
||||
mode: toRouteNumber(options.mode),
|
||||
};
|
||||
});
|
||||
|
||||
const onReady = async () => {
|
||||
pageStage.value = pageStages.LOADING;
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
try {
|
||||
await startPractiseAPI();
|
||||
practiseResult.value = {};
|
||||
scores.value = [];
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
audioManager.play("练习开始");
|
||||
} catch (error) {
|
||||
start.value = false;
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const onOver = async () => {
|
||||
practiseResult.value = await getPractiseAPI(practiseId.value);
|
||||
if (!isShootingStage.value) return;
|
||||
|
||||
clearHighlightTestTimer();
|
||||
pageStage.value = pageStages.LOADING;
|
||||
start.value = false;
|
||||
|
||||
try {
|
||||
practiseResult.value = (await getPractiseAPI(practiseId.value)) || {};
|
||||
pageStage.value = hasPractiseResult.value
|
||||
? pageStages.RESULT
|
||||
: pageStages.DISTANCE;
|
||||
} catch (error) {
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult && isShootingStage.value) {
|
||||
scores.value = msg.details;
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
// setTimeout(onOver, 1500);
|
||||
@@ -65,17 +222,25 @@ async function onReceiveMessage(msg) {
|
||||
}
|
||||
|
||||
function onComplete() {
|
||||
pageStage.value = pageStages.LOADING;
|
||||
start.value = false;
|
||||
uni.$emit(trainingDifficultyRefreshEvent);
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
async function onRetry() {
|
||||
pageStage.value = pageStages.LOADING;
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
practiseId.value = "";
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
const result = await createPractiseAPI(total, 120, targetType.value);
|
||||
if (result) practiseId.value = result.id;
|
||||
try {
|
||||
await createPractice();
|
||||
} finally {
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
const onClickShare = debounce(async () => {
|
||||
@@ -103,8 +268,7 @@ onMounted(async () => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("share-image", onClickShare);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
const result = await createPractiseAPI(total, 120, targetType.value);
|
||||
if (result) practiseId.value = result.id;
|
||||
await createPractice();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -115,18 +279,21 @@ onBeforeUnmount(() => {
|
||||
uni.$off("share-image", onClickShare);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
audioManager.stopAll();
|
||||
clearHighlightTestTimer();
|
||||
endPractiseAPI();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container
|
||||
:bgType="!start && !practiseResult.id?9:10"
|
||||
:showBottom="!start && !scores.length"
|
||||
:bgType="isDistanceStage ? 9 : 10"
|
||||
:showBottom="isDistanceStage"
|
||||
:scroll="!isShootingStage"
|
||||
>
|
||||
<view>
|
||||
<TestDistance v-if="!start && !practiseResult.id" />
|
||||
<block v-else>
|
||||
<view class="practise-content">
|
||||
<TestDistance v-if="isDistanceStage" />
|
||||
<view v-else-if="isShootingStage" class="shooting-layout">
|
||||
<view class="shooting-fixed">
|
||||
<ShootProgress
|
||||
:start="start"
|
||||
:onStop="onOver"
|
||||
@@ -143,7 +310,25 @@ onBeforeUnmount(() => {
|
||||
:totalRound="start ? total / 4 : 0"
|
||||
:currentRound="scores.length % 3"
|
||||
:scores="scores"
|
||||
:showCrosshair="false"
|
||||
:highlightAreas="targetHighlightAreas"
|
||||
/>
|
||||
<view v-if="env !== 'release'" class="highlight-test-actions">
|
||||
<button
|
||||
class="highlight-test-btn"
|
||||
hover-class="none"
|
||||
@click="runHighlightTest"
|
||||
>
|
||||
高亮测试
|
||||
</button>
|
||||
<button
|
||||
class="highlight-test-btn"
|
||||
hover-class="none"
|
||||
@click="resetHighlightTest"
|
||||
>
|
||||
重置高亮
|
||||
</button>
|
||||
</view>
|
||||
<view class="sound-text-box">
|
||||
<button class="sound-btn" hover-class="none" @click="updateSound">
|
||||
<image
|
||||
@@ -168,9 +353,18 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="score-scroll"
|
||||
scroll-y
|
||||
:enhanced="true"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<ScorePanel2 :arrows="scores" :total="total" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
<ScoreResult
|
||||
v-if="practiseResult.details"
|
||||
v-else-if="showResult"
|
||||
:rowCount="6"
|
||||
:total="total"
|
||||
:onClose="onComplete"
|
||||
@@ -178,7 +372,6 @@ onBeforeUnmount(() => {
|
||||
:result="practiseResult"
|
||||
/>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
</block>
|
||||
</view>
|
||||
<template #bottom>
|
||||
<view class="btn-box">
|
||||
@@ -194,6 +387,30 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.practise-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shooting-layout {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shooting-fixed {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-box{
|
||||
width: 488rpx;
|
||||
height: 234rpx;
|
||||
@@ -221,6 +438,33 @@ onBeforeUnmount(() => {
|
||||
bottom: -36rpx;
|
||||
}
|
||||
|
||||
.highlight-test-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: -24rpx;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.highlight-test-btn {
|
||||
width: 150rpx;
|
||||
height: 48rpx;
|
||||
line-height: 48rpx;
|
||||
padding: 0;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
color: #fed847;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.highlight-test-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.highlight-test-btn + .highlight-test-btn {
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.sound-text-box{
|
||||
height: 125rpx;
|
||||
padding: 0 56rpx;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 12 KiB |
@@ -137,6 +137,10 @@ export default defineStore("store", {
|
||||
this.device.deviceId = deviceId;
|
||||
this.device.deviceName = deviceName;
|
||||
},
|
||||
clearDevice() {
|
||||
this.device = getDefaultDevice();
|
||||
this.online = false;
|
||||
},
|
||||
async updateConfig(config) {
|
||||
this.config = config;
|
||||
if (this.user.scores !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user