Skip to content

引力 -1

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <canvas v-if="isRunning" id="gravity1" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('gravity1')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const particles: any = []
  const totalNum = 50

  for (let i = 0; i < totalNum; i++) {
    const color = Math.random() * (0xffffff)
    const ball = new Ball(5, color)
    ball.x = Math.random() * canvas.width
    ball.y = Math.random() * canvas.height
    ball.mass = 1
    particles.push(ball)
  }

  const gravitate = (partA, partB) => {
    const dx = partB.x - partA.x
    const dy = partB.y - partA.y
    const distQ = dx * dx + dy * dy
    const dist = Math.sqrt(distQ)

    // F = (G * m1 * m2) / (r * r)
    // r 是两者的距离,不是半径
    // 此处省略G
    const F = partA.mass * partB.mass / distQ

    // F = m * a
    // 在 x 轴方向上,加速度分量 ax 为 
    // ax = Fx / mA
    // (F * △x / r) / mA
    // (F * △x) / (mA * r)
    // 其中 r = Math.sqrt(distQ)

    // 加速度
    // dx/dist 和 dy/dist 是单位向量的分量,分别表示引力在水平方向和垂直方向上的加速度分量,表示引力在水平和垂直方向上的投影
    const ax = F * dx / dist
    const ay = F * dy / dist
    partA.vx += ax / partA.mass
    partA.vy += ay / partA.mass
    partB.vx -= ax / partB.mass
    partB.vy -= ay / partB.mass
  }

  const draw = (particle) => {
    particle.draw(ctx)
  }

  // 受引力移动
  const move = (partA, i) => {
    partA.x += partA.vx
    partA.y += partA.vy

    for (let j = i + 1; j < totalNum; j++) {
      const partB = particles[j]
      gravitate(partA, partB)
    }
  }

  


  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    particles.forEach(move)
    particles.forEach(draw)
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
}

onUnmounted(() => {
  destroy()
})
</script>

引力 -2

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <canvas v-if="isRunning" id="gravity2" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

const BLACK_HOLD_RADIUS = 50
const BLACK_HOLD_MASS = Math.pow(10, 3)

class BlackHold {
  ctx: any
  x: number
  y: number
  radius: number
  color: string
  mass: number
  rotationAngle: number

	constructor(ctx, config) {
		const {
			x,
			y,
			radius,
			color
		} = config
		this.ctx = ctx
		this.x = x
		this.y = y
		this.radius = radius
		this.color = color
		this.mass = BLACK_HOLD_MASS
		this.rotationAngle = 0
	}

	addRotatinAngle() {
		this.rotationAngle += 0.005
	}

	draw() {
		this.addRotatinAngle()
		this.drawRound()
		this.drawRect()
	}

	drawRound() {
		this.ctx.save()
		this.ctx.beginPath()
		this.ctx.fillStyle = this.color
		this.ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
		this.ctx.fill()
		this.ctx.restore()
	}

	drawRect() {
		this.ctx.save()
		this.ctx.beginPath()
		// 将画布原点移动到矩形的中心
		this.ctx.translate(this.x, this.y)
		// 应用旋转
		this.ctx.rotate(this.rotationAngle)
		this.ctx.fillStyle = 'orange'
		// this.ctx.fillRect(this.x - 25, this.y - 25, 50, 50) // 因为平移到this.x, this.y,所以不用this.x - 25, this.y - 25
		this.ctx.fillRect(-25, -25, 50, 50)
		this.ctx.fill()
		this.ctx.restore()
	}
}


class Ball {
  ctx: any
  x: number
  y: number
  radius: number
  color: string
  mass: number
  angle: number
  orbitRadius: number // 设置轨道半径
  tangentialSpeed: number // 切向速度
  vx: number
  vy: number
  isAlive: boolean

	constructor(ctx, config) {
		const {
			radius,
			x,
			y,
			color,
			mass,
			angle,
			orbitRadius, // 设置轨道半径
		} = config
		this.ctx = ctx
		this.radius = radius
		this.x = x
		this.y = y
		this.color = color
		this.mass = mass
		this.angle = angle
		this.orbitRadius = orbitRadius // 设置轨道半径
		this.tangentialSpeed = Math.sqrt(BLACK_HOLD_MASS / this.orbitRadius) // 根据轨道半径计算切向速度,v = √ ̄(GM/r),M是被围绕的物体,r是两者的距离

		this.vx = 0
		this.vy = 0

		this.isAlive = true
	}


