update:代码备份
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -26,12 +26,33 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 45,
|
default: 45,
|
||||||
},
|
},
|
||||||
|
sizeUnit: {
|
||||||
|
type: String,
|
||||||
|
default: "px",
|
||||||
|
},
|
||||||
|
imageMode: {
|
||||||
|
type: String,
|
||||||
|
default: "widthFix",
|
||||||
|
},
|
||||||
borderColor: {
|
borderColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const avatarFrame = ref("");
|
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(
|
watch(
|
||||||
() => [config.value, props.rankLvl],
|
() => [config.value, props.rankLvl],
|
||||||
() => {
|
() => {
|
||||||
@@ -51,10 +72,7 @@ watch(
|
|||||||
v-if="avatarFrame"
|
v-if="avatarFrame"
|
||||||
:src="avatarFrame"
|
:src="avatarFrame"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
:style="{
|
:style="avatarFrameStyle"
|
||||||
width: Number(size) + 10 + 'px',
|
|
||||||
height: Number(size) + 10 + 'px',
|
|
||||||
}"
|
|
||||||
class="avatar-frame"
|
class="avatar-frame"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
@@ -78,13 +96,8 @@ watch(
|
|||||||
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||||
<image
|
<image
|
||||||
:src="src || '../static/user-icon.png'"
|
:src="src || '../static/user-icon.png'"
|
||||||
mode="widthFix"
|
:mode="imageMode"
|
||||||
:style="{
|
:style="avatarImageStyle"
|
||||||
width: size + 'px',
|
|
||||||
height: size + 'px',
|
|
||||||
minHeight: size + 'px',
|
|
||||||
borderColor: borderColor || '#fff',
|
|
||||||
}"
|
|
||||||
class="avatar-image"
|
class="avatar-image"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
headerClass: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
showBottom: {
|
showBottom: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
@@ -152,6 +156,7 @@ const goCalibration = async () => {
|
|||||||
<AppBackground :type="bgType" :bgColor="bgColor" />
|
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||||
<Header
|
<Header
|
||||||
v-if="!isHome"
|
v-if="!isHome"
|
||||||
|
:class="headerClass"
|
||||||
:title="title"
|
:title="title"
|
||||||
:onBack="onBack"
|
:onBack="onBack"
|
||||||
:whiteBackArrow="whiteBackArrow"
|
:whiteBackArrow="whiteBackArrow"
|
||||||
|
|||||||
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>
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
import {
|
||||||
|
computed,
|
||||||
|
getCurrentInstance,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||||
|
import TargetCanvas from "@/components/TargetCanvas.vue";
|
||||||
|
|
||||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||||
import { simulShootAPI } from "@/apis";
|
import { simulShootAPI } from "@/apis";
|
||||||
@@ -8,6 +17,7 @@ import useStore from "@/store";
|
|||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user, device } = storeToRefs(store);
|
const { user, device } = storeToRefs(store);
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentRound: {
|
currentRound: {
|
||||||
@@ -34,6 +44,39 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
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);
|
const pMode = ref(true);
|
||||||
@@ -45,6 +88,100 @@ 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 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(
|
watch(
|
||||||
() => props.scores,
|
() => props.scores,
|
||||||
@@ -80,14 +217,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 () => {
|
const simulShoot = async () => {
|
||||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||||
};
|
};
|
||||||
@@ -111,6 +240,160 @@ const arrowStyle = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentArrowIndex = computed(() => {
|
||||||
|
return props.scores.length + props.blueScores.length + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getHighlightArrowIndex = (area = {}) => {
|
||||||
|
const arrowIndex = Number(area.arrowIndex ?? area.arrowNo ?? area.arrow);
|
||||||
|
return Number.isInteger(arrowIndex) && arrowIndex > 0 ? arrowIndex : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentHighlightAreas = computed(() => {
|
||||||
|
if (!Array.isArray(props.highlightAreas) || props.highlightAreas.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExplicitArrowIndex = props.highlightAreas.some((area = {}) => {
|
||||||
|
return getHighlightArrowIndex(area) !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedAreas = props.highlightAreas.filter((area = {}) => {
|
||||||
|
return getHighlightArrowIndex(area) === currentArrowIndex.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasExplicitArrowIndex) {
|
||||||
|
return matchedAreas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.highlightAreas.length === 1) {
|
||||||
|
return props.highlightAreas.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentArea = props.highlightAreas[currentArrowIndex.value - 1];
|
||||||
|
return currentArea ? [currentArea] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const showHighlightCanvas = computed(() => {
|
||||||
|
return props.totalRound > 0 && currentHighlightAreas.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetImageNaturalSize = ref({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetLayerRect = ref({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
ready: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetHighlightLayerStyle = computed(() => ({
|
||||||
|
left: `${targetLayerRect.value.left}px`,
|
||||||
|
top: `${targetLayerRect.value.top}px`,
|
||||||
|
width: `${targetLayerRect.value.width}px`,
|
||||||
|
height: `${targetLayerRect.value.height}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getRectNumber = (value, fallback = 0) => {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageContentRect = (imageRect) => {
|
||||||
|
const naturalWidth = getRectNumber(targetImageNaturalSize.value.width);
|
||||||
|
const naturalHeight = getRectNumber(targetImageNaturalSize.value.height);
|
||||||
|
const imageWidth = getRectNumber(imageRect?.width);
|
||||||
|
const imageHeight = getRectNumber(imageRect?.height);
|
||||||
|
|
||||||
|
if (naturalWidth <= 0 || naturalHeight <= 0 || imageWidth <= 0 || imageHeight <= 0) {
|
||||||
|
return imageRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
const naturalRatio = naturalWidth / naturalHeight;
|
||||||
|
const boxRatio = imageWidth / imageHeight;
|
||||||
|
|
||||||
|
if (naturalRatio > boxRatio) {
|
||||||
|
const contentHeight = imageWidth / naturalRatio;
|
||||||
|
return {
|
||||||
|
left: imageRect.left,
|
||||||
|
top: imageRect.top + (imageHeight - contentHeight) / 2,
|
||||||
|
width: imageWidth,
|
||||||
|
height: contentHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentWidth = imageHeight * naturalRatio;
|
||||||
|
return {
|
||||||
|
left: imageRect.left + (imageWidth - contentWidth) / 2,
|
||||||
|
top: imageRect.top,
|
||||||
|
width: contentWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTargetLayerRect = async () => {
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const query = uni.createSelectorQuery().in(instance?.proxy);
|
||||||
|
query.select(".target").boundingClientRect();
|
||||||
|
query.select(".target-image").boundingClientRect();
|
||||||
|
query.exec((rects = []) => {
|
||||||
|
const stageRect = rects[0];
|
||||||
|
const imageRect = rects[1];
|
||||||
|
|
||||||
|
if (!stageRect || !imageRect || !imageRect.width || !imageRect.height) {
|
||||||
|
targetLayerRect.value = {
|
||||||
|
...targetLayerRect.value,
|
||||||
|
ready: false,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentRect = getImageContentRect(imageRect);
|
||||||
|
const width = Math.round(getRectNumber(contentRect?.width));
|
||||||
|
const height = Math.round(getRectNumber(contentRect?.height));
|
||||||
|
|
||||||
|
targetLayerRect.value = {
|
||||||
|
left: getRectNumber(contentRect?.left) - getRectNumber(stageRect.left),
|
||||||
|
top: getRectNumber(contentRect?.top) - getRectNumber(stageRect.top),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
ready: width > 0 && height > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTargetImageLoad = (event) => {
|
||||||
|
const width = getRectNumber(event?.detail?.width);
|
||||||
|
const height = getRectNumber(event?.detail?.height);
|
||||||
|
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
targetImageNaturalSize.value = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTargetLayerRect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowResize = () => {
|
||||||
|
updateTargetLayerRect();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
showHighlightCanvas,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
updateTargetLayerRect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
async function onReceiveMessage(message) {
|
async function onReceiveMessage(message) {
|
||||||
if (Array.isArray(message)) return;
|
if (Array.isArray(message)) return;
|
||||||
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
|
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
|
||||||
@@ -135,6 +418,10 @@ async function onReceiveMessage(message) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
setTimeout(updateTargetLayerRect, 30);
|
||||||
|
if (uni.onWindowResize) {
|
||||||
|
uni.onWindowResize(onWindowResize);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -147,6 +434,9 @@ onBeforeUnmount(() => {
|
|||||||
dirTimer.value = null;
|
dirTimer.value = null;
|
||||||
}
|
}
|
||||||
uni.$off("socket-inbox", onReceiveMessage);
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
|
if (uni.offWindowResize) {
|
||||||
|
uni.offWindowResize(onWindowResize);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -160,6 +450,25 @@ onBeforeUnmount(() => {
|
|||||||
}}</text>
|
}}</text>
|
||||||
</view> -->
|
</view> -->
|
||||||
<view class="target">
|
<view class="target">
|
||||||
|
<image
|
||||||
|
class="target-image"
|
||||||
|
src="../../../static/bow-target.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
@load="onTargetImageLoad"
|
||||||
|
/>
|
||||||
|
<TargetCanvas
|
||||||
|
v-if="showHighlightCanvas && targetLayerRect.ready"
|
||||||
|
class="target-highlight-layer"
|
||||||
|
:style="targetHighlightLayerStyle"
|
||||||
|
:canvasWidth="targetLayerRect.width"
|
||||||
|
:canvasHeight="targetLayerRect.height"
|
||||||
|
:coordinateRadius="coordinateRadius"
|
||||||
|
:showCrosshair="false"
|
||||||
|
:showQuadrantLabels="false"
|
||||||
|
:showRingLabels="false"
|
||||||
|
:highlightOnly="true"
|
||||||
|
:highlightAreas="currentHighlightAreas"
|
||||||
|
/>
|
||||||
<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="../../../static/dot-circle.png" mode="widthFix" />
|
||||||
@@ -169,20 +478,14 @@ onBeforeUnmount(() => {
|
|||||||
<view
|
<view
|
||||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||||
class="e-value fade-in-out"
|
class="e-value fade-in-out"
|
||||||
:style="{
|
:style="getExperienceTipStyle(latestOne)"
|
||||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
|
|
||||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
经验 +1
|
经验 +1
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="latestOne"
|
v-if="latestOne"
|
||||||
class="round-tip fade-in-out"
|
class="round-tip fade-in-out"
|
||||||
:style="{
|
:style="getRoundTipStyle(latestOne)"
|
||||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
|
|
||||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
|
|
||||||
}"
|
|
||||||
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
||||||
}}<text v-if="latestOne.ring">环</text>
|
}}<text v-if="latestOne.ring">环</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -193,20 +496,14 @@ onBeforeUnmount(() => {
|
|||||||
user.id === bluelatestOne.playerId
|
user.id === bluelatestOne.playerId
|
||||||
"
|
"
|
||||||
class="e-value fade-in-out"
|
class="e-value fade-in-out"
|
||||||
:style="{
|
:style="getExperienceTipStyle(bluelatestOne)"
|
||||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
|
|
||||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
经验 +1
|
经验 +1
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="bluelatestOne"
|
v-if="bluelatestOne"
|
||||||
class="round-tip fade-in-out"
|
class="round-tip fade-in-out"
|
||||||
:style="{
|
:style="getRoundTipStyle(bluelatestOne)"
|
||||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
|
|
||||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
|
|
||||||
}"
|
|
||||||
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
||||||
}}<text v-if="bluelatestOne.ring">环</text></view
|
}}<text v-if="bluelatestOne.ring">环</text></view
|
||||||
>
|
>
|
||||||
@@ -217,8 +514,7 @@ onBeforeUnmount(() => {
|
|||||||
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||||
}`"
|
}`"
|
||||||
:style="{
|
:style="{
|
||||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
...getHitStyle(bow),
|
||||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
|
||||||
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
||||||
}"
|
}"
|
||||||
><text v-if="pMode">{{ index + 1 }}</text></view
|
><text v-if="pMode">{{ index + 1 }}</text></view
|
||||||
@@ -231,15 +527,13 @@ onBeforeUnmount(() => {
|
|||||||
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
||||||
}`"
|
}`"
|
||||||
:style="{
|
:style="{
|
||||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
...getHitStyle(bow),
|
||||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
|
||||||
backgroundColor: '#1840FF',
|
backgroundColor: '#1840FF',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<text v-if="pMode">{{ index + 1 }}</text>
|
<text v-if="pMode">{{ index + 1 }}</text>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<image src="../../../static/bow-target.png" mode="widthFix" />
|
|
||||||
</view>
|
</view>
|
||||||
<view class="footer">
|
<view class="footer">
|
||||||
<PointSwitcher
|
<PointSwitcher
|
||||||
@@ -266,7 +560,21 @@ onBeforeUnmount(() => {
|
|||||||
margin: 10px;
|
margin: 10px;
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
height: 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;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.e-value {
|
.e-value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -275,7 +583,7 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -284,7 +592,7 @@ onBeforeUnmount(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -292,31 +600,44 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
.target > image:last-child {
|
@keyframes target-tip-fade-in-out {
|
||||||
width: 100%;
|
0% {
|
||||||
height: 100%;
|
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 {
|
.hit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
z-index: 1;
|
z-index: 3;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.s-point {
|
.s-point {
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
min-width: 4px;
|
|
||||||
min-height: 4px;
|
|
||||||
}
|
}
|
||||||
.b-point {
|
.b-point {
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
min-width: 10px;
|
|
||||||
min-height: 10px;
|
|
||||||
border: 1px solid #fff;
|
border: 1px solid #fff;
|
||||||
z-index: 1;
|
z-index: 3;
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -332,6 +653,19 @@ onBeforeUnmount(() => {
|
|||||||
transform: translate(-50%, -50%);*/
|
transform: translate(-50%, -50%);*/
|
||||||
margin-top: 2rpx;
|
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 {
|
.header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -382,7 +716,7 @@ onBeforeUnmount(() => {
|
|||||||
height: 60px;
|
height: 60px;
|
||||||
left: calc(50% - 100px);
|
left: calc(50% - 100px);
|
||||||
top: calc(50% - 30px);
|
top: calc(50% - 30px);
|
||||||
z-index: 99;
|
z-index: 5;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.arrow-dir {
|
.arrow-dir {
|
||||||
@@ -391,6 +725,7 @@ onBeforeUnmount(() => {
|
|||||||
height: 52%;
|
height: 52%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 50%;
|
bottom: 50%;
|
||||||
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
124
src/pages/training/components/ScorePanel.vue
Normal file
124
src/pages/training/components/ScorePanel.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<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 width = ref(92);
|
||||||
|
const itemWidth = ref(0);
|
||||||
|
const bgImages = [
|
||||||
|
"../static/complete-light1.png",
|
||||||
|
"../static/complete-light2.png",
|
||||||
|
];
|
||||||
|
const bgIndex = ref(0);
|
||||||
|
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"
|
||||||
|
:style="{
|
||||||
|
width: 100 / (rowCount + 2) + 'vw',
|
||||||
|
height: 100 / (rowCount + 2) + 'vw',
|
||||||
|
lineHeight: 100 / (rowCount + 2) + 'vw',
|
||||||
|
fontSize: fontSize + 'px',
|
||||||
|
margin: 100 / (total * 2) + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<image src="/static/score-bg.png" mode="widthFix" />
|
||||||
|
<text
|
||||||
|
:style="{ fontWeight: arrows[index] !== undefined ? 'bold' : 'normal' }"
|
||||||
|
>{{
|
||||||
|
!arrows[index] ? "-" : arrows[index].ringX ? "X" : arrows[index].ring
|
||||||
|
}}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: 92vw;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 4vw;
|
||||||
|
position: relative;
|
||||||
|
padding: 1vw 0;
|
||||||
|
}
|
||||||
|
.score-item {
|
||||||
|
/* background-image: url("../static/score-bg.png");
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center; */
|
||||||
|
color: #fed847;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.score-item > image {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
top: 5%;
|
||||||
|
}
|
||||||
|
.score-item > text {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.complete-light {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -206,7 +206,12 @@ onBeforeUnmount(() => {
|
|||||||
<view class="progress-card__header">
|
<view class="progress-card__header">
|
||||||
<view class="progress-card__profile">
|
<view class="progress-card__profile">
|
||||||
<view class="progress-card__avatar-shell">
|
<view class="progress-card__avatar-shell">
|
||||||
<Avatar :src="user.avatar" :size="40" />
|
<Avatar
|
||||||
|
:src="avatarSrc"
|
||||||
|
:size="80"
|
||||||
|
size-unit="rpx"
|
||||||
|
image-mode="aspectFill"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<text class="progress-card__name">{{ displayName }}</text>
|
<text class="progress-card__name">{{ displayName }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -271,6 +276,9 @@ onBeforeUnmount(() => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(180deg, rgba(255, 209, 153, 1), rgba(162, 119, 55, 1));
|
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 {
|
.progress-card__avatar {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed, nextTick, ref } from "vue";
|
import { computed, nextTick, ref } from "vue";
|
||||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import TargetPicker from "@/components/TargetPicker.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";
|
||||||
@@ -40,6 +39,7 @@ const routeModeTypeMap = {
|
|||||||
precision: "precision",
|
precision: "precision",
|
||||||
rhythm: "rhythm",
|
rhythm: "rhythm",
|
||||||
};
|
};
|
||||||
|
const defaultTargetType = 1;
|
||||||
|
|
||||||
const resolveTrainingType = (mode) => {
|
const resolveTrainingType = (mode) => {
|
||||||
const normalizedMode = String(mode || "").toLowerCase();
|
const normalizedMode = String(mode || "").toLowerCase();
|
||||||
@@ -232,7 +232,6 @@ const emptyDifficulty = {
|
|||||||
const pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
|
const pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
|
||||||
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
|
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
|
||||||
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
|
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
|
||||||
const showTargetPicker = ref(false);
|
|
||||||
const nodesScrollTop = ref(0);
|
const nodesScrollTop = ref(0);
|
||||||
const nodesScrollWithAnimation = ref(false);
|
const nodesScrollWithAnimation = ref(false);
|
||||||
const routeOptions = ref({});
|
const routeOptions = ref({});
|
||||||
@@ -555,11 +554,63 @@ const initPageState = async (options = {}, refreshOptions = {}) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveTargetPaperType = (target) => {
|
const cleanQueryValue = (value) => {
|
||||||
return Number(target) === 1 ? "20厘米全环靶" : "40厘米全环靶";
|
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;
|
const difficulty = selectedDifficulty.value;
|
||||||
|
|
||||||
if (!difficulty.id) {
|
if (!difficulty.id) {
|
||||||
@@ -571,9 +622,8 @@ const saveTrainingContext = (target) => {
|
|||||||
trainingTitle: pageConfig.value.title,
|
trainingTitle: pageConfig.value.title,
|
||||||
difficultyId: difficulty.id,
|
difficultyId: difficulty.id,
|
||||||
difficultyLabel: difficulty.label,
|
difficultyLabel: difficulty.label,
|
||||||
targetPaperType: target
|
targetType: defaultTargetType,
|
||||||
? resolveTargetPaperType(target)
|
targetPaperType: difficulty.targetPaperType,
|
||||||
: difficulty.targetPaperType,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -606,30 +656,8 @@ const handleStart = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveTrainingContext();
|
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({
|
uni.navigateTo({
|
||||||
url: `/pages/training/practise-one?target=${target}`,
|
url: createPracticeUrl(selectedDifficulty.value),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -708,15 +736,10 @@ onUnload(() => {
|
|||||||
<view class="difficulty-page__start">
|
<view class="difficulty-page__start">
|
||||||
<TrainingDifficultyStartButton
|
<TrainingDifficultyStartButton
|
||||||
:text="selectedDifficulty.startText"
|
:text="selectedDifficulty.startText"
|
||||||
@click="openTargetPicker"
|
@click="handleStart"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<TargetPicker
|
|
||||||
:show="showTargetPicker"
|
|
||||||
:onClose="() => (showTargetPicker = false)"
|
|
||||||
:onConfirm="handleTargetConfirm"
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } 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";
|
||||||
@@ -30,21 +30,140 @@ const { user } = storeToRefs(store);
|
|||||||
const sound = ref(true);
|
const sound = ref(true);
|
||||||
const start = ref(false);
|
const start = ref(false);
|
||||||
const scores = ref([]);
|
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 practiseResult = ref({});
|
||||||
const practiseId = ref("");
|
const practiseId = ref("");
|
||||||
const showGuide = ref(false);
|
const showGuide = ref(false);
|
||||||
const tips = ref("");
|
const tips = ref("");
|
||||||
const targetType = ref(1);
|
const targetType = ref(defaultTargetType);
|
||||||
|
const trainingParams = ref({});
|
||||||
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
|
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
|
||||||
|
const useHighlightTest = ref(false);
|
||||||
|
const highlightTestTimer = ref(null);
|
||||||
|
|
||||||
onLoad((options) => {
|
const env = computed(() => {
|
||||||
if (options.target) {
|
try {
|
||||||
targetType.value = Number(options.target);
|
return uni.getAccountInfoSync().miniProgram.envVersion;
|
||||||
|
} catch (error) {
|
||||||
|
return "release";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 = {};
|
||||||
|
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 () => {
|
const onReady = async () => {
|
||||||
|
clearHighlightTestTimer();
|
||||||
|
useHighlightTest.value = false;
|
||||||
await startPractiseAPI();
|
await startPractiseAPI();
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
start.value = true;
|
start.value = true;
|
||||||
@@ -70,12 +189,13 @@ function onComplete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onRetry() {
|
async function onRetry() {
|
||||||
|
clearHighlightTestTimer();
|
||||||
|
useHighlightTest.value = false;
|
||||||
practiseId.value = "";
|
practiseId.value = "";
|
||||||
practiseResult.value = {};
|
practiseResult.value = {};
|
||||||
start.value = false;
|
start.value = false;
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
const result = await createPractiseAPI(total, 120, targetType.value);
|
await createPractice();
|
||||||
if (result) practiseId.value = result.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickShare = debounce(async () => {
|
const onClickShare = debounce(async () => {
|
||||||
@@ -103,8 +223,7 @@ onMounted(async () => {
|
|||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
uni.$on("share-image", onClickShare);
|
uni.$on("share-image", onClickShare);
|
||||||
uni.$on("audioEnded", onAudioEnded);
|
uni.$on("audioEnded", onAudioEnded);
|
||||||
const result = await createPractiseAPI(total, 120, targetType.value);
|
await createPractice();
|
||||||
if (result) practiseId.value = result.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -115,6 +234,7 @@ onBeforeUnmount(() => {
|
|||||||
uni.$off("share-image", onClickShare);
|
uni.$off("share-image", onClickShare);
|
||||||
uni.$off("audioEnded", onAudioEnded);
|
uni.$off("audioEnded", onAudioEnded);
|
||||||
audioManager.stopAll();
|
audioManager.stopAll();
|
||||||
|
clearHighlightTestTimer();
|
||||||
endPractiseAPI();
|
endPractiseAPI();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -123,6 +243,7 @@ onBeforeUnmount(() => {
|
|||||||
<Container
|
<Container
|
||||||
:bgType="!start && !practiseResult.id?9:10"
|
:bgType="!start && !practiseResult.id?9:10"
|
||||||
:showBottom="!start && !scores.length"
|
:showBottom="!start && !scores.length"
|
||||||
|
headerClass="training-practise-header"
|
||||||
>
|
>
|
||||||
<view>
|
<view>
|
||||||
<TestDistance v-if="!start && !practiseResult.id" />
|
<TestDistance v-if="!start && !practiseResult.id" />
|
||||||
@@ -143,7 +264,25 @@ onBeforeUnmount(() => {
|
|||||||
:totalRound="start ? total / 4 : 0"
|
:totalRound="start ? total / 4 : 0"
|
||||||
:currentRound="scores.length % 3"
|
:currentRound="scores.length % 3"
|
||||||
:scores="scores"
|
: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">
|
<view class="sound-text-box">
|
||||||
<button class="sound-btn" hover-class="none" @click="updateSound">
|
<button class="sound-btn" hover-class="none" @click="updateSound">
|
||||||
<image
|
<image
|
||||||
@@ -221,6 +360,38 @@ onBeforeUnmount(() => {
|
|||||||
bottom: -36rpx;
|
bottom: -36rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.training-practise-header .back-btn) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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{
|
.sound-text-box{
|
||||||
height: 125rpx;
|
height: 125rpx;
|
||||||
padding: 0 56rpx;
|
padding: 0 56rpx;
|
||||||
|
|||||||
Reference in New Issue
Block a user