玩原神学编程-原神时钟

633 阅读5分钟

前言

最近喜欢玩原神这种开放世界探索的游戏(还有黑神话、古墓丽影等),只能说纳塔版本的boss盾真的厚,萌新的我去打boss,从白天打到黑夜,黑夜再打到白天(游戏里面的时间)。

闲话结束,进入正题..........

说到游戏时间,原神里面有一个可以玩家自己调节时间的时钟,看着挺不错的,所以由“钟(感)”而发,利用JavaScript复刻一下。

b0efacc2-deaa-43e4-a9c3-aa7fd92f472d.jpg

准备工作

前端框架:Vue3

动画库:GSAP(主要是为了方便统一处理时间线)

素材:github.com/Mashiro-Sor…

复刻思路

搭建场景

这里先堆一下素材,他们的位置给相对固定住,然后确保旋转轴是素材的中心点位置,具体的位置自行调整

20241220132903.png

对于样式,要解决的是径向渐变问题,先利用mask-image遮罩一层,然后给clock_TimeZone定义一个样式变量(直接用 :style绑定也是可以的,看个人喜欢),用来径向渐变的效果。

未使用mask-image遮罩 20241220173224.png

使用mask-image遮罩 20241220173316.png

.clock_TimeZone{
    background: url("images/UI_Clock_TimeZoneColor.png") no-repeat; 
    mask-image: url("images/UI_Clock_TimeZone.png");/*把图片background遮罩在UI_Clock_TimeZone内*/
    mask-size: cover;
    background-size: 100%;
}
.clock_TimeZone::after{
    position: absolute;
    content: '';
    /*定义一个样式变量,用来径向渐变的效果*/
    background: conic-gradient(from var(--start-value), #00bebe 0deg var(--mask-angle) ,#000000 0deg 360deg);
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
}

之所以要用--start-value和--mask-angle两个变量,是因为要确保--start-value增加的同时,--mask-angle要减少,才能保证结束位置的固定。

只有--start-value的时候

20241220_140949.gif

--start-value和--mask-angle都有的时候

20241220_164044.gif

齿轮旋转

接下来先处理齿轮的旋转,这里需要处理的是horoscope03、horoscope04、horoscope05、horoscope051、horoscope061这几个齿轮,给他们的style绑定上旋转属性。

  <label class="clock_unit_mask_wrapper clock_horoscope03" :style="{ rotate: `${horoscope03}deg` }" />
  <label class="center center_90 clock_horoscope04" :style="{ rotate: `${horoscope04}deg` }" />
  <div class="center center_35">
    <label class="center-clock clock_horoscope05_1" :style="{ rotate: `${horoscope051}deg` }" />
  </div>
  <div class="center center_50">
    <label class="center-clock clock_horoscope05" :style="{ rotate: `${horoscope05}deg` }" />
  </div>
  <div class="center">
    <label class="center-clock clock_horoscope06" />
  </div>
  <div class="center">
    <label class="center-clock clock_horoscope06 clock_horoscope06_1" :style="{ rotate: `${horoscope061}deg` }" />
  </div>
  <label class="timeZone_wrapper clock_TimeZone" />
  <label class="timeZone_wrapper clock_TimeZone clock_TimeZone_1" />
</div>

这里之所以要用GSAP,主要是因为,鼠标旋转指针时,所有的齿轮速度是要变化的,原本我也想直接用css的animated来处理就好,但会发现,每次旋转指针齿轮动画都会重新执行

const horoscope03 = ref(0);
const horoscope04 = ref(0);
const horoscope05 = ref(0);
const horoscope051 = ref(0);
const horoscope061 = ref(0);

gsap.to(horoscope03,{ value: -360,duration: 40,repeat: -1,ease: 'none' });
gsap.to(horoscope04,{ value: -360,duration: 40,repeat: -1,ease: 'none' });
gsap.to(horoscope05,{ value: 360,duration: 20,repeat: -1,ease: 'none' });
gsap.to(horoscope051,{ value: 360,duration: 30,repeat: -1,ease: 'none' });
gsap.to(horoscope061,{ value: -360,duration: 30,repeat: -1,ease: 'none' });

// ..........
gsap.globalTimeline.timeScale(toothedGearRotationSpeed.value); // toothedGearRotationSpeed旋转速度

添加鼠标事件

通过转换鼠标位置信息,实现时针的角度旋转

<label
  ref="rotatableElement"
  class="clock_unit clock_hourHand"
  :style="{ rotate: `${rotation}deg` }"
  @mousedown.self="startRotate"
  @mousemove.self="rotate"
  @mouseleave.self="stopRotate"
  @mouseup.self="stopRotate"
/>

记录开始的位置信息

function startRotate(event: MouseEvent):void {
  event.stopPropagation();
  event.preventDefault();
  rotating.value = true;

  const target = event.target as HTMLDivElement;
  const elRect = target?.getBoundingClientRect();
  startX.value = elRect.left + elRect.width / 2;
  startY.value = elRect.top + elRect.height / 2;

  initRotation.value = rotation.value;
}

把位置差转换成角度把范围控制在[0, 360]之间。

function rotate(event: MouseEvent):void {
  if (!rotating.value) return;
  const deltaX = event.clientX - startX.value;
  const deltaY = event.clientY - startY.value;

  let angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI) + 90; 

// 把范围控制在[0,360]之间
  if (angle < 0) {
    angle += 360;
  }
 // ......
 // 
 }