	draw() {
		this.ctx.save()
		this.ctx.beginPath()
		this.ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI)
		this.ctx.fillStyle = this.color
		this.ctx.fill()
		this.ctx.restore()
	}

	destroy() {
		this.isAlive = false
	}
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('gravity2')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height
  const centerX = width / 2
  const centerY = height / 2

  const gravitate = (blackHold, star) => {
    const dx = star.x - blackHold.x
    const dy = star.y - blackHold.y
    const distQ = dx * dx + dy * dy
    const dist = Math.sqrt(distQ)

    // 引力计算,计算朝着黑洞前进的x,y
    const F = blackHold.mass * star.mass / distQ
    // 引力加速度分量
    const ax = F * dx / dist
    const ay = F * dy / dist
    star.vx -= ax / star.mass
    star.vy -= ay / star.mass
    star.x += star.vx
    star.y += star.vy

    // 计算完引力后,计算围绕黑洞旋转的x,y
    const afterDx = star.x - blackHold.x
    const afterDy = star.y - blackHold.y
    const afterDistQ = afterDx * afterDx + afterDy * afterDy
    const afterDist = Math.sqrt(afterDistQ)
    star.angle += star.tangentialSpeed / star.orbitRadius
    star.orbitRadius = afterDist
    star.x = blackHold.x + star.orbitRadius * Math.cos(star.angle)
    star.y = blackHold.y + star.orbitRadius * Math.sin(star.angle)
  }

  const draw = particle => {
    particle.draw(particle.ctx)
  }

  const move = (blackHold, star) => {
    gravitate(blackHold, star)
  }

  const destroy = (blackHold, star) => {
    const blackHoldRadius = blackHold.radius
    const starRadius = star.radius

    const dx = star.x - blackHold.x
    const dy = star.y - blackHold.y
    const distQ = dx * dx + dy * dy
    const dist = Math.sqrt(distQ)

    const deathLine = Math.abs(starRadius - blackHoldRadius)

    return dist <= deathLine
  }

  let starAliveList: any = []
  
  const blackHold = new BlackHold(ctx, {
    radius: BLACK_HOLD_RADIUS,
    x: centerX,
    y: centerY,
    color: '#000',
    mass: BLACK_HOLD_MASS
  })

  for (let i = 0; i < 2; i++) {
    const radius = Math.random() * 15
    const mass = radius * Math.random()
    const orbitRadius = 50 + Math.random() * 200 // 随机生成轨道半径
    const angle = Math.random() * 2 * Math.PI // 随机生成初始角度
    const x = centerX + orbitRadius * Math.cos(angle) // 根据轨道半径和角度计算初始 x 坐标
    const y = centerY + orbitRadius * Math.sin(angle) // 根据轨道半径和角度计算初始 y 坐标
    const star = new Ball(ctx, {
      radius: radius,
      x: x,
      y: y,
      color: '#0c7',
      mass: Math.pow(10, mass),
      angle: angle,
      orbitRadius: orbitRadius // 设置轨道半径
    })

    starAliveList.push(star)
  }


  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    starAliveList.forEach(star => {
      move(blackHold, star)
      draw(star)

      const isDeath = destroy(blackHold, star)
      if (isDeath) star.destroy()
    })

    

    starAliveList = starAliveList.filter(v => v.isAlive)
    if(starAliveList.length <= 0 ) {
      for (let i = 0; i < 2; i++) {
        const radius = Math.random() * 15
        const mass = radius * Math.random()
        const orbitRadius = 50 + Math.random() * 200 // 随机生成轨道半径
        const angle = Math.random() * 2 * Math.PI // 随机生成初始角度
        const x = centerX + orbitRadius * Math.cos(angle) // 根据轨道半径和角度计算初始 x 坐标
        const y = centerY + orbitRadius * Math.sin(angle) // 根据轨道半径和角度计算初始 y 坐标
        const star = new Ball(ctx, {
          radius: radius,
          x: x,
          y: y,
          color: '#0c7',
          mass: Math.pow(10, mass),
          angle: angle,
          orbitRadius: orbitRadius // 设置轨道半径
        })

        starAliveList.push(star)
      }
    }

    blackHold.draw()


    requestID.value = requestAnimationFrame(runAnimate)

  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
}

onUnmounted(() => {
  destroy()
})
</script>

摩擦力 -1

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <canvas v-if="isRunning" id="friction1" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('friction1')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const f = 0.9
  let vx = Math.random() * 40 - 25
  let vy = Math.random() * 40 - 25
  const color = Math.random() * (0xffffff)
  const ball = new Ball(5, color)
  ball.x = canvas.width / 2
  ball.y = canvas.height / 2


  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    vx *= f
    vy *= f

    ball.x += vx
    ball.y += vy

    ball.draw(ctx)
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
}

onUnmounted(() => {
  destroy()
})
</script>

摩擦力 -2

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <canvas v-if="isRunning" id="friction2" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('friction2')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const f = 0.05
  let speed = 0
  let angle = 0
  let vx = 4
  let vy = 3
  const color = Math.random() * (0xffffff)
  const ball = new Ball(5, color)
  ball.x = canvas.width / 2
  ball.y = canvas.height / 2


  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    speed = Math.sqrt(vx * vx + vy * vy)
    angle = Math.atan2(vy, vx)
    
    if (speed > f) {
      speed -= f // 通过摩擦力减小速度
    } else {
      speed = 0
    }
    
    vx = Math.cos(angle) * speed
    vy = Math.sin(angle) * speed
    
    ball.x += vx
    ball.y += vy

    ball.draw(ctx)
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
}

onUnmounted(() => {
  destroy()
})
</script>

小球碰撞 -1(说明)

