519 lines
11 KiB
Vue
519 lines
11 KiB
Vue
<script setup>
|
||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||
import PointSwitcher from "./PointSwitcher.vue";
|
||
|
||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||
import { simulShootAPI } 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: () => [],
|
||
},
|
||
latestShotFlash: {
|
||
type: Object,
|
||
default: null,
|
||
},
|
||
mode: {
|
||
type: String,
|
||
default: "solo", // solo 单排,team 双排
|
||
},
|
||
stop: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
targetRadius: {
|
||
type: Number,
|
||
default: 20,
|
||
},
|
||
hitRadiusPx: {
|
||
type: Number,
|
||
default: 2,
|
||
},
|
||
zoomHitRadiusPx: {
|
||
type: Number,
|
||
default: 5,
|
||
},
|
||
});
|
||
|
||
const pMode = ref(true);
|
||
const latestOne = ref(null);
|
||
const bluelatestOne = ref(null);
|
||
const timer = ref(null);
|
||
const dirTimer = ref(null);
|
||
const angle = ref(null);
|
||
const circleColor = ref("");
|
||
|
||
function showShotFlash(flash) {
|
||
const shootData = flash?.shootData;
|
||
if (!shootData) return;
|
||
if (timer.value) clearTimeout(timer.value);
|
||
|
||
if (flash.team === "red") {
|
||
latestOne.value = shootData;
|
||
timer.value = setTimeout(() => {
|
||
latestOne.value = null;
|
||
}, 1000);
|
||
return;
|
||
}
|
||
|
||
bluelatestOne.value = shootData;
|
||
timer.value = setTimeout(() => {
|
||
bluelatestOne.value = null;
|
||
}, 1000);
|
||
}
|
||
|
||
watch(
|
||
() => props.latestShotFlash,
|
||
(newVal) => {
|
||
showShotFlash(newVal);
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
const safeTargetRadius = computed(() => {
|
||
const radius = Number(props.targetRadius);
|
||
return Number.isFinite(radius) && radius > 0 ? radius : 20;
|
||
});
|
||
|
||
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) {
|
||
if (!point) return { display: "none" };
|
||
|
||
const radius = safeTargetRadius.value;
|
||
const diameter = radius * 2;
|
||
const direction = getPointDirection(point);
|
||
const xOffset = direction ? direction.x * offsetPx : 0;
|
||
const yOffset = direction ? -direction.y * offsetPx : 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 getTipStyle(shot) {
|
||
const point = getShotPoint(shot, true);
|
||
return getTargetPositionStyle(point, shot?.ring ? currentHitRadiusPx.value : 0);
|
||
}
|
||
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 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%)`,
|
||
};
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (timer.value) {
|
||
clearTimeout(timer.value);
|
||
timer.value = null;
|
||
}
|
||
if (dirTimer.value) {
|
||
clearTimeout(dirTimer.value);
|
||
dirTimer.value = null;
|
||
}
|
||
uni.$off("socket-inbox", onReceiveMessage);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<view class="container">
|
||
<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">
|
||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||
<view :style="{ background: circleColor }">
|
||
<image src="../../../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"
|
||
class="e-value fade-in-out"
|
||
:style="getTipStyle(latestOne)"
|
||
>
|
||
经验 +1
|
||
</view>
|
||
<view
|
||
v-if="latestOne"
|
||
class="round-tip fade-in-out"
|
||
:style="getTipStyle(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="getTipStyle(bluelatestOne)"
|
||
>
|
||
经验 +1
|
||
</view>
|
||
<view
|
||
v-if="bluelatestOne"
|
||
class="round-tip fade-in-out"
|
||
:style="getTipStyle(bluelatestOne)"
|
||
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
||
}}<text v-if="bluelatestOne.ring">环</text></view
|
||
>
|
||
<block v-for="(bow, index) in scores" :key="index">
|
||
<view
|
||
v-if="bow.ring > 0"
|
||
: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>
|
||
<image src="../../../static/bow-target.png" mode="widthFix" />
|
||
</view>
|
||
<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>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.container {
|
||
width: calc(100vw - 30px);
|
||
height: calc(100vw - 30px);
|
||
padding: 0px 15px;
|
||
position: relative;
|
||
}
|
||
.target {
|
||
position: relative;
|
||
margin: 10px;
|
||
width: calc(100% - 20px);
|
||
height: calc(100% - 20px);
|
||
z-index: -1;
|
||
}
|
||
.e-value {
|
||
position: absolute;
|
||
background-color: #0006;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
padding: 4px 7px;
|
||
border-radius: 5px;
|
||
z-index: 2;
|
||
width: 50px;
|
||
text-align: center;
|
||
}
|
||
.round-tip {
|
||
position: absolute;
|
||
color: #fff;
|
||
font-size: 30px;
|
||
font-weight: bold;
|
||
z-index: 2;
|
||
width: 100px;
|
||
text-align: center;
|
||
}
|
||
.round-tip > text {
|
||
font-size: 24px;
|
||
margin-left: 5px;
|
||
}
|
||
.target > image:last-child {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.hit {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
z-index: 1;
|
||
color: #fff;
|
||
transition: all 0.3s ease;
|
||
box-sizing: border-box;
|
||
}
|
||
.b-point {
|
||
border: 1px solid #fff;
|
||
z-index: 1;
|
||
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 target-pump-in {
|
||
from {
|
||
transform: translate(-50%, -50%) scale(2);
|
||
}
|
||
|
||
to {
|
||
transform: translate(-50%, -50%) scale(1);
|
||
}
|
||
}
|
||
.hit.pump-in {
|
||
animation: target-pump-in 0.3s ease-out forwards;
|
||
transform-origin: center center;
|
||
}
|
||
.header {
|
||
width: 100%;
|
||
display: flex;
|
||
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: 99;
|
||
font-weight: bold;
|
||
}
|
||
.arrow-dir {
|
||
position: absolute;
|
||
width: 100%;
|
||
height: 52%;
|
||
left: 50%;
|
||
bottom: 50%;
|
||
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>
|