update:对接个人训练改版

This commit is contained in:
2026-07-23 09:44:58 +08:00
parent a78ab1daeb
commit 3c5754b3fd
64 changed files with 1769 additions and 488 deletions
+257 -70
View File
@@ -1,11 +1,14 @@
<script setup>
import {
computed,
getCurrentInstance,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import BowShotEffect from "@/components/BowShotEffect.vue";
import PointSwitcher from "@/components/PointSwitcher.vue";
import TargetCanvas from "@/components/TargetCanvas.vue";
@@ -33,6 +36,14 @@ const props = defineProps({
type: Array,
default: () => [],
},
isSvip: {
type: Boolean,
default: false,
},
shotEffectToken: {
type: Number,
default: 0,
},
mode: {
type: String,
default: "solo", // solo 单排,team 双排
@@ -57,23 +68,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
showQuadrantLabels: {
sectorCount: {
type: Number,
default: 0,
},
activeSector: {
type: Number,
default: 0,
},
activeRing: {
type: Number,
default: 0,
},
showSectorLabels: {
type: Boolean,
default: false,
},
quadrantLabels: {
type: Object,
default: () => ({
1: "1",
2: "2",
3: "3",
4: "4",
}),
},
highlightAreas: {
type: Array,
default: () => [],
},
});
const pMode = ref(true);
@@ -85,6 +95,12 @@ const timer = ref(null);
const dirTimer = ref(null);
const angle = ref(null);
const circleColor = ref("");
const shotEffect = ref(null);
const hiddenLatestKey = ref("");
const targetShaking = ref(false);
const targetSize = ref({ width: 0, height: 0 });
const shakeTimer = ref(null);
const instance = getCurrentInstance();
const ROUND_TIP_OFFSET_Y = -32;
const EXPERIENCE_TIP_OFFSET_Y = -68;
@@ -162,6 +178,13 @@ function getHitStyle(shot) {
};
}
function getSvipHitBgStyle(shot) {
const radius = currentHitRadiusPx.value;
const point = getShotPoint(shot);
return getTargetPositionStyle(point, radius);
}
function getRoundTipStyle(shot) {
const point = getShotPoint(shot, true);
return getTargetPositionStyle(
@@ -180,15 +203,116 @@ function getExperienceTipStyle(shot) {
);
}
function clearTipTimer() {
if (!timer.value) return;
clearTimeout(timer.value);
timer.value = null;
}
function showShotTip(shot) {
clearTipTimer();
latestOne.value = shot;
timer.value = setTimeout(() => {
latestOne.value = null;
timer.value = null;
}, 1000);
}
function hasShotPoint(shot) {
return !!getShotPoint(shot);
}
function shouldPlayShotEffect(shot) {
return (
props.isSvip &&
!!shot &&
Number(shot.ring) > 0 &&
hasShotPoint(shot)
);
}
function buildShotEffectKey(shot, index) {
return [
props.shotEffectToken,
index,
shot?.playerId ?? "",
shot?.x ?? "",
shot?.y ?? "",
shot?.ring ?? "",
shot?.ringX ? 1 : 0,
].join("-");
}
function triggerShotEffect(shot, index) {
const key = buildShotEffectKey(shot, index);
clearTipTimer();
latestOne.value = null;
hiddenLatestKey.value = key;
shotEffect.value = { key, shot };
}
function completeShotEffect(key) {
if (!shotEffect.value || shotEffect.value.key !== key) return;
const shot = shotEffect.value.shot;
hiddenLatestKey.value = "";
shotEffect.value = null;
showShotTip(shot);
}
function shouldHideLatestHit(index) {
return !!hiddenLatestKey.value && index === props.scores.length - 1;
}
function shakeTarget() {
targetShaking.value = false;
if (shakeTimer.value) {
clearTimeout(shakeTimer.value);
shakeTimer.value = null;
}
nextTick(() => {
targetShaking.value = true;
shakeTimer.value = setTimeout(() => {
targetShaking.value = false;
shakeTimer.value = null;
}, 260);
});
}
function updateTargetSize() {
nextTick(() => {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery();
query
.select(".target")
.boundingClientRect((rect) => {
const width = Number(rect?.width);
const height = Number(rect?.height);
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
if (width <= 0 || height <= 0) return;
targetSize.value = { width, height };
})
.exec();
});
}
function handleWindowResize() {
updateTargetSize();
}
watch(
() => props.scores,
(newVal) => {
if (newVal.length - prevScores.value.length === 1) {
latestOne.value = newVal[newVal.length - 1];
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => {
latestOne.value = null;
}, 1000);
showShotTip(newVal[newVal.length - 1]);
} else if (newVal.length < prevScores.value.length) {
clearTipTimer();
latestOne.value = null;
hiddenLatestKey.value = "";
shotEffect.value = null;
}
prevScores.value = [...newVal];
},
@@ -197,6 +321,19 @@ watch(
}
);
watch(
() => props.shotEffectToken,
(token) => {
// token 只由实时 ShootResult 推进,同步快照不会重播飞箭。
if (!token || props.scores.length === 0) return;
const latestIndex = props.scores.length - 1;
const latestShot = props.scores[latestIndex];
if (shouldPlayShotEffect(latestShot)) {
triggerShotEffect(latestShot, latestIndex);
}
}
);
watch(
() => props.blueScores,
(newVal) => {
@@ -237,42 +374,9 @@ const arrowStyle = computed(() => {
};
});
const currentArrowIndex = computed(() => {
return props.scores.length + props.blueScores.length + 1;
});
const getHighlightArrowIndex = (area = {}) => {
const arrowIndex = Number(area.arrowIndex ?? area.arrowNo ?? area.arrow);
return Number.isInteger(arrowIndex) && arrowIndex > 0 ? arrowIndex : null;
};
const currentHighlightAreas = computed(() => {
if (!Array.isArray(props.highlightAreas) || props.highlightAreas.length === 0) {
return [];
}
const hasExplicitArrowIndex = props.highlightAreas.some((area = {}) => {
return getHighlightArrowIndex(area) !== null;
});
const matchedAreas = props.highlightAreas.filter((area = {}) => {
return getHighlightArrowIndex(area) === currentArrowIndex.value;
});
if (hasExplicitArrowIndex) {
return matchedAreas;
}
if (props.highlightAreas.length === 1) {
return props.highlightAreas.slice(0, 1);
}
const currentArea = props.highlightAreas[currentArrowIndex.value - 1];
return currentArea ? [currentArea] : [];
});
const showHighlightCanvas = computed(() => {
return props.totalRound > 0 && currentHighlightAreas.value.length > 0;
const showSectorCanvas = computed(() => {
const count = Number(props.sectorCount);
return props.totalRound > 0 && Number.isInteger(count) && count > 0;
});
async function onReceiveMessage(message) {
@@ -299,23 +403,27 @@ async function onReceiveMessage(message) {
onMounted(() => {
uni.$on("socket-inbox", onReceiveMessage);
updateTargetSize();
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
});
onBeforeUnmount(() => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
clearTipTimer();
if (dirTimer.value) {
clearTimeout(dirTimer.value);
dirTimer.value = null;
}
if (shakeTimer.value) {
clearTimeout(shakeTimer.value);
shakeTimer.value = null;
}
uni.$off("socket-inbox", onReceiveMessage);
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
});
</script>
<template>
<view class="container">
<view :class="['container', { 'container--effecting': shotEffect }]">
<!-- <view class="header" v-if="totalRound > 0">
<text v-if="totalRound > 0" class="round-count">{{
(currentRound > totalRound ? totalRound : currentRound) +
@@ -323,37 +431,44 @@ onBeforeUnmount(() => {
totalRound
}}</text>
</view> -->
<view class="target">
<view :class="['target', { 'target--shake': targetShaking }]">
<image
class="target-image"
src="../../../static/bow-target.png"
src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png"
mode="aspectFit"
/>
<TargetCanvas
v-if="showHighlightCanvas"
v-if="showSectorCanvas"
class="target-highlight-layer"
:coordinateRadius="coordinateRadius"
:showCrosshair="false"
:showQuadrantLabels="false"
:showRingLabels="false"
:highlightOnly="true"
:highlightAreas="currentHighlightAreas"
:sectorCount="sectorCount"
:activeSector="activeSector"
:activeRing="activeRing"
:showSectorLabels="showSectorLabels"
/>
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
<view :style="{ background: circleColor }">
<image src="../../../static/dot-circle.png" mode="widthFix" />
<image src="https://static.shelingxingqiu.com/shootmini/static/dot-circle.png" mode="widthFix" />
</view>
</view>
<view v-if="stop" class="stop-sign">中场休息</view>
<view
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
v-if="
!shotEffect &&
latestOne &&
latestOne.ring &&
user.id === latestOne.playerId
"
class="e-value fade-in-out"
:style="getExperienceTipStyle(latestOne)"
>
经验 +1
</view>
<view
v-if="latestOne"
v-if="!shotEffect && latestOne"
class="round-tip fade-in-out"
:style="getRoundTipStyle(latestOne)"
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
@@ -378,8 +493,15 @@ onBeforeUnmount(() => {
}}<text v-if="bluelatestOne.ring">环</text></view
>
<block v-for="(bow, index) in scores" :key="index">
<image
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideLatestHit(index)"
class="svip-hit-bg"
src="../../../static/vip/svip-xuan.png"
:style="getSvipHitBgStyle(bow)"
mode="aspectFit"
/>
<view
v-if="bow.ring > 0"
v-if="bow.ring > 0 && !shouldHideLatestHit(index)"
:class="`hit ${pMode ? 'b' : 's'}-point ${
index === scores.length - 1 && latestOne ? 'pump-in' : ''
}`"
@@ -404,6 +526,16 @@ onBeforeUnmount(() => {
<text v-if="pMode">{{ index + 1 }}</text>
</view>
</block>
<BowShotEffect
:shot="shotEffect && shotEffect.shot"
:playKey="shotEffect ? shotEffect.key : ''"
:targetRadius="safeTargetRadius"
:targetWidth="targetSize.width"
:targetHeight="targetSize.height"
:hitOffsetPx="currentHitRadiusPx"
@impact="shakeTarget"
@complete="completeShotEffect"
/>
</view>
<view class="footer">
<PointSwitcher
@@ -424,13 +556,22 @@ onBeforeUnmount(() => {
height: calc(100vw - 30px);
padding: 0px 15px;
position: relative;
z-index: 3;
}
.container--effecting {
z-index: 10000;
}
.target {
position: relative;
margin: 10px;
width: calc(100% - 20px);
height: calc(100% - 20px);
z-index: 0;
z-index: 1;
pointer-events: none;
transform-origin: center center;
}
.target--shake {
animation: target-shake 0.26s ease-out;
}
.target-image {
position: absolute;
@@ -499,6 +640,15 @@ onBeforeUnmount(() => {
.e-value.fade-in-out {
animation: target-tip-fade-in-out 1.2s ease forwards;
}
.svip-hit-bg {
position: absolute;
width: 48rpx;
height: 48rpx;
z-index: 2;
pointer-events: none;
transform-origin: center center;
animation: svip-hit-xuan 1.2s linear infinite;
}
.hit {
position: absolute;
border-radius: 50%;
@@ -527,6 +677,20 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%);*/
margin-top: 2rpx;
}
@keyframes svip-hit-xuan {
0% {
opacity: 0.9;
transform: translate(-50%, -50%) rotate(0deg) scale(0.92);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) rotate(180deg) scale(1.08);
}
100% {
opacity: 0.9;
transform: translate(-50%, -50%) rotate(360deg) scale(0.92);
}
}
@keyframes target-pump-in {
from {
transform: translate(-50%, -50%) scale(2);
@@ -536,6 +700,29 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes target-shake {
0% {
transform: translate(0, 0);
}
14% {
transform: translate(-20rpx, 8rpx);
}
28% {
transform: translate(16rpx, -8rpx);
}
44% {
transform: translate(-12rpx, 6rpx);
}
64% {
transform: translate(8rpx, -4rpx);
}
82% {
transform: translate(-4rpx, 2rpx);
}
100% {
transform: translate(0, 0);
}
}
.hit.pump-in {
animation: target-pump-in 0.3s ease-out forwards;
transform-origin: center center;
+2 -2
View File
@@ -85,8 +85,8 @@ onBeforeUnmount(() => {
class="score-item-bg"
:src="
isLowScore(arrows[index])
? '/static/training-difficulty-design/block-gray.png'
: '/static/training-difficulty-design/block-gold.png'
? '../static/training-difficulty-design/block-gray.png'
: '../static/training-difficulty-design/block-gold.png'
"
/>
<text
@@ -25,9 +25,8 @@ const isLowScore = (arrow = {}) => {
const displayArrows = computed(() => {
const list = [...props.arrows];
if (props.total > 0 && list.length < props.total) {
list.push(null);
}
// total 是达标箭数,不是实际射箭上限;训练中始终预留下一箭空框。
list.push(null);
return list;
});
</script>
@@ -40,7 +39,7 @@ const displayArrows = computed(() => {
:key="index"
class="score-card"
>
<image class="score-card-bg" :src="isLowScore(arrow)?'/static/training-difficulty-design/block-gray.png':'/static/training-difficulty-design/block-gold.png'"></image>
<image class="score-card-bg" :src="isLowScore(arrow)?'../static/training-difficulty-design/block-gray.png':'../static/training-difficulty-design/block-gold.png'"></image>
<text
class="score-value"
:class="{ 'score-value--low': isLowScore(arrow) }"
+240 -73
View File
@@ -27,6 +27,14 @@ const props = defineProps({
type: Number,
default: 0,
},
trainingType: {
type: String,
default: "",
},
difficultyLevel: {
type: Number,
default: 0,
},
result: {
type: Object,
default: () => ({}),
@@ -60,12 +68,6 @@ function onClickShare() {
uni.$emit("share-image");
}
onMounted(() => {
if (props.result.lvl > user.value.lvl) {
showUpgrade.value = true;
}
});
const details = computed(() => props.result.details || []);
const arrows = computed(() => {
@@ -81,25 +83,89 @@ const totalRing = computed(() =>
details.value.reduce((last, next) => last + (Number(next.ring) || 0), 0)
);
const gainedExp = computed(
() => props.result.exp || props.result.experience || validArrows.value
const hasResultValue = (...keys) =>
keys.some((key) => {
const value = props.result[key];
return value !== undefined && value !== null && value !== "";
});
const readResultNumber = (keys, fallback = 0) => {
for (const key of keys) {
const value = props.result[key];
if (value === undefined || value === null || value === "") continue;
const numberValue = Number(value);
if (Number.isFinite(numberValue)) return numberValue;
}
return fallback;
};
const beforeExp = computed(() =>
readResultNumber(["beforeExp", "before_exp"])
);
const currentLevel = computed(
() => props.result.lvl || user.value.lvl || user.value.rankLvl || 1
);
const currentExp = computed(() => {
const userScores = Number(user.value.scores);
return readResultNumber(
["currentExp", "current_exp", "score"],
Number.isFinite(userScores) ? userScores : 0
);
});
const currentExp = computed(
() => props.result.currentExp || props.result.score || user.value.scores || 0
);
// 新版练习结算返回练习前后累计经验,本局经验由两者相减得到。
const gainedExp = computed(() => {
if (
hasResultValue("beforeExp", "before_exp") &&
hasResultValue("currentExp", "current_exp")
) {
return Math.max(0, currentExp.value - beforeExp.value);
}
return Math.max(0, readResultNumber(["exp", "experience"]));
});
const nextExp = computed(
() => props.result.nextExp || props.result.upgradeScore || 100
const beforeLevel = computed(() => {
const currentUserLevel = Number(user.value.lvl);
return readResultNumber(
["beforeLevel", "before_level"],
Number.isFinite(currentUserLevel) ? currentUserLevel : 0
);
});
const userLevel = computed(() => {
const fallbackLevel = Number(user.value.lvl ?? user.value.rankLvl ?? 1);
const level = readResultNumber(
["level", "lvl"],
Number.isFinite(fallbackLevel) ? fallbackLevel : 1
);
return Math.max(1, Math.trunc(level));
});
const resultDifficultyLevel = computed(() => {
const level = Number(props.difficultyLevel);
return Number.isInteger(level) && level > 0 ? level : "--";
});
const upgradeExp = computed(() =>
Math.max(
0,
readResultNumber(
["upgradeExp", "upgrade_exp", "nextExp", "upgradeScore"],
100
)
)
);
const expPercent = computed(() => {
if (!nextExp.value) return 0;
return Math.min(100, Math.max(0, (currentExp.value / nextExp.value) * 100));
if (!upgradeExp.value) return 0;
return Math.min(
100,
Math.max(0, (currentExp.value / upgradeExp.value) * 100)
);
});
onMounted(() => {
if (userLevel.value > beforeLevel.value) {
showUpgrade.value = true;
}
});
const findValue = (...keys) => {
@@ -108,83 +174,184 @@ const findValue = (...keys) => {
};
const formatDuration = (value) => {
const seconds = Number(value || 0);
if (!seconds) return "--";
const valueNumber = Number(value);
const seconds = Number.isFinite(valueNumber)
? Math.max(0, Math.round(valueNumber))
: 0;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return minutes ? `${minutes}${rest}` : `${rest}`;
};
const usedTime = computed(() =>
findValue("duration", "usedTime", "shootTime", "time")
const formatMetricNumber = (value) => {
const valueNumber = Number(value);
if (!Number.isFinite(valueNumber)) return "0";
return String(Number(valueNumber.toFixed(2)));
};
const readMetricNumber = (keys, fallback = 0) => {
const value = findValue(...keys);
const valueNumber = Number(value);
return Number.isFinite(valueNumber) ? valueNumber : fallback;
};
const resultTrainingType = computed(
() => props.result.trainingType || props.trainingType || "precision"
);
const hitCompare = computed(
() => Number(findValue("hitCompare", "hitDiff", "hitDelta") || 0)
const metricConfigs = {
base: [
{
label: "平均环数",
valueKeys: ["averageRing", "average_ring"],
unit: "环",
deltaKeys: ["deltaAverageRing", "delta_average_ring"],
deltaUnit: "环",
},
{
label: "稳定性",
valueKeys: ["stability"],
unit: "",
deltaKeys: ["deltaStability", "delta_stability"],
deltaUnit: "",
},
],
rhythm: [
{
label: "最高连击次数",
valueKeys: ["maxCombo", "max_combo"],
unit: "连",
deltaKeys: ["deltaMaxCombo", "delta_max_combo"],
deltaUnit: "连",
},
{
label: "共命中环数",
valueKeys: ["currentRings", "current_rings"],
unit: "环",
deltaKeys: ["deltaTotalRings", "delta_total_rings"],
deltaUnit: "环",
},
],
endurance: [
{
label: "完成箭数",
valueKeys: ["totalArrows", "total_arrows"],
unit: "支",
deltaKeys: ["deltaTotalArrows", "delta_total_arrows"],
deltaUnit: "支",
},
{
label: "命中环数",
valueKeys: ["currentRings", "current_rings"],
unit: "环",
deltaKeys: ["deltaTotalRings", "delta_total_rings"],
deltaUnit: "环",
},
],
precision: [
{
label: "共命中目标",
valueKeys: ["totalHits", "total_hits"],
fallback: () => 0,
unit: "次",
deltaKeys: [
"deltaTotalHits",
"delta_total_hits",
"hitCompare",
"hitDiff",
"hitDelta",
],
deltaUnit: "次",
},
{
label: "用时",
valueKeys: ["duration", "usedTime", "shootTime", "time"],
unit: "",
deltaKeys: [
"deltaDuration",
"delta_duration",
"timeCompare",
"timeDiff",
"durationDiff",
],
deltaUnit: "",
duration: true,
},
],
};
const resultRows = computed(() => {
const configs = metricConfigs[resultTrainingType.value] || metricConfigs.precision;
return configs.map((config) => {
const fallback = config.fallback ? config.fallback() : 0;
const value = readMetricNumber(config.valueKeys, fallback);
const delta = readMetricNumber(config.deltaKeys);
const formatter = config.duration ? formatDuration : formatMetricNumber;
return {
...config,
valueText: formatter(value),
delta,
deltaText: formatter(Math.abs(delta)),
};
});
});
const advancesDifficulty = computed(() =>
["base", "endurance"].includes(resultTrainingType.value)
);
const primaryText = computed(() =>
advancesDifficulty.value ? "下一难度" : "再来一次"
);
const timeCompare = computed(
() => Number(findValue("timeCompare", "timeDiff", "durationDiff") || 0)
);
const handlePrimary = () => {
if (advancesDifficulty.value) {
closePanel();
return;
}
retryPractice();
};
const calories = computed(
() => Number(findValue("calories", "calorie", "kcal") || 0)
() => formatMetricNumber(readMetricNumber(["calories", "calorie", "kcal"]))
);
</script>
<template>
<view :class="['result-mask', showPanel ? 'result-mask--show' : 'result-mask--hide']">
<image class="hero-glow" src="/static/training-difficulty-design/result-bg.png" mode="widthFix" />
<image class="hero-glow" src="../static/training-difficulty-design/result-bg.png" mode="widthFix" />
<view class="result-title">
<image class="result-title-bg" src="/static/training-difficulty-design/result-t-bg.png" mode="widthFix" />
<view class="result-title-text">Lv{{ currentLevel }}</view>
<image class="result-title-bg" src="../static/training-difficulty-design/result-t-bg.png" mode="widthFix" />
<view class="result-title-text">Lv{{ resultDifficultyLevel }}</view>
</view>
<view class="result-panel">
<view class="line-top"></view>
<view class="line-bottom"></view>
<view class="stats">
<view class="stat-row">
<image class="stat-bg" src="/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view v-for="row in resultRows" :key="row.label" class="stat-row">
<image class="stat-bg" src="../static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view class="stat-cell">
<text class="stat-label">共命中目标</text>
<text class="stat-label">{{ row.label }}</text>
<view class="stat-value">
<text>{{ validArrows }}</text>
<text class="stat-unit"></text>
<text>{{ row.valueText }}</text>
<text v-if="row.unit" class="stat-unit">{{ row.unit }}</text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-cell stat-cell--compare">
<text class="stat-label">对比上次</text>
<view class="stat-value">
<text>{{ Math.abs(hitCompare) }}</text>
<text class="stat-unit"></text>
<image class="trend-icon" :class="{ 'trend-icon--down': hitCompare < 0 }"
src="/static/training-difficulty-design/result-up.png" mode="widthFix" />
</view>
</view>
</view>
<view class="stat-row">
<image class="stat-bg" src="/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view class="stat-cell">
<text class="stat-label">用时</text>
<view class="stat-value">
<text>{{ formatDuration(usedTime) }}</text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-cell stat-cell--compare">
<text class="stat-label">对比上次</text>
<view class="stat-value">
<text>{{ formatDuration(Math.abs(timeCompare)) }}</text>
<image class="trend-icon" :class="{ 'trend-icon--down': timeCompare <= 0 }"
src="/static/training-difficulty-design/result-up.png" mode="widthFix" />
<view v-if="row.delta !== 0" class="stat-value">
<text>{{ row.delta > 0 ? "+" : "-" }}{{ row.deltaText }}</text>
<text v-if="row.deltaUnit" class="stat-unit">{{ row.deltaUnit }}</text>
<image class="trend-icon" :class="{ 'trend-icon--down': row.delta < 0 }"
src="../static/training-difficulty-design/result-up.png" mode="widthFix" />
</view>
<view v-else class="stat-value">--</view>
</view>
</view>
<view class="stat-row">
<image class="stat-bg" src="/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<image class="stat-bg" src="../static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view class="stat-cell">
<text class="stat-label">消耗卡路里</text>
<view class="stat-value">
@@ -196,7 +363,7 @@ const calories = computed(
<view class="stat-cell stat-cell--compare">
<view class="stat-value">
<image v-for="index in 3" :key="index" class="rice-icon"
src="/static/training-difficulty-design/result-rice.png" mode="widthFix" />
src="../static/training-difficulty-design/result-rice.png" mode="widthFix" />
</view>
</view>
</view>
@@ -204,15 +371,15 @@ const calories = computed(
<view class="actions">
<view class="action-item" @click="() => (showBowData = true)">
<image class="action-icon" src="/static/training-difficulty-design/result-icon-1.png" mode="widthFix" />
<image class="action-icon" src="../static/training-difficulty-design/result-icon-1.png" mode="widthFix" />
<text>查看靶纸</text>
</view>
<view v-if="validArrows === total" class="action-item" @click="() => (showComment = true)">
<image class="action-icon" src="/static/training-difficulty-design/result-icon-2.png" mode="widthFix" />
<view class="action-item" @click="() => (showComment = true)">
<image class="action-icon" src="../static/training-difficulty-design/result-icon-2.png" mode="widthFix" />
<text>教练点评</text>
</view>
<view v-if="validArrows === total" class="action-item" @click="onClickShare">
<image class="action-icon" src="/static/training-difficulty-design/result-icon-3.png" mode="widthFix" />
<view class="action-item" @click="onClickShare">
<image class="action-icon" src="../static/training-difficulty-design/result-icon-3.png" mode="widthFix" />
<text>分享成绩</text>
</view>
</view>
@@ -222,20 +389,20 @@ const calories = computed(
<view class="exp-area">
<text class="exp-gain">+{{ gainedExp }}经验</text>
<view class="level-progress">
<text class="level-text">LV.{{ currentLevel }}</text>
<text class="level-text">LV.{{ userLevel }}</text>
<view class="progress-track">
<view class="progress-fill" :style="{ width: `${expPercent}%` }"></view>
</view>
<text class="progress-text">{{ currentExp }} / {{ nextExp }}</text>
<text class="progress-text">{{ currentExp }} / {{ upgradeExp }}</text>
</view>
</view>
<view class="footer-actions">
<view class="result-btn result-btn--muted" @click="closePanel">
<text>{{ validArrows === total ? "完成" : "返回" }}</text>
<text>完成</text>
</view>
<view class="result-btn result-btn--primary" @click="retryPractice">
<text>再来一次</text>
<view class="result-btn result-btn--primary" @click="handlePrimary">
<text>{{ primaryText }}</text>
</view>
</view>
</view>
@@ -268,7 +435,7 @@ const calories = computed(
</ScreenHint>
<BowData :total="arrows.length" :arrows="result.details" :show="showBowData"
:onClose="() => (showBowData = false)" />
<UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="result.lvl" />
<UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="userLevel" />
</view>
</template>
+5 -5
View File
@@ -27,29 +27,29 @@ const getContentHeight = () => {
<view class="scale-in" :style="{ height: getContentHeight() }">
<image
v-if="mode === 'normal'"
src="/static/screen-hint-bg.png"
src="https://static.shelingxingqiu.com/shootmini/static/screen-hint-bg.png"
mode="widthFix"
/>
<image
v-if="mode === 'tall'"
src="/static/coach-comment.png"
src="https://static.shelingxingqiu.com/shootmini/static/coach-comment.png"
mode="widthFix"
/>
<image
v-if="mode === 'square'"
src="/static/prompt-bg-square.png"
src="https://static.shelingxingqiu.com/shootmini/static/prompt-bg-square.png"
mode="widthFix"
/>
<image
v-if="mode === 'small'"
src="/static/finish-frame.png"
src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png"
mode="widthFix"
/>
<slot />
</view>
<IconButton
v-if="!!onClose"
src="/static/close-gold-outline.png"
src="https://static.shelingxingqiu.com/shootmini/static/close-gold-outline.png"
:width="30"
:onClick="onClose"
/>
+64 -20
View File
@@ -27,6 +27,14 @@ const props = defineProps({
type: Number,
default: 120,
},
countdownEnabled: {
type: Boolean,
default: true,
},
trainingType: {
type: String,
default: "precision",
},
currentRound: {
type: Number,
default: 0,
@@ -45,8 +53,19 @@ const props = defineProps({
},
});
const trainingTitleIconMap = Object.freeze({
base: "../static/training-difficulty-design/text-icon-jcxl.png",
precision: "../static/training-difficulty-design/text-icon-jingzxl.png",
rhythm: "../static/training-difficulty-design/text-icon-jzxl.png",
endurance: "../static/training-difficulty-design/text-icon-nlxl.png",
});
const trainingTitleIcon = computed(
() =>
trainingTitleIconMap[props.trainingType] || trainingTitleIconMap.precision
);
const barColor = ref("#fed847");
const remain = ref(props.total);
const remain = ref(props.countdownEnabled ? props.total : 0);
const timer = ref(null);
const sound = ref(true);
const currentRound = ref(props.currentRound);
@@ -56,7 +75,7 @@ const wait = ref(0);
const transitionStyle = ref("all 1s linear");
const progressPercent = computed(() => {
if (!props.total) return 0;
if (!props.countdownEnabled || !props.total) return 0;
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
});
@@ -98,9 +117,23 @@ watch(
}
);
const clearTimer = () => {
if (!timer.value) return;
clearInterval(timer.value);
timer.value = null;
};
const resetTimer = (count) => {
if (timer.value) clearInterval(timer.value);
const newVal = Math.round(count);
clearTimer();
if (!props.countdownEnabled) {
remain.value = 0;
return;
}
const countValue = Number(count);
const newVal = Number.isFinite(countValue)
? Math.max(0, Math.round(countValue))
: 0;
if (newVal >= remain.value) {
transitionStyle.value = "none";
@@ -115,7 +148,7 @@ const resetTimer = (count) => {
if (remain.value > 0) {
timer.value = setInterval(() => {
if (remain.value === 0) {
clearInterval(timer.value);
clearTimer();
props.onStop();
}
if (remain.value > 0) remain.value--;
@@ -124,13 +157,13 @@ const resetTimer = (count) => {
};
watch(
() => props.start,
(newVal) => {
if (newVal) {
() => [props.start, props.countdownEnabled],
([started, countdownEnabled]) => {
if (started && countdownEnabled) {
resetTimer(props.total);
} else {
clearTimer();
remain.value = 0;
clearInterval(timer.value);
}
},
{
@@ -158,18 +191,29 @@ async function onReceiveMessage(msg) {
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
audioManager.play("比赛结束", false);
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
let arrow = {};
if (msg.details && Array.isArray(msg.details)) {
arrow = msg.details[msg.details.length - 1];
} else {
if (msg.shootData.playerId !== user.value.id) return;
if (msg.shootData) arrow = msg.shootData;
const latestDetail =
Array.isArray(msg.details) && msg.details.length > 0
? msg.details[msg.details.length - 1]
: null;
// 语音和 ACK 优先使用同一份当前箭数据,details 仅作为兼容兜底。
const arrow = msg.shootData || latestDetail;
if (!arrow) return;
if (
arrow.playerId !== undefined &&
arrow.playerId !== null &&
String(arrow.playerId) !== String(user.value?.id)
) {
return;
}
let key = [];
const key = [];
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}` : "未上靶");
if (arrow.angle !== null) {
if (arrow.angle !== null && arrow.angle !== undefined) {
key.push(`${getDirectionText(arrow.angle)}调整`);
}
if (arrow.threeConsecutive10Rings === true) {
key.push("tententen");
}
audioManager.play(key, false);
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
halfTime.value = true;
@@ -197,7 +241,7 @@ onBeforeUnmount(() => {
uni.$off("update-remain", resetTimer);
uni.$off("socket-inbox", onReceiveMessage);
uni.$off("play-sound", playSound);
if (timer.value) clearInterval(timer.value);
clearTimer();
});
</script>
@@ -228,10 +272,10 @@ onBeforeUnmount(() => {
<view class="progress-card__track-wrap">
<image
class="progress-card__titile"
src="../../../static/training-difficulty-design/text-icon-cgxl.png"
:src="trainingTitleIcon"
mode="aspectFit"
/>
<view class="progress-card__track">
<view v-if="countdownEnabled" class="progress-card__track">
<view
class="progress-card__fill"
:style="{
@@ -23,6 +23,10 @@ const props = defineProps({
type: Number,
default: 15,
},
targetType: {
type: [Number, String],
default: "",
},
});
const arrow = ref({});
const distance = ref(0);
@@ -78,7 +82,7 @@ onBeforeUnmount(() => {
<view class="test-area">
<image
class="text-bg"
src="../../../static/training-difficulty-design/par-bg.png"
src="../static/training-difficulty-design/par-bg.png"
mode="widthFix"
/>
<button
@@ -90,7 +94,7 @@ onBeforeUnmount(() => {
模拟射箭
</button>
<view class="warnning-text">
<view class="target-tip">当前靶子为<text class="text-yellow">20cm</text>全环靶,请更换靶子</view>
<view class="target-tip">当前靶子为<text class="text-yellow">{{ targetType }}cm</text>全环靶,请更换靶子</view>
<block v-if="distance > 0">
<text>当前距离<text class="text-yellow">{{ distance }}</text></text>
<text v-if="distance >= 5">已达到距离要求</text>
@@ -2,9 +2,9 @@
import { computed } from "vue";
const lockedBadgeBackground =
"/static/training-difficulty-design/unlock.svg";
"../static/training-difficulty-design/unlock.svg";
const unlockedBadgeBackground =
"/static/training-difficulty-design/lock.svg";
"../static/training-difficulty-design/lock.svg";
const props = defineProps({
node: {
@@ -21,7 +21,7 @@ const previewLines = computed(() => {
<view class="difficulty-preview">
<image
class="difficulty-preview__bg"
src="/static/training-difficulty-design/text.png"
src="../static/training-difficulty-design/text.png"
mode="widthFix"
/>
<view class="difficulty-preview__content">
@@ -52,10 +52,15 @@ const previewLines = computed(() => {
.difficulty-preview__content {
position: absolute;
top: 28rpx;
top: 0;
left: 30rpx;
box-sizing: border-box;
width: 486rpx;
height: 93%;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
}
.difficulty-preview__title {
@@ -21,7 +21,7 @@ const handleClick = () => {
>
<image
class="difficulty-start__button"
src="/static/training-difficulty-design/btn.png"
src="../static/training-difficulty-design/btn.png"
mode="widthFix"
/>
</button>