点击运行
点击展开图片
点击展开文字
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <div @click="isShowImg = !isShowImg" class="pointer">{{ !isShowImg ? '点击展开图片' : '点击收起图片' }}</div>
    <div @click="showText = !showText" class="pointer">{{ !showText ? '点击展开文字' : '点击收起文字' }}</div>
    <div v-if="isShowImg">
      <img src="/public/markdown/canvas/part4/ballCollision1-1.png" alt="">
      <img src="/public/markdown/canvas/part4/ballCollision1-2.png" alt="">
      <img src="/public/markdown/canvas/part4/ballCollision1-3.png" alt="">
      <img src="/public/markdown/canvas/part4/ballCollision1-4.png" alt="">
      <img src="/public/markdown/canvas/part4/ballCollision1-5.png" alt="">
    </div>
    <div v-if="showText">
      <div>1、首先把物体的位置,速度全部都旋转到了水平位置。速度是矢量,把它沿着水平和竖直分解。</div>
      <div>2、可以直接忽视竖直方向的速度,而只考虑水平方向。对物体做碰撞处理,因为只考虑改变水平方向,所以竖直方向不变,处理完水平的碰撞后,将竖直方向的速度与水平方向的合成。</div>
      <div>3、最后,把所有的东西在旋转回去。</div>
      <div>4、使用旋转公式,以其中一个物体为原点,旋转整个系统,将两物体的中心连线置为水平场景。</div>
      <div>5、求出物体x轴上的速度。</div>
      <div>6、使用动量守恒计算x轴上的碰撞后速度。</div>
      <div>7、再旋转回来。</div>
      <div>注意:旋转是以ball0为原点进行的,也就是说旋转中的所有位置和速度都是相对于ball0的,所有回旋后的位置和速度需要转换成相对于相对区域位置。</div>
    </div>
    <canvas v-if="isRunning" id="ballCollision1" class="stage"></canvas>
  </div>
</template>


<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)
const isShowImg = ref(false)
const showText = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('ballCollision1')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const bounce = -1.0
  const balls: any = []
  const ballNum = 4
           
  for(let i = 0; i < ballNum; i++) {
    const radius = Math.random() * 50 + 5;
    const color = Math.random()*(0xffffff);
    const ball = new Ball(radius, color);
    ball.mass = Math.random() * 5 + 2;
    ball.x = Math.random() * canvas.width;
    ball.y = Math.random() * canvas.height;
    ball.vx = Math.random() * 6 - 3;
    ball.vy = Math.random() * 6 - 3;
    balls.push(ball);
  }

  // 实现二维坐标旋转的功能,根据给定的正弦值(sin)和余弦值(cos)将一个点 (x,y) 旋转到新的位置
  // reverse 参数,用于控制旋转的方向(顺时针或逆时针),true是顺时针,false是逆时针
  function rotate (x, y, sin, cos, reverse) {
    return {
      x: (reverse) ? (x * cos + y * sin) : (x * cos - y * sin),
      y: (reverse) ? (y * cos - x * sin) : (y * cos + x * sin)
    }
  }

  function checkWalls (ball) {
    if (ball.x + ball.radius > canvas.width) {
      ball.x = canvas.width - ball.radius
      ball.vx *= bounce
    } else if (ball.x - ball.radius < 0) {
      ball.x = ball.radius
      ball.vx *= bounce
    }
    
    if (ball.y + ball.radius > canvas.height) {
      ball.y = canvas.height - ball.radius
      ball.vy *= bounce
    } else if (ball.y - ball.radius < 0) {
      ball.y = ball.radius
      ball.vy *= bounce
    }
  }

  // 为什么是
  // const dx = ballB.x - ballA.x
  // const dy = ballB.y - ballA.y
  // 而不能
  // const dx = ballA.x - ballB.x
  // const dy = ballA.y - ballB.y
  // 因为选择 dx = ballA.x - ballB.x 和 dy = ballA.y - ballB.y,那么后续的计算需要相应调整:
  // 1. 角度计算
  //   如果选择 dx = ballA.x - ballB.x 和 dy = ballA.y - ballB.y,那么计算角度时:
  //   const angle = Math.atan2(dy, dx);
  //   这里的 angle 将是从 B 到 A 的方向,而不是从 A 到 B
  // 2. 旋转方向
  //   在旋转坐标和速度时,需要调整旋转的方向。例如:
  //   const posB = rotate(dx, dy, sin, cos, true);
  //   如果 dx 和 dy 的方向相反,那么旋转后的坐标和速度也会受到影响
  // 3. 碰撞响应
  //   在碰撞响应中,相对速度的计算也会受到影响。例如:
  //   const vxTotal = velA.x - velB.x;
  //   如果 dx 和 dy 的方向相反,那么 velA.x 和 velB.x 的方向也会相反,这可能需要调整碰撞响应的公式
  function checkCollision (ballA, ballB) {
    const dx = ballB.x - ballA.x
    const dy = ballB.y - ballA.y
    const dist = Math.sqrt(dx * dx + dy * dy)

    if (dist < ballA.radius + ballB.radius) {
      // 计算两物体间的夹角,得出坐标旋转需要的sin,cos
      const angle = Math.atan2(dy, dx)
      const sin = Math.sin(angle)
      const cos = Math.cos(angle)

      // ballA旋转后的坐标,假设ballA是原点
      const posA = { x: 0, y: 0 }
      // ballB旋转后的坐标
      const posB = rotate(dx, dy, sin, cos, true)

      // ballA旋转后的速度
      const velA = rotate(ballA.vx, ballA.vy, sin, cos, true)
      // ballB旋转后的速度
      const velB = rotate(ballB.vx, ballB.vy, sin, cos, true)

      // vxTotal 碰撞前的相对速度
      // 动量守恒,弹性碰撞公式
      // vA' = ((mA - mB) * vA + 2 * mB * vB) / (mA + mB)
      // vB' = vA' + (vA - vB)
      // 当然,vB' 也可以用公式计算 vB' = ((mB - mA) * vB + 2 * mA * vA) / (mA + mB)
      // 这里是节省计算采用 vB' = vA' + (vA - vB)
      const vxTotal = velA.x - velB.x

      // 计算碰撞后的速度
      velA.x = ((ballA.mass - ballB.mass) * velA.x + 2 * ballB.mass * velB.x) / (ballA.mass + ballB.mass)
      velB.x = vxTotal + velA.x

      // 更新ballA和ballB的坐标
      posA.x += velA.x
      posB.x += velB.x

      // 此时要旋转回去
      const posA2Back = rotate(posA.x, posA.y, sin, cos, false)
      const posB2Back = rotate(posB.x, posB.y, sin, cos, false)

      // 1、先调整球体实际位于屏幕的位置
      // 要先调整B再调整A
      ballB.x = ballA.x + posB2Back.x
      ballB.y = ballA.y + posB2Back.y
      ballA.x = ballA.x + posA2Back.x
      ballA.y = ballA.y + posA2Back.y

      // 2、再调整球体实际位于屏幕的速度
      const velA2Back = rotate(velA.x, velA.y, sin, cos, false)
      const velB2Back = rotate(velB.x, velB.y, sin, cos, false)

      ballA.vx = velA2Back.x
      ballA.vy = velA2Back.y
      ballB.vx = velB2Back.x
      ballB.vy = velB2Back.y
    }
  }


  const drawLine = (ball0, ball1, context) => {
    const dx = ball1.x - ball0.x
    const dy = ball1.y - ball0.y
    const dist = Math.sqrt(dx * dx + dy * dy)

    if(dist < 400) {
      context.save()
      context.strokeStyle = "rgba(1, 255, 255, 0.5)"
      context.beginPath()
      context.moveTo(ball0.x, ball0.y)
      context.lineTo(ball1.x, ball1.y)
      context.closePath()
      context.stroke()
      context.restore()
    }
  }

  const move = (ball) => {
    ball.x += ball.vx
    ball.y += ball.vy
    checkWalls(ball)
  }

  
  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    balls.forEach(ball => {
      move(ball)
    })

    for(let i = 0; i < ballNum - 1; i++) {
      let ballA = balls[i]
      for(var j = i + 1; j < ballNum; j++) {
        let ballB = balls[j]
        checkCollision(ballA, ballB)
        drawLine(ballA, ballB, ctx)
      }
    }

    balls.forEach(ball => {
      ball.draw(ctx)
    })
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
}

