Appearance
引力 -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>