首页
Preview

vue3 composition-api实现游动锦鲤

制作一个myFish锦鲤组件

jinli (1) (2).gif

通过CSS Transform 改变锦鲤坐标,

<template>
  <div>
    <div class="FishRoot" :style="style" ></div>
  </div>
</template>

<script setup lang="ts">
import {computed} from "vue";

const props = defineProps({
  x: Number,
  y: Number,
  angle: Number,
  color: String,
  scale: Number
})


const scale = props.scale || 1;

const style = computed(()=>{
  return `color: ${props.color};transform: translate(${props.x}px, ${
      props.y
  }px) rotate(${props.angle}deg) scale(${scale}, ${
      scale * 0.8
  }) rotate(${-45}deg);`;
})


</script>

<style lang="scss" scoped>
.FishRoot {
  box-sizing: border-box;
  position: absolute;
  top: 0;
  left: 0;
  width: 20px ;
  height: 20px ;
  background-color: transparent;
  border-color: currentColor;
  border-style: solid;
  border-width: 0 17px  17px  0;
  border-radius: 6px ;
  opacity: 0.7;
  will-change: transform;
  pointer-events: none;
&::after {
   position: absolute;
   content: "";
   width: 5px ;
   height: 5px ;
   right: -22px ;
   bottom: -22px ;
   background-color: transparent;
   border-color: currentColor;
   border-style: solid;
   border-width: 7px 0 0 7px ;
   border-radius: 2px ;
   transform-origin: left top;
 }
&::before {
   position: absolute;
   content: "";
   width: 3px ;
   height: 3px ;
   left: 9px ;
   top: 3px ;
   background-color: #111;
   border-radius: 3px ;
 }
}
</style>

创建FishLayer组件来排列多条锦鲤

<template>
  <!--显示指定数量的锦鲤-->
  <div class="FishLayerRoot">
      <my-fish
          v-for="fishProps in stageState.fishList"
          :key="fishProps.id"
          class="FishElement"
          :x="fishProps.position.x"
          :y="fishProps.position.y"
          :angle="fishProps.angle"
          :color="fishProps.color"
          :scale="fishProps.scale"
      />
  </div>
</template>

<script lang="ts">
import {computed, defineComponent, reactive} from "vue";
import { FishModel } from "../core/FishModel";
import { Point } from "../core/Point";
import {useMouse} from "../core/useMouse";
import { useAnimationFrame } from "../core/useAnimationFrame";
import { useClick } from "../core/useClick";
import MyFish from "./myFish.vue";
// 锦鲤状态,
type StageState = {
  fishList: FishModel[]; //创建了一个类来定义锦鲤的状态和运动
};
export default defineComponent({
  components: {MyFish},
  props: {
    //最大锦鲤数
    maxFish: {
      type: Number,
      default: 10
    },
  },

  setup(props,ctx) {
    // 状态
    const stageState = reactive<StageState>({
      fishList:[]
    })
    // 使用鼠标坐标作为状态
    const {mousePos:destination} = useMouse(); //管理指针坐标
    // 现在的锦鲤数
    const fishCount = computed(()=>stageState.fishList.length);
    // 更新锦鲤的位置和速度
    const updateFish = () => {
      const destPoint = new Point(destination.x, destination.y);
      stageState.fishList.forEach((fish) => fish.update(destPoint));
    };
    //添加锦鲤数量
    const addFish = ()  => {
      stageState.fishList.push(new FishModel());
      ctx.emit("count-changed", fishCount.value);
    };
    // 移除锦鲤
    const removeFish = () => {
      stageState.fishList.shift();
      ctx.emit("count-changed", fishCount.value);
    }
    // 更新锦鲤状态
    useAnimationFrame(()=> {
      updateFish();
      if (fishCount.value < props.maxFish){
        addFish();
      }else if (fishCount.value > props.maxFish){
        removeFish();
      }
      //如果它返回 true,它将被重复调用直到销毁
      return true
    });

    //点击动作,施加与光标方向相反的力,使锦鲤逃脱
    useClick(() => {
      stageState.fishList.forEach((fish) =>
          fish.setForce(-1 - Math.random() * 4)
      );
    });

    return {
      stageState
    }
  }
})
</script>

创建了一个FishModel类来定义锦鲤的状态和运动

import { Point } from "./Point";

const DEFAULT_FORCE = 0.25;
const FORCE_DECREMENT_RATE = 0.03;
let instanseCount = 0;

const random = (min: number, max: number) => min + (max - min) * Math.random();

const randomPoint = (): Point => {
    return new Point(
        Math.random() * window.innerWidth,
        Math.random() * window.innerHeight
    );
};

const randomFishColor = (): string => {
    const isRed = Math.random() < 0.8;
    return isRed
        ? `hsl(${random(0, 20)}, 80%, 60%)`
        : `hsl(${random(240, 260)}, 30%, 40%)`;
};