onUnmounted(() => {
  destroy()
})
</script>

小球碰撞 -2

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <canvas v-if="isRunning" id="ballCollision2" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

function Line (x1, y1, x2, y2) {
  this.x = 0
  this.y = 0
  this.x1 = (x1 === undefined) ? 0 : x1
  this.y1 = (y1 === undefined) ? 0 : y1
  this.x2 = (x2 === undefined) ? 0 : x2
  this.y2 = (y2 === undefined) ? 0 : y2
  this.rotation = 0
  this.scaleX = 1
  this.scaleY = 1
  this.lineWidth = 1
}

Line.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.beginPath()
  context.moveTo(this.x1, this.y1)
  context.lineTo(this.x2, this.y2)
  context.closePath()
  context.stroke()
  context.restore()
}

Line.prototype.getBounds = function () {
  if(this.rotation === 0){
    const minX = Math.min(this.x1, this.x2)
    const minY = Math.min(this.y1, this.y2)
    const maxX = Math.max(this.x1, this.x2)
    const maxY = Math.max(this.y1, this.y2)
    return {
      x: this.x + minX,
      y: this.y + minY,
      width: maxX - minX,
      height: maxY - minY
    }
  } else {
    const sin = Math.sin(this.rotation)
    const cos = Math.cos(this.rotation)
    const x1r = cos * this.x1 + sin * this.y1
    const x2r = cos * this.x2 + sin * this.y2
    const y1r = cos * this.y1 + sin * this.x1
    const y2r = cos * this.y2 + sin * this.x2
    return {
      x: this.x + Math.min(x1r, x2r),
      y: this.y + Math.min(y1r, y2r),
      width: Math.max(x1r, x2r) - Math.min(x1r, x2r),
      height: Math.max(y1r, y2r) - Math.min(y1r, y2r)
    } 
  }
}


