Files
shoot-miniprograms/src/pages/training/components/BowTarget.vue
T
2026-08-04 16:18:30 +08:00

955 lines
22 KiB
Vue

<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";
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import { simulShootAPI, laserAimAPI, laserCloseAPI } from "@/apis";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user, device } = storeToRefs(store);
const props = defineProps({
currentRound: {
type: Number,
default: 0,
},
totalRound: {
type: Number,
default: 0,
},
scores: {
type: Array,
default: () => [],
},
blueScores: {
type: Array,
default: () => [],
},
isSvip: {
type: Boolean,
default: false,
},
shotEffectToken: {
type: Number,
default: 0,
},
mode: {
type: String,
default: "solo", // solo 单排,team 双排
},
stop: {
type: Boolean,
default: false,
},
coordinateRadius: {
type: Number,
default: 20,
},
hitRadiusPx: {
type: Number,
default: 2,
},
zoomHitRadiusPx: {
type: Number,
default: 5,
},
showCrosshair: {
type: Boolean,
default: false,
},
sectorCount: {
type: Number,
default: 0,
},
activeSector: {
type: Number,
default: 0,
},
activeRing: {
type: Number,
default: 0,
},
showSectorLabels: {
type: Boolean,
default: false,
},
stableShotEffect: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["shot-effect-complete"]);
const pMode = ref(true);
const latestOne = ref(null);
const bluelatestOne = ref(null);
const prevScores = ref([]);
const prevBlueScores = ref([]);
const timer = ref(null);
const dirTimer = ref(null);
const angle = ref(null);
const circleColor = ref("");
const shotEffect = ref(null);
const pendingShotEffect = ref(null);
const hiddenLatestKey = ref("");
const targetShaking = ref(false);
const targetRect = ref({ left: 0, top: 0, width: 0, height: 0 });
const shakeTimer = ref(null);
const instance = getCurrentInstance();
let shotEffectRequestGeneration = 0;
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 getSvipHitBgStyle(shot) {
const radius = currentHitRadiusPx.value;
const point = getShotPoint(shot);
return getTargetPositionStyle(point, radius);
}
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 }
);
}
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, viewportMode = false) {
const key = buildShotEffectKey(shot, index);
clearTipTimer();
latestOne.value = null;
hiddenLatestKey.value = key;
shotEffect.value = {
key,
shot,
token: props.shotEffectToken,
viewportMode,
};
}
async function prepareShotEffect(shot, index) {
const requestGeneration = ++shotEffectRequestGeneration;
const key = buildShotEffectKey(shot, index);
pendingShotEffect.value = { generation: requestGeneration, key };
clearTipTimer();
latestOne.value = null;
const viewportMode = props.stableShotEffect
? await updateTargetRect()
: false;
if (requestGeneration !== shotEffectRequestGeneration) {
if (pendingShotEffect.value?.generation === requestGeneration) {
pendingShotEffect.value = null;
}
return;
}
pendingShotEffect.value = null;
triggerShotEffect(shot, index, viewportMode);
}
function completeShotEffect(key) {
if (!shotEffect.value || shotEffect.value.key !== key) return;
const completedEffect = shotEffect.value;
const shot = completedEffect.shot;
hiddenLatestKey.value = "";
shotEffect.value = null;
showShotTip(shot);
emit("shot-effect-complete", {
key,
token: completedEffect.token,
});
}
function shouldHideLatestHit(index) {
return (
(!!hiddenLatestKey.value || !!pendingShotEffect.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 hasValidTargetRect(rect = targetRect.value) {
return (
Number.isFinite(Number(rect?.left)) &&
Number.isFinite(Number(rect?.top)) &&
Number(rect?.width) > 0 &&
Number(rect?.height) > 0
);
}
async function updateTargetRect() {
await nextTick();
return new Promise((resolve) => {
let settled = false;
const finish = (rect) => {
if (settled) return;
settled = true;
const isValid = hasValidTargetRect(rect);
if (isValid) {
targetRect.value = {
left: Number(rect.left),
top: Number(rect.top),
width: Number(rect.width),
height: Number(rect.height),
};
}
resolve(isValid);
};
try {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery();
query
.select(".target")
.boundingClientRect()
.exec((result) => finish(Array.isArray(result) ? result[0] : null));
} catch {
finish(null);
}
});
}
function handleWindowResize() {
void updateTargetRect();
}
watch(
() => props.scores,
(newVal) => {
if (newVal.length - prevScores.value.length === 1) {
showShotTip(newVal[newVal.length - 1]);
} else if (newVal.length < prevScores.value.length) {
shotEffectRequestGeneration += 1;
pendingShotEffect.value = null;
clearTipTimer();
latestOne.value = null;
hiddenLatestKey.value = "";
shotEffect.value = null;
}
prevScores.value = [...newVal];
},
{
deep: true,
}
);
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)) {
void prepareShotEffect(latestShot, latestIndex);
} else {
shotEffectRequestGeneration += 1;
pendingShotEffect.value = null;
}
}
);
watch(
() => props.blueScores,
(newVal) => {
if (newVal.length - prevBlueScores.value.length === 1) {
bluelatestOne.value = newVal[newVal.length - 1];
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => {
bluelatestOne.value = null;
}, 1000);
}
prevBlueScores.value = [...newVal];
},
{
deep: true,
}
);
const simulShoot = async () => {
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
};
const simulShoot2 = async () => {
if (device.value.deviceId) {
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
await simulShootAPI(device.value.deviceId, r1, r1);
}
};
const openAim = async () => {
await laserAimAPI();
};
const closeAim = async () => {
await laserCloseAPI();
};
const env = computed(() => {
const accountInfo = uni.getAccountInfoSync();
return accountInfo.miniProgram.envVersion;
});
const arrowStyle = computed(() => {
return {
transform: `rotateX(180deg) translate(-50%, -50%) rotate(${
360 - angle.value
}deg) translateY(105%)`,
};
});
const showSectorCanvas = computed(() => {
const count = Number(props.sectorCount);
return props.totalRound > 0 && Number.isInteger(count) && count > 0;
});
async function onReceiveMessage(message) {
if (Array.isArray(message)) return;
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
if (
message.shootData.playerId === user.value.id &&
!message.shootData.ring &&
message.shootData.angle >= 0
) {
angle.value = null;
setTimeout(() => {
if (props.scores[0]) {
circleColor.value =
message.shootData.playerId === props.scores[0].playerId
? "#ff4444"
: "#1840FF";
}
angle.value = message.shootData.angle;
}, 200);
}
}
}
onMounted(() => {
uni.$on("socket-inbox", onReceiveMessage);
void updateTargetRect();
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
});
onBeforeUnmount(() => {
shotEffectRequestGeneration += 1;
pendingShotEffect.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', { 'container--effecting': shotEffect }]">
<!-- <view class="header" v-if="totalRound > 0">
<text v-if="totalRound > 0" class="round-count">{{
(currentRound > totalRound ? totalRound : currentRound) +
"/" +
totalRound
}}</text>
</view> -->
<view :class="['target', { 'target--shake': targetShaking }]">
<image
class="target-image"
src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png"
mode="aspectFit"
/>
<TargetCanvas
v-if="showSectorCanvas"
class="target-highlight-layer"
:coordinateRadius="coordinateRadius"
:showCrosshair="false"
:showRingLabels="false"
:highlightOnly="true"
:sectorCount="sectorCount"
:activeSector="activeSector"
:activeRing="activeRing"
:showSectorLabels="showSectorLabels"
/>
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
<view :style="{ background: circleColor }">
<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="
!shotEffect &&
latestOne &&
latestOne.ring &&
user.id === latestOne.playerId
"
class="e-value fade-in-out"
:style="getExperienceTipStyle(latestOne)"
>
经验 +1
</view>
<view
v-if="!shotEffect && latestOne"
class="round-tip fade-in-out"
:style="getRoundTipStyle(latestOne)"
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
}}<text v-if="latestOne.ring">环</text>
</view>
<view
v-if="
bluelatestOne &&
bluelatestOne.ring &&
user.id === bluelatestOne.playerId
"
class="e-value fade-in-out"
:style="getExperienceTipStyle(bluelatestOne)"
>
经验 +1
</view>
<view
v-if="bluelatestOne"
class="round-tip fade-in-out"
:style="getRoundTipStyle(bluelatestOne)"
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
}}<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 && !shouldHideLatestHit(index)"
:class="`hit ${pMode ? 'b' : 's'}-point ${
index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`"
:style="{
...getHitStyle(bow),
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
}"
><text v-if="pMode">{{ index + 1 }}</text></view
>
</block>
<block v-for="(bow, index) in blueScores" :key="index">
<view
v-if="bow.ring > 0"
:class="`hit ${pMode ? 'b' : 's'}-point ${
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
}`"
:style="{
...getHitStyle(bow),
backgroundColor: '#1840FF',
}"
>
<text v-if="pMode">{{ index + 1 }}</text>
</view>
</block>
<BowShotEffect
v-if="!shotEffect || !shotEffect.viewportMode"
:shot="shotEffect && shotEffect.shot"
:playKey="shotEffect ? shotEffect.key : ''"
:targetRadius="safeTargetRadius"
:targetLeft="targetRect.left"
:targetTop="targetRect.top"
:targetWidth="targetRect.width"
:targetHeight="targetRect.height"
:hitOffsetPx="currentHitRadiusPx"
@impact="shakeTarget"
@complete="completeShotEffect"
/>
</view>
<BowShotEffect
v-if="shotEffect && shotEffect.viewportMode"
:shot="shotEffect.shot"
:playKey="shotEffect.key"
:targetRadius="safeTargetRadius"
:targetLeft="targetRect.left"
:targetTop="targetRect.top"
:targetWidth="targetRect.width"
:targetHeight="targetRect.height"
:hitOffsetPx="currentHitRadiusPx"
:viewportMode="true"
@impact="shakeTarget"
@complete="completeShotEffect"
/>
<view class="footer">
<PointSwitcher
:onChange="(val) => (pMode = val)"
:style="{ zIndex: 999 }"
/>
</view>
<view class="simul" v-if="env !== 'release'">
<button @click="simulShoot">模拟</button>
<button @click="simulShoot2">射箭</button>
<button @click="openAim">开瞄</button>
<button @click="closeAim">关瞄</button>
</view>
</view>
</template>
<style scoped>
.container {
width: calc(100vw - 30px);
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: 1;
pointer-events: none;
transform-origin: center center;
}
.target--shake {
animation: target-shake 0.26s ease-out;
}
.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;
background-color: #0006;
color: #fff;
font-size: 12px;
padding: 4px 7px;
border-radius: 5px;
z-index: 4;
width: 50px;
text-align: center;
}
.round-tip {
position: absolute;
color: #fff;
font-size: 30px;
font-weight: bold;
z-index: 4;
width: 100px;
text-align: center;
}
.round-tip > text {
font-size: 24px;
margin-left: 5px;
}
@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;
}
.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%;
z-index: 3;
color: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
}
.s-point {
}
.b-point {
border: 1px solid #fff;
z-index: 3;
display: flex;
justify-content: center;
align-items: center;
}
.b-point > text {
font-size: 16rpx;
color: #fff;
font-family: "DINCondensed";
/* text-align: center;
position: absolute;
top: 50%;
left: 50%;
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);
}
to {
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;
}
.header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: -40px;
}
.header > image:first-child {
width: 40px;
height: 40px;
}
.round-count {
font-size: 20px;
color: #fed847;
top: 75px;
font-weight: bold;
}
.footer {
width: calc(100% - 20px);
padding: 0 10px;
display: flex;
margin-top: -40px;
justify-content: flex-end;
}
.footer > image {
width: 40px;
min-height: 40px;
max-height: 40px;
border-radius: 50%;
border: 1px solid #fff;
}
.simul {
position: absolute;
top: 0;
right: 20px;
margin-left: 20px;
z-index: 999;
}
.simul > button {
color: #fff;
}
.stop-sign {
position: absolute;
font-size: 44px;
color: #fff9;
text-align: center;
width: 200px;
height: 60px;
left: calc(50% - 100px);
top: calc(50% - 30px);
z-index: 5;
font-weight: bold;
}
.arrow-dir {
position: absolute;
width: 100%;
height: 52%;
left: 50%;
bottom: 50%;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
}
.arrow-dir > view {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
}
.arrow-dir > view > image {
width: 100rpx;
height: 100rpx;
transform: translate(-30%, -30%);
}
@keyframes spring-in {
0% {
transform: scale(2);
opacity: 0.4;
}
15% {
transform: scale(3);
opacity: 1;
}
30% {
transform: scale(2);
opacity: 0.4;
}
45% {
transform: scale(3);
opacity: 1;
}
60% {
transform: scale(2);
opacity: 0.4;
}
75% {
transform: scale(3);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes disappear {
0% {
opacity: 1;
}
75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.arrow-dir > view {
animation: disappear 3s ease forwards;
}
.arrow-dir > view > image {
animation: spring-in 3s ease forwards;
width: 100%;
}
</style>