放开鼠标后,记录位置信息,由于指针的位置,并不是每次都是从0位置开始的,就要记录当前角度。

function stopRotate():void {
  rotating.value = false;
  // 记录遮罩角度和结束角度
  initMaskAngle.value = rotation.value - initStartAngle.value > 0
      ? rotation.value - initStartAngle.value
      : 360 - initStartAngle.value + rotation.value;
// 结束的位置,让初始的位置,不断逼近它,即执行动画
  endRotation.value = initMaskAngle.value + getCurrentAngle.hoursAngle;
  
   // ......
}

解决旋转问题

这里要解决几个问题

1、如何知道是正向旋转,还是逆向旋转?

2、如何知道是正向旋转多少圈,还是逆向旋转多少圈?

3、如何执行动画?

正向和逆向

如果用小于或大于当前位置判断方向

那么假设(前面通过把角度范围控制在[0, 360]之间):

指针初始位置是30°,当前位置角度>30°是顺时针,当前位置角度<30°是逆时针,那么如果当角度为0°时,下一个值将是360°>30°,就变成了顺时针,与实际相违背。

所有这里想到的做法是利用扇区来区分,即把一个圆分成四份

17346798379904.png

[0,90]->第一扇区、[90,180]->第二扇区、[180,270]->第三扇区、[270,360]->第四扇区,然后记录扇区的前后关系,即可知道是正向或逆向,例如30°,在第一扇区,前一个扇区是第二扇区,后一个扇区是第四扇区,逆向的情况就是大扇区向小扇区逼近,如果是在第一扇区,逆向是第四扇区,特殊处理一下就好。

/**
 * 保存当前位置信息
 * @param e
 */
function mousePos(e:MouseEvent){
  if (e.pageX || e.pageY) {
    return { x: e.pageX, y: e.pageY };
  }
  return {
    x: e.clientX + document.body.scrollLeft - document.body.clientLeft,
    y: e.clientY + document.body.scrollTop - document.body.clientTop
  };
}
/**
 * 获取当前扇区和判断顺、逆时针
 * @param e
 * @param angle
 */
function getAreaSection(e:MouseEvent,angle: number){
  let prePos = null;

  if (movePosArr.value.length > 0) {
    prePos = movePosArr.value[movePosArr.value.length - 1];
  }

  // 记录最新的位置
  curPos.value = mousePos(e);
  movePosArr.value[movePosArr.value.length] = curPos.value;

  if (prePos){
    if (angle >= 0 && angle < 90){
      // 右上扇区
      areaSection.value = 1; // 定义扇区值
      if (prePos.x < curPos.value.x && prePos.y < curPos.value.y){ // 顺时针
        isClock.value = true;
      }else if (prePos.x > curPos.value.x && prePos.y > curPos.value.y){  // 逆时针
        isClock.value = false;
      }
    }else if (angle >= 90 && angle < 180){
      // 右下扇区
      areaSection.value = 2;
      if (prePos.x > curPos.value.x && prePos.y < curPos.value.y) {
        isClock.value = true;
      }else if (prePos.x < curPos.value.x && prePos.y > curPos.value.y){
        isClock.value = false
      }
    }else if (angle >= 180 && angle < 270){
      // 左下扇区
      areaSection.value = 3;
      if (prePos.x > curPos.value.x && prePos.y > curPos.value.y){
        isClock.value = true;
      }else if(prePos.x < curPos.value.x && prePos.y < curPos.value.y){
        isClock.value = false;
      }
    }else if (angle >= 270 && angle < 360) {
      // 左上扇区
      areaSection.value = 4;
      if (prePos.x < curPos.value.x && prePos.y > curPos.value.y){
        isClock.value = true;
      }else if (prePos.x > curPos.value.x && prePos.y < curPos.value.y){
        isClock.value = false;
      }
    }
  }
}

