feat(工具箱): 完善“原神时钟”,添加动画效果,支持旋转指针

This commit is contained in:
2024-10-12 22:41:52 +08:00
parent e37d12a5f2
commit 6516fe4905
5 changed files with 900 additions and 1 deletions

View File

@@ -0,0 +1,163 @@
<template>
<svg
class="clock-color"
:viewBox="`0 0 ${elSize} ${elSize}`"
>
<!-- 定义 -->
<defs>
<!-- 背景图案 -->
<pattern
id="color-pattern"
x="0"
y="0"
patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse"
:width="elSize"
:height="elSize"
>
<image
:width="elSize"
:height="elSize"
:href="IMAGE_TIME_ZONE_COLOR"
/>
</pattern>
</defs>
<!-- 外层圆环 -->
<path
class="color-circle"
:class="{ faded: currAngle > 360 }"
:d="state.dOuter"
fill="url(#color-pattern)"
fill-rule="evenodd"
></path>
<!-- 内层圆环 -->
<path
ref="innerCircle"
class="color-circle"
:d="state.dInner"
fill="url(#color-pattern)"
fill-rule="evenodd"
></path>
</svg>
</template>
<script setup>
import {
computed,
onMounted,
reactive, watch,
} from 'vue';
import {
IMAGE_TIME_ZONE_COLOR,
} from './common-data';
import arc from '@/assets/js/svg-arc';
const props = defineProps({
elSize: {
type: Number,
default: 300,
},
currAngle: {
type: Number,
default: 0,
},
startAngle: {
type: Number,
default: 0,
},
radius: {
type: Number,
default: 150,
},
thickness: {
type: Number,
default: 8,
},
});
const state = reactive({
dInner: '',
dOuter: '',
});
const halfSize = computed(() => {
return (props.elSize / 2);
});
// 角度变化时更新状态
watch(() => (props.currAngle), () => {
updateCircle();
});
watch(() => (props.startAngle), () => {
updateCircle();
});
/** 更新圆环状态 */
function updateCircle() {
const { currAngle, startAngle, thickness } = props;
const radius = halfSize.value;
const size = thickness;
const offset = size / 4;
const endAngleInner = Math.max(0, currAngle - 360) + startAngle;
const endAngleOuter = Math.min(360, currAngle) + startAngle;
// 内层圆环
state.dInner = arc({
x: radius,
y: radius,
R: radius - size * 2 - offset,
r: radius - size - offset,
start: startAngle,
end: endAngleInner,
});
// 外层圆环
state.dOuter = arc({
x: radius,
y: radius,
R: radius - size,
r: radius,
start: startAngle,
end: endAngleOuter,
});
}
onMounted(() => {
updateCircle();
});
</script>
<style lang="less">
.clock-color {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.color-circle {
filter: brightness(1);
transition: filter 0.25s;
}
.faded {
filter: brightness(0.6);
}
}
</style>

View File

