介绍
我的位置:ljn1998codeing.love
该网页是我模仿一位国外大佬的网页实现,网页功能大概模仿实现了 7 成,如果想看原网页效果可以前往传送门
下面简单聊聊在该 demo 中我做了什么以及各位能学到什么:
- 二维方面实现了网页滚动翻页和元素展示动画
- 三维方面实现了天空图、相机模型位置根据滚动位置变化
- 大模型预览
- 使用精灵实现标点
- 精灵标点被模型遮挡隐藏
- 鼠标移到标点处弹出提示框
- 点击标点进入下一场景
实现
二维实现滚动翻页和元素展示动画
// 页面滚动数据
const pageScrollingData = reactive({
scrollviewHeight: 0, // 滚动视图高度
pageHeight: 0, // 每页高度
totalPage: 5, // 总页数
currentPage: 1, // 当前页面
isScrolling: false, // 是否正在滚动
scrollPos: 0, // 滚轮滚动位置
ending: false, // 是否滚动到底部
});
// 控制页面元素数据
const elementStatus = reactive({
pageOnetitle: false,
pageOneStart: false,
pageTwoText: false,
pageThreeLeftImage: false,
pageThreeHeader: false,
pageThreeRightText: false,
pageFourALeftText: false,
pageFourArightText: false,
quitButton: false,
});
// 初始化滚动视图数据
const initScrollViewData = (): void => {
// 每一页高度 = 浏览器窗口viewport的高度
pageScrollingData.pageHeight = window.innerHeight;
// 滚动视图总高度 = 每页高度 * 总页数
pageScrollingData.scrollviewHeight = pageScrollingData.pageHeight * pageScrollingData.totalPage;
};
// 鼠标滚轮滚动控制
const mouseWheelHandle = (event: any): void | boolean => {
const evt = event || window.event;
// 阻止默认事件
if (evt.stopPropagation) {
evt.stopPropagation();
} else {
evt.returnValue = false;
}
// 当前正在滚动中则不做任何操作
if (pageScrollingData.isScrolling) {
return false;
}
const e = event.originalEvent || event;
// 记录滚动位置
pageScrollingData.scrollPos = e.deltaY || e.detail;
if (pageScrollingData.scrollPos > 0) { // 当鼠标滚轮向上滚动时
pageTurning(true);
} else if (pageScrollingData.scrollPos < 0) { // 当鼠标滚轮向下滚动时
pageTurning(false);
}
};
// 页面移动方向处理
const pageTurning = (direction: boolean): void => {
if (direction) {
// 往上滚动时,判断当前页码 + 1 是否 <= 总页码 ?? 页码 + 1,执行页面滚动操作,
if (pageScrollingData.currentPage + 1 <= pageScrollingData.totalPage) {
pageScrollingData.currentPage += 1;
pageMove(pageScrollingData.currentPage);
}
} else {
// 同样往下滚动时,判断当前页码 - 1 是否 > 0 ?? 页码 - 1,执行页面滚动操作
if (pageScrollingData.currentPage - 1 > 0) {
pageScrollingData.currentPage -= 1;
pageMove(pageScrollingData.currentPage);
}
}
};
// 页面滚动
const pageMove = (pageNo: number): void => {
// 设置滚动状态
pageScrollingData.isScrolling = true;
// 计算滚动高度
const scrollHeight = -(pageNo - 1) * pageScrollingData.pageHeight + "px";
// 设置css样式
scrollview.value.style.transform = `translateY(${scrollHeight})`;
// 重新设置下当前页码
pageScrollingData.currentPage = pageNo;
handingElementshow();
// 定时器做一个防抖,避免一秒内多次触发
setTimeout(() => {
pageScrollingData.isScrolling = false;
}, 1500);
};
// 处理元素出现或隐藏
const handingElementshow = (): void => {
setTimeout(() => {
switch (pageScrollingData.currentPage) {
case 1:
elementStatus.pageOnetitle = true;
elementStatus.pageOneStart = true;
elementStatus.pageTwoText = false;
break;
case 2:
elementStatus.pageOnetitle = false;
elementStatus.pageOneStart = false;
elementStatus.pageTwoText = true;
elementStatus.pageThreeLeftImage = false;
elementStatus.pageThreeHeader = false;
elementStatus.pageThreeRightText = false;
break;
case 3:
elementStatus.pageTwoText = false;
elementStatus.pageThreeLeftImage = true;
elementStatus.pageThreeHeader = true;
elementStatus.pageThreeRightText = true;
elementStatus.pageFourALeftText = false;
elementStatus.pageFourArightText = false;
break;
case 4:
elementStatus.pageThreeLeftImage = false;
elementStatus.pageThreeHeader = false;
elementStatus.pageThreeRightText = false;
elementStatus.pageFourALeftText = true;
elementStatus.pageFourArightText = true;
break;
case 5:
elementStatus.pageFourALeftText = false;
elementStatus.pageFourArightText = false;
break;
}
}, 1000);
};
配合 CSS3 动画可以达到以下效果:
三维效果实现
初始化场景、相机、渲染器、灯光
使用three.js里面的CubeTextureLoader渲染一个天空图
// 初始化场景
const initScene = (): void => {
scene = new THREE.Scene();
// 天空图图片集合,指定顺序pos-x, neg-x, pos-y, neg-y, pos-z, neg-z
const skyBg = [
getAssetsFile("sky/px.jpg"),
getAssetsFile("sky/nx.jpg"),
getAssetsFile("sky/py.jpg"),
getAssetsFile("sky/ny.jpg"),
getAssetsFile("sky/pz.jpg"),
getAssetsFile("sky/nz.jpg"),
];
const cubeLoader: THREE.CubeTextureLoader = new THREE.CubeTextureLoader();
skyEnvMap = cubeLoader.load(skyBg);
// 设置场景背景
scene.background = skyEnvMap;
};
// 初始化相机
const initCamera = (width: number, height: number): void => {
camera = new THREE.PerspectiveCamera(cameraFov, width / height, 1, 1000);
cameraPostion = new THREE.Vector3(0, -13, 48);
camera.position.copy(cameraPostion);
scene.add(camera);
};
// 初始化渲染器
const initRenderer = (width: number, height: number): void => {
renderer = new THREE.WebGLRenderer({
antialias: true, // 抗锯齿
});
renderer.setSize(width, height);
// 指定输出编码格式,当设置renderer.outputEncoding为sRGBEncoding时,渲染器会将输出的颜色值转换为sRGB格式,以便正确呈现在屏幕上
renderer.outputEncoding = THREE.sRGBEncoding;
canvas.value.appendChild(renderer.domElement);
renderer.render(scene, camera);
};
// 初始化灯光
const initLight = (): void => {
// 环境光
const ambientLight: THREE.AmbientLight = new THREE.AmbientLight(
new THREE.Color("rgb(255, 255, 255)")
);
// 平行光
const directionalLight: THREE.DirectionalLight = new THREE.DirectionalLight(
new THREE.Color("rgb(255, 99, 71)"),
2 // 光照强度为2
);
directionalLight.position.set(-220, 30, 50);
scene.add(ambientLight, directionalLight);
};
模型加载
模型加载需要使用three.js里面的DRACOLoader、GLTFLoader两个类,需要从node_modules中把draco拷贝出来放到项目的public目录中
使用DRACOLoader是因为glTF 模型使用了DRACO压缩算法进行了压缩
DRACO是一种用于压缩 3D 几何数据的算法,它可以将 3D 模型文件的大小减小到原来的 10%到 30%之间,从而提高加载和渲染速度。在使用DRACO进行压缩后,模型文件将被转换为DRACO 格式,这意味着Three.js需要使用DRACOLoader来读取和解压缩模型文件。
const dracoLoader: DRACOLoader = new DRACOLoader();
dracoLoader.setDecoderPath("draco/");
dracoLoader.preload();
const gltfLoader: GLTFLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
// 加载建筑模型
const loadBuildingModel = (): void => {
gltfLoader.load(getAssetsFile("building/building.glb"), (gltf) => {
// 保存模型初始位置
originalModelPos.value = new THREE.Vector3(14, -40.8, 0);
// 设置模型位置
gltf.scene.position.copy(originalModelPos.value);
// 设置模型旋转角度
const currentRotation = gltf.scene.rotation.clone();
const newRotation = new THREE.Euler(
currentRotation.x,
currentRotation.y - (131 * Math.PI) / 180,
currentRotation.z,
currentRotation.order
);
gltf.scene.rotation.copy(newRotation);
// 循环模型内Mesh并找到窗户所属的Mesh,设置该Mesh中材质的环境贴图以及环境贴图的强度
const ObjectGroup = gltf.scene.children;
for (let i = 0; i < ObjectGroup.length; i++) {
if (
ObjectGroup[i] instanceof THREE.Group &&
ObjectGroup[i].name === "AB1_OBJ_02"
) {
ObjectGroup[i].children &&
ObjectGroup[i].children.forEach((item) => {
if (item instanceof THREE.Mesh && item.name === "AB1_OBJ_02_1") {
item.material.envMap = skyEnvMap;
item.material.envMapIntensity = 0.5;
}
});
}
}
// 保存模型数据,后面设置动画会直接使用到
buildingModel = gltf.scene;
scene.add(buildingModel);
});
};
模型动画,模型根据页面滚动设置动画
在切换页面的同时,我们需要让模型做出相应动画来进行滚动交互
// 滚动时相机和模型动画
const handingScrolling = (): void => {
// 判断是否滚动到最后一页,因为第3、4页模型的位置是不需要改变,也就是没有相对应地模型动画,所以当前页面是最后一页时,那么只能玩上滚动,并且需要执行第二页的模型动画
const pos = pageScrollingData.ending ? 2 - 1 : pageScrollingData.currentPage - 1;
// 计算新的模型位置
const newModelPos: THREE.Vector3 = originalModelPos.value && originalModelPos.value.clone().add(new THREE.Vector3(pos * 10, pos * 8.6, pos * 13));
// 当前为第一页时,模型位置设置为初始值
if (pageScrollingData.currentPage === 1) {
newModelPos.copy(originalModelPos.value);
}
if (pageScrollingData.currentPage <= 2 || pageScrollingData.ending) { // 当前页码 <= 第2页时 或者 页面滚动到最底部,执行该动画
gsap.to(camera.position, {
x: pos * 18,
y: cameraPostion.y + pos * 14,
ease: "Power2.inOut",
duration: 1,
});
gsap.to(buildingModel.position, {
x: newModelPos.x,
y: newModelPos.y,
z: newModelPos.z,
ease: "Power2.inOut",
duration: 1,
});
pageScrollingData.ending = false;
} else if (pageScrollingData.currentPage === 5) { // 当前页码 === 第5页时,执行该动画
gsap.to(camera.position, {
x: -24,
y: -30,
ease: "Power2.inOut",
duration: 1,
});
gsap.to(buildingModel.position, {
x: -6,
y: -59,
z: 18,
ease: "Power2.inOut",
duration: 1,
});
pageScrollingData.ending = true;
}
// 控制页面元素显示隐藏
handingElementshow();
};
配合上面的pageMove函数,可以达到以下效果:
模型探索与退出探索
模型探索所做的操作就是将页面三维容器层级设置到最高,同时设置相机和模型的动画,并开启控制器交互
退出探索则是把相机模型位置设置回第5页时的状态,并且把控制器属性设置回原来的状态
// 探索模型
const explorarModel = (): void => {
// 设置三维容器层级
canvas.value.style.zIndex = 1;
// 相机动画改变相机位置
const cameraGasp: gsap.core.Tween = gsap.to(camera.position, {
x: -6,
y: 6,
z: 80,
ease: "Power0.inOut",
duration: 2,
});
// 模型动画改变模型位置
const buildingGasp: gsap.core.Tween = gsap.to(buildingModel.position, {
x: 0,
y: -22,
z: 0,
ease: "Power0.inOut",
duration: 2,
});
// 等待执行
const delayedCall: Promise<unknown> = new Promise((resolve) => {
gsap.delayedCall(1, resolve);
});
// 当所有动画执行完成时的操作
Promise.all([cameraGasp, buildingGasp, delayedCall])
.then(() => {
elementStatus.quitButton = true; // 展示退出探索按钮
controls.enabled = true; // 开启控制器交互
controls.maxPolarAngle = Math.PI / 2 - 0.01; // 设置垂直旋转的角度的上限
controls.autoRotate = true; // 开启自动旋转
controls.minDistance = 40; // 设置相机向内移动上限
controls.maxDistance = 86; // 设置相机向外移动上限
})
.catch((err) => {
console.log(err);
});
};
// 退出探索模型
const quitExporarModel = (key: number): void => {
// 移除标点
scene.remove(pointGroup);
// 设置三维容器层级
canvas.value.style.zIndex = -1;
// 隐藏退出按钮
elementStatus.quitButton = false;
// 把控制器一些参数设置回初始值
controls.maxPolarAngle = Math.PI;
controls.enabled = false;
controls.autoRotate = false;
controls.minDistance = 0;
controls.maxDistance = Infinity;
// 执行动画操作
gsap.to(camera.position, {
x: -24,
y: -30,
z: 48,
ease: "Power0.inOut",
duration: 1,
});
gsap.to(buildingModel.position, {
x: -6,
y: -59,
z: 18,
ease: "Power0.inOut",
duration: 1,
});
gsap.to(controls.target, {
x: 0,
y: 0,
z: 0,
ease: "Power0.inOut",
duration: 1,
});
};
得到当前如下效果:
给模型添加标点
在开发时我在项目中写了一个方法利用three.js中的Raycaster类拾取了3个坐标,下面直接看方法
// 给模型添加标点
const addPointWithModel = (): void => {
// 标点数据
const pointArr: PointType[] = [
{
x: -16.979381448617573,
y: -19.167911412787436,
z: 1.4417293738365617,
text: "aaaaa",
},
{
x: 4.368890112320235,
y: -12.020210823358955,
z: 10.590562296036955,
text: "bbbbb",
},
{
x: -4.655517564465063,
y: 12.146541899849993,
z: 11.879293977258593,
ware: true, // 是否展示涟漪动画
otherScene: true, // 是否可以前往下一个场景
text: "ccccc", // 弹框展示的文字
},
];
// 贴图加载
const circleTexture: THREE.Texture = textureLoader.load(
getAssetsFile("building/sprite.png")
);
const waveTexture: THREE.Texture = textureLoader.load(
getAssetsFile("wave.png")
);
// 遍历标点数据创建精灵标点
pointArr.forEach((item: PointType) => {
const spriteMaterial: THREE.SpriteMaterial = new THREE.SpriteMaterial({
map: circleTexture,
});
const sprite: THREE.Sprite & Info = new THREE.Sprite(spriteMaterial);
sprite.name = "point";
sprite.text = item.text;
sprite.otherScene = item.otherScene;
sprite.position.set(item.x, item.y + 0.2, item.z + 2);
sprite.scale.set(1.4, 1.4, 1);
// 需要涟漪动画则要创建一个涟漪精灵
if (item.ware) {
const waveMaterial: THREE.SpriteMaterial = new THREE.SpriteMaterial({
map: waveTexture,
color: new THREE.Color("rgb(255, 255, 255)"),
transparent: true,
opacity: 1.0,
side: THREE.DoubleSide,
depthWrite: false,
});
let waveSprite: THREE.Sprite & Info = new THREE.Sprite(waveMaterial);
waveSprite.name = "wave";
waveSprite.text = item.text;
waveSprite.otherScene = item.otherScene;
waveSprite.size = 8 * 0.3;
waveSprite._s = Math.random() * 1.0 + 1.0;
waveSprite.position.set(item.x, item.y + 0.2, item.z + 2);
pointGroup.add(waveSprite);
}
pointGroup.add(sprite);
});
scene.add(pointGroup);
};
在render函数中我们需要添加如下代码,来实现涟漪动画
// 涟漪动画
const pointGroup = scene.children.find((item) => item.name === "pointGroup"); // 查找标点组合
if (pointGroup) { // 组合存在
const wave: any = pointGroup.children.length && pointGroup.children.find((sprite) => sprite.name === "wave"); // 找到涟漪精灵
if (wave) {
// 修改精灵的大小和材质的透明度达到涟漪的效果
wave._s += 0.01;
wave.scale.set(
wave.size * wave._s,
wave.size * wave._s,
wave.size * wave._s
);
if (wave._s <= 1.5) {
//mesh._s=1,透明度=0 mesh._s=1.5,透明度=1
wave.material.opacity = (wave._s - 1) * 2;
} else if (wave._s > 1.5 && wave._s <= 2) {
//mesh._s=1.5,透明度=1 mesh._s=2,透明度=0
wave.material.opacity = 1 - (wave._s - 1.5) * 2;
} else {
wave._s = 1.0;
}
}
}
效果如下图:
建筑遮挡隐藏标点
当建筑遮挡标点时,因为精灵是一个总是面朝着摄像机的平面,所以即便被建筑遮挡,射线依旧能选中这个标点,这不利于后面的功能
在three.js中,Raycaster可以用于检测鼠标或者屏幕上某个点是否与场景中的物体相交
Raycaster的原理是基于3D空间中的射线投射,它会从一个起点(例如相机位置)发出一条射线,直到它与场景中的某个物体相交。Raycaster并不会遮挡检测,但是通过检测物体与射线相交的顺序,可以判断它们之间是否存在遮挡关系
在这个功能中,我们把标点作为射线的起点,相机为终点,当射线检测到的对象是不是精灵标点sprite,则隐藏标点,具体原理如下图
// 判断模型是否遮挡精灵
const spriteVisible = (): void => {
// 创建一个Raycaster对象
const raycaster = new THREE.Raycaster();
raycaster.camera = camera;
// 精灵标点集合
const spriteArr: THREE.Object3D<THREE.Event>[] = [];
pointGroup.children.forEach((sprite) => {
spriteArr.push(sprite);
});
for (let i = 0; i < spriteArr.length; i++) {
const sprite: THREE.Object3D<THREE.Event> = spriteArr[i];
// 将Sprite的位置作为射线的起点
// 创建一个新的 Vector3 对象,然后使用 setFromMatrixPosition 方法将该对象设置为 Sprite 对象在世界坐标系下的位置
// 最终得到一个 Vector3 对象,表示了 Sprite 对象在世界坐标系下的位置。这个位置可以用于计算精灵与相机的相对位置,或者用于计算精灵的旋转方向
const spritePosition: THREE.Vector3 = new THREE.Vector3().setFromMatrixPosition(
sprite.matrixWorld
);
const rayOrigin: THREE.Vector3 = spritePosition.clone();
// 将摄像机位置作为射线的终点
const cameraPosition: THREE.Vector3 = new THREE.Vector3().setFromMatrixPosition(
camera.matrixWorld
);
// 计算spritePosition指向cameraPosition的单位向量代码
// ameraPosition.clone() 将 cameraPosition 对象进行克隆,得到一个新的 Vector3 对象。这么做是为了避免修改原始的 cameraPosition 对象
// sub(spritePosition) 将 spritePosition 对象从上一步得到的新的 Vector3 对象中减去,得到一个指向 spritePosition 的向量
// normalize():将上一步得到的指向 spritePosition 的向量进行标准化,得到一个单位向量,即长度为 1 的向量
const rayDirection: THREE.Vector3 = cameraPosition.clone().sub(spritePosition).normalize();
// 设置射线的起点和方向
raycaster.set(rayOrigin, rayDirection);
// 检查是否存在与Sprite相交的物体
const intersects = raycaster.intersectObjects(buildingModel.children, true);
let isOccluded = false;
for (let j = 0; j < intersects.length; j++) {
const intersection = intersects[j];
const object = intersection.object;
if (object !== sprite && object.name !== "Plane") {
// 当前相交对象不是Sprite,那Sprite被遮挡了
isOccluded = true;
break;
}
}
// 如果Sprite被遮挡了,将其隐藏,因为不能直接用gasp操作sprite.visible属性,所以只能改变opacity属性,并且当执行完成时需要隐藏精灵,要不然射线还会选到
if (isOccluded) {
gsap.to((sprite as THREE.Sprite).material, {
opacity: 0,
ease: "Power0.inOut",
duration: 0.5,
onComplete: () => {
sprite.visible = false;
},
});
} else {
gsap.to((sprite as THREE.Sprite).material, {
opacity: 1,
ease: "Power0.inOut",
duration: 0.5,
onComplete: () => {
sprite.visible = true;
},
});
}
}
};
鼠标移到标点出弹出信息框
在上面的标点数据中已经存在了信息框数据,这边主要是操作document元素来创建或移除元素,然后监听鼠标的移动事件,配合Raycaster射线拾取来实现
// 检测鼠标与模型标点相交
const detectionMouseIntersectPoint = (event: any): void => {
if (!elementStatus.quitButton) return;
// 创建射线
const raycaster = new THREE.Raycaster();
// 将终点设置为固定的点
const rayEndpoint = new THREE.Vector3(0, 0, 0);
// 创建鼠标向量
const mouse = new THREE.Vector2();
// 计算鼠标点击位置的归一化设备坐标(NDC)
// NDC 坐标系的范围是 [-1, 1],左下角为 (-1, -1),右上角为 (1, 1)
if (!canvas.value) return;
mouse.x = (event.clientX / canvas.value.clientWidth) * 2 - 1;
mouse.y = -(event.clientY / canvas.value.clientHeight) * 2 + 1;
// 更新射线的起点和方向
raycaster.setFromCamera(mouse, camera);
// 将终点设置为距离相机100的位置
raycaster.ray.at(100, rayEndpoint);
// 计算射线与场景中的所有标点相交
const intersects = raycaster.intersectObjects(pointGroup.children, true);
// 如果存在相交点,则获取第一个相交点的坐标
if (intersects.length > 0) {
const object: NewObject3d = intersects[0].object;
// 获取标点在屏幕上的位置
const point = new THREE.Vector3().copy(object.position);
// 标点从三维空间投影到二维屏幕上
point.project(camera);
// 判断下如果标点是隐藏状态就不做任何操作
if(!object.visible) return
addTipElementOrRemove(object, point, true);
} else {
if (isClick) return;
addTipElementOrRemove(null, null, false);
}
};
// 添加或移除提示信息框
const addTipElementOrRemove = (
object: NewObject3d | null, // 鼠标拾取到的对象
point: THREE.Vector3 | null, // 对象在屏幕上的位置
status: boolean // 状态 添加true 移除false
): void => {
// 获取文档中ID为tooltip的元素
const tooltipElement: HTMLElement | null = document.getElementById("tooltip");
// 状态是true并且元素已存在,就不再执行添加操作
if (status && tooltipElement) return;
// 状态是true并且元素不存在执行添加操作
if (!tooltipElement && status) {
const tooltipDiv: HTMLElement = document.createElement("div");
tooltipDiv.innerHTML = (object && object.text) || "";
tooltipDiv.setAttribute("id", "tooltip");
tooltipDiv.style.position = "absolute";
tooltipDiv.style.left = `${
point && ((point.x + 1) * canvas.value.clientWidth) / 2 + 10
}px`;
tooltipDiv.style.top = `${
point && ((-point.y + 1) * canvas.value.clientHeight) / 2 + 10
}px`;
tooltipDiv.style.zIndex = "100";
tooltipDiv.style.padding = "4px 6px";
tooltipDiv.style.fontSize = "12px";
tooltipDiv.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
tooltipDiv.style.border = "1px solid #ffffff";
tooltipDiv.style.borderRadius = "6px";
canvas.value.appendChild(tooltipDiv);
} else {
// 状态为false并且元素存在执行移除操作
if (!status && tooltipElement) {
canvas.value.removeChild(tooltipElement);
}
}
};
// 监听鼠标移动事件
window.addEventListener("mousemove", detectionMouseIntersectPoint, false);
点击前往第二场景
前往第二场景,主要操作是通过修改相机位置来实现,下面直接看代码
// 点击前往第二个场景
const goOtherScene = (object: NewObject3d): void => {
// 设置控制器属性
controls.enabled = false;
controls.enableZoom = false;
controls.autoRotate = false;
controls.minDistance = 0;
controls.maxDistance = Infinity;
// 遍历建筑模型,找到第二场景的位置
buildingModel.traverse((child) => {
if (child.name === "Area002") {
const newPosition = new THREE.Vector3();
child.updateMatrixWorld();
newPosition.setFromMatrixPosition(child.matrixWorld);
// 设置controls的中心点
controls.target.set(newPosition.x, newPosition.y, newPosition.z);
elementStatus.quitButton = false;
// 相机动画
gsap.to(camera.position, {
x: newPosition.x - 4,
y: newPosition.y + 2,
z: newPosition.z,
ease: "Power0.inOut",
duration: 1,
onUpdate: () => {
// 设置相机的广角
if (cameraFov < 50) {
cameraFov += 1;
camera.fov = cameraFov;
camera.updateProjectionMatrix();
}
},
onComplete: () => {
controls.enabled = true;
elementStatus.quitButton = true;
buttonText.key = 2;
buttonText.value = "OUT";
},
});
}
});
};
完整代码
资源文件
链接: pan.baidu.com/s/1BJCOx2CS… 提取码: 7xc6
总结
本文暂时就到这了,在文章中我贴了大量的代码加上注释以及效果图,在需要讲解的地方我也加上了自己的理解,希望大家能看明白,不明白之处或者觉得处理的不好的地方可以评论区留言,期待和各位大佬的交流😊