记录默认位置的扇区

/**
 * 获取默认扇区
 * @param angle
 */
function getInitSection(angle: number){
  let section = 0
  if (angle >= 0 && angle < 90){
    section = 1
  }else if (angle >= 90 && angle < 180){
    section = 2
  }else if (angle >= 180 && angle < 270){
    section = 3
  }else if (angle >= 270 && angle < 360){
    section = 4
  }
  let pre = 0; // 前一个扇区
  let next = 0; // 后一个扇区
  switch (section) {
    case 1: pre = 4; next = 2; break;
    case 2: pre = 1; next = 3; break;
    case 3: pre = 2; next = 4; break;
    case 4: pre = 3; next = 1; break;
  }
  return { section, pre, next }
}

圈数问题

上面已经解决正向和逆向和扇区问题,那么接下来要解决的是圈数问题,这里想到的是用步数来记录更细的数据,例如step = 1,即经过了一个扇区,step = 3(一圈),step = 6(两圈)

20241220_163510.gif

// 计算步数
watch(areaSection,(value,oldValue) => {
  if (value - oldValue > 0){
    step.value++;
  }else {
    step.value--;
  }
  if (value === 1 && oldValue === 4){
    step.value++; // 从第四过渡到第一扇区 ++
  }
  if (value === 4 && oldValue === 1){
    step.value--; // 从第一过渡到第四扇区 --
  }
});

执行动画

在requestAnimationFrame循环里执行动画,让initStartAngle.value不断逼近 endRotation.value

20241220_163555.gif

function render(){
  isChange.value && animateAngle();
  requestAnimationFrame(() => {
    render();
  })
}

function animateAngle(){
  update();
  gsap.globalTimeline.timeScale(toothedGearRotationSpeed.value);
  minutesAngle.value = initStartAngle.value // 指针位置
  if (isRotationAngle.value){ // 超出一圈时的处理
    if (initStartAngle.value < endRotation.value){
      document.documentElement.style.setProperty('--start-angle', `${initStartAngle.value += 1}deg`);
      document.documentElement.style.setProperty('--mask-angle1', `${initMaskAngle.value -= 1}deg`); // 遮罩角度
    }else {
      isRotationAngle.value = false;
      initMaskAngle.value = 360;
      initStartAngle.value = rotation.value;
      endRotation.value = 360 + rotation.value;
      document.documentElement.style.setProperty('--mask-angle1', `0deg`);
    }
  }else { // 一圈内的处理
    if (initStartAngle.value < endRotation.value) {
      document.documentElement.style.setProperty('--start-angle', `${initStartAngle.value += 1}deg`);
      document.documentElement.style.setProperty('--mask-angle', `${initMaskAngle.value -= 1}deg`); // 遮罩角度
    } else {
      document.documentElement.style.setProperty('--mask-angle', `0deg`);
      document.documentElement.style.setProperty('--mask-angle1', `0deg`);
      gsap.globalTimeline.timeScale(1); // 动画播放速度
      setTimeout(() => {
        isChange.value = false;
        handleClose();
      },500)
    }
  }
}

边界值处理

// 在起点,既步数step=0时,不允许逆时针旋转的判断
if (initSection.section === 4){
  if (step.value <= 0){
    if (angle < time && angle > 180){
      return;
    }
  }
}
if (initSection.section === 1){
  if (step.value <= 0){
    if ((angle < time && angle > 0) || (angle > time && angle >= 180)){
      return;
    }
  }
}
if (initSection.section === 2 || initSection.section === 3){
  if (step.value <= 0){
    if (angle < time && angle > 0){
      return;
    }
  }
}

if (step.value > 6){
  return;
}
// 在终点,既步数step=6时,不允许逆时针旋转的判断
if (step.value === 6){
  if (initSection.section === 4){ // 判断[0,135)的阈值
    if (angle >= 0 && angle < 135){
      return;
    }
  }
  if (initSection.section === 1){
    if (angle > 0 && angle < 180 && angle > time){
      return
    }
  }
  if (initSection.section !== 1){
    if (angle > time){
      return;
    }
  }
}

结语

代码

加上场景试试

20241113_174520.gif