18 Commits

Author SHA1 Message Date
69ed5bda1b Merge branch 'fix-bug' into test 2026-06-15 09:30:24 +08:00
5bab30d2e4 update:修复大乱斗显示问题,修复连续3次10环播报 2026-06-15 09:30:02 +08:00
8b8366f30f update:大乱斗优化 2026-06-12 16:44:34 +08:00
8b2ea24f38 update:大乱斗优化 2026-06-12 16:44:17 +08:00
c30aa45e5b update:更新靶纸 2026-06-11 18:13:12 +08:00
5cf243d187 update:优化设备缓存逻辑 2026-06-11 18:10:59 +08:00
bfdd40ec93 update:修复对战后退异常问题,修复对战中设备离线弹窗点击问题 2026-06-10 17:36:37 +08:00
996754be7f Merge branch 'fix-bug' into test 2026-06-10 11:44:51 +08:00
50a2829519 update:修复射箭无效只触发一次的问题 2026-06-10 11:44:19 +08:00
0d5866b82a update:更换靶纸,新增X环标识 2026-06-02 15:51:41 +08:00
2a5394155a update:更新弓箭绑定设别图 2026-05-29 09:26:58 +08:00
ef5b97530a update:优化文字靶浮层 2026-05-28 11:15:11 +08:00
d932ce3dea update:新手教程流程优化 2026-05-27 17:42:43 +08:00
b8d1654476 feat:修复靶纸贴边问题 2026-05-26 18:00:43 +08:00
c5a8100c38 update:修复靶纸贴边问题 2026-05-26 17:58:54 +08:00
1e568db861 update:优化 2026-05-25 14:28:42 +08:00
fe8b38bc6f fix:修复语音,射箭数字,第一轮提前报靶问题 2026-05-22 17:57:20 +08:00
ef2a71f793 update:重构排位赛 2026-05-22 15:40:55 +08:00
20 changed files with 691 additions and 207 deletions

View File

@@ -22,7 +22,8 @@
const { const {
updateUser, updateUser,
updateOnline, updateOnline,
clearSessionState clearSessionState,
clearDevice
} = store; } = store;
watch( watch(
@@ -63,6 +64,11 @@
updateOnline(data.online); updateOnline(data.online);
} }
function onDeviceBindInvalid() {
clearDevice();
uni.setStorageSync("calibration", false);
}
function onDeviceShoot() { function onDeviceShoot() {
// audioManager.play("射箭声音") // audioManager.play("射箭声音")
} }
@@ -78,6 +84,7 @@
uni.$on("update-user", emitUpdateUser); uni.$on("update-user", emitUpdateUser);
uni.$on("update-online", emitUpdateOnline); uni.$on("update-online", emitUpdateOnline);
uni.$on("session-kicked-out", onSessionKickedOut); uni.$on("session-kicked-out", onSessionKickedOut);
uni.$on("device-bind-invalid", onDeviceBindInvalid);
const token = uni.getStorageSync( const token = uni.getStorageSync(
`${uni.getAccountInfoSync().miniProgram.envVersion}_token` `${uni.getAccountInfoSync().miniProgram.envVersion}_token`
); );
@@ -91,6 +98,7 @@
uni.$off("update-user", emitUpdateUser); uni.$off("update-user", emitUpdateUser);
uni.$off("update-online", emitUpdateOnline); uni.$off("update-online", emitUpdateOnline);
uni.$off("session-kicked-out", onSessionKickedOut); uni.$off("session-kicked-out", onSessionKickedOut);
uni.$off("device-bind-invalid", onDeviceBindInvalid);
websocket.closeWebSocket(); websocket.closeWebSocket();
}); });
</script> </script>

View File