const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('ballCollision2')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const gravity = 0.05
  const bounce = -0.3
  const lines: any = []
  const lineInfo = [[200, 80, 20], [220, 160, 28], [320, 120, -20], [310, 200, -25], [190, 240, 15], [300, 260, -30], [210, 300, 20]]
  const ball = new Ball(12, '#ff0000')
  ball.x = 200
  ball.y = 50

  for (let i = 0; i < lineInfo.length; i++) {
    const info = lineInfo[i]
    const line = new Line(-50, 0, 50, 0)
    line.x = info[0]
    line.y = info[1]
    line.rotation = info[2] * Math.PI / 180
    lines.push(line)
  }

  const checkLine = (line) => {
    // 调用 line.getBounds() 方法获取线段的边界框(bound)
    // 边界框是一个矩形,通常包含属性 x、y、width 和 height,表示线段在旋转前的最小外接矩形
    const bound = line.getBounds()
  
    // ball.x + ball.radius > bound.x:球的右边界是否超出边界框的左边界
    // ball.x - ball.radius < bound.x + bound.width:球的左边界是否在边界框的右边界内
    if (ball.x + ball.radius > bound.x && ball.x - ball.radius < bound.x + bound.width) {
      // 计算线段的旋转角度 line.rotation 的余弦值 cos 和正弦值 sin
      // 这些值将用于后续的旋转坐标变换
      const cos = Math.cos(line.rotation)
      const sin = Math.sin(line.rotation)

      // 将球的坐标 (ball.x, ball.y) 转换为相对于线段起点 (line.x, line.y) 的局部坐标 (x1, y1)
      let x1 = ball.x - line.x
      let y1 = ball.y - line.y

      // 使用旋转矩阵公式将球的局部坐标 (x1, y1) 旋转到线段的旋转方向
      // y2 是旋转后的 y 坐标
      let y2 = cos * y1 - sin * x1

      // vy1 是球的速度在旋转后的 y 方向上的分量
      let vy1 = ball.vy * cos - ball.vx * sin


      // 检查旋转后的球的 y 坐标 y2 是否在 -ball.radius 和 vy1 之间,判断球是否与线段发生碰撞
      if (y2 > -ball.radius && y2 < vy1) {

        // 使用旋转矩阵公式计算旋转后的 x 坐标 x2
        const x2 = x1 * cos + y1 * sin
                       
        // 计算旋转后的球的速度在 x 方向上的分量 vx1
        const vx1 = ball.vx * cos + ball.vy * sin
        
        // 将球的 y 坐标调整为 -ball.radius,表示球与线段碰撞后的位置
        // 目的是为了确保球在碰撞后不会嵌入线段内部,而是正确地“贴合”在线段的边缘
        y2 = -ball.radius

        // 将球的速度分量 vy1 乘以反弹系数 bounce,模拟碰撞后的反弹效果
        vy1 *= bounce
          
        // 使用逆旋转矩阵公式将球的坐标 (x2, y2) 旋转回全局坐标系 (x1, y1)
        x1 = x2 * cos - y2 * sin
        y1 = y2 * cos + x2 * sin
        

        // 使用逆旋转矩阵公式将球的速度 (vx1, vy1) 旋转回全局坐标系 (ball.vx, ball.vy)
        ball.vx = vx1 * cos - vy1 * sin
        ball.vy = vy1 * cos + vx1 * sin
      
        // 将球的局部坐标 (x1, y1) 转换回全局坐标 (ball.x, ball.y),并更新球的位置
        ball.x = line.x + x1
        ball.y = line.y + y1
      }
    }
  }

  const checkWalls = () => {
    if (ball.x + ball.radius > width) {
      ball.x = width - ball.radius
      ball.vx *= bounce
    } else if (ball.x - ball.radius < 0) {
      ball.x = ball.radius
      ball.vx *= bounce
    }
    
    if (ball.y + ball.radius > height) {
      ball.y = height - ball.radius
      ball.vy *= bounce
    } else if (ball.y - ball.radius < 0) {
      ball.y = ball.radius
      ball.vy *= bounce
    }
  }

  const drawLines = (line) => {
    checkLine(line)
    line.draw(ctx)
  }

  
  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    ball.vy += gravity
    ball.x += ball.vx
    ball.y += ball.vy

    checkWalls()

    lines.forEach(drawLines)
    ball.draw(ctx)

  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
}

onUnmounted(() => {
  destroy()
})
</script>

小球碰撞 -3

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer flex flex-end">
      <div>点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <div id="ballCollision3Box">
      <canvas v-if="isRunning" id="ballCollision3" class="stage bg-white"></canvas>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)
const angle = ref(0)
const lineX = 50
const lineY = 180

const onTrigger = async () => {
  angle.value = 0
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}

function intersects (rectA, rectB) {
  return !(rectA.x + rectA.width < rectB.x ||
            rectB.x + rectB.width < rectA.x ||
            rectA.y + rectA.height < rectB.y ||
            rectB.y + rectB.height < rectA.y)
}

function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

function Line (x1, y1, x2, y2) {
  this.x = 0
  this.y = 0
  this.x1 = (x1 === undefined) ? 0 : x1
  this.y1 = (y1 === undefined) ? 0 : y1
  this.x2 = (x2 === undefined) ? 0 : x2
  this.y2 = (y2 === undefined) ? 0 : y2
  this.rotation = 0
  this.scaleX = 1
  this.scaleY = 1
  this.lineWidth = 1
}

Line.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.beginPath()
  context.moveTo(this.x1, this.y1)
  context.lineTo(this.x2, this.y2)
  context.closePath()
  context.stroke()
  context.restore()
}

