538 lines
13 KiB
Vue
538 lines
13 KiB
Vue
<script setup>
|
||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||
import { onLoad } from "@dcloudio/uni-app";
|
||
import Container from "@/components/Container.vue";
|
||
import ShootProgress from "./components/ShootProgress.vue";
|
||
import BowTarget from "./components/BowTarget.vue";
|
||
import ScorePanel2 from "./components/ScorePanel2.vue";
|
||
import ScoreResult from "./components/ScoreResult.vue";
|
||
import Avatar from "@/components/Avatar.vue";
|
||
import BowPower from "@/components/BowPower.vue";
|
||
import TestDistance from "./components/TestDistance.vue";
|
||
import BubbleTip from "./components/BubbleTip.vue";
|
||
import audioManager from "@/audioManager";
|
||
|
||
import {
|
||
createPractiseAPI,
|
||
startPractiseAPI,
|
||
endPractiseAPI,
|
||
getPractiseAPI,
|
||
} from "@/apis";
|
||
import { sharePractiseData } from "@/canvas";
|
||
import { wxShare, debounce } from "@/util";
|
||
import { MESSAGETYPESV2, roundsName } from "@/constants";
|
||
|
||
import useStore from "@/store";
|
||
import { storeToRefs } from "pinia";
|
||
const store = useStore();
|
||
const { user } = storeToRefs(store);
|
||
|
||
const sound = ref(true);
|
||
const start = ref(false);
|
||
const pageStages = Object.freeze({
|
||
DISTANCE: "distance",
|
||
SHOOTING: "shooting",
|
||
RESULT: "result",
|
||
LOADING: "loading",
|
||
});
|
||
const pageStage = ref(pageStages.DISTANCE);
|
||
const scores = ref([]);
|
||
const defaultTotal = 12;
|
||
const defaultShootTime = 120;
|
||
const defaultTargetType = 1;
|
||
const total = ref(defaultTotal);
|
||
const shootTime = ref(defaultShootTime);
|
||
const practiseResult = ref({});
|
||
const practiseId = ref("");
|
||
const showGuide = ref(false);
|
||
const tips = ref("");
|
||
const targetType = ref(defaultTargetType);
|
||
const trainingParams = ref({});
|
||
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
|
||
const useHighlightTest = ref(false);
|
||
const highlightTestTimer = ref(null);
|
||
|
||
const env = computed(() => {
|
||
try {
|
||
return uni.getAccountInfoSync().miniProgram.envVersion;
|
||
} catch (error) {
|
||
return "release";
|
||
}
|
||
});
|
||
|
||
const isDistanceStage = computed(() => pageStage.value === pageStages.DISTANCE);
|
||
const isShootingStage = computed(() => pageStage.value === pageStages.SHOOTING);
|
||
const hasPractiseResult = computed(() => !!practiseResult.value?.details);
|
||
const showResult = computed(
|
||
() => pageStage.value === pageStages.RESULT && hasPractiseResult.value
|
||
);
|
||
|
||
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 = {};
|
||
pageStage.value = pageStages.SHOOTING;
|
||
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 () => {
|
||
pageStage.value = pageStages.LOADING;
|
||
clearHighlightTestTimer();
|
||
useHighlightTest.value = false;
|
||
try {
|
||
await startPractiseAPI();
|
||
practiseResult.value = {};
|
||
scores.value = [];
|
||
start.value = true;
|
||
pageStage.value = pageStages.SHOOTING;
|
||
audioManager.play("练习开始");
|
||
} catch (error) {
|
||
start.value = false;
|
||
pageStage.value = pageStages.DISTANCE;
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const onOver = async () => {
|
||
if (!isShootingStage.value) return;
|
||
|
||
clearHighlightTestTimer();
|
||
pageStage.value = pageStages.LOADING;
|
||
start.value = false;
|
||
|
||
try {
|
||
practiseResult.value = (await getPractiseAPI(practiseId.value)) || {};
|
||
pageStage.value = hasPractiseResult.value
|
||
? pageStages.RESULT
|
||
: pageStages.DISTANCE;
|
||
} catch (error) {
|
||
start.value = true;
|
||
pageStage.value = pageStages.SHOOTING;
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
async function onReceiveMessage(msg) {
|
||
if (msg.type === MESSAGETYPESV2.ShootResult && isShootingStage.value) {
|
||
scores.value = msg.details;
|
||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||
// setTimeout(onOver, 1500);
|
||
}
|
||
}
|
||
|
||
function onComplete() {
|
||
pageStage.value = pageStages.LOADING;
|
||
start.value = false;
|
||
uni.$emit(trainingDifficultyRefreshEvent);
|
||
uni.navigateBack();
|
||
}
|
||
|
||
async function onRetry() {
|
||
pageStage.value = pageStages.LOADING;
|
||
clearHighlightTestTimer();
|
||
useHighlightTest.value = false;
|
||
practiseId.value = "";
|
||
practiseResult.value = {};
|
||
start.value = false;
|
||
scores.value = [];
|
||
try {
|
||
await createPractice();
|
||
} finally {
|
||
pageStage.value = pageStages.DISTANCE;
|
||
}
|
||
}
|
||
|
||
const onClickShare = debounce(async () => {
|
||
await sharePractiseData("shareCanvas", 2, user.value, practiseResult.value);
|
||
await wxShare("shareCanvas");
|
||
});
|
||
|
||
function onAudioEnded(s) {
|
||
if (s.indexOf("比赛结束") >= 0) {
|
||
onOver()
|
||
}
|
||
}
|
||
|
||
const updateSound = () => {
|
||
sound.value = !sound.value;
|
||
audioManager.setMuted(!sound.value);
|
||
};
|
||
|
||
|
||
onMounted(async () => {
|
||
// audioManager.play("第一轮");
|
||
uni.setKeepScreenOn({
|
||
keepScreenOn: true,
|
||
});
|
||
uni.$on("socket-inbox", onReceiveMessage);
|
||
uni.$on("share-image", onClickShare);
|
||
uni.$on("audioEnded", onAudioEnded);
|
||
await createPractice();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
uni.setKeepScreenOn({
|
||
keepScreenOn: false,
|
||
});
|
||
uni.$off("socket-inbox", onReceiveMessage);
|
||
uni.$off("share-image", onClickShare);
|
||
uni.$off("audioEnded", onAudioEnded);
|
||
audioManager.stopAll();
|
||
clearHighlightTestTimer();
|
||
endPractiseAPI();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Container
|
||
:bgType="isDistanceStage ? 9 : 10"
|
||
:showBottom="isDistanceStage"
|
||
:scroll="!isShootingStage"
|
||
>
|
||
<view class="practise-content">
|
||
<TestDistance v-if="isDistanceStage" />
|
||
<view v-else-if="isShootingStage" class="shooting-layout">
|
||
<view class="shooting-fixed">
|
||
<ShootProgress
|
||
:start="start"
|
||
:onStop="onOver"
|
||
/>
|
||
<view class="user-row">
|
||
<!-- <Avatar :src="user.avatar" :size="35" /> -->
|
||
<BubbleTip v-if="showGuide" type="normal2">
|
||
<text>还有两场,坚持</text>
|
||
<text>就是胜利!💪</text>
|
||
</BubbleTip>
|
||
<!-- <BowPower /> -->
|
||
</view>
|
||
<BowTarget
|
||
:totalRound="start ? total / 4 : 0"
|
||
:currentRound="scores.length % 3"
|
||
: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">
|
||
<button class="sound-btn" hover-class="none" @click="updateSound">
|
||
<image
|
||
class="sound-icon"
|
||
:src="`/static/sound${sound ? '' : '-off'}-yellow.png`"
|
||
mode="aspectFit"
|
||
/>
|
||
</button>
|
||
<view class="bat-text-big-box">
|
||
<image
|
||
class="dao-icon"
|
||
src="../../static/training-difficulty-design/dao-icon.png"
|
||
mode="widthFix"
|
||
/>
|
||
<view class="bat-text-box">
|
||
<view class="bat-text-small-box">
|
||
<view class="text-round-box">
|
||
<view class="text1">每箭命中9环之上</view>
|
||
<view class="text2">剩余<text class="text2-yellow">3</text>箭</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<scroll-view
|
||
class="score-scroll"
|
||
scroll-y
|
||
:enhanced="true"
|
||
:show-scrollbar="false"
|
||
>
|
||
<ScorePanel2 :arrows="scores" :total="total" />
|
||
</scroll-view>
|
||
</view>
|
||
<ScoreResult
|
||
v-else-if="showResult"
|
||
:rowCount="6"
|
||
:total="total"
|
||
:onClose="onComplete"
|
||
:onRetry="onRetry"
|
||
:result="practiseResult"
|
||
/>
|
||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||
</view>
|
||
<template #bottom>
|
||
<view class="btn-box">
|
||
<image
|
||
class="btn-box-bg"
|
||
src="../../static/training-difficulty-design/par-star.png"
|
||
mode="widthFix"
|
||
/>
|
||
<button class="btn" @click="onReady">准备好了,开始练习</button>
|
||
</view>
|
||
</template>
|
||
</Container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.practise-content {
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.shooting-layout {
|
||
height: 100%;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.shooting-fixed {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.score-scroll {
|
||
flex: 1;
|
||
height: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.btn-box{
|
||
width: 488rpx;
|
||
height: 234rpx;
|
||
position: fixed;
|
||
bottom: 130rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
.btn-box-bg{
|
||
width: 100%;
|
||
}
|
||
.btn{
|
||
width: 330rpx;
|
||
height: 70rpx;
|
||
line-height: 70rpx;
|
||
background: #FED847;
|
||
border-radius: 44rpx;
|
||
text-align: center;
|
||
color: #000000;
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
bottom: -36rpx;
|
||
}
|
||
|
||
.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{
|
||
height: 125rpx;
|
||
padding: 0 56rpx;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
}
|
||
.sound-btn {
|
||
width: 76rpx;
|
||
height: 70rpx;
|
||
border: none;
|
||
}
|
||
|
||
.sound-btn::after {
|
||
border: none;
|
||
}
|
||
|
||
.sound-icon {
|
||
width: 76rpx;
|
||
height: 70rpx;
|
||
}
|
||
.bat-text-big-box{
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
.dao-icon{
|
||
width: 160rpx;
|
||
height: 125rpx;
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 0;
|
||
}
|
||
.text-round-box{
|
||
width: 100%;
|
||
}
|
||
.bat-text-box{
|
||
display: flex;
|
||
}
|
||
.bat-text-small-box{
|
||
background: rgba(0, 0, 0, 0.5);
|
||
width: auto;
|
||
min-width: 100rpx;
|
||
border-radius: 16rpx 60rpx 60rpx 16rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
padding-left: 176rpx;
|
||
height: 112rpx;
|
||
padding-right: 30rpx;
|
||
font-size: 30rpx;
|
||
color: #E7BA80;
|
||
}
|
||
.text1{
|
||
font-size: 30rpx;
|
||
font-weight: 400;
|
||
color: #E7BA80;
|
||
line-height: 42rpx;
|
||
}
|
||
.text2{
|
||
color: #FFFFFF;
|
||
font-size: 26rpx;
|
||
font-weight: 400;
|
||
line-height: 36rpx;
|
||
}
|
||
.text2-yellow{
|
||
font-size: 30rpx;
|
||
color: #FFD947;
|
||
font-weight: 500;
|
||
margin: 0 4rpx;
|
||
}
|
||
</style>
|