update:对接个人训练改版

This commit is contained in:
2026-07-23 09:44:58 +08:00
parent a78ab1daeb
commit 3c5754b3fd
64 changed files with 1769 additions and 488 deletions
+257 -70
View File
@@ -1,11 +1,14 @@
<script setup>
import {
computed,
getCurrentInstance,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import BowShotEffect from "@/components/BowShotEffect.vue";
import PointSwitcher from "@/components/PointSwitcher.vue";
import TargetCanvas from "@/components/TargetCanvas.vue";
@@ -33,6 +36,14 @@ const props = defineProps({
type: Array,
default: () => [],
},
isSvip: {
type: Boolean,
default: false,
},
shotEffectToken: {
type: Number,
default: 0,
},
mode: {
type: String,
default: "solo", // solo 单排,team 双排
@@ -57,23 +68,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
showQuadrantLabels: {
sectorCount: {
type: Number,
default: 0,
},
activeSector: {
type: Number,
default: 0,
},
activeRing: {
type: Number,
default: 0,
},
showSectorLabels: {
type: Boolean,
default: false,
},
quadrantLabels: {
type: Object,
default: () => ({
1: "1",
2: "2",
3: "3",
4: "4",
}),
},
highlightAreas: {
type: Array,
default: () => [],
},
});
const pMode = ref(true);
@@ -85,6 +95,12 @@ const timer = ref(null);
const dirTimer = ref(null);
const angle = ref(null);
const circleColor = ref("");
const shotEffect = ref(null);
const hiddenLatestKey = ref("");
const targetShaking = ref(false);
const targetSize = ref({ width: 0, height: 0 });
const shakeTimer = ref(null);
const instance = getCurrentInstance();
const ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68;
@@ -162,6 +178,13 @@ function getHitStyle(shot) {
};
}
function getSvipHitBgStyle(shot) {
const radius = currentHitRadiusPx.value;
const point = getShotPoint(shot);
return getTargetPositionStyle(point, radius);
}
function getRoundTipStyle(shot) {
const point = getShotPoint(shot, true);
return getTargetPositionStyle(
@@ -180,15 +203,116 @@ function getExperienceTipStyle(shot) {
);
}
function clearTipTimer() {
if (!timer.value) return;
clearTimeout(timer.value);
timer.value = null;
}
function showShotTip(shot) {
clearTipTimer();
latestOne.value = shot;
timer.value = setTimeout(() => {
latestOne.value = null;
timer.value = null;
}, 1000);
}
function hasShotPoint(shot) {
return !!getShotPoint(shot);
}
function shouldPlayShotEffect(shot) {
return (
props.isSvip &&
!!shot &&
Number(shot.ring) > 0 &&
hasShotPoint(shot)
);
}
function buildShotEffectKey(shot, index) {
return [
props.shotEffectToken,
index,
shot?.playerId ?? "",
shot?.x ?? "",
shot?.y ?? "",
shot?.ring ?? "",
shot?.ringX ? 1 : 0,
].join("-");
}
function triggerShotEffect(shot, index) {
const key = buildShotEffectKey(shot, index);
clearTipTimer();
latestOne.value = null;
hiddenLatestKey.value = key;
shotEffect.value = { key, shot };
}
function completeShotEffect(key) {
if (!shotEffect.value || shotEffect.value.key !== key) return;
const shot = shotEffect.value.shot;
hiddenLatestKey.value = "";
shotEffect.value = null;
showShotTip(shot);
}
function shouldHideLatestHit(index) {
return !!hiddenLatestKey.value && index === props.scores.length - 1;
}
function shakeTarget() {
targetShaking.value = false;
if (shakeTimer.value) {
clearTimeout(shakeTimer.value);
shakeTimer.value = null;
}
nextTick(() => {
targetShaking.value = true;
shakeTimer.value = setTimeout(() => {
targetShaking.value = false;
shakeTimer.value = null;
}, 260);
});
}
function updateTargetSize() {
nextTick(() => {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery();
query
.select(".target")
.boundingClientRect((rect) => {
const width = Number(rect?.width);
const height = Number(rect?.height);
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
if (width <= 0 || height <= 0) return;
targetSize.value = { width, height };
})
.exec();
});
}
function handleWindowResize() {
updateTargetSize();
}
watch(
() => props.scores,
(newVal) => {
if (newVal.length - prevScores.value.length === 1) {
latestOne.value = newVal[newVal.length - 1];
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => {
latestOne.value = null;
}, 1000);
showShotTip(newVal[newVal.length - 1]);
} else if (newVal.length < prevScores.value.length) {
clearTipTimer();
latestOne.value = null;
hiddenLatestKey.value = "";
shotEffect.value = null;
}
prevScores.value = [...newVal];
},
@@ -197,6 +321,19 @@ watch(
}
);
watch(
() => props.shotEffectToken,
(token) => {
// token 只由实时 ShootResult 推进,同步快照不会重播飞箭。
if (!token || props.scores.length === 0) return;
const latestIndex = props.scores.length - 1;
const latestShot = props.scores[latestIndex];
if (shouldPlayShotEffect(latestShot)) {
triggerShotEffect(latestShot, latestIndex);
}
}
);
watch(
() => props.blueScores,
(newVal) => {
@@ -237,42 +374,9 @@ const arrowStyle = computed(() => {
};
});
const currentArrowIndex = computed(() => {
return props.scores.length + props.blueScores.length + 1;
});
const getHighlightArrowIndex = (area = {}) => {
const arrowIndex = Number(area.arrowIndex ?? area.arrowNo ?? area.arrow);
return Number.isInteger(arrowIndex) && arrowIndex > 0 ? arrowIndex : null;
};
const currentHighlightAreas = computed(() => {
if (!Array.isArray(props.highlightAreas) || props.highlightAreas.length === 0) {
return [];
}
const hasExplicitArrowIndex = props.highlightAreas.some((area = {}) => {
return getHighlightArrowIndex(area) !== null;
});
const matchedAreas = props.highlightAreas.filter((area = {}) => {
return getHighlightArrowIndex(area) === currentArrowIndex.value;
});
if (hasExplicitArrowIndex) {
return matchedAreas;
}
if (props.highlightAreas.length === 1) {
return props.highlightAreas.slice(0, 1);
}
const currentArea = props.highlightAreas[currentArrowIndex.value - 1];
return currentArea ? [currentArea] : [];
});
const showHighlightCanvas = computed(() => {
return props.totalRound > 0 && currentHighlightAreas.value.length > 0;
const showSectorCanvas = computed(() => {
const count = Number(props.sectorCount);
return props.totalRound > 0 && Number.isInteger(count) && count > 0;
});
async function onReceiveMessage(message) {
@@ -299,23 +403,27 @@ async function onReceiveMessage(message) {
onMounted(() => {
uni.$on("socket-inbox", onReceiveMessage);
updateTargetSize();
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
});
onBeforeUnmount(() => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
clearTipTimer();
if (dirTimer.value) {
clearTimeout(dirTimer.value);
dirTimer.value = null;
}
if (shakeTimer.value) {
clearTimeout(shakeTimer.value);
shakeTimer.value = null;
}
uni.$off("socket-inbox", onReceiveMessage);
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
});
</script>
<template>
<view class="container">
<view :class="['container', { 'container--effecting': shotEffect }]">
<!-- <view class="header" v-if="totalRound > 0">
<text v-if="totalRound > 0" class="round-count">{{
(currentRound > totalRound ? totalRound : currentRound) +
@@ -323,37 +431,44 @@ onBeforeUnmount(() => {
totalRound
}}</text>
</view> -->
<view class="target">
<view :class="['target', { 'target--shake': targetShaking }]">
<image
class="target-image"
src="../../../static/bow-target.png"
src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png"
mode="aspectFit"
/>
<TargetCanvas
v-if="showHighlightCanvas"
v-if="showSectorCanvas"
class="target-highlight-layer"
:coordinateRadius="coordinateRadius"
:showCrosshair="false"
:showQuadrantLabels="false"
:showRingLabels="false"
:highlightOnly="true"
:highlightAreas="currentHighlightAreas"
:sectorCount="sectorCount"
:activeSector="activeSector"
:activeRing="activeRing"
:showSectorLabels="showSectorLabels"
/>
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
<view :style="{ background: circleColor }">
<image src="../../../static/dot-circle.png" mode="widthFix" />
<image src="https://static.shelingxingqiu.com/shootmini/static/dot-circle.png" mode="widthFix" />
</view>
</view>
<view v-if="stop" class="stop-sign">中场休息</view>
<view
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
v-if="
!shotEffect &&
latestOne &&
latestOne.ring &&
user.id === latestOne.playerId
"
class="e-value fade-in-out"
:style="getExperienceTipStyle(latestOne)"
>
经验 +1
</view>
<view
v-if="latestOne"
v-if="!shotEffect && latestOne"
class="round-tip fade-in-out"
:style="getRoundTipStyle(latestOne)"
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
@@ -378,8 +493,15 @@ onBeforeUnmount(() => {
}}<text v-if="bluelatestOne.ring">环</text></view
>
<block v-for="(bow, index) in scores" :key="index">
<image
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideLatestHit(index)"
class="svip-hit-bg"
src="../../../static/vip/svip-xuan.png"
:style="getSvipHitBgStyle(bow)"
mode="aspectFit"
/>
<view
v-if="bow.ring > 0"
v-if="bow.ring > 0 && !shouldHideLatestHit(index)"
:class="`hit ${pMode ? 'b' : 's'}-point ${
index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`"
@@ -404,6 +526,16 @@ onBeforeUnmount(() => {
<text v-if="pMode">{{ index + 1 }}</text>
</view>
</block>
<BowShotEffect
:shot="shotEffect && shotEffect.shot"
:playKey="shotEffect ? shotEffect.key : ''"
:targetRadius="safeTargetRadius"
:targetWidth="targetSize.width"
:targetHeight="targetSize.height"
:hitOffsetPx="currentHitRadiusPx"
@impact="shakeTarget"
@complete="completeShotEffect"
/>
</view>
<view class="footer">
<PointSwitcher
@@ -424,13 +556,22 @@ onBeforeUnmount(() => {
height: calc(100vw - 30px);
padding: 0px 15px;
position: relative;
z-index: 3;
}
.container--effecting {
z-index: 10000;
}
.target {
position: relative;
margin: 10px;
width: calc(100% - 20px);
height: calc(100% - 20px);
z-index: 0;
z-index: 1;
pointer-events: none;
transform-origin: center center;
}
.target--shake {
animation: target-shake 0.26s ease-out;
}
.target-image {
position: absolute;
@@ -499,6 +640,15 @@ onBeforeUnmount(() => {
.e-value.fade-in-out {
animation: target-tip-fade-in-out 1.2s ease forwards;
}
.svip-hit-bg {
position: absolute;
width: 48rpx;
height: 48rpx;
z-index: 2;
pointer-events: none;
transform-origin: center center;
animation: svip-hit-xuan 1.2s linear infinite;
}
.hit {
position: absolute;
border-radius: 50%;
@@ -527,6 +677,20 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%);*/
margin-top: 2rpx;
}
@keyframes svip-hit-xuan {
0% {
opacity: 0.9;
transform: translate(-50%, -50%) rotate(0deg) scale(0.92);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) rotate(180deg) scale(1.08);
}
100% {
opacity: 0.9;
transform: translate(-50%, -50%) rotate(360deg) scale(0.92);
}
}
@keyframes target-pump-in {
from {
transform: translate(-50%, -50%) scale(2);
@@ -536,6 +700,29 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes target-shake {
0% {
transform: translate(0, 0);
}
14% {
transform: translate(-20rpx, 8rpx);
}
28% {
transform: translate(16rpx, -8rpx);
}
44% {
transform: translate(-12rpx, 6rpx);
}
64% {
transform: translate(8rpx, -4rpx);
}
82% {
transform: translate(-4rpx, 2rpx);
}
100% {
transform: translate(0, 0);
}
}
.hit.pump-in {
animation: target-pump-in 0.3s ease-out forwards;
transform-origin: center center;