Line.prototype.getBounds = function () {
  if(this.rotation === 0){
    const minX = Math.min(this.x1, this.x2)
    const minY = Math.min(this.y1, this.y2)
    const maxX = Math.max(this.x1, this.x2)
    const maxY = Math.max(this.y1, this.y2)
    return {
      x: this.x + minX,
      y: this.y + minY,
      width: maxX - minX,
      height: maxY - minY
    }
  } else {
    const sin = Math.sin(this.rotation)
    const cos = Math.cos(this.rotation)
    const x1r = cos * this.x1 + sin * this.y1
    const x2r = cos * this.x2 + sin * this.y2
    const y1r = cos * this.y1 + sin * this.x1
    const y2r = cos * this.y2 + sin * this.x2
    return {
      x: this.x + Math.min(x1r, x2r),
      y: this.y + Math.min(y1r, y2r),
      width: Math.max(x1r, x2r) - Math.min(x1r, x2r),
      height: Math.max(y1r, y2r) - Math.min(y1r, y2r)
    } 
  }
}

const onMouseMove = (e) => {
  const x = e.offsetX
  const y = e.offsetY
  angle.value = Math.atan2(y - lineY, x - lineX)
}


const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('ballCollision3')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const parent = document.getElementById('ballCollision3Box') as any
  parent.addEventListener('mousemove', onMouseMove)

  const gravity = 0.1
  const bounce = -0.3
  const ball = new Ball(12, '#ff0000')
  ball.x = 100
  ball.y = 50

  const newLine = new Line(0, 0, 400, 0)
  newLine.x = lineX
  newLine.y = lineY
  newLine.rotation = 0
  
  const checkLine = (line) => {
    if (intersects(ball.getBounds(), line.getBounds())) {
      // 计算线段的旋转角度 line.rotation 的余弦值 cos 和正弦值 sin
      // 这些值将用于后续的旋转坐标变换
      const cos = Math.cos(line.rotation)
      const sin = Math.sin(line.rotation)

      // 将球的坐标 (ball.x, ball.y) 转换为相对于线段起点 (line.x, line.y) 的局部坐标 (x1, y1)
      let x1 = ball.x - line.x
      let y1 = ball.y - line.y

      // 使用旋转矩阵公式将球的局部坐标 (x1, y1) 旋转到线段的旋转方向
      // y2 是旋转后的 y 坐标
      let y2 = cos * y1 - sin * x1


      // 检查旋转后的球的 y 坐标 y2 是否大于 -ball.radius
      if (y2 > -ball.radius) {

        // 使用旋转矩阵公式计算旋转后的 x 坐标 x2
        const x2 = x1 * cos + y1 * sin
                       
        // 计算旋转后的球的速度在 x 方向上的分量 vx1
        const vx1 = ball.vx * cos + ball.vy * sin

        // vy1 是球的速度在旋转后的 y 方向上的分量
        // 将球的速度分量 vy1 乘以反弹系数 bounce,模拟碰撞后的反弹效果
        const vy1 = (ball.vy * cos - ball.vx * sin) * bounce
        
        // 将球的 y 坐标调整为 -ball.radius,表示球与线段碰撞后的位置
        y2 = -ball.radius

        // 使用逆旋转矩阵公式将球的坐标 (x2, y2) 旋转回全局坐标系 (x1, y1)
        x1 = x2 * cos - y2 * sin
        y1 = y2 * cos + x2 * sin

        // 使用逆旋转矩阵公式将球的速度 (vx1, vy1) 旋转回全局坐标系 (ball.vx, ball.vy)
        ball.vx = vx1 * cos - vy1 * sin
        ball.vy = vy1 * cos + vx1 * sin
      
        // 将球的局部坐标 (x1, y1) 转换回全局坐标 (ball.x, ball.y),并更新球的位置
        ball.x = line.x + x1
        ball.y = line.y + y1
      }
    }
  }

  const checkWalls = () => {
    if (ball.x + ball.radius > width) {
      ball.x = width - ball.radius
      ball.vx *= bounce
    } else if (ball.x - ball.radius < 0) {
      ball.x = ball.radius
      ball.vx *= bounce
    }
    
    if (ball.y + ball.radius > height) {
      ball.y = height - ball.radius
      ball.vy *= bounce
    } else if (ball.y - ball.radius < 0) {
      ball.y = ball.radius
      ball.vy *= bounce
    }
  }

  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    newLine.rotation = angle.value

    ball.vy += gravity
    ball.x += ball.vx
    ball.y += ball.vy

    
    checkLine(newLine)

    checkWalls()
    newLine.draw(ctx)
    ball.draw(ctx)

  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
  const parent = document.getElementById('ballCollision3Box') as any
  if(parent) parent.removeEventListener('mousemove', onMouseMove)
}

onUnmounted(() => {
  destroy()
})
</script>

缓动 -1(说明)

点击运行
点击展开文字
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <div @click="showText = !showText" class="pointer">{{ !showText ? '点击展开文字' : '点击收起文字' }}</div>
    <div v-if="showText">
      <div>缓动</div>
      <div>object.x += (targetX - object.x) * easing;</div>
      <div>object.y += (targetY - object.y) * easing;</div>
    </div>
    <div id="easing1Box">
      <canvas v-if="isRunning" id="easing1" class="stage"></canvas>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)
const showText = ref(false)
const mouseInfo = ref({ x: 0, y: 0 })
const easing1 = 0.05
const easing2 = 0.07
let vx1 = 0
let vy1 = 0
let vx2 = 0
let vy2 = 0


const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}


