936 lines
23 KiB
Vue
936 lines
23 KiB
Vue
<script setup>
|
|
import {
|
|
ref,
|
|
watch,
|
|
onMounted,
|
|
onBeforeUnmount,
|
|
computed,
|
|
nextTick,
|
|
} from "vue";
|
|
import audioManager, {
|
|
getTrainingStartAudioKey,
|
|
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
|
} from "@/audioManager";
|
|
import { MESSAGETYPESV2 } from "@/constants";
|
|
import {
|
|
getDirectionText,
|
|
getInvalidShotAudioKey,
|
|
getInvalidShotText,
|
|
} from "@/util";
|
|
import Avatar from "@/components/Avatar.vue";
|
|
|
|
import useStore from "@/store";
|
|
import { storeToRefs } from "pinia";
|
|
const store = useStore();
|
|
const { user } = storeToRefs(store);
|
|
|
|
const props = defineProps({
|
|
show: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
start: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
tips: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
total: {
|
|
type: Number,
|
|
default: 120,
|
|
},
|
|
countdownEnabled: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
trainingType: {
|
|
type: String,
|
|
default: "precision",
|
|
},
|
|
roundTime: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
shootTime: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
shootWindowStart: {
|
|
type: [Number, String],
|
|
default: 0,
|
|
},
|
|
inShootWindow: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
serverTimestamp: {
|
|
type: [Number, String],
|
|
default: 0,
|
|
},
|
|
hitReq: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
energyPercent: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
energyReqPercent: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
isVip: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
isSvip: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
externalShootResultAudio: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
currentRound: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
battleId: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
melee: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
onStop: {
|
|
type: Function,
|
|
default: () => {},
|
|
},
|
|
});
|
|
|
|
const trainingTitleIconMap = Object.freeze({
|
|
base:
|
|
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png",
|
|
precision:
|
|
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jingzxl.png",
|
|
rhythm:
|
|
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jzxl.png",
|
|
endurance:
|
|
"https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-nlxl.png",
|
|
});
|
|
const trainingTitleIcon = computed(
|
|
() =>
|
|
trainingTitleIconMap[props.trainingType] || trainingTitleIconMap.precision
|
|
);
|
|
|
|
const barColor = ref("#fed847");
|
|
const remain = ref(props.countdownEnabled ? props.total : 0);
|
|
const timer = ref(null);
|
|
const sound = ref(true);
|
|
const currentRound = ref(props.currentRound);
|
|
const currentRoundEnded = ref(false);
|
|
const halfTime = ref(false);
|
|
const wait = ref(0);
|
|
const transitionStyle = ref("all 1s linear");
|
|
const rhythmRemainingMs = ref(0);
|
|
const rhythmRemainingSeconds = ref(0);
|
|
const rhythmIsShootWindow = ref(false);
|
|
const rhythmTransitionStyle = ref("none");
|
|
let rhythmCountdownTimer = null;
|
|
let rhythmTransitionTimer = null;
|
|
let rhythmServerClockOffsetMs = 0;
|
|
let rhythmSyncGeneration = 0;
|
|
|
|
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
|
|
const isStabilityTraining = computed(
|
|
() => props.trainingType === "stability"
|
|
);
|
|
|
|
const normalizePositiveInteger = (value) => {
|
|
const numberValue = Number(value);
|
|
return Number.isFinite(numberValue) && numberValue > 0
|
|
? Math.round(numberValue)
|
|
: 0;
|
|
};
|
|
|
|
// 服务端时间戳可能由 protobuf int64 以字符串形式下发。
|
|
const normalizeTimestamp = (value) => {
|
|
const numberValue = Number(value);
|
|
if (!Number.isFinite(numberValue) || numberValue <= 0) return 0;
|
|
return numberValue < 1e12 ? numberValue * 1000 : numberValue;
|
|
};
|
|
|
|
const validRhythmRoundTime = computed(() =>
|
|
normalizePositiveInteger(props.roundTime)
|
|
);
|
|
const validRhythmShootTime = computed(() =>
|
|
Math.min(
|
|
normalizePositiveInteger(props.shootTime),
|
|
validRhythmRoundTime.value
|
|
)
|
|
);
|
|
const validRhythmHitReq = computed(() =>
|
|
normalizePositiveInteger(props.hitReq)
|
|
);
|
|
const rhythmRoundDurationMs = computed(
|
|
() => validRhythmRoundTime.value * 1000
|
|
);
|
|
const rhythmShootDurationMs = computed(
|
|
() => validRhythmShootTime.value * 1000
|
|
);
|
|
|
|
const progressPercent = computed(() => {
|
|
if (!props.countdownEnabled || !props.total) return 0;
|
|
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
|
});
|
|
|
|
const stabilityEnergyPercent = computed(() =>
|
|
Math.max(0, Math.min(100, Number(props.energyPercent) || 0))
|
|
);
|
|
const stabilityEnergyReqPercent = computed(() =>
|
|
Math.max(0, Math.min(100, Number(props.energyReqPercent) || 0))
|
|
);
|
|
const stabilityEnergyReached = computed(
|
|
() =>
|
|
stabilityEnergyReqPercent.value > 0 &&
|
|
stabilityEnergyPercent.value >= stabilityEnergyReqPercent.value
|
|
);
|
|
|
|
const rhythmMarkerPercent = computed(() => {
|
|
if (!validRhythmRoundTime.value) return 0;
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
100,
|
|
(validRhythmShootTime.value / validRhythmRoundTime.value) * 100
|
|
)
|
|
);
|
|
});
|
|
|
|
const rhythmProgressPercent = computed(() => {
|
|
if (!rhythmRoundDurationMs.value) return 0;
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
100,
|
|
(rhythmRemainingMs.value / rhythmRoundDurationMs.value) * 100
|
|
)
|
|
);
|
|
});
|
|
|
|
const rhythmTitle = computed(() => {
|
|
if (!validRhythmShootTime.value) return "节奏训练";
|
|
if (!validRhythmHitReq.value) {
|
|
return `在最后的${validRhythmShootTime.value}秒内射箭`;
|
|
}
|
|
return `在最后的${validRhythmShootTime.value}秒并命中${validRhythmHitReq.value}环内`;
|
|
});
|
|
|
|
const displayName = computed(() => {
|
|
return (
|
|
user.value?.nickName ||
|
|
user.value?.nickname ||
|
|
user.value?.name ||
|
|
"Archer"
|
|
);
|
|
});
|
|
|
|
const avatarSrc = computed(() => {
|
|
return user.value?.avatar || "/static/shooter2.png";
|
|
});
|
|
|
|
watch(
|
|
() => props.tips,
|
|
(newVal) => {
|
|
let key = "";
|
|
if (newVal.includes("红队")) key = "请红方射箭";
|
|
if (newVal.includes("蓝队")) key = "请蓝方射箭";
|
|
if (key) {
|
|
if (currentRoundEnded.value) {
|
|
currentRound.value += 1;
|
|
currentRoundEnded.value = false;
|
|
if (currentRound.value === 1) audioManager.play("第一轮");
|
|
if (currentRound.value === 2) audioManager.play("第二轮");
|
|
if (currentRound.value === 3) audioManager.play("第三轮");
|
|
if (currentRound.value === 4) audioManager.play("第四轮");
|
|
if (currentRound.value === 5) audioManager.play("第五轮");
|
|
setTimeout(() => {
|
|
audioManager.play(key);
|
|
}, 1000);
|
|
} else {
|
|
audioManager.play(key);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const clearTimer = () => {
|
|
if (!timer.value) return;
|
|
clearInterval(timer.value);
|
|
timer.value = null;
|
|
};
|
|
|
|
const clearRhythmTimers = () => {
|
|
if (rhythmCountdownTimer) {
|
|
clearTimeout(rhythmCountdownTimer);
|
|
rhythmCountdownTimer = null;
|
|
}
|
|
if (rhythmTransitionTimer) {
|
|
clearTimeout(rhythmTransitionTimer);
|
|
rhythmTransitionTimer = null;
|
|
}
|
|
};
|
|
|
|
const getRhythmServerNow = () => Date.now() + rhythmServerClockOffsetMs;
|
|
|
|
const getRhythmRoundRemainingMs = () => {
|
|
const shootWindowStart = normalizeTimestamp(props.shootWindowStart);
|
|
if (!shootWindowStart || !rhythmRoundDurationMs.value) return 0;
|
|
|
|
const firstRoundEnd = shootWindowStart + rhythmShootDurationMs.value;
|
|
const serverNow = getRhythmServerNow();
|
|
let roundEnd = firstRoundEnd;
|
|
|
|
// 服务端锚点过期后继续按 round_time 推演下一轮,避免进度停在 0。
|
|
if (serverNow >= firstRoundEnd) {
|
|
const elapsedRounds =
|
|
Math.floor(
|
|
(serverNow - firstRoundEnd) / rhythmRoundDurationMs.value
|
|
) + 1;
|
|
roundEnd = firstRoundEnd + elapsedRounds * rhythmRoundDurationMs.value;
|
|
}
|
|
|
|
return Math.max(
|
|
0,
|
|
Math.min(rhythmRoundDurationMs.value, roundEnd - serverNow)
|
|
);
|
|
};
|
|
|
|
const updateRhythmShootWindow = (isInWindow) => {
|
|
const nextIsInWindow = Boolean(isInWindow);
|
|
const enteredShootWindow =
|
|
nextIsInWindow && !rhythmIsShootWindow.value;
|
|
rhythmIsShootWindow.value = nextIsInWindow;
|
|
|
|
// 只在进入窗口的边沿立即提示;预热和播放均为异步,不阻塞倒计时。
|
|
if (enteredShootWindow && props.start && isRhythmTraining.value) {
|
|
audioManager.play(RHYTHM_SHOOT_WINDOW_AUDIO_KEY);
|
|
}
|
|
};
|
|
|
|
// 每秒只更新一次目标宽度,实际推进交给 CSS transition,减少响应式刷新。
|
|
const scheduleRhythmCountdownStep = () => {
|
|
rhythmCountdownTimer = null;
|
|
const currentRemaining = getRhythmRoundRemainingMs();
|
|
if (currentRemaining <= 0) {
|
|
rhythmTransitionStyle.value = "none";
|
|
rhythmRemainingMs.value = 0;
|
|
rhythmRemainingSeconds.value = 0;
|
|
updateRhythmShootWindow(false);
|
|
return;
|
|
}
|
|
|
|
// 数字展示使用服务端校准后的真实剩余时间,不读取提前写入的动画目标值。
|
|
rhythmRemainingSeconds.value = Math.ceil(currentRemaining / 1000);
|
|
updateRhythmShootWindow(
|
|
currentRemaining <= rhythmShootDurationMs.value
|
|
);
|
|
|
|
// 一轮结束后先无动画恢复至 100%,再开始下一轮向左递减。
|
|
if (currentRemaining - rhythmRemainingMs.value > 1000) {
|
|
rhythmTransitionStyle.value = "none";
|
|
rhythmRemainingMs.value = currentRemaining;
|
|
rhythmTransitionTimer = setTimeout(() => {
|
|
rhythmTransitionTimer = null;
|
|
scheduleRhythmCountdownStep();
|
|
}, 50);
|
|
return;
|
|
}
|
|
|
|
const targetRemaining = Math.max(
|
|
0,
|
|
(Math.ceil(currentRemaining / 1000) - 1) * 1000
|
|
);
|
|
const stepDuration = Math.max(50, currentRemaining - targetRemaining);
|
|
rhythmTransitionStyle.value = `width ${stepDuration}ms linear`;
|
|
rhythmRemainingMs.value = targetRemaining;
|
|
rhythmCountdownTimer = setTimeout(
|
|
scheduleRhythmCountdownStep,
|
|
stepDuration
|
|
);
|
|
};
|
|
|
|
const syncRhythmCountdown = async () => {
|
|
const generation = ++rhythmSyncGeneration;
|
|
clearRhythmTimers();
|
|
|
|
if (!isRhythmTraining.value) {
|
|
rhythmTransitionStyle.value = "none";
|
|
rhythmRemainingMs.value = 0;
|
|
rhythmRemainingSeconds.value = 0;
|
|
updateRhythmShootWindow(false);
|
|
return;
|
|
}
|
|
|
|
const serverTimestamp = normalizeTimestamp(props.serverTimestamp);
|
|
rhythmServerClockOffsetMs = serverTimestamp
|
|
? serverTimestamp - Date.now()
|
|
: 0;
|
|
|
|
if (
|
|
!validRhythmRoundTime.value ||
|
|
!validRhythmShootTime.value ||
|
|
!normalizeTimestamp(props.shootWindowStart)
|
|
) {
|
|
rhythmTransitionStyle.value = "none";
|
|
rhythmRemainingMs.value = 0;
|
|
rhythmRemainingSeconds.value = 0;
|
|
updateRhythmShootWindow(false);
|
|
return;
|
|
}
|
|
|
|
rhythmTransitionStyle.value = "none";
|
|
rhythmRemainingMs.value = getRhythmRoundRemainingMs();
|
|
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
|
|
updateRhythmShootWindow(
|
|
rhythmRemainingMs.value > 0 &&
|
|
rhythmRemainingMs.value <= rhythmShootDurationMs.value
|
|
);
|
|
await nextTick();
|
|
if (generation !== rhythmSyncGeneration) return;
|
|
|
|
rhythmTransitionTimer = setTimeout(() => {
|
|
rhythmTransitionTimer = null;
|
|
scheduleRhythmCountdownStep();
|
|
}, 50);
|
|
};
|
|
|
|
const resetTimer = (count) => {
|
|
clearTimer();
|
|
if (isRhythmTraining.value) {
|
|
remain.value = 0;
|
|
return;
|
|
}
|
|
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";
|
|
remain.value = newVal;
|
|
setTimeout(() => {
|
|
transitionStyle.value = "all 1s linear";
|
|
}, 50);
|
|
} else {
|
|
remain.value = newVal;
|
|
}
|
|
|
|
if (remain.value > 0) {
|
|
timer.value = setInterval(() => {
|
|
if (remain.value === 0) {
|
|
clearTimer();
|
|
props.onStop();
|
|
}
|
|
if (remain.value > 0) remain.value--;
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
watch(
|
|
() => [props.start, props.countdownEnabled, props.trainingType],
|
|
([started, countdownEnabled, trainingType]) => {
|
|
if (trainingType === "rhythm") {
|
|
clearTimer();
|
|
remain.value = 0;
|
|
} else if (started && countdownEnabled) {
|
|
resetTimer(props.total);
|
|
} else {
|
|
clearTimer();
|
|
remain.value = 0;
|
|
}
|
|
},
|
|
{
|
|
immediate: true,
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => [
|
|
props.trainingType,
|
|
props.roundTime,
|
|
props.shootTime,
|
|
props.shootWindowStart,
|
|
props.serverTimestamp,
|
|
],
|
|
syncRhythmCountdown,
|
|
{ immediate: true }
|
|
);
|
|
|
|
const tipContent = computed(() => {
|
|
if (halfTime.value) {
|
|
return props.battleId ? "中场休息" : `中场休息(${wait.value}秒)`;
|
|
}
|
|
return props.start && remain.value === 0 ? "时间到!" : props.tips;
|
|
});
|
|
|
|
const updateSound = () => {
|
|
sound.value = !sound.value;
|
|
audioManager.setMuted(!sound.value);
|
|
};
|
|
|
|
async function onReceiveMessage(msg) {
|
|
if (Array.isArray(msg)) return;
|
|
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
|
halfTime.value = false;
|
|
// 已实现的个人训练由页面播放专属提示,避免重复播报。
|
|
if (!getTrainingStartAudioKey(props.trainingType)) {
|
|
audioManager.play("比赛开始");
|
|
}
|
|
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
|
audioManager.play("练习结束", false);
|
|
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
|
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
|
|
if (props.externalShootResultAudio) return;
|
|
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;
|
|
}
|
|
|
|
const key = [];
|
|
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
|
|
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;
|
|
audioManager.play("中场休息");
|
|
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
|
uni.showToast({
|
|
title: getInvalidShotText(msg.shootData),
|
|
icon: "none",
|
|
});
|
|
audioManager.play(getInvalidShotAudioKey(msg.shootData));
|
|
}
|
|
}
|
|
|
|
const playSound = (key) => {
|
|
audioManager.play(key);
|
|
};
|
|
|
|
onMounted(() => {
|
|
uni.$on("update-remain", resetTimer);
|
|
uni.$on("socket-inbox", onReceiveMessage);
|
|
uni.$on("play-sound", playSound);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
uni.$off("update-remain", resetTimer);
|
|
uni.$off("socket-inbox", onReceiveMessage);
|
|
uni.$off("play-sound", playSound);
|
|
clearTimer();
|
|
rhythmSyncGeneration += 1;
|
|
clearRhythmTimers();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<view
|
|
v-if="show"
|
|
:class="
|
|
isRhythmTraining
|
|
? 'rhythm-progress'
|
|
: isStabilityTraining
|
|
? 'stability-progress'
|
|
: 'progress-card'
|
|
"
|
|
>
|
|
<template v-if="isStabilityTraining">
|
|
<text class="stability-progress__time">{{ remain }}秒</text>
|
|
<view class="stability-progress__track">
|
|
<view
|
|
class="stability-progress__fill"
|
|
:class="{
|
|
'stability-progress__fill--reached': stabilityEnergyReached,
|
|
}"
|
|
:style="{ width: `${stabilityEnergyPercent}%` }"
|
|
/>
|
|
<view
|
|
class="stability-progress__marker"
|
|
:style="{ left: `${stabilityEnergyReqPercent}%` }"
|
|
/>
|
|
<text class="stability-progress__value">
|
|
{{ Math.round(stabilityEnergyPercent) }}%
|
|
</text>
|
|
</view>
|
|
</template>
|
|
<template v-else-if="isRhythmTraining">
|
|
<text class="rhythm-progress__title">{{ rhythmTitle }}</text>
|
|
<view class="rhythm-progress__track">
|
|
<view
|
|
class="rhythm-progress__fill"
|
|
:class="{
|
|
'rhythm-progress__fill--shooting': rhythmIsShootWindow,
|
|
}"
|
|
:style="{
|
|
width: `${rhythmProgressPercent}%`,
|
|
transition: rhythmTransitionStyle,
|
|
}"
|
|
/>
|
|
<view
|
|
class="rhythm-progress__marker"
|
|
:style="{ left: `${rhythmMarkerPercent}%` }"
|
|
/>
|
|
<text
|
|
v-if="rhythmRemainingSeconds > 0"
|
|
class="rhythm-progress__window-label"
|
|
>{{ rhythmRemainingSeconds }}秒</text>
|
|
</view>
|
|
</template>
|
|
<template v-else>
|
|
<view class="progress-card__header">
|
|
<view class="progress-card__profile">
|
|
<view class="progress-card__avatar-shell">
|
|
<Avatar
|
|
:src="avatarSrc"
|
|
:size="80"
|
|
size-unit="rpx"
|
|
image-mode="aspectFill"
|
|
/>
|
|
</view>
|
|
<view
|
|
:class="[
|
|
'progress-card__name',
|
|
'member-nickname',
|
|
isVip && !isSvip ? 'member-nickname--vip' : '',
|
|
isSvip ? 'member-nickname--svip' : '',
|
|
]"
|
|
>
|
|
<text class="member-nickname__text">{{ displayName }}</text>
|
|
<text v-if="isSvip" class="member-nickname__shine">
|
|
{{ displayName }}
|
|
</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- <button class="progress-card__sound" hover-class="none" @click="updateSound">
|
|
<image
|
|
class="progress-card__sound-icon"
|
|
:src="`/static/sound${sound ? '' : '-off'}-yellow.png`"
|
|
mode="aspectFit"
|
|
/>
|
|
</button> -->
|
|
</view>
|
|
|
|
<view class="progress-card__track-wrap">
|
|
<image
|
|
class="progress-card__titile"
|
|
:src="trainingTitleIcon"
|
|
mode="aspectFit"
|
|
/>
|
|
<view v-if="countdownEnabled" class="progress-card__track">
|
|
<view
|
|
class="progress-card__fill"
|
|
:style="{
|
|
width: `${progressPercent}%`,
|
|
backgroundColor: barColor,
|
|
right: tips.includes('红队') ? 0 : 'unset',
|
|
transition: transitionStyle,
|
|
}"
|
|
/>
|
|
<view class="progress-card__badge">
|
|
<text class="progress-card__badge-text">剩余{{ remain }}秒</text>
|
|
</view>
|
|
</view>
|
|
<!-- <text v-if="tipContent" class="progress-card__tip">{{ tipContent }}123</text> -->
|
|
</view>
|
|
</template>
|
|
</view>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.progress-card {
|
|
box-sizing: border-box;
|
|
/* padding: 50rpx 30rpx 0 30rpx; */
|
|
margin: 70rpx 30rpx 0 30rpx;
|
|
}
|
|
|
|
.progress-card__header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.progress-card__profile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.progress-card__avatar-shell {
|
|
width: 86rpx;
|
|
height: 86rpx;
|
|
padding: 3rpx;
|
|
box-sizing: border-box;
|
|
border-radius: 50%;
|
|
background: linear-gradient(180deg, rgba(255, 209, 153, 1), rgba(162, 119, 55, 1));
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.progress-card__avatar {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.progress-card__name {
|
|
width: 86rpx;
|
|
color: #fff;
|
|
font-size: 18rpx;
|
|
line-height: 1;
|
|
justify-content: center;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
text-align: center;
|
|
margin-top: 10rpx;
|
|
}
|
|
|
|
.progress-card__sound {
|
|
width: 68rpx;
|
|
height: 68rpx;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: none;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(28, 24, 21, 0.72);
|
|
box-shadow: 0 8rpx 18rpx rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.progress-card__sound::after {
|
|
border: none;
|
|
}
|
|
|
|
.progress-card__sound-icon {
|
|
width: 34rpx;
|
|
height: 34rpx;
|
|
}
|
|
|
|
.progress-card__track-wrap {
|
|
margin-top: -156rpx;
|
|
padding-left: 102rpx;
|
|
}
|
|
|
|
.progress-card__titile{
|
|
width: 260rpx;
|
|
height: 72rpx;
|
|
margin-left: 110rpx;
|
|
}
|
|
|
|
.progress-card__track {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 24rpx;
|
|
border-radius: 18rpx;
|
|
overflow: hidden;
|
|
background: #444444;
|
|
}
|
|
|
|
.progress-card__fill {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
border-radius: 18rpx;
|
|
background: linear-gradient( 133deg, #FFD19A 0%, #A17636 100%);
|
|
}
|
|
|
|
.progress-card__badge {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
min-width: 156rpx;
|
|
height: 40rpx;
|
|
padding: 0 24rpx;
|
|
box-sizing: border-box;
|
|
border-radius: 999rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
/* background: rgba(164, 117, 47, 0.94); */
|
|
/* box-shadow: 0 4rpx 10rpx rgba(76, 45, 7, 0.22); */
|
|
}
|
|
|
|
.progress-card__badge-text {
|
|
color: #fff7de;
|
|
font-size: 18rpx;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.progress-card__tip {
|
|
display: block;
|
|
margin-top: 16rpx;
|
|
color: rgba(255, 243, 216, 0.88);
|
|
font-size: 24rpx;
|
|
line-height: 1.4;
|
|
text-align: center;
|
|
}
|
|
|
|
.stability-progress {
|
|
box-sizing: border-box;
|
|
margin: 32rpx 84rpx 0;
|
|
}
|
|
|
|
.stability-progress__time {
|
|
display: block;
|
|
margin-bottom: 20rpx;
|
|
color: #ffffff;
|
|
font-size: 34rpx;
|
|
font-weight: 500;
|
|
line-height: 48rpx;
|
|
text-align: center;
|
|
}
|
|
|
|
.stability-progress__track {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 24rpx;
|
|
overflow: hidden;
|
|
border-radius: 18rpx;
|
|
background: #444444;
|
|
}
|
|
|
|
.stability-progress__fill {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
border-radius: 18rpx;
|
|
background: linear-gradient(90deg, #87f1df 0%, #5ba8e8 100%);
|
|
transition: width 240ms linear, background 240ms ease;
|
|
}
|
|
|
|
.stability-progress__fill--reached {
|
|
background: linear-gradient(90deg, #a5df62 0%, #61c787 100%);
|
|
}
|
|
|
|
.stability-progress__marker {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
z-index: 2;
|
|
width: 4rpx;
|
|
transform: translateX(-2rpx);
|
|
background: rgba(26, 24, 22, 0.92);
|
|
}
|
|
|
|
.stability-progress__value {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 3;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #eefaff;
|
|
font-size: 18rpx;
|
|
line-height: 24rpx;
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.rhythm-progress {
|
|
box-sizing: border-box;
|
|
margin: 32rpx 84rpx 0;
|
|
}
|
|
|
|
.rhythm-progress__title {
|
|
display: block;
|
|
margin-bottom: 20rpx;
|
|
color: #ffffff;
|
|
font-size: 30rpx;
|
|
font-weight: 500;
|
|
line-height: 42rpx;
|
|
text-align: center;
|
|
}
|
|
|
|
.rhythm-progress__track {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 24rpx;
|
|
overflow: hidden;
|
|
border-radius: 18rpx;
|
|
background: #444444;
|
|
}
|
|
|
|
.rhythm-progress__fill {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
border-radius: 18rpx;
|
|
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
|
|
}
|
|
|
|
.rhythm-progress__fill--shooting {
|
|
background: linear-gradient(90deg, #e2bd2e 0%, #fff047 100%);
|
|
}
|
|
|
|
.rhythm-progress__marker {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
z-index: 2;
|
|
width: 4rpx;
|
|
transform: translateX(-2rpx);
|
|
background: rgba(26, 24, 22, 0.9);
|
|
}
|
|
|
|
.rhythm-progress__window-label {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 50%;
|
|
z-index: 3;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transform: translateX(-50%);
|
|
color: #fff7de;
|
|
font-size: 18rpx;
|
|
line-height: 24rpx;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|