@@ -70,6 +70,15 @@ function request(method, url, data = {}) {
resolve({binded: true}); resolve({binded: true});
return; return;
} }
if (message === "BIND_FAILD") {
uni.$emit("device-bind-invalid");
uni.showToast({
title: "设备绑定状态已失效,请重新绑定",
icon: "none",
});
reject({type: "DEVICE_BIND_INVALID", message});
return;
}
if (message === "ERROR_ORDER_UNPAY") { if (message === "ERROR_ORDER_UNPAY") {
uni.showToast({ uni.showToast({
title: "当前有未支付订单", title: "当前有未支付订单",

View File

@@ -34,6 +34,18 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
targetRadius: {
type: Number,
default: 20,
},
hitRadiusPx: {
type: Number,
default: 2,
},
zoomHitRadiusPx: {
type: Number,
default: 5,
},
}); });
const pMode = ref(true); const pMode = ref(true);
@@ -45,6 +57,8 @@ const timer = ref(null);
const dirTimer = ref(null); const dirTimer = ref(null);
const angle = ref(null); const angle = ref(null);
const circleColor = ref(""); const circleColor = ref("");
const ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68;
watch( watch(
() => props.scores, () => props.scores,
@@ -80,13 +94,92 @@ watch(
} }
); );
function calcRealX(num, offset = 3.4) { const safeTargetRadius = computed(() => {
const len = 20.4 + num; const radius = Number(props.targetRadius);
return `calc(${(len / 40.8) * 100 - offset / 2}%)`; 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 calcRealY(num, offset = 3.4) {
const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num; function getPointDirection(point) {
return `calc(${(len / 40.8) * 100 - offset / 2}%)`; 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 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 }
);
} }
const simulShoot = async () => { const simulShoot = async () => {
if (device.value.deviceId) await simulShootAPI(device.value.deviceId); if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
@@ -169,20 +262,14 @@ onBeforeUnmount(() => {
<view <view
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId" v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
class="e-value fade-in-out" class="e-value fade-in-out"
:style="{ :style="getExperienceTipStyle(latestOne)"
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
}"
> >
经验 +1 经验 +1
</view> </view>
<view <view
v-if="latestOne" v-if="latestOne"
class="round-tip fade-in-out" class="round-tip fade-in-out"
:style="{ :style="getRoundTipStyle(latestOne)"
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
}"
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶" >{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
}}<text v-if="latestOne.ring"></text> }}<text v-if="latestOne.ring"></text>
</view> </view>
@@ -193,20 +280,14 @@ onBeforeUnmount(() => {
user.id === bluelatestOne.playerId user.id === bluelatestOne.playerId
" "
class="e-value fade-in-out" class="e-value fade-in-out"
:style="{ :style="getExperienceTipStyle(bluelatestOne)"
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
}"
> >
经验 +1 经验 +1
</view> </view>
<view <view
v-if="bluelatestOne" v-if="bluelatestOne"
class="round-tip fade-in-out" class="round-tip fade-in-out"
:style="{ :style="getRoundTipStyle(bluelatestOne)"
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
}"
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶" >{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
}}<text v-if="bluelatestOne.ring">环</text></view }}<text v-if="bluelatestOne.ring">环</text></view
> >
@@ -217,8 +298,7 @@ onBeforeUnmount(() => {
index === scores.length - 1 && latestOne ? 'pump-in' : '' index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`" }`"
:style="{ :style="{
left: calcRealX(bow.x, pMode ? '3.4' : '2'), ...getHitStyle(bow),
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000', backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
}" }"
><text v-if="pMode">{{ index + 1 }}</text></view ><text v-if="pMode">{{ index + 1 }}</text></view
@@ -231,8 +311,7 @@ onBeforeUnmount(() => {
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : '' index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
}`" }`"
:style="{ :style="{
left: calcRealX(bow.x, pMode ? '3.4' : '2'), ...getHitStyle(bow),
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
backgroundColor: '#1840FF', backgroundColor: '#1840FF',
}" }"
> >
@@ -292,6 +371,31 @@ onBeforeUnmount(() => {
font-size: 24px; font-size: 24px;
margin-left: 5px; 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;
}
.target > image:last-child { .target > image:last-child {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -302,21 +406,11 @@ onBeforeUnmount(() => {
z-index: 1; z-index: 1;
color: #fff; color: #fff;
transition: all 0.3s ease; transition: all 0.3s ease;
} box-sizing: border-box;
.s-point {
width: 4px;
height: 4px;
min-width: 4px;
min-height: 4px;
} }
.b-point { .b-point {
width: 10px;
height: 10px;
min-width: 10px;
min-height: 10px;
border: 1px solid #fff; border: 1px solid #fff;
z-index: 1; z-index: 1;
box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -332,6 +426,19 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%);*/ transform: translate(-50%, -50%);*/
margin-top: 2rpx; 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 { .header {
width: 100%; width: 100%;
display: flex; display: flex;

View File

@@ -142,8 +142,8 @@ onBeforeUnmount(() => {
</view> </view>
<block <block
v-if=" v-if="
'-凹造型-感知距离-小试牛刀'.indexOf(title) === -1 || '-箭前准备-感知距离-小试牛刀'.indexOf(title) === -1 ||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === 10 '-箭前准备-感知距离-小试牛刀'.indexOf(title) === 11
" "
> >
<text>{{ title }}</text> <text>{{ title }}</text>
@@ -151,12 +151,12 @@ onBeforeUnmount(() => {
<block <block
v-if=" v-if="
title && title &&
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== -1 && '-箭前准备-感知距离-小试牛刀'.indexOf(title) !== -1 &&
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== 10 '-箭前准备-感知距离-小试牛刀'.indexOf(title) !== 11
" "
> >
<view class="first-try-steps"> <view class="first-try-steps">
<text :class="title === '-凹造型' ? 'current-step' : ''">凹造型</text> <text :class="title === '-箭前准备' ? 'current-step' : ''">箭前准备</text>
<text>-</text> <text>-</text>
<text :class="title === '-感知距离' ? 'current-step' : ''" <text :class="title === '-感知距离' ? 'current-step' : ''"
>感知距离</text >感知距离</text

View File

@@ -15,6 +15,12 @@ const props = defineProps({
}); });
const rowCount = new Array(6).fill(0); const rowCount = new Array(6).fill(0);
const getRingText = (arrow) => {
if (!arrow) return "-";
if (arrow.ringX && arrow.ring) return "X环";
return arrow.ring ? `${arrow.ring}` : "-";
};
</script> </script>
<template> <template>
@@ -35,23 +41,19 @@ const rowCount = new Array(6).fill(0);
<view> <view>
<view> <view>
<view v-for="(_, index) in rowCount" :key="index"> <view v-for="(_, index) in rowCount" :key="index">
<text>{{ <text>{{ getRingText(scores[0]?.[index]) }}</text>
scores[0] && scores[0][index] ? `${scores[0][index].ring}` : "-"
}}</text>
</view> </view>
</view> </view>
<view> <view>
<view v-for="(_, index) in rowCount" :key="index"> <view v-for="(_, index) in rowCount" :key="index">
<text>{{ <text>{{ getRingText(scores[1]?.[index]) }}</text>
scores[1] && scores[1][index] ? `${scores[1][index].ring}` : "-"
}}</text>
</view> </view>
</view> </view>
</view> </view>
<text <text
>{{ >{{
scores scores
.map((s) => s.reduce((last, next) => last + next.ring, 0)) .map((s) => (s || []).reduce((last, next) => last + next.ring, 0))
.reduce((last, next) => last + next, 0) .reduce((last, next) => last + next, 0)
}}</text }}</text
> >

View File

@@ -16,7 +16,7 @@ import {
import useStore from "@/store"; import useStore from "@/store";
const store = useStore(); const store = useStore();
const { updateUser, updateDevice, updateOnline } = store; const { updateUser, updateDevice, updateOnline, clearDevice } = store;
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -107,6 +107,8 @@ async function doLogin() {
); );
const data = await getDeviceBatteryAPI(); const data = await getDeviceBatteryAPI();
updateOnline(data.online); updateOnline(data.online);
} else {
clearDevice();
} }
props.onClose(); props.onClose();
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref } from "vue"; import { ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
interval: { interval: {
@@ -14,13 +14,24 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
current: {
type: Number,
default: 0,
},
onChange: { onChange: {
type: Function, type: Function,
default: (index) => {}, default: (index) => {},
}, },
}); });
const currentIndex = ref(0); const currentIndex = ref(props.current);
watch(
() => props.current,
(index) => {
currentIndex.value = index;
}
);
const handleChange = (e) => { const handleChange = (e) => {
currentIndex.value = e.detail.current; currentIndex.value = e.detail.current;
@@ -75,7 +86,7 @@ const handleChange = (e) => {
.dots { .dots {
position: absolute; position: absolute;
bottom: 5%; bottom: 2%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from "vue"; import { computed, ref, onMounted, onBeforeUnmount } from "vue";
import Guide from "@/components/Guide.vue"; import Guide from "@/components/Guide.vue";
import SButton from "@/components/SButton.vue"; import SButton from "@/components/SButton.vue";
import Swiper from "@/components/Swiper.vue"; import Swiper from "@/components/Swiper.vue";
@@ -18,6 +18,8 @@ import {
startPractiseAPI, startPractiseAPI,
endPractiseAPI, endPractiseAPI,
getPractiseAPI, getPractiseAPI,
laserAimAPI,
laserCloseAPI,
} from "@/apis"; } from "@/apis";
import { sharePractiseData } from "@/canvas"; import { sharePractiseData } from "@/canvas";
import { wxShare, debounce } from "@/util"; import { wxShare, debounce } from "@/util";
@@ -32,6 +34,7 @@ const total = 12;
const stepButtonTexts = [ const stepButtonTexts = [
"开始", "开始",
"进入下一个任务", "进入下一个任务",
"我已校准",
"进入下一个任务", "进入下一个任务",
"我准备好了,开始", "我准备好了,开始",
"", "",
@@ -43,8 +46,11 @@ const practiseResult = ref({});
const btnDisabled = ref(false); const btnDisabled = ref(false);
const practiseId = ref(""); const practiseId = ref("");
const showGuide = ref(false); const showGuide = ref(false);
const laserActive = ref(false);
const guideSwiperIndex = ref(0);
const guideImages = [ const guideImages = [
"https://static.shelingxingqiu.com/shootmini/static/target.png",
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwdpgwt9e6du.png", "https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwdpgwt9e6du.png",
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wvv9sw4zioqk.png", "https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wvv9sw4zioqk.png",
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0ww3khaycallu.png", "https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0ww3khaycallu.png",
@@ -54,10 +60,45 @@ const guideImages = [
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwr6hfjhyfn5.png", "https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwr6hfjhyfn5.png",
]; ];
const calibrationGuides = [
{
title: "箭头面向靶子",
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5wscg7hrfo.png",
},
{
title: "摆出拉弓姿势",
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5b7ljrhx3o.png",
},
{
title: "调整瞄准器",
src: "https://static.shelingxingqiu.com/attachment/2025-10-29/dduexjgrcxf9wjaiv4.png",
},
];
const onSwiperIndexChange = (index) => { const onSwiperIndexChange = (index) => {
if (index + 1 === guideImages.length) { guideSwiperIndex.value = index;
showGuide.value = true; showGuide.value = index + 1 === guideImages.length;
} };
const isGuideLastImage = computed(
() => guideSwiperIndex.value + 1 === guideImages.length
);
const currentStepButtonText = computed(() => {
if (step.value === 1 && isGuideLastImage.value) return "去校准智能弓";
return stepButtonTexts[step.value];
});
const openCalibrationLaser = async () => {
if (laserActive.value) return;
await laserAimAPI();
laserActive.value = true;
};
const closeCalibrationLaser = async () => {
if (!laserActive.value) return;
await laserCloseAPI();
laserActive.value = false;
}; };
const createPractise = async (arrows) => { const createPractise = async (arrows) => {
@@ -75,7 +116,7 @@ async function onReceiveMessage(msg) {
scores.value = msg.details; scores.value = msg.details;
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
setTimeout(onOver, 1500); setTimeout(onOver, 1500);
} else if (msg.type === MESSAGETYPESV2.TestDistance) { } else if (msg.type === MESSAGETYPESV2.TestDistance && step.value === 3) {
if (msg.shootData.distance / 100 >= 5) { if (msg.shootData.distance / 100 >= 5) {
audioManager.play("距离合格"); audioManager.play("距离合格");
btnDisabled.value = false; btnDisabled.value = false;
@@ -110,12 +151,13 @@ onMounted(() => {
uni.$on("share-image", onClickShare); uni.$on("share-image", onClickShare);
}); });
onBeforeUnmount(() => { onBeforeUnmount(async () => {
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: false, keepScreenOn: false,
}); });
uni.$off("socket-inbox", onReceiveMessage); uni.$off("socket-inbox", onReceiveMessage);
uni.$off("share-image", onClickShare); uni.$off("share-image", onClickShare);
await closeCalibrationLaser();
audioManager.stopAll(); audioManager.stopAll();
endPractiseAPI(); endPractiseAPI();
}); });
@@ -123,28 +165,39 @@ onBeforeUnmount(() => {
const nextStep = async () => { const nextStep = async () => {
if (step.value === 0) { if (step.value === 0) {
step.value = 1; step.value = 1;
title.value = "-凹造型"; title.value = "-箭前准备";
} else if (step.value === 1) { } else if (step.value === 1) {
if (!isGuideLastImage.value) {
guideSwiperIndex.value += 1;
showGuide.value = guideSwiperIndex.value + 1 === guideImages.length;
return;
}
showGuide.value = false;
step.value = 2;
// title.value = "-校准智能弓";
await openCalibrationLaser();
} else if (step.value === 2) {
await closeCalibrationLaser();
showGuide.value = false; showGuide.value = false;
btnDisabled.value = true; btnDisabled.value = true;
step.value = 2; step.value = 3;
title.value = "-感知距离"; title.value = "-感知距离";
const result = await createPractiseAPI(total, 120); const result = await createPractiseAPI(total, 120);
if (result) practiseId.value = result.id; if (result) practiseId.value = result.id;
} else if (step.value === 2) {
showGuide.value = false;
step.value = 3;
title.value = "-小试牛刀";
} else if (step.value === 3) { } else if (step.value === 3) {
showGuide.value = false;
step.value = 4;
title.value = "-小试牛刀";
} else if (step.value === 4) {
title.value = "小试牛刀"; title.value = "小试牛刀";
await startPractiseAPI(); await startPractiseAPI();
scores.value = []; scores.value = [];
step.value = 4; step.value = 5;
start.value = true; start.value = true;
setTimeout(() => { setTimeout(() => {
uni.$emit("play-sound", "请开始射击"); uni.$emit("play-sound", "请开始射击");
}, 300); }, 300);
} else if (step.value === 5) { } else if (step.value === 6) {
uni.navigateBack({ uni.navigateBack({
delta: 1, delta: 1,
}); });
@@ -159,13 +212,13 @@ const onClose = async () => {
setTimeout(() => { setTimeout(() => {
practiseResult.value = {}; practiseResult.value = {};
showGuide.value = false; showGuide.value = false;
step.value = 5; step.value = 6;
}, 500); }, 500);
} else { } else {
practiseResult.value = {}; practiseResult.value = {};
start.value = false; start.value = false;
scores.value = []; scores.value = [];
step.value = 3; step.value = 4;
const result = await createPractiseAPI(total, 120); const result = await createPractiseAPI(total, 120);
if (result) practiseId.value = result.id; if (result) practiseId.value = result.id;
} }
@@ -173,14 +226,14 @@ const onClose = async () => {
</script> </script>
<template> <template>
<Container :bgType="1" :title="title" :showBottom="step !== 4"> <Container :bgType="1" :title="title" :showBottom="step !== 5">
<view class="container"> <view class="container">
<Guide <Guide
v-if="step !== 4" v-if="step !== 5"
:type=" :type="
step === 2 step === 3
? 2 ? 2
: step === 5 || (step === 0 && user.nickName.length > 6) : step === 6 || (step === 0 && user.nickName.length > 6)
? 1 ? 1
: 0 : 0
" "
@@ -196,25 +249,28 @@ const onClose = async () => {
这是新人必刷小任务0基础小白也能快速掌握弓箭技巧和游戏规则哦~ 这是新人必刷小任务0基础小白也能快速掌握弓箭技巧和游戏规则哦~
</text> </text>
<text v-if="step === 1" :style="{ fontSize: '28rpx' }" <text v-if="step === 1" :style="{ fontSize: '28rpx' }"
>这是我们人帅技高的高教练首先请按教练示范尝试自己去做这些动作和手势吧</text >位就是人帅技高的高教练接下来请跟随教练指引做好射箭前期准备</text
>
<text v-if="step === 2" :style="{ fontSize: '28rpx' }"
>请按下方步骤完成智能弓校准让瞄准器和靶子保持对齐</text
> >
<view <view
class="guide-tips" class="guide-tips"
:style="{ marginTop: '8rpx' }" :style="{ marginTop: '8rpx' }"
v-if="step === 2" v-if="step === 3"
> >
<text>你知道5米射程有多远吗</text> <text>你知道5米射程有多远吗</text>
<text> <text>
在我们的排位赛中射程小于5米的成绩无效建议平时练习距离至少5米现在来边射箭边调整你的站位点吧 在我们的排位赛中射程小于5米的成绩无效建议平时练习距离至少5米现在来边射箭边调整你的站位点吧
</text> </text>
</view> </view>
<view class="guide-tips" v-if="step === 3"> <view class="guide-tips" v-if="step === 4">
<text>一切准备就绪</text> <text>一切准备就绪</text>
<text :style="{ fontSize: '28rpx' }" <text :style="{ fontSize: '28rpx' }"
>试着完成一个真正的弓箭手任务吧</text >试着完成一个真正的弓箭手任务吧</text
> >
</view> </view>
<view class="guide-tips" v-if="step === 5"> <view class="guide-tips" v-if="step === 6">
<text>新手试炼场通关啦优秀</text> <text>新手试炼场通关啦优秀</text>
<text :style="{ fontSize: '28rpx' }" <text :style="{ fontSize: '28rpx' }"
>反曲弓运动基本知识和射灵世界系统规则你已Get是不是挺容易呀</text >反曲弓运动基本知识和射灵世界系统规则你已Get是不是挺容易呀</text
@@ -231,35 +287,53 @@ const onClose = async () => {
src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png" src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png"
class="try-tip" class="try-tip"
mode="widthFix" mode="widthFix"
v-if="step === 3" v-if="step === 4"
/> />
<image <image
src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpz9lav58g5drl.png" src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpz9lav58g5drl.png"
class="try-tip" class="try-tip"
mode="widthFix" mode="widthFix"
v-if="step === 5" v-if="step === 6"
/> />
<view style="height: 570px" v-if="step === 1"> <view style="height: 570px" v-if="step === 1">
<Swiper :onChange="onSwiperIndexChange" :data="guideImages" /> <Swiper
:current="guideSwiperIndex"
:onChange="onSwiperIndexChange"
:data="guideImages"
/>
</view> </view>
<ShootProgress v-if="step === 4" tips="请开始连续射箭" :start="start" /> <view class="calibration-container" v-if="step === 2">
<TestDistance v-if="step === 2" :guide="false" /> <view
v-for="(guide, index) in calibrationGuides"
:key="guide.title"
class="calibration-guide"
>
<view>
<text>{{ index + 1 }}</text>
<text>{{ guide.title }}</text>
</view>
<image :src="guide.src" mode="widthFix" />
</view>
<text>请完成以上步骤校准智能弓</text>
</view>
<ShootProgress v-if="step === 5" tips="请开始连续射箭" :start="start" />
<TestDistance v-if="step === 3" :guide="false" />
<view <view
class="user-row" class="user-row"
v-if="step === 4" v-if="step === 5"
:style="{ marginBottom: step === 2 ? '40px' : '0' }" :style="{ marginBottom: '0' }"
> >
<Avatar :src="user.avatar" :size="35" /> <Avatar :src="user.avatar" :size="35" />
<BowPower /> <BowPower />
</view> </view>
<BowTarget <BowTarget
v-if="step === 4" v-if="step === 5"
:currentRound="step === 4 ? scores.length : 0" :currentRound="step === 5 ? scores.length : 0"
:totalRound="step === 4 ? total : 0" :totalRound="step === 5 ? total : 0"
:scores="scores" :scores="scores"
/> />
<ScorePanel <ScorePanel
v-if="step === 4" v-if="step === 5"
:total="total" :total="total"
:rowCount="6" :rowCount="6"
:arrows="scores" :arrows="scores"
@@ -287,7 +361,7 @@ const onClose = async () => {
step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了" step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了"
}}</text> }}</text>
</BubbleTip> </BubbleTip>
{{ stepButtonTexts[step] }} {{ currentStepButtonText }}
</SButton> </SButton>
</template> </template>
</Container> </Container>
@@ -301,4 +375,43 @@ const onClose = async () => {
width: calc(100% - 20px); width: calc(100% - 20px);
margin: 0 10px; margin: 0 10px;
} }
.calibration-container {
display: flex;
flex-direction: column;
align-items: center;
}
.calibration-guide {
display: flex;
flex-direction: column;
align-items: center;
font-size: 26rpx;
color: #ffffff;
margin-bottom: 15rpx;
}
.calibration-guide > view {
width: 100%;
margin: 25rpx 0;
display: flex;
align-items: center;
}
.calibration-guide > view > text:first-child {
font-size: 24rpx;
background: #e89024;
border-radius: 50%;
width: 32rpx;
height: 32rpx;
line-height: 32rpx;
display: block;
text-align: center;
margin-right: 15rpx;
}
.calibration-guide > image {
width: 630rpx;
height: 250rpx;
}
.calibration-container > text {
font-size: 24rpx;
color: #fff9;
margin: 30rpx;
}
</style> </style>

View File

@@ -305,7 +305,7 @@ function goBack() {
<Container <Container
:bgType="data.mode > 3 ? -1 : 0" :bgType="data.mode > 3 ? -1 : 0"
bgColor="#000000" bgColor="#000000"
:onBack="goBack" :onBack="exit"
> >
<!-- ----- Banner game 胜负展示图 NvN 对抗模式----- --> <!-- ----- Banner game 胜负展示图 NvN 对抗模式----- -->

View File

@@ -26,6 +26,7 @@ const {
updateConfig, updateConfig,
updateUser, updateUser,
updateDevice, updateDevice,
clearDevice,
getLvlName, getLvlName,
getLvlNameByScore, getLvlNameByScore,
updateOnline, updateOnline,
@@ -127,6 +128,8 @@ onShow(async () => {
); );
const data = await getDeviceBatteryAPI(); const data = await getDeviceBatteryAPI();
updateOnline(data.online); updateOnline(data.online);
} else {
clearDevice();
} }
} }
} }

View File

@@ -30,11 +30,65 @@ const playersSorted = ref([]);
const playersScores = ref([]); const playersScores = ref([]);
const halfTimeTip = ref(false); const halfTimeTip = ref(false);
const halfRest = ref(false); const halfRest = ref(false);
const HALF_REST_SECONDS = 20;
const halfRestRemain = ref(HALF_REST_SECONDS);
let halfRestTimer = null;
/** 控制设备离线提示弹窗的显示状态 */ /** 控制设备离线提示弹窗的显示状态 */
const showOfflineModal = ref(false); const showOfflineModal = ref(false);
/** 记录每位玩家当前半场连续 X 环key 为 playerId用于触发 tententen 音效 */ /** 记录每位玩家当前半场连续 10 环及以上次key 为 playerId用于触发 tententen 音效 */
const xRingStreaks = ref({}); const xRingStreaks = ref({});
function clearHalfRestCountdown() {
if (halfRestTimer) {
clearInterval(halfRestTimer);
halfRestTimer = null;
}
}
function getHalfRestSeconds(battleInfo) {
const remainCandidates = [
battleInfo?.halfRestRemain,
battleInfo?.halfRestRemainSeconds,
battleInfo?.restRemain,
battleInfo?.restRemainSeconds,
];
for (const item of remainCandidates) {
const remain = Number(item);
if (Number.isFinite(remain) && remain > 0 && remain <= HALF_REST_SECONDS) {
return Math.ceil(remain);
}
}
const endTime = Number(battleInfo?.halfRestEndTime ?? battleInfo?.restEndTime);
if (!Number.isFinite(endTime) || endTime <= 0) return HALF_REST_SECONDS;
const timestamp = endTime < 1e12 ? endTime * 1000 : endTime;
const diffSeconds = (timestamp - Date.now()) / 1000;
if (diffSeconds > 0 && diffSeconds <= HALF_REST_SECONDS) {
return Math.ceil(diffSeconds);
}
return HALF_REST_SECONDS;
}
function startHalfRestCountdown(seconds = HALF_REST_SECONDS) {
clearHalfRestCountdown();
halfRestRemain.value = Math.max(0, Math.ceil(Number(seconds) || HALF_REST_SECONDS));
if (halfRestRemain.value <= 0) return;
halfRestTimer = setInterval(() => {
if (halfRestRemain.value <= 1) {
halfRestRemain.value = 0;
clearHalfRestCountdown();
return;
}
halfRestRemain.value -= 1;
}, 1000);
}
/** /**
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户 * 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
*/ */
@@ -91,8 +145,7 @@ function recoverData(battleInfo, { force = false } = {}) {
halfTimeTip.value = true; halfTimeTip.value = true;
halfRest.value = true; halfRest.value = true;
tips.value = "准备下半场"; tips.value = "准备下半场";
// 剩余休息时间 startHalfRestCountdown(getHalfRestSeconds(battleInfo));
// const remain = (Date.now() - battleInfo.timeoutTime) / 1000;
setTimeout(() => { setTimeout(() => {
uni.$emit("update-remain", 0); uni.$emit("update-remain", 0);
}, 200); }, 200);
@@ -123,23 +176,27 @@ onLoad(async (options) => {
}); });
/** /**
* 检测指定玩家连续 X 环是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效 * 检测指定玩家连续 10 环及以上是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
* @param {number|string} playerId - 本次射手的 ID大乱斗中 ShootResult 保留 playerId * @param {number|string} playerId - 本次射手的 ID大乱斗中 ShootResult 保留 playerId
* @param {boolean} isXRing - 本次射击是否为 X 环 * @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
*/ */
function checkAndPlayTententen(playerId, isXRing) { function isTenPlusRing(shot) {
return !!(shot?.ringX || Number(shot?.ring) >= 10);
}
function checkAndPlayTententen(playerId, isTenPlusRingShot) {
if (!playerId) return; if (!playerId) return;
const id = parseInt(playerId); const id = parseInt(playerId);
if (isXRing) { if (isTenPlusRingShot) {
xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1; xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1;
// 同一玩家连续 3 箭均为 X 环,追加到环数音效队列尾部播放 // 同一玩家连续 3 箭均为 10 环及以上,追加到环数音效队列尾部播放
if (xRingStreaks.value[id] >= 3) { if (xRingStreaks.value[id] >= 3) {
xRingStreaks.value[id] = 0; xRingStreaks.value[id] = 0;
// nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen避免播放顺序颠倒 // nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen避免播放顺序颠倒
nextTick(() => audioManager.play("tententen", false)); nextTick(() => audioManager.play("tententen", false));
} }
} else { } else {
// 非 X 环则重置该玩家的连续计数 // 低于 10 环或未上靶则重置该玩家的连续计数
xRingStreaks.value[id] = 0; xRingStreaks.value[id] = 0;
} }
} }
@@ -147,6 +204,7 @@ function checkAndPlayTententen(playerId, isXRing) {
async function onReceiveMessage(msg) { async function onReceiveMessage(msg) {
if (Array.isArray(msg)) return; if (Array.isArray(msg)) return;
if (msg.type === MESSAGETYPESV2.BattleStart) { if (msg.type === MESSAGETYPESV2.BattleStart) {
clearHalfRestCountdown();
halfTimeTip.value = false; halfTimeTip.value = false;
halfRest.value = false; halfRest.value = false;
recoverData(msg); recoverData(msg);
@@ -161,22 +219,23 @@ async function onReceiveMessage(msg) {
// 对比更新后数据找出箭数增加的玩家(即本次射手),并读取其最新箭的 ring 数据 // 对比更新后数据找出箭数增加的玩家(即本次射手),并读取其最新箭的 ring 数据
const newRound = playersScores.value[playersScores.value.length - 1] || {}; const newRound = playersScores.value[playersScores.value.length - 1] || {};
let shooterId = null; let shooterId = null;
let isXRing = false; let isTenPlusRingShot = false;
for (const pid of Object.keys(newRound)) { for (const pid of Object.keys(newRound)) {
const newLen = (newRound[pid] || []).length; const newLen = (newRound[pid] || []).length;
if (newLen > (prevCounts[pid] || 0)) { if (newLen > (prevCounts[pid] || 0)) {
shooterId = parseInt(pid); shooterId = parseInt(pid);
const shot = newRound[pid][newLen - 1]; const shot = newRound[pid][newLen - 1];
isXRing = !!(shot?.ringX && shot?.ring); isTenPlusRingShot = isTenPlusRing(shot);
break; break;
} }
} }
// 检测同一玩家三箭全 X 环,触发 tententen 音效 // 检测同一玩家连续三箭 10 环及以上,触发 tententen 音效
checkAndPlayTententen(shooterId, isXRing); checkAndPlayTententen(shooterId, isTenPlusRingShot);
} else if (msg.type === MESSAGETYPESV2.HalfRest) { } else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTimeTip.value = true; halfTimeTip.value = true;
halfRest.value = true; halfRest.value = true;
tips.value = "准备下半场"; tips.value = "准备下半场";
startHalfRestCountdown();
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
setTimeout(() => { setTimeout(() => {
// 全部跳转到新结算页 // 全部跳转到新结算页
@@ -197,6 +256,7 @@ onBeforeUnmount(() => {
uni.setKeepScreenOn({ uni.setKeepScreenOn({
keepScreenOn: false, keepScreenOn: false,
}); });
clearHalfRestCountdown();
uni.$off("socket-inbox", onReceiveMessage); uni.$off("socket-inbox", onReceiveMessage);
audioManager.stopAll(); audioManager.stopAll();
}); });
@@ -261,7 +321,7 @@ onShow(async () => {
> >
<view class="half-time-tip"> <view class="half-time-tip">
<text>上半场结束休息一下吧:</text> <text>上半场结束休息一下吧:</text>
<text>20秒后开始下半场</text> <text>{{ halfRestRemain }}秒后开始下半场</text>
</view> </view>
</ScreenHint> </ScreenHint>
<!-- 设备离线提示弹窗 --> <!-- 设备离线提示弹窗 -->

View File

@@ -16,7 +16,7 @@ const showTip = ref(false);
const confirmBindTip = ref(false); const confirmBindTip = ref(false);
const addDevice = ref(); const addDevice = ref();
const store = useStore(); const store = useStore();
const { updateDevice } = store; const { updateDevice, clearDevice } = store;
const { user, device } = storeToRefs(store); const { user, device } = storeToRefs(store);
const justBind = ref(false); const justBind = ref(false);
const calibration = ref(false); const calibration = ref(false);
@@ -84,13 +84,21 @@ const toFristTryPage = () => {
}; };
const unbindDevice = async () => { const unbindDevice = async () => {
try {
await unbindDeviceAPI(device.value.deviceId); await unbindDeviceAPI(device.value.deviceId);
} catch (error) {
if (error?.type === "DEVICE_BIND_INVALID") {
uni.setStorageSync("calibration", false);
clearDevice();
}
return;
}
uni.setStorageSync("calibration", false); uni.setStorageSync("calibration", false);
uni.showToast({ uni.showToast({
title: "解绑成功", title: "解绑成功",
icon: "success", icon: "success",
}); });
device.value = {}; clearDevice();
}; };
const toDeviceIntroPage = () => { const toDeviceIntroPage = () => {
@@ -122,8 +130,23 @@ const goCalibration = async () => {
}); });
}; };
onShow(() => { const syncDeviceBinding = async () => {
if (!user.value.id) return;
try {
const devices = await getMyDevicesAPI();
if (devices.bindings && devices.bindings.length) {
updateDevice(devices.bindings[0].deviceId, devices.bindings[0].deviceName);
} else {
clearDevice();
}
} catch (error) {
console.log("sync device binding error", error);
}
};
onShow(async () => {
calibration.value = uni.getStorageSync("calibration"); calibration.value = uni.getStorageSync("calibration");
await syncDeviceBinding();
}); });
</script> </script>
@@ -212,11 +235,19 @@ onShow(() => {
<text>{{ user.nickName }}</text> <text>{{ user.nickName }}</text>
</view> </view>
</view> </view>
<block v-if="calibration"> <!-- <block v-if="calibration"> -->
<SButton :onClick="toFristTryPage" width="60vw" :rounded="40" <view>
>进入新手试炼</SButton <text>恭喜你的弓箭和账号已成功绑定</text>
> <text :style="{ color: '#fed847' }">已赠送6个月射灵世界会员</text>
<view :style="{ marginTop: '15px' }"> </view>
<!-- <SButton :onClick="goCalibration" width="60vw" :rounded="40">
开启智能弓进行校准
</SButton>
<text :style="{ marginTop: '20rpx', fontSize: '24rpx', color: '#fff9' }"
>校准时弓箭激光将开启请勿直视激光</text
> -->
<view>
<SButton <SButton
:onClick="backToHome" :onClick="backToHome"
backgroundColor="#fff3" backgroundColor="#fff3"
@@ -226,19 +257,13 @@ onShow(() => {
>返回首页</SButton >返回首页</SButton
> >
</view> </view>
</block> <view :style="{ marginTop: '15px' }">
<block v-else> <SButton :onClick="toFristTryPage" width="60vw" :rounded="40">进入新手试炼</SButton>
<view>
<text>恭喜你的弓箭和账号已成功绑定</text>
<text :style="{ color: '#fed847' }">已赠送6个月射灵世界会员</text>
</view> </view>
<SButton :onClick="goCalibration" width="60vw" :rounded="40"> <!-- </block> -->
开启智能弓进行校准 <!-- <block v-else>
</SButton>
<text :style="{ marginTop: '20rpx', fontSize: '24rpx', color: '#fff9' }" </block> -->
>校准时弓箭激光将开启请勿直视激光</text
>
</block>
</view> </view>
<view v-if="device.deviceId && !justBind" class="has-device"> <view v-if="device.deviceId && !justBind" class="has-device">
<view class="device-binded"> <view class="device-binded">
@@ -394,7 +419,7 @@ onShow(() => {
width: 140rpx; width: 140rpx;
height: 140rpx; height: 140rpx;
margin-bottom: 5px; margin-bottom: 5px;
border-radius: 10px; border-radius: 12px;
} }
.device-binded > view > text { .device-binded > view > text {
width: 120px; width: 120px;

View File

@@ -31,7 +31,7 @@ const { user } = storeToRefs(store);
const start = ref(false); const start = ref(false);
const scores = ref([]); const scores = ref([]);
const total = 12; const total = 12;
/** 当前练习中连续 X 环计数,用于触发 tententen 音效 */ /** 当前练习中连续 10 环及以上计数,用于触发 tententen 音效 */
const xRingStreak = ref(0); const xRingStreak = ref(0);
const practiseResult = ref({}); const practiseResult = ref({});
const practiseId = ref(""); const practiseId = ref("");
@@ -48,7 +48,7 @@ onLoad((options) => {
const onReady = async () => { const onReady = async () => {
await startPractiseAPI(); await startPractiseAPI();
scores.value = []; scores.value = [];
xRingStreak.value = 0; // 新一局开始,重置 X 环连续计数 xRingStreak.value = 0; // 新一局开始,重置 10 环及以上连续计数
start.value = true; start.value = true;
audioManager.play("练习开始"); audioManager.play("练习开始");
}; };
@@ -59,19 +59,23 @@ const onOver = async () => {
}; };
/** /**
* 检测连续 X 环是否达到 3 箭,达到则播放 tententen 音效 * 检测连续 10 环及以上是否达到 3 箭,达到则播放 tententen 音效
* @param {boolean} isXRing - 本次射击是否为 X 环 * @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
*/ */
function checkAndPlayTententen(isXRing) { function isTenPlusRing(shot) {
if (isXRing) { return !!(shot?.ringX || Number(shot?.ring) >= 10);
}
function checkAndPlayTententen(isTenPlusRingShot) {
if (isTenPlusRingShot) {
xRingStreak.value += 1; xRingStreak.value += 1;
// 连续 3 箭均为 X 环,在环数播报入队后追加 tententen避免播放顺序颠倒 // 连续 3 箭均为 10 环及以上,在环数播报入队后追加 tententen避免播放顺序颠倒
if (xRingStreak.value >= 3) { if (xRingStreak.value >= 3) {
xRingStreak.value = 0; xRingStreak.value = 0;
nextTick(() => audioManager.play("tententen", false)); nextTick(() => audioManager.play("tententen", false));
} }
} else { } else {
// 非 X 环则重置连续计数 // 低于 10 环或未上靶则重置连续计数
xRingStreak.value = 0; xRingStreak.value = 0;
} }
} }
@@ -80,10 +84,10 @@ async function onReceiveMessage(msg) {
if (msg.type === MESSAGETYPESV2.ShootResult) { if (msg.type === MESSAGETYPESV2.ShootResult) {
const prevLen = scores.value.length; const prevLen = scores.value.length;
scores.value = msg.details; scores.value = msg.details;
// 有新箭时取最后一箭判断是否 X 环并检测连续计数 // 有新箭时取最后一箭判断是否 10 环及以上并检测连续计数
if (scores.value.length > prevLen) { if (scores.value.length > prevLen) {
const latestArrow = scores.value[scores.value.length - 1]; const latestArrow = scores.value[scores.value.length - 1];
checkAndPlayTententen(!!(latestArrow?.ringX && latestArrow?.ring)); checkAndPlayTententen(isTenPlusRing(latestArrow));
} }
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
// setTimeout(onOver, 1500); // setTimeout(onOver, 1500);
@@ -101,7 +105,7 @@ async function onComplete() {
practiseResult.value = {}; practiseResult.value = {};
start.value = false; start.value = false;
scores.value = []; scores.value = [];
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数 xRingStreak.value = 0; // 重新开始练习,重置 10 环及以上连续计数
const result = await createPractiseAPI(total, 120); const result = await createPractiseAPI(total, 120);
if (result) practiseId.value = result.id; if (result) practiseId.value = result.id;
} }

View File

@@ -31,7 +31,7 @@ const { user } = storeToRefs(store);
const start = ref(false); const start = ref(false);
const scores = ref([]); const scores = ref([]);
const total = 36; const total = 36;
/** 当前练习中连续 X 环计数,用于触发 tententen 音效 */ /** 当前练习中连续 10 环及以上计数,用于触发 tententen 音效 */
const xRingStreak = ref(0); const xRingStreak = ref(0);
const practiseResult = ref({}); const practiseResult = ref({});
const practiseId = ref(""); const practiseId = ref("");
@@ -47,7 +47,7 @@ onLoad((options) => {
const onReady = async () => { const onReady = async () => {
await startPractiseAPI(); await startPractiseAPI();
scores.value = []; scores.value = [];
xRingStreak.value = 0; // 新一局开始,重置 X 环连续计数 xRingStreak.value = 0; // 新一局开始,重置 10 环及以上连续计数
start.value = true; start.value = true;
audioManager.play("练习开始"); audioManager.play("练习开始");
}; };
@@ -58,19 +58,23 @@ const onOver = async () => {
}; };
/** /**
* 检测连续 X 环是否达到 3 箭,达到则播放 tententen 音效 * 检测连续 10 环及以上是否达到 3 箭,达到则播放 tententen 音效
* @param {boolean} isXRing - 本次射击是否为 X 环 * @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
*/ */
function checkAndPlayTententen(isXRing) { function isTenPlusRing(shot) {
if (isXRing) { return !!(shot?.ringX || Number(shot?.ring) >= 10);
}
function checkAndPlayTententen(isTenPlusRingShot) {
if (isTenPlusRingShot) {
xRingStreak.value += 1; xRingStreak.value += 1;
// 连续 3 箭均为 X 环,在环数播报入队后追加 tententen避免播放顺序颠倒 // 连续 3 箭均为 10 环及以上,在环数播报入队后追加 tententen避免播放顺序颠倒
if (xRingStreak.value >= 3) { if (xRingStreak.value >= 3) {
xRingStreak.value = 0; xRingStreak.value = 0;
nextTick(() => audioManager.play("tententen", false)); nextTick(() => audioManager.play("tententen", false));
} }
} else { } else {
// 非 X 环则重置连续计数 // 低于 10 环或未上靶则重置连续计数
xRingStreak.value = 0; xRingStreak.value = 0;
} }
} }
@@ -79,10 +83,10 @@ async function onReceiveMessage(msg) {
if (msg.type === MESSAGETYPESV2.ShootResult) { if (msg.type === MESSAGETYPESV2.ShootResult) {
const prevLen = scores.value.length; const prevLen = scores.value.length;
scores.value = msg.details; scores.value = msg.details;
// 有新箭时取最后一箭判断是否 X 环并检测连续计数 // 有新箭时取最后一箭判断是否 10 环及以上并检测连续计数
if (scores.value.length > prevLen) { if (scores.value.length > prevLen) {
const latestArrow = scores.value[scores.value.length - 1]; const latestArrow = scores.value[scores.value.length - 1];
checkAndPlayTententen(!!(latestArrow?.ringX && latestArrow?.ring)); checkAndPlayTententen(isTenPlusRing(latestArrow));
} }
} else if (msg.type === MESSAGETYPESV2.BattleEnd) { } else if (msg.type === MESSAGETYPESV2.BattleEnd) {
setTimeout(onOver, 1500); setTimeout(onOver, 1500);
@@ -116,7 +120,7 @@ async function onComplete() {
practiseResult.value = {}; practiseResult.value = {};
start.value = false; start.value = false;
scores.value = []; scores.value = [];
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数 xRingStreak.value = 0; // 重新开始练习,重置 10 环及以上连续计数
const result = await createPractiseAPI(total, 3600); const result = await createPractiseAPI(total, 3600);
if (result) practiseId.value = result.id; if (result) practiseId.value = result.id;
} }

View File

@@ -49,7 +49,7 @@ const battleWay = ref(0);
const lastToSomeoneShootKey = ref(""); const lastToSomeoneShootKey = ref("");
/** 控制设备离线提示弹窗的显示状态 */ /** 控制设备离线提示弹窗的显示状态 */
const showOfflineModal = ref(false); const showOfflineModal = ref(false);
/** 记录每位玩家当前轮连续 X 环key 为 playerId用于触发 tententen 音效 */ /** 记录每位玩家当前轮连续 10 环及以上次key 为 playerId用于触发 tententen 音效 */
const xRingStreaks = ref({}); const xRingStreaks = ref({});
/** /**
@@ -234,22 +234,26 @@ function onNewRound(msg, prevRound) {
} }
/** /**
* 检测指定射手连续 X 环是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效 * 检测指定射手连续 10 环及以上是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
* @param {number} shooterId - 本次射手的 ID取自 currentShooterId.value * @param {number} shooterId - 本次射手的 ID取自 currentShooterId.value
* @param {boolean} isXRing - 本次射击是否为 X 环 * @param {boolean} isTenPlusRingShot - 本次射击是否为 10 环及以上
*/ */
function checkAndPlayTententen(shooterId, isXRing) { function isTenPlusRing(shot) {
return !!(shot?.ringX || Number(shot?.ring) >= 10);
}
function checkAndPlayTententen(shooterId, isTenPlusRingShot) {
if (!shooterId) return; if (!shooterId) return;
if (isXRing) { if (isTenPlusRingShot) {
xRingStreaks.value[shooterId] = (xRingStreaks.value[shooterId] || 0) + 1; xRingStreaks.value[shooterId] = (xRingStreaks.value[shooterId] || 0) + 1;
// 同一玩家连续 3 箭均为 X 环,追加到环数音效队列尾部播放 // 同一玩家连续 3 箭均为 10 环及以上,追加到环数音效队列尾部播放
if (xRingStreaks.value[shooterId] >= 3) { if (xRingStreaks.value[shooterId] >= 3) {
xRingStreaks.value[shooterId] = 0; xRingStreaks.value[shooterId] = 0;
// nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen避免播放顺序颠倒 // nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen避免播放顺序颠倒
nextTick(() => audioManager.play("tententen", false)); nextTick(() => audioManager.play("tententen", false));
} }
} else { } else {
// 非 X 环则重置该玩家的连续计数 // 低于 10 环或未上靶则重置该玩家的连续计数
xRingStreaks.value[shooterId] = 0; xRingStreaks.value[shooterId] = 0;
} }
} }
@@ -268,9 +272,9 @@ async function onReceiveMessage(msg) {
} else if (msg.type === MESSAGETYPESV2.ShootResult) { } else if (msg.type === MESSAGETYPESV2.ShootResult) {
showRoundTip.value = false; showRoundTip.value = false;
recoverData(msg, {arrowOnly: true}); recoverData(msg, {arrowOnly: true});
// 检测同一玩家三箭全 X 环,触发 tententen 音效 // 检测同一玩家连续三箭 10 环及以上,触发 tententen 音效
// currentShooterId 在 ToSomeoneShoot 时写入ShootResult 不会覆盖,可靠识别本次射手 // currentShooterId 在 ToSomeoneShoot 时写入ShootResult 不会覆盖,可靠识别本次射手
checkAndPlayTententen(currentShooterId.value, !!(msg.shootData?.ringX && msg.shootData?.ring)); checkAndPlayTententen(currentShooterId.value, isTenPlusRing(msg.shootData));
} else if (msg.type === MESSAGETYPESV2.NewRound) { } else if (msg.type === MESSAGETYPESV2.NewRound) {
// 在进入延迟前先捕获当前轮次,供 onNewRound 使用,防止 800ms 内 ToSomeoneShoot 提前更新 currentRound 造成 Tip 展示错轮 // 在进入延迟前先捕获当前轮次,供 onNewRound 使用,防止 800ms 内 ToSomeoneShoot 提前更新 currentRound 造成 Tip 展示错轮
const prevRound = currentRound.value; const prevRound = currentRound.value;

View File

@@ -38,6 +38,18 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
targetRadius: {
type: Number,
default: 20,
},
hitRadiusPx: {
type: Number,
default: 2,
},
zoomHitRadiusPx: {
type: Number,
default: 5,
},
}); });
const pMode = ref(true); const pMode = ref(true);
@@ -47,6 +59,8 @@ const timer = ref(null);
const dirTimer = ref(null); const dirTimer = ref(null);
const angle = ref(null); const angle = ref(null);
const circleColor = ref(""); const circleColor = ref("");
const ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68;
function showShotFlash(flash) { function showShotFlash(flash) {
const shootData = flash?.shootData; const shootData = flash?.shootData;
@@ -75,13 +89,92 @@ watch(
{ immediate: true } { immediate: true }
); );
function calcRealX(num, offset = 3.4) { const safeTargetRadius = computed(() => {
const len = 20.4 + num; const radius = Number(props.targetRadius);
return `calc(${(len / 40.8) * 100 - offset / 2}%)`; 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 calcRealY(num, offset = 3.4) {
const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num; function getPointDirection(point) {
return `calc(${(len / 40.8) * 100 - offset / 2}%)`; 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 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 }
);
} }
const simulShoot = async () => { const simulShoot = async () => {
if (device.value.deviceId) await simulShootAPI(device.value.deviceId); if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
@@ -164,20 +257,14 @@ onBeforeUnmount(() => {
<view <view
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId" v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
class="e-value fade-in-out" class="e-value fade-in-out"
:style="{ :style="getExperienceTipStyle(latestOne)"
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
}"
> >
经验 +1 经验 +1
</view> </view>
<view <view
v-if="latestOne" v-if="latestOne"
class="round-tip fade-in-out" class="round-tip fade-in-out"
:style="{ :style="getRoundTipStyle(latestOne)"
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
}"
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶" >{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
}}<text v-if="latestOne.ring"></text> }}<text v-if="latestOne.ring"></text>
</view> </view>
@@ -188,20 +275,14 @@ onBeforeUnmount(() => {
user.id === bluelatestOne.playerId user.id === bluelatestOne.playerId
" "
class="e-value fade-in-out" class="e-value fade-in-out"
:style="{ :style="getExperienceTipStyle(bluelatestOne)"
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
}"
> >
经验 +1 经验 +1
</view> </view>
<view <view
v-if="bluelatestOne" v-if="bluelatestOne"
class="round-tip fade-in-out" class="round-tip fade-in-out"
:style="{ :style="getRoundTipStyle(bluelatestOne)"
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
}"
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶" >{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
}}<text v-if="bluelatestOne.ring">环</text></view }}<text v-if="bluelatestOne.ring">环</text></view
> >
@@ -212,8 +293,7 @@ onBeforeUnmount(() => {
index === scores.length - 1 && latestOne ? 'pump-in' : '' index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`" }`"
:style="{ :style="{
left: calcRealX(bow.x, pMode ? '3.4' : '2'), ...getHitStyle(bow),
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000', backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
}" }"
><text v-if="pMode">{{ index + 1 }}</text></view ><text v-if="pMode">{{ index + 1 }}</text></view
@@ -226,8 +306,7 @@ onBeforeUnmount(() => {
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : '' index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
}`" }`"
:style="{ :style="{
left: calcRealX(bow.x, pMode ? '3.4' : '2'), ...getHitStyle(bow),
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
backgroundColor: '#1840FF', backgroundColor: '#1840FF',
}" }"
> >
@@ -287,6 +366,31 @@ onBeforeUnmount(() => {
font-size: 24px; font-size: 24px;
margin-left: 5px; 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;
}
.target > image:last-child { .target > image:last-child {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -297,21 +401,11 @@ onBeforeUnmount(() => {
z-index: 1; z-index: 1;
color: #fff; color: #fff;
transition: all 0.3s ease; transition: all 0.3s ease;
} box-sizing: border-box;
.s-point {
width: 4px;
height: 4px;
min-width: 4px;
min-height: 4px;
} }
.b-point { .b-point {
width: 10px;
height: 10px;
min-width: 10px;
min-height: 10px;
border: 1px solid #fff; border: 1px solid #fff;
z-index: 1; z-index: 1;
box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -327,6 +421,19 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%);*/ transform: translate(-50%, -50%);*/
margin-top: 2rpx; 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 { .header {
width: 100%; width: 100%;
display: flex; display: flex;

View File

@@ -142,8 +142,8 @@ onBeforeUnmount(() => {
</view> </view>
<block <block
v-if=" v-if="
'-凹造型-感知距离-小试牛刀'.indexOf(title) === -1 || '-箭前准备-感知距离-小试牛刀'.indexOf(title) === -1 ||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === 10 '-箭前准备-感知距离-小试牛刀'.indexOf(title) === 11
" "
> >
<text>{{ title }}</text> <text>{{ title }}</text>
@@ -151,12 +151,12 @@ onBeforeUnmount(() => {
<block <block
v-if=" v-if="
title && title &&
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== -1 && '-箭前准备-感知距离-小试牛刀'.indexOf(title) !== -1 &&
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== 10 '-箭前准备-感知距离-小试牛刀'.indexOf(title) !== 11
" "
> >
<view class="first-try-steps"> <view class="first-try-steps">
<text :class="title === '-凹造型' ? 'current-step' : ''">凹造型</text> <text :class="title === '-箭前准备' ? 'current-step' : ''">箭前准备</text>
<text>-</text> <text>-</text>
<text :class="title === '-感知距离' ? 'current-step' : ''" <text :class="title === '-感知距离' ? 'current-step' : ''"
>感知距离</text >感知距离</text

View File

@@ -318,6 +318,22 @@ function enqueueBattleMessage(message) {
if (battleEnded && message.type !== MESSAGETYPESV2.BattleEnd) return; if (battleEnded && message.type !== MESSAGETYPESV2.BattleEnd) return;
if (message.type === MESSAGETYPESV2.BattleEnd) battleEnded = true; if (message.type === MESSAGETYPESV2.BattleEnd) battleEnded = true;
if (message.type === MESSAGETYPESV2.InvalidShot) {
const receivedAt = Date.now();
const order = ++queueOrder;
battleQueue.value.push({
message,
type: message.type,
key: `${message.type}:invalid:${receivedAt}:${order}`,
serverTime: 0,
receivedAt,
order,
});
sortBattleQueue();
runBattleQueue();
return;
}
// 入队阶段只做排序、去重和时间边界判断,不直接改 UI。 // 入队阶段只做排序、去重和时间边界判断,不直接改 UI。
const serverTime = getServerTime(message); const serverTime = getServerTime(message);
const key = getMessageKey(message); const key = getMessageKey(message);
@@ -463,7 +479,8 @@ function updateGoldenRound(battleInfo) {
} }
const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : []; const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
const finishedGoldCount = rounds.filter((round) => !!round?.ifGold).length; const finishedGoldCount = rounds.filter((round) => !!round?.ifGold).length;
goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0)); // goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0));
goldenRound.value = Math.max(1, finishedGoldCount);
} }
// Restore an info snapshot whose eventType points at the NewRound phase. // Restore an info snapshot whose eventType points at the NewRound phase.
@@ -841,10 +858,14 @@ async function runToSomeoneShootTask(task, runId) {
}); });
} }
function updateXRingStreak(shooterId, isXRing) { function isTenPlusRing(shot) {
return !!(shot?.ringX || Number(shot?.ring) >= 10);
}
function updateXRingStreak(shooterId, isTenPlusRingShot) {
if (!shooterId) return false; if (!shooterId) return false;
const id = String(shooterId); const id = String(shooterId);
if (!isXRing) { if (!isTenPlusRingShot) {
xRingStreaks.value[id] = 0; xRingStreaks.value[id] = 0;
saveXRingStreaks(); saveXRingStreaks();
return false; return false;
@@ -889,7 +910,7 @@ async function runShootResultTask(task) {
const isTententen = updateXRingStreak( const isTententen = updateXRingStreak(
currentShooterId.value, currentShooterId.value,
!!(battleInfo.shootData?.ringX && battleInfo.shootData?.ring) isTenPlusRing(battleInfo.shootData)
); );
const audioKeys = buildShootResultAudioKeys(battleInfo.shootData); const audioKeys = buildShootResultAudioKeys(battleInfo.shootData);
if (isTententen) audioKeys.push("tententen"); if (isTententen) audioKeys.push("tententen");
@@ -1233,7 +1254,7 @@ onShow(() => {
<view class="offline-modal"> <view class="offline-modal">
<text class="offline-title">设备已离线</text> <text class="offline-title">设备已离线</text>
<text class="offline-desc">检测到设备已断开连接请检查设备后继续比赛</text> <text class="offline-desc">检测到设备已断开连接请检查设备后继续比赛</text>
<SButton @click="showOfflineModal = false">我知道了</SButton> <SButton :onClick="() => (showOfflineModal = false)">我知道了</SButton>
</view> </view>
</SModal> </SModal>
</view> </view>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -137,6 +137,10 @@ export default defineStore("store", {
this.device.deviceId = deviceId; this.device.deviceId = deviceId;
this.device.deviceName = deviceName; this.device.deviceName = deviceName;
}, },
clearDevice() {
this.device = getDefaultDevice();
this.online = false;
},
async updateConfig(config) { async updateConfig(config) {
this.config = config; this.config = config;
if (this.user.scores !== undefined) { if (this.user.scores !== undefined) {