401 lines
8.8 KiB
Vue
401 lines
8.8 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
|
|
const props = defineProps({
|
|
shot: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
playKey: {
|
|
type: [String, Number],
|
|
default: "",
|
|
},
|
|
targetRadius: {
|
|
type: Number,
|
|
default: 20,
|
|
},
|
|
targetWidth: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
targetHeight: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
hitOffsetPx: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(["complete", "impact"]);
|
|
|
|
const phase = ref("idle");
|
|
const activePlayKey = ref("");
|
|
const animationKey = ref("");
|
|
const impactEmitted = ref(false);
|
|
const activeShot = ref(null);
|
|
let timers = [];
|
|
|
|
const isActive = computed(() => phase.value !== "idle");
|
|
const ARROW_IMPACT_MS = 340;
|
|
const COMPLETE_FALLBACK_MS = 980;
|
|
|
|
const safeTargetRadius = computed(() => {
|
|
const radius = Number(props.targetRadius);
|
|
return Number.isFinite(radius) && radius > 0 ? radius : 20;
|
|
});
|
|
|
|
const safeTargetSize = computed(() => {
|
|
const width = Number(props.targetWidth);
|
|
const height = Number(props.targetHeight);
|
|
return {
|
|
width: Number.isFinite(width) && width > 0 ? width : 0,
|
|
height: Number.isFinite(height) && height > 0 ? height : 0,
|
|
};
|
|
});
|
|
|
|
function hasShotPoint(shot) {
|
|
const x = Number(shot?.x);
|
|
const y = Number(shot?.y);
|
|
return Number.isFinite(x) && Number.isFinite(y);
|
|
}
|
|
|
|
const effectiveShot = computed(() => activeShot.value || props.shot);
|
|
|
|
const shotPoint = computed(() => {
|
|
const x = Number(effectiveShot.value?.x);
|
|
const y = Number(effectiveShot.value?.y);
|
|
return {
|
|
x: Number.isFinite(x) ? x : 0,
|
|
y: Number.isFinite(y) ? y : 0,
|
|
};
|
|
});
|
|
|
|
const pointDirection = computed(() => {
|
|
const point = shotPoint.value;
|
|
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,
|
|
};
|
|
});
|
|
|
|
const hitOffset = computed(() => {
|
|
const offset = Number(props.hitOffsetPx);
|
|
const safeOffset = Number.isFinite(offset) && offset > 0 ? offset : 0;
|
|
const direction = pointDirection.value;
|
|
|
|
return {
|
|
x: direction ? direction.x * safeOffset : 0,
|
|
y: direction ? -direction.y * safeOffset : 0,
|
|
};
|
|
});
|
|
|
|
const hitPercent = computed(() => {
|
|
const point = shotPoint.value;
|
|
const radius = safeTargetRadius.value;
|
|
const diameter = radius * 2;
|
|
|
|
return {
|
|
left: ((point.x + radius) / diameter) * 100,
|
|
top: ((radius - point.y) / diameter) * 100,
|
|
};
|
|
});
|
|
|
|
const arrowAngle = computed(() => {
|
|
const size = safeTargetSize.value;
|
|
if (!size.width || !size.height) {
|
|
const dx = hitPercent.value.left - 50;
|
|
const dy = 114 - hitPercent.value.top;
|
|
const fallbackAngle = Math.atan2(dx, dy || 1) * (180 / Math.PI);
|
|
return Math.max(-18, Math.min(18, fallbackAngle));
|
|
}
|
|
|
|
const startX = size.width * 0.5;
|
|
const startY = size.height * 1.14;
|
|
const endX = size.width * (hitPercent.value.left / 100) + hitOffset.value.x;
|
|
const endY = size.height * (hitPercent.value.top / 100) + hitOffset.value.y;
|
|
const dx = endX - startX;
|
|
const dy = startY - endY;
|
|
const angle = Math.atan2(dx, dy || 1) * (180 / Math.PI);
|
|
return Math.max(-18, Math.min(18, angle));
|
|
});
|
|
|
|
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}%`;
|
|
}
|
|
|
|
const crackStyle = computed(() => ({
|
|
left: formatTargetPosition(hitPercent.value.left, hitOffset.value.x),
|
|
top: formatTargetPosition(hitPercent.value.top, hitOffset.value.y),
|
|
}));
|
|
|
|
function getTargetTranslate(percent) {
|
|
const absPercent = Math.abs(percent);
|
|
const operator = percent >= 0 ? "-" : "+";
|
|
return `calc(${percent}vw ${operator} ${absPercent * 0.5}px)`;
|
|
}
|
|
|
|
const arrowMoveStyle = computed(() => {
|
|
const size = safeTargetSize.value;
|
|
let x = getTargetTranslate(hitPercent.value.left - 50);
|
|
let y = getTargetTranslate(hitPercent.value.top - 114);
|
|
|
|
if (size.width && size.height) {
|
|
const startX = size.width * 0.5;
|
|
const startY = size.height * 1.14;
|
|
const endX = size.width * (hitPercent.value.left / 100) + hitOffset.value.x;
|
|
const endY = size.height * (hitPercent.value.top / 100) + hitOffset.value.y;
|
|
x = `${endX - startX}px`;
|
|
y = `${endY - startY}px`;
|
|
}
|
|
|
|
return {
|
|
"--shot-tx": x,
|
|
"--shot-ty": y,
|
|
"--shot-angle": `${arrowAngle.value}deg`,
|
|
};
|
|
});
|
|
|
|
function clearTimers() {
|
|
timers.forEach((timer) => clearTimeout(timer));
|
|
timers = [];
|
|
}
|
|
|
|
function queueTimer(callback, delay) {
|
|
const timer = setTimeout(callback, delay);
|
|
timers.push(timer);
|
|
}
|
|
|
|
function emitImpactOnce(playKey) {
|
|
if (phase.value === "idle" || activePlayKey.value !== playKey || impactEmitted.value) return;
|
|
impactEmitted.value = true;
|
|
emit("impact");
|
|
}
|
|
|
|
function finish(playKey) {
|
|
if (phase.value === "idle" || activePlayKey.value !== playKey) return;
|
|
clearTimers();
|
|
phase.value = "idle";
|
|
activePlayKey.value = "";
|
|
activeShot.value = null;
|
|
emit("complete", playKey);
|
|
}
|
|
|
|
function play() {
|
|
if (!props.playKey || !props.shot || !props.shot.ring || !hasShotPoint(props.shot)) {
|
|
return;
|
|
}
|
|
|
|
clearTimers();
|
|
activePlayKey.value = props.playKey;
|
|
animationKey.value = `${props.playKey}`;
|
|
impactEmitted.value = false;
|
|
activeShot.value = { ...props.shot };
|
|
phase.value = "playing";
|
|
|
|
queueTimer(() => {
|
|
emitImpactOnce(activePlayKey.value);
|
|
}, ARROW_IMPACT_MS);
|
|
queueTimer(() => {
|
|
finish(activePlayKey.value);
|
|
}, COMPLETE_FALLBACK_MS);
|
|
}
|
|
|
|
function handleArrowAnimationEnd() {
|
|
emitImpactOnce(activePlayKey.value);
|
|
}
|
|
|
|
function handleCrackAnimationEnd() {
|
|
finish(activePlayKey.value);
|
|
}
|
|
|
|
watch(
|
|
() => props.playKey,
|
|
() => {
|
|
play();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTimers();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<view
|
|
v-show="isActive"
|
|
:class="['shot-effect', `shot-effect--${phase}`]"
|
|
:style="arrowMoveStyle"
|
|
>
|
|
<view
|
|
:key="`arrow-${animationKey}`"
|
|
class="shot-arrow-track"
|
|
@animationend="handleArrowAnimationEnd"
|
|
>
|
|
<image
|
|
class="shot-arrow"
|
|
src="../static/vip/svip-jian.png"
|
|
mode="heightFix"
|
|
/>
|
|
</view>
|
|
<view
|
|
:key="`flash-${animationKey}`"
|
|
class="shot-flash"
|
|
:style="crackStyle"
|
|
></view>
|
|
<view
|
|
:key="`crack-anchor-${animationKey}`"
|
|
class="shot-crack-anchor"
|
|
:style="crackStyle"
|
|
>
|
|
<image
|
|
:key="`crack-${animationKey}`"
|
|
class="shot-crack"
|
|
src="../static/vip/svip-lie.png"
|
|
mode="aspectFit"
|
|
@animationend="handleCrackAnimationEnd"
|
|
/>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.shot-effect {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
z-index: 9999;
|
|
pointer-events: none;
|
|
overflow: visible;
|
|
transform: translateZ(0);
|
|
}
|
|
|
|
.shot-arrow-track {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 114%;
|
|
width: 0;
|
|
height: 0;
|
|
opacity: 0;
|
|
transform: translate3d(0, 0, 0);
|
|
animation: none;
|
|
backface-visibility: hidden;
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.shot-arrow {
|
|
position: absolute;
|
|
width: 248rpx;
|
|
height: 1186rpx;
|
|
left: 0;
|
|
top: 0;
|
|
opacity: 1;
|
|
transform-origin: 44.35% 3.04%;
|
|
transform: translate(-44.35%, -3.04%) rotate(var(--shot-angle));
|
|
backface-visibility: hidden;
|
|
will-change: transform;
|
|
}
|
|
|
|
.shot-effect--playing .shot-arrow-track {
|
|
animation: shot-arrow-fly 0.38s cubic-bezier(0.68, 0, 0.9, 0.62) forwards;
|
|
}
|
|
|
|
.shot-flash,
|
|
.shot-crack-anchor {
|
|
position: absolute;
|
|
transform: translate(-50%, -50%);
|
|
backface-visibility: hidden;
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.shot-flash {
|
|
width: 86rpx;
|
|
height: 86rpx;
|
|
border-radius: 50%;
|
|
border: 3rpx solid rgba(255, 236, 166, 0.9);
|
|
opacity: 0;
|
|
animation: none;
|
|
}
|
|
|
|
.shot-crack-anchor {
|
|
width: 750rpx;
|
|
height: 750rpx;
|
|
}
|
|
|
|
.shot-crack {
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
transform-origin: center center;
|
|
animation: none;
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.shot-effect--playing .shot-flash {
|
|
animation: shot-flash 0.42s ease-out 0.32s forwards;
|
|
}
|
|
|
|
.shot-effect--playing .shot-crack {
|
|
animation: shot-crack-hit 0.52s ease-out 0.34s forwards;
|
|
}
|
|
|
|
@keyframes shot-arrow-fly {
|
|
0% {
|
|
opacity: 1;
|
|
transform: translate3d(0, 0, 0);
|
|
}
|
|
86% {
|
|
opacity: 1;
|
|
transform: translate3d(var(--shot-tx), var(--shot-ty), 0);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translate3d(var(--shot-tx), var(--shot-ty), 0);
|
|
}
|
|
}
|
|
|
|
@keyframes shot-flash {
|
|
0% {
|
|
opacity: 0.95;
|
|
transform: translate(-50%, -50%) scale(0.2);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(1.9);
|
|
}
|
|
}
|
|
|
|
@keyframes shot-crack-hit {
|
|
0% {
|
|
opacity: 0;
|
|
transform: scale(0.55);
|
|
}
|
|
28% {
|
|
opacity: 1;
|
|
transform: scale(1.08);
|
|
}
|
|
56% {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: scale(1.18);
|
|
}
|
|
}
|
|
</style>
|