@@ -0,0 +1,603 @@
<template>
<div
ref="clockElement"
:class="{
'clock-element': true,
'clock-rotation': clockState.isRotation,
}"
:style="elStyle"
>
<!-- 外部 -->
<div class="clock-outer bg-contain"></div>
<!-- 内部 -->
<div class="clock-inner">
<!-- 背景 -->
<div class="inner-bg bg-cover"></div>
<div class="inner-star bg-cover"></div>
<!-- 齿轮 -->
<div class="clock-gear">
<div class="gear-6"></div>
<div class="gear-5"></div>
<div class="gear-4"></div>
<div class="gear-3"></div>
<div class="gear-2"></div>
<div class="gear-1"></div>
</div>
<!-- 色环 -->
<clock-color
:curr-angle="upperRealAngle"
:start-angle="lowerPointer.viewAngle"
/>
<!-- 下层指针 -->
<div class="pointer-wrapper pointer-lower bg-contain">
<div class="pointer-content"></div>
</div>
<!-- 上层指针 -->
<div class="pointer-wrapper pointer-upper bg-contain">
<div
class="pointer-content"
@mousedown="handleDragPointer('upper')"
></div>
</div>
</div>
<!-- 表盘 -->
<div class="clock-dial bg-contain">
<div class="time-icons">
<div class="time-morning bg-contain"></div>
<div class="time-noon bg-contain"></div>
<div class="time-dusk bg-contain"></div>
<div class="time-night bg-contain"></div>
</div>
</div>
</div>
</template>
<script setup>
import {
computed,
onBeforeUnmount, onMounted,
reactive, ref,
} from 'vue';
import {
IMAGE_CLOCK_BG_INNER,
IMAGE_CLOCK_BG_OUTER,
IMAGE_CLOCK_DIAL,
IMAGE_CLOCK_GEAR_1,
IMAGE_CLOCK_GEAR_4,
IMAGE_CLOCK_GEAR_5,
IMAGE_CLOCK_GEAR_6,
IMAGE_CLOCK_PARTICLES,
IMAGE_POINTER_LOWER,
IMAGE_POINTER_UPPER,
IMAGE_TIME_ICON_DUSK,
IMAGE_TIME_ICON_MORNING,
IMAGE_TIME_ICON_NIGHT,
IMAGE_TIME_ICON_NOON,
} from './common-data';
import ClockColor from './ClockColor.vue';
const clockElement = ref(null);
/** 时钟状态 */
const clockState = reactive({
/** 指针是否正在旋转 */
isRotation: false,
/** 最新一次获取到的指针角度 */
lastAngle: 0,
/** 定时器 ID */
rotationWatcher: null,
});
/** 下层指针状态 */
const lowerPointer = reactive({
/** 视图角度 */
viewAngle: 60,
});
/** 上层指针状态 */
const upperPointer = reactive({
/** 是否为第二圈 */
isSecond: false,
/** 实际角度 */
dataAngle: 0,
/** 视图角度 */
viewAngle: 60,
});
/** 元素 CSS */
const elStyle = computed(() => {
return {
'--image-clock-bg-inner': IMAGE_CLOCK_BG_INNER,
'--image-clock-bg-outer': IMAGE_CLOCK_BG_OUTER,
'--image-clock-dial': IMAGE_CLOCK_DIAL,
'--image-clock-gear-1': IMAGE_CLOCK_GEAR_1,
'--image-clock-gear-4': IMAGE_CLOCK_GEAR_4,
'--image-clock-gear-5': IMAGE_CLOCK_GEAR_5,
'--image-clock-gear-6': IMAGE_CLOCK_GEAR_6,
'--image-clock-particles': IMAGE_CLOCK_PARTICLES,
'--image-pointer-lower': IMAGE_POINTER_LOWER,
'--image-pointer-upper': IMAGE_POINTER_UPPER,
'--image-time-icon-dusk': IMAGE_TIME_ICON_DUSK,
'--image-time-icon-morning': IMAGE_TIME_ICON_MORNING,
'--image-time-icon-night': IMAGE_TIME_ICON_NIGHT,
'--image-time-icon-noon': IMAGE_TIME_ICON_NOON,
'--pointer-lower-angle': `${lowerPointer.viewAngle}deg`,
'--pointer-upper-angle': `${upperPointer.viewAngle}deg`,
};
});
/** 上层指针实际已旋转角度 */
const upperRealAngle = computed(() => {
const { dataAngle, isSecond } = upperPointer;
return (isSecond ? dataAngle + 360 : dataAngle);
});
/** 初始化定时器 */
function timerInit() {
// 指针旋转检测
clockState.rotationWatcher = setInterval(function () {
const currAngle = upperRealAngle.value;
const lastAngle = clockState.lastAngle;
clockState.isRotation = (Math.abs(currAngle - lastAngle) >= 5);
clockState.lastAngle = currAngle;
}, 250);
}
/** 重置定时器 */
function timerReset() {
clearInterval(clockState.rotationWatcher);
}
/**
* @description 获取元素中心坐标
* @param {HTMLElement} el
*/
function getCenterPoint(el) {
if (el) {
const rect = el.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
} else {
return null;
}
}
/** 处理拖拽上层指针 */
function handleDragPointer() {
const center = getCenterPoint(clockElement.value);
if (!center) {
return;
}
const centerX = center.x;
const centerY = center.y;
// 节流
let last = 0;
document.onmousemove = function (ev) {
let curr = Date.now();
if (curr - last >= 20) {
last = curr;
} else {
return;
}
let { pageX, pageY } = ev;
let numX = pageX - centerX;
let numY = pageY - centerY;
// 计算两点弧度 & 转换为角度(-180 ~ 180
let calcAngle = Math.round(Math.atan2(numY, numX) * (180 / Math.PI));
// 转换为视图角度0 ~ 359
let viewAngle = calcAngle + (calcAngle >= -90 ? 90 : 450);
// 用于数据处理的角度
let dataAngle = 0;
// 起始角度偏移值
let offsetAngle = lowerPointer.viewAngle;
// 处理偏移,获取数据角度
if (viewAngle >= offsetAngle) {
dataAngle = viewAngle - offsetAngle;
} else if (viewAngle < offsetAngle) {
dataAngle = viewAngle + (360 - offsetAngle);
}
// 新角度与原角度的差值
let diff = dataAngle - upperPointer.dataAngle;
// 顺时针越过起始点
if (diff <= -180) {
if (upperPointer.isSecond) {
// 当前为第二圈,阻止移动
dataAngle = 360;
viewAngle = 360 + offsetAngle;
} else {
// 当前为第一圈,进入第二圈
upperPointer.isSecond = true;
}
}
// 逆时针越过起始点
if (diff >= 180) {
if (upperPointer.isSecond) {
// 当前为第二圈,返回第一圈
upperPointer.isSecond = false;
} else {
// 当前为第一圈,阻止移动
dataAngle = 0;
viewAngle = offsetAngle;
}
}
upperPointer.dataAngle = dataAngle;
upperPointer.viewAngle = viewAngle;
};
document.onmouseup = function () {
document.onmousemove = null;
document.onmouseup = null;
};
}
/** 确定当前选择的时间,下层指针旋转至上层指针的位置 */
function handleSubmitTime() {
let lowerAngleStart = lowerPointer.viewAngle;
let upperAngleStart = upperRealAngle.value;
let upperAngleCurr = upperAngleStart;
let timer = setInterval(() => {
// 每次 -2最小值为 0
upperAngleCurr = Math.max(0, upperAngleCurr - 2);
// 结束
if (upperAngleCurr === 0) {
clearInterval(timer);
}
let upperAngleDiff = upperAngleStart - upperAngleCurr;
let lowerAngleCurr = lowerAngleStart + upperAngleDiff;
lowerPointer.viewAngle = lowerAngleCurr % 360;
upperPointer.dataAngle = upperAngleCurr % 360;
upperPointer.isSecond = (upperAngleCurr >= 360);
}, 50);
}
defineExpose({
handleSubmitTime,
});
onMounted(() => {
timerInit();
});
onBeforeUnmount(() => {
timerReset();
});
</script>
<style lang="less" scoped>
.bg-contain {
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.bg-cover {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.clock-element {
--pointer-lower-angle: 0deg;
--pointer-upper-angle: 0deg;
position: relative;
width: 32em;
height: 32em;
border-radius: 50%;
font-size: 1rem;
overflow: hidden;
filter: brightness(1.1) saturate(0.9);
}
.clock-outer {
position: absolute;
top: -1%;
left: -1%;
width: 102%;
height: 102%;
background-image: var(--image-clock-bg-outer);
}
.clock-inner {
--size: 46%;
position: absolute;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
border-radius: 50%;
background-color: #000;
overflow: hidden;
.inner-bg, .inner-star {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.inner-bg {
background-image: var(--image-clock-bg-inner);
animation: rotation-backward 60s linear infinite;
}
.inner-star {
background-image: var(--image-clock-particles);
}
}
.clock-gear {
--size: 120%;
--ratio: 1.55;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.6;
> div {
--ani-duration: 2s;
position: absolute;
animation-duration: var(--ani-duration);
animation-iteration-count: infinite;
animation-timing-function: linear;
transform: rotate(0);
will-change: transform;
&::after {
display: block;
content: "";
width: 100%;
height: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
animation-duration: calc(var(--ani-duration) / 12);
animation-iteration-count: infinite;
animation-name: inherit;
animation-play-state: paused;
animation-timing-function: linear;
}
}
.gear-1 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-2 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(0% - var(--size) / 2);
left: calc(72% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-3 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(77% - var(--size) / 2);
left: calc(100% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
opacity: 0.5;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-4 {
--angle-offset: 4deg;
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(77% - var(--size) / 2);
left: calc(100% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-forward;
&::after {
background-image: var(--image-clock-gear-4);
}
}
// 正常:约 74s / 圈
// 加速:约 7s / 圈
.gear-5 {
--ani-duration: calc(1s * (var(--gear-teeth) - 1) * var(--ratio));
--gear-teeth: 49;
top: calc(60% - var(--size) / 2);
left: calc(33% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-5);
}
}
.gear-6 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 49;
top: calc(42% - var(--size) / 2);
left: calc(62% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-6);
}
}
}
// 齿轮加速转动
.clock-rotation .clock-gear > div::after {
animation-play-state: running;
}
.pointer-wrapper {
--size: 180%;
position: absolute;
top: 50%;
left: 50%;
width: var(--size);
height: var(--size);
&.pointer-lower {
background-image: var(--image-pointer-lower);
transform: translate(-50%, -50%) rotate(var(--pointer-lower-angle));
}
&.pointer-upper {
background-image: var(--image-pointer-upper);
transform: translate(-50%, -50%) rotate(var(--pointer-upper-angle));
}
.pointer-content {
--width: 5%;
position: absolute;
left: calc(50% - var(--width) / 2);
width: var(--width);
height: 50%;
cursor: grab;
}
}
.clock-dial {
--size: 88%;
position: absolute;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
background-image: var(--image-clock-dial);
pointer-events: none;
.time-icons {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
> div {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 1;
// transition: opacity 1s;
}
.time-morning {
background-image: var(--image-time-icon-morning);
}
.time-noon {
background-image: var(--image-time-icon-noon);
}
.time-dusk {
background-image: var(--image-time-icon-dusk);
}
.time-night {
background-image: var(--image-time-icon-night);
}
}
}
// 顺时针旋转
@keyframes rotation-forward {
0% {
transform: rotate(calc(0deg + var(--angle-offset, 0deg)));
}
100% {
transform: rotate(calc(360deg + var(--angle-offset, 0deg)));
}
}
// 逆时针旋转
@keyframes rotation-backward {
0% {
transform: rotate(calc(360deg + var(--angle-offset, 0deg)));
}
100% {
transform: rotate(calc(0deg + var(--angle-offset, 0deg)));
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="genshin-button">
<span
v-if="hasIcon"
class="btn-icon mdi"
:class="iconName"
:style="{ color: iconColor }"
></span>
<span class="btn-label">
<slot></slot>
</span>
</div>
</template>
<script setup>
defineProps({
hasIcon: {
type: Boolean,
default: true,
},
iconColor: {
type: String,
default: '#FFFFFF',
},
iconName: {
type: String,
default: 'mdi-help',
},
});
</script>
<style lang="less" scoped>
.genshin-button {
display: inline-block;
position: relative;
margin: 1rem 0.5rem;
padding: 0.5rem;
border-radius: 1.5rem;
background-color: #ECE3D6;
color: #494246;
font-size: 1rem;
font-weight: bold;
line-height: 1;
white-space: nowrap;
opacity: 1;
transition: opacity 0.25s;
cursor: pointer;
&:hover {
opacity: 0.5;
}
span {
vertical-align: middle;
}
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: 50%;
background-color: #2D2D2D;
color: #FFFFFF;
}
.btn-label {
display: inline-block;
padding: 0 3.5rem;
}
</style>

View File

@@ -1,9 +1,50 @@
<template> <template>
<div class="tool-detail-page"></div> <div class="tool-detail-page">
<clock-element ref="clockRef" />
<!-- <genshin-button
icon-color="#F44336"
icon-name="mdi-close"
>取消</genshin-button> -->
<genshin-button
icon-color="#FFC107"
icon-name="mdi-circle-outline"
@click="handleConfirm"
>确认</genshin-button>
</div>
</template> </template>
<script setup> <script setup>
import {
ref,
} from 'vue';
import ClockElement from './ClockElement.vue';
import GenshinButton from './GenshinButton.vue';
/**
* @desc 时钟元素 ref
* @type {VueRef<InstanceType<ClockElement>>}
*/
const clockRef = ref(null);
/** 处理点击确认按钮 */
function handleConfirm() {
let el = clockRef.value;
if (el) {
el.handleSubmitTime();
}
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.tool-detail-page {
background-color: #000;
}
</style> </style>

View File

@@ -0,0 +1,16 @@
export const IMAGE_BASE = `https://c.frost-zx.top/data/static/image/genshin-impact-clock`;
export const IMAGE_CLOCK_BG_INNER = `url("${IMAGE_BASE}/clock_bg_inner.png")`;
export const IMAGE_CLOCK_BG_OUTER = `url("${IMAGE_BASE}/clock_bg_outer.png")`;
export const IMAGE_CLOCK_DIAL = `url("${IMAGE_BASE}/clock_dial.png")`;
export const IMAGE_CLOCK_GEAR_1 = `url("${IMAGE_BASE}/clock_gear_1.png")`;
export const IMAGE_CLOCK_GEAR_4 = `url("${IMAGE_BASE}/clock_gear_4.png")`;
export const IMAGE_CLOCK_GEAR_5 = `url("${IMAGE_BASE}/clock_gear_5.png")`;
export const IMAGE_CLOCK_GEAR_6 = `url("${IMAGE_BASE}/clock_gear_6.png")`;
export const IMAGE_CLOCK_PARTICLES = `url("${IMAGE_BASE}/clock_particles.gif")`;
export const IMAGE_POINTER_LOWER = `url("${IMAGE_BASE}/pointer_lower.png")`;
export const IMAGE_POINTER_UPPER = `url("${IMAGE_BASE}/pointer_upper.png")`;
export const IMAGE_TIME_ICON_DUSK = `url("${IMAGE_BASE}/time_icon_dusk.png")`;
export const IMAGE_TIME_ICON_MORNING = `url("${IMAGE_BASE}/time_icon_morning.png")`;
export const IMAGE_TIME_ICON_NIGHT = `url("${IMAGE_BASE}/time_icon_night.png")`;
export const IMAGE_TIME_ICON_NOON = `url("${IMAGE_BASE}/time_icon_noon.png")`;
export const IMAGE_TIME_ZONE_COLOR = `${IMAGE_BASE}/time_zone_color.png`;