const randomScale = (): number => {
    return Math.random() * 0.5 + 0.6;
};
export class FishModel {
    readonly id = instanseCount++;
    //位置
    position = randomPoint();
    // 方向
    angle = Math.random() * 360;
    // 速度矢量图
    vector = new Point();
    //目标导向的力量
    force = DEFAULT_FORCE;
    //颜色
    color = randomFishColor();
    // 缩放
    scale = randomScale();
    
    insensitiveTerms = 0;
    //更新锦鲤速度和位置
    update(destPoint: Point) {
        const MAX_SPEED = 3;
        this.force = this.force * (1 - FORCE_DECREMENT_RATE) + DEFAULT_FORCE * FORCE_DECREMENT_RATE
        if (this.insensitiveTerms <= 0) {
            const distVec = destPoint.sub(this.position);
            const dist = distVec.length;
            const aVec = distVec.unit((dist * this.force) / 100);
            this.vector = this.vector.add(aVec).limit(MAX_SPEED);
            if (dist < 20) {
                this.vector = this.vector.rotate(random(-70, 70));
                this.insensitiveTerms = random(20, 30);
            }
        } else {
            this.insensitiveTerms--;
        }
        this.angle = this.vector.angle + 180;
        this.position = this.position.add(this.vector);
    }
  //设置锦鲤向“目标点”移动的力。如果你指定一个负值,它会排斥并从该点逃逸
    setForce(value: number) {
        this.force = value;
    }
}

创建一个Point

export class Point {
    readonly x: number;
    readonly y: number;
    constructor(x = 0, y = 0, a = 0) {
        this.x = x;
        this.y = y;
    }

    get length(): number {
        return Math.sqrt(this.x ** 2 + this.y ** 2);
    }

    get angle(): number {
        const rad2angle = (r: number): number => (r / Math.PI) * 180;
        return rad2angle(Math.atan2(this.y, this.x));
    }

    add(p: Point): Point {
        return new Point(this.x + p.x, this.y + p.y);
    }

    sub(p: Point): Point {
        return new Point(this.x - p.x, this.y - p.y);
    }

    times(n: number): Point {
        return new Point(this.x * n, this.y * n);
    }

    unit(unitLength = 1): Point {
        const len = this.length;
        return new Point((this.x / len) * unitLength, (this.y / len) * unitLength);
    }

    limit(maxLength = 1): Point {
        return this.length <= maxLength ? this : this.unit(maxLength);
    }

    rotate(deg: number): Point {
        const angle2rad = (a: number): number => (a * Math.PI) / 180;
        const rad = angle2rad(this.angle + deg);
        const l = this.length;
        return new Point(Math.cos(rad) * l, Math.sin(rad) * l);
    }
}

useMouse单独管理指针坐标

useMouse是在composition-api的讲解中几乎总是会出现的一个sample,但它其实是编写交互式事件处理时使用composition-api的一种非常有效的方式。

import { onMounted, reactive, onUnmounted } from "vue";

export const useMouse = (targetDom?: HTMLElement) => {
    const mousePos = reactive({
        x: 0,
        y: 0,
    });
    //可以通过“在PC上移动光标”或“在手机上触摸并滑动”来引导锦鲤
    const onMove = (ev: PointerEvent): void => {
        mousePos.x = ev.clientX;
        mousePos.y = ev.clientY;
    };
    const onMoveTouch = (ev: TouchEvent): void => {
        mousePos.x = ev.touches[0].clientX;
        mousePos.y = ev.touches[0].clientY;
    };
    onMounted(() => {
        const target = targetDom ?? document.body;
        target.addEventListener("pointermove", onMove);
        target.addEventListener("touchmove", onMoveTouch);
    });
    onUnmounted(() => {
        const target = targetDom ?? document.body;
        target.removeEventListener("pointermove", onMove);
        target.removeEventListener("touchmove", onMoveTouch);
    });

    return {
        mousePos,
    };
};

requestAnimationFrame的管理

在没有动画库的情况下实现交互式游戏或动画表达式时,window.requestAnimation会大量使用计时器。这种代码也让组件变长,可读性降低,所以最好用composition-api暴露出来。

import { onMounted, onBeforeUnmount } from "vue";

//返回 true 以继续调用下一帧
export const useAnimationFrame = (onFire: () => boolean) => {
    let isTerminated = false;

    onMounted(() => {
        const tick = () => {
            requestAnimationFrame(() => {
                if (isTerminated) {
                    return;
                }
                const shouldContinue = onFire();
                if (shouldContinue) {
                    tick();
                }
            });
        };
        tick();
    });
    onBeforeUnmount(() => {
        isTerminated = true;
    });

    return {};
};

绘制一个背景组件StageBg

<template>
  <!-- 绘制背景的组件 -->
  <div class="StageBgRoot">
    <transition-group name="list">
      <div
          class="Stone"
          v-for="stone in stones"
          :key="stone.id"
          :style="{
          left: `calc(${stone.x * 100}% - ${stone.size / 2}px)`,
          top: `calc(${stone.y * 100}% - ${stone.size / 2}px)`,
          width: `${stone.size}px`,
          height: `${stone.size}px`,
          backgroundColor: stone.color,
        }"
      />
    </transition-group>
    <slot />
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import { useTicker } from "../core/useTicker";