function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const onMouseMove = (e) => {
  mouseInfo.value = {
    x: e.offsetX,
    y: e.offsetY
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('easing1')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const parent = document.getElementById('easing1Box') as any
  parent.addEventListener('mousemove', onMouseMove)

  
  const ball1 = new Ball(20, '#ff0000')
  const ball2 = new Ball(10, '#ffff00')

  const ballMove = () => {
    vx1 = (mouseInfo.value.x - ball1.x) * easing1
    vy1 = (mouseInfo.value.y - ball1.y) * easing1
    
    vx2 = (ball1.x - ball2.x) * easing2
    vy2 = (ball1.y - ball2.y) * easing2

    ball1.x += vx1
    ball1.y += vy1
    
    ball2.x += vx2
    ball2.y += vy2
               
    ball1.draw(ctx)
    ball2.draw(ctx)
  }


 

  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    ballMove()
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
  const parent = document.getElementById('easing1Box') as any
  if (parent) parent.removeEventListener('mousemove', onMouseMove)
}

onUnmounted(() => {
  destroy()
})
</script>

弹簧 -1(说明)

点击运行
点击展开文字
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <div @click="showText = !showText" class="pointer">{{ !showText ? '点击展开文字' : '点击收起文字' }}</div>
    <div v-if="showText">
      <div>1、基本弹簧</div>
      <div>vx += (targetX - object.x) * spring;</div>
      <div>vy += (targetY - object.y) * spring;</div>
      <div>object.x += (vx *= f);</div>
      <div>object.y += (vy *= f);</div>
      <div>------------------------------------------</div>
      <div>2、偏移弹簧</div>
      <div>dx = object.x - fixedX;</div>
      <div>dy = object.y - fixedY;</div>
      <div>angle = Math.atan2(dy, dx);</div>
      <div>targetX = fixed + Math.cos(angle) * springLength;</div>
      <div>targetY = fixed + Math.sin(angle) * springLength;</div>
    </div>
    <div id="spring1Box">
      <canvas v-if="isRunning" id="spring1" class="stage"></canvas>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)
const showText = ref(false)
const handles = ref<any>([])
const movingHandle = ref<any>(null)
const handleNum = 3
const spring = 0.03
const f = 0.9


const onTrigger = async () => {
  handles.value = []
  movingHandle.value = null
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}


function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const containsPoint = (rect, x, y) => {
  return !(x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height)
}

const onMouseDown = (event) => {
  handles.value.forEach((handle) => {
    if (containsPoint(handle.getBounds(), event.offsetX, event.offsetY)) {
      movingHandle.value = handle
    }
  })
}

const onMouseUp = (event) => {
  if (movingHandle.value) {
    movingHandle.value = null
  }
}

const onMouseMove = (event) => {
  if (movingHandle.value) {
    movingHandle.value.x = event.offsetX
    movingHandle.value.y = event.offsetY
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('spring1')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const parent = document.getElementById('spring1Box') as any
  parent.addEventListener('mousedown', onMouseDown)
  parent.addEventListener('mouseup', onMouseUp)
  parent.addEventListener('mousemove', onMouseMove)

  
  const ball = new Ball(20, '#ff0000')

  for(let i = 0; i < handleNum; i++){
    const handle = new Ball(10, '#0000ff')
    handle.x = Math.random() * width
    handle.y = Math.random() * height
    handles.value.push(handle)
  }

  // 根据一个“把手”(handle)的位置来更新球(ball)的速度
  // 这种实现通常用于模拟弹簧力对球的作用,使球朝向把手移动
  const applyHandle = (handle) => {
    // dx 表示水平方向的位移差,即把手的 x 坐标减去球的 x 坐标
    // dy 表示垂直方向的位移差,即把手的 y 坐标减去球的 y 坐标
    const dx = handle.x - ball.x
    const dy = handle.y - ball.y
    
    // 位移差 dx 和 dy 分别乘以一个常数 spring,这个常数可以理解为弹簧的弹性系数(或强度)
    // 决定了球朝向把手移动的速度大小
    // 如果 spring 是正数,球会朝向把手移动
    // 如果 spring 是负数,球会远离把手移动
    ball.vx += dx * spring
    ball.vy += dy * spring
  }
    
  // 画操作点到小球的线
  const drawHandle = (handle) => {
    ctx.strokeStyle = 'orange'
    ctx.moveTo(ball.x, ball.y)
    ctx.lineTo(handle.x, handle.y)
    ctx.stroke()
    handle.draw(ctx)
  }


  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)

    handles.value.forEach(applyHandle)

    // f 是一个小于1的正数(例如0.9),这表示球的速度会逐渐减小,模拟了空气阻力或摩擦力对球运动的阻尼效果
    ball.vx *= f
    ball.vy *= f

    ball.x += ball.vx
    ball.y += ball.vy
    
    
    ctx.beginPath()
    handles.value.forEach(drawHandle)
    ctx.closePath()
    
    ball.draw(ctx)
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
  const parent = document.getElementById('spring1Box') as any
  if (parent) {
    parent.removeEventListener('mousedown', onMouseDown)
    parent.removeEventListener('mouseup', onMouseUp)
    parent.removeEventListener('mousemove', onMouseMove)
  }
}

onUnmounted(() => {
  destroy()
})
</script>

弹簧 -2

点击运行
<template>
  <div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <div id="spring2Box">
      <canvas v-if="isRunning" id="spring2" class="stage"></canvas>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, nextTick } from 'vue'

