制作一个 myFish 锦鲤组件
通过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>