type StoneModel = {
  id: number;
  x: number;
  y: number;
  size: number;
  color: string;
};

const randomStoneColor = (sizeRate: number): string => {
  const isRed = sizeRate < 0.25;
  const l = (1 - sizeRate) * 50 + 10;
  return isRed
      ? `hsl(${0 + Math.random() * 30}, 70%, 70%, ${1 - sizeRate})`
      : `hsl(${170 + Math.random() * 50}, 30%, ${l}%, ${1 - sizeRate})`;
};

const createStone = (): StoneModel => {
  const sizeR = Math.random();
  return {
    id: Math.random(),
    x: Math.random(),
    y: Math.random(),
    size: 20 + sizeR ** 2 * 200,
    color: randomStoneColor(sizeR),
  };
};

const MAX_STONE = 50;

export default defineComponent({
  name: "StageBg",
  setup(_, ctx) {
    const stones = ref<StoneModel[]>([]);

    const addStone = () => {
      stones.value.push(createStone());
      if (stones.value.length > MAX_STONE) {
        stones.value.shift();
      }
    };

    useTicker(addStone, 400);
    return {
      stones,
    };
  },
});
</script>

<style lang="scss" scoped>
.Stone.list-enter-from, .Stone.list-leave-to {
  opacity: 0;
}

.Stone {
  position: absolute;
  border-radius: 100%;
  opacity: 1;
  transition: opacity 5s;
}

.StageBgRoot {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
</style>

useTicker 规定时间内增加的石头

import { onMounted, onUnmounted } from "vue";

export const useTicker = (onTick: () => void, interval = 1000) => {
    let timer = 0;
    onMounted(() => {
        timer = window.setInterval(onTick, interval);
    });
    onUnmounted(() => {
        window.clearInterval(timer);
    });

    return {};
};

背景和锦鲤组合成一个组件stageFish

<template>
  <!--合成背景、锦鲤-->
  <stage-bg class="StageRoot" >
    <FishLayer :maxFish="maxFish" @count-changed="fishCountChanged" />
  </stage-bg>

</template>

<script lang="ts">
import FishLayer from "./FishLayer.vue";
import {defineComponent} from "vue";
import StageBg from "./stageBg.vue";
export default defineComponent({
  name: "stageFish",
  components: {StageBg, FishLayer},
  props: {
    maxFish: { type: Number, default: 50 },
  },
  setup(props,ctx) {
    //锦鲤数量变化时的事件
    const fishCountChanged = (count: number) => {
      ctx.emit('count-changed', count);
    }
    return {
      fishCountChanged
    }
  }
})
</script>

使用

<template>
  <div id="app">
    <div class="Control">
      <button @click="addFish">Add 10 Fish</button>
      <button @click="removeFish">Remove 10 Fish</button>
      <span>fish count = {{ fishCount }}</span>
    </div>
    <stage-fish :maxFish="maxFish" @count-changed="fishCountChanged" />
  </div>
</template>

<script setup lang="ts">
import {ref} from "vue";
import StageFish from "./components/stageFish.vue";
const maxFish = ref(10)
const fishCount = ref(0)
const addFish = () => {
  maxFish.value += 2;
}
const removeFish = () => {
  maxFish.value = Math.max(0, maxFish.value - 10);

}
const fishCountChanged = (count: number) => {
  fishCount.value = count;
}


</script>

<style lang="scss">
* {
  box-sizing: border-box;
}
html,
body {
  margin: 0;
  padding: 0;
  position: relative;
  height: 100%;
  background-color: rgb(31, 36, 43);
  color: rgb(80, 110, 124);
  font-family: "Franklin Gothic Medium", "Arial Narrow", Arial, sans-serif;
  overflow: hidden;
}
button {
  display: inline-block;
  margin-right: 5px;
  border: 2px solid rgb(80, 110, 124);
  color: rgb(80, 110, 124);
  font-family: "Franklin Gothic Medium", "Arial Narrow", Arial, sans-serif;
  padding: 2px 5px;
  background-color: transparent;
}
#app {
  position: absolute;
  width: 100%;
  height: 100%;
}
.Control {
  position: absolute;
  z-index: 1;
  width: 100%;
  padding: 5px;
  background-color: #00000066;
}

</style>

版权声明:本文内容由TeHub注册用户自发贡献,版权归原作者所有,TeHub社区不拥有其著作权,亦不承担相应法律责任。 如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

点赞(0)
收藏(0)
Hedy
大家好!我是一位前端开发工程师,拥有6年以上的前端开发经验。我熟练掌握HTML、CSS、JavaScript等语言,能够灵活运用各种前端框架,如Vue、React、Uniapp、Flutter等。我注重理论与实践相结合,能够为学员提供丰富的案例和实践项目,并以生动、易懂的语言为学员讲解前端开发的核心知识和技能。我不仅注重传授技能,更关注学员的职业发展,希望通过我的教学,帮助学员成为一名优秀的前端开发工程师。

评论(0)

添加评论