const requestID = ref<any>()
const isRunning = ref(false)
const mouseInfo = ref({ x: 0, y: 0 })
const f = 0.9
const spring = 0.03
const springLength = 100
const gravity = 0.2
let dx = 0
let dy = 0
let vx = 0
let vy = 0
let targetX = 0
let targetY = 0
let angle = 0


const onTrigger = async () => {
  dx = 0
  dy = 0
  vx = 0
  vy = 0
  targetX = 0
  targetY = 0
  angle = 0
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    onRunning()
  } else {
    isRunning.value = false
    destroy()
  }
}


function parseColor (color, toNumber) {
  if (toNumber === true) {
    if (typeof color === 'number') {
      return (color | 0) // chop off decimal
    }
    if (typeof color === 'string' && color[0] === '#') {
      color = color.slice(1)
    }
    return window.parseInt(color, 16)
  } else {
    if (typeof color === 'number') {
      color = '#' + ('00000' + (color | 0).toString(16)).substr(-6)
    }
    return color
  }
}

function Ball(radius, color){
  if (radius === undefined) { radius = 40 }
  if (color === undefined) { color = '#00ff00' }
  this.x = 0
  this.y = 0
  this.vx = 0
  this.vy = 0
  this.radius = radius
  this.rotation = 0
  this.mass = 1
  this.scaleX = 1
  this.scaleY = 1
  this.name = ""
  this.color = parseColor(color, false)
  this.lineWidth = 1
}

Ball.prototype.draw = function (context) {
  context.save()
  context.translate(this.x, this.y)
  context.rotate(this.rotation)
  context.scale(this.scaleX, this.scaleY)
  context.lineWidth = this.lineWidth
  context.fillStyle = this.color
  context.strokeStyle = this.color
  context.beginPath()
  context.arc(0, 0, this.radius, 0, Math.PI * 2, false)
  context.closePath()
  context.fill()
  context.stroke()
  context.restore()
}

// 得到球体的左上角坐标
Ball.prototype.getBounds = function () {
  return {
    x: this.x - this.radius,
    y: this.y - this.radius,
    width: this.radius * 2,
    height: this.radius * 2
  }
}

const onMouseMove = (e) => {
  mouseInfo.value = {
    x: e.offsetX,
    y: e.offsetY
  }
}

const onRunning = async() => {
  await nextTick()
  const canvas: any = document.getElementById('spring2')
  const ctx = canvas.getContext('2d')

  const width = Number(window.getComputedStyle(canvas).width.split('px')[0])
  const height = Number(window.getComputedStyle(canvas).height.split('px')[0])
  canvas.width = width
  canvas.height = height

  const parent = document.getElementById('spring2Box') as any
  parent.addEventListener('mousemove', onMouseMove)
  
  const ball = new Ball(20, '#ff0000')

  // 模拟一个带有弹簧效果和重力影响的球的运动
  const ballSpring = () => {
    // 计算了球的当前位置与鼠标位置之间的水平和垂直位移差
    dx = ball.x - mouseInfo.value.x
    dy = ball.y - mouseInfo.value.y

    angle = Math.atan2(dy, dx);
    
    // 根据夹角和弹簧长度(springLength)计算球的目标位置
    // targetX 是鼠标位置加上沿 x 方向的弹簧长度分量
    // targetY 是鼠标位置加上沿 y 方向的弹簧长度分量
    // 这个是保留一段距离
    // targetX = mouseInfo.value.x + Math.cos(angle) * springLength
    // targetY = mouseInfo.value.y + Math.sin(angle) * springLength
    // 这个是不保留距离
    targetX = mouseInfo.value.x
    targetY = mouseInfo.value.y
    
    // 根据目标位置和球的当前位置计算弹簧力,并更新球的速度
    // (targetX - ball.x) 和 (targetY - ball.y) 分别是球在 x 和 y 方向上与目标位置的位移差
    // 乘以 spring(弹簧系数)后,得到弹簧力对速度的影响
    // 这种实现模拟了弹簧力的线性特性,即力与位移成正比
    vx += (targetX - ball.x) * spring
    vy += (targetY - ball.y) * spring
    
    // 将球的速度乘以衰减因子 f,模拟空气阻力或摩擦力
    vx *= f
    vy *= f
    
    // 将重力加速度 gravity 加到球的垂直速度上,模拟重力对球的影响
    vy += gravity
    
    ball.x += vx
    ball.y += vy

    ball.draw(ctx)
  }

  const drawLine = () => {
    ctx.save()
    ctx.beginPath()
    ctx.strokeStyle = 'orange'
    ctx.moveTo(ball.x, ball.y)
    ctx.lineTo(mouseInfo.value.x, mouseInfo.value.y)
    ctx.stroke()
    ctx.closePath()
    ctx.restore()
  }


  const runAnimate = () => {
    ctx.clearRect(0, 0, width, height)

    requestID.value = requestAnimationFrame(runAnimate)
    
    ballSpring()
    drawLine()
  }

  runAnimate()
}

const destroy = () => {
  cancelAnimationFrame(requestID.value)
  const parent = document.getElementById('spring2Box') as any
  if (parent) {
    parent.removeEventListener('mousemove', onMouseMove)
  }
}

onUnmounted(() => {
  destroy()
})
</script>