尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Canvas 2D 交互开发实战:从命中检测到创意项目实现

Canvas 2D 交互开发实战:从命中检测到创意项目实现 最近在逛技术社区时发现一个有趣的现象越来越多的开发者开始用代码“整活”将个人兴趣与编程技能结合创造出一些既有趣又有技术含量的项目。比如有人用算法生成动漫风格头像有人用神经网络给老游戏上色还有人用物理引擎模拟布料和头发。今天要聊的就是一个典型的“兴趣驱动开发”案例——一个名为“【东方project】世界は可愛く出来ている摸个灵梦”的项目。只看标题你可能会以为这是某个动漫同人作品但它的内核其实是一个融合了计算机图形学、图像处理和轻量级Web技术的创意编程实践。对于很多开发者来说纯粹的业务代码写久了难免枯燥。而这个项目展示了一种可能性如何用你熟悉的技术栈去实现一个充满个人表达欲的创意项目并在这个过程中巩固甚至深化你的技术能力。它不只是一个“摸鱼”作品更是一个涵盖了前端动画、Canvas绘图、事件交互乃至简单物理模拟的综合性练手项目。本文将为你拆解这类创意编码项目的核心思路。即使你不是“东方Project”的粉丝也能从中学会如何将一个抽象的创意比如“让角色动起来”、“实现触摸反馈”转化为具体的代码实现并掌握其中可复用的技术模块。1. 从“兴趣项目”中我们能学到什么在开始技术细节之前我们首先要破除一个误区做个人兴趣项目就是“不务正业”。恰恰相反一个定义良好的兴趣项目是绝佳的技术试验场。以“摸个灵梦”假设这是一个点击屏幕与动漫角色“博丽灵梦”进行互动的网页应用为例它可能涉及以下技术点而这些点在严肃的业务开发中同样重要Canvas/SVG 图形绘制如何绘制一个复杂的、分层的角色立绘这涉及到图形API的熟练使用。动画与状态管理角色如何从“待机”状态平滑过渡到“被触摸”的反应状态这需要管理动画帧和状态机。用户交互处理如何精准捕获鼠标点击、触摸、拖拽事件并判断是否点中了角色的特定部位如头、手响应式与性能如何确保动画在不同设备、不同分辨率下流畅运行如何避免内存泄漏项目结构与模块化即使是一个小项目清晰的代码结构也至关重要。这个项目的价值在于它用一个高趣味性的目标包裹了多个扎实的前端开发技能点。完成它的过程就是一次小型全栈偏前端实战。2. 核心技术栈选型与原理要实现一个可交互的动态角色我们有多条技术路径。这里分析几种常见方案并说明为什么我们最终的 demo 会选择特定组合。2.1 方案对比DOM、Canvas 还是 WebGL方案优点缺点适用场景DOM CSS动画开发简单可利用CSS3硬件加速元素可被SEO抓取。性能受DOM数量影响大复杂图形和精细动画控制力弱。交互简单的UI组件、图标动画。Canvas 2D高性能位图操作完全控制每一帧的绘制适合复杂动画和游戏。绘制内容为位图缩放可能失真内部元素无法直接绑定事件。数据可视化、2D游戏、图像处理、需要逐帧控制的动画。WebGL直接调用GPU性能极致可实现3D效果和复杂粒子特效。学习曲线陡峭代码复杂度高开发效率较低。3D场景、大型复杂游戏、需要大量实时图形计算的场景。我们的选择Canvas 2D API对于“摸个灵梦”这类2D角色交互项目Canvas 2D在性能、控制力和开发复杂度上取得了最佳平衡。我们可以精细控制角色的每一个部件如头发、衣服、面部表情的绘制与变化。2.2 核心交互原理命中检测Hit Detection在DOM中点击按钮会触发click事件。但在Canvas里你画上去的只是一个像素集合如何知道用户点击了“角色的手”而不是背景呢这就是命中检测要解决的问题。常用方法有几何形状检测为角色的每个可交互部位定义一个简单的几何形状如矩形、圆形、多边形。当点击发生时计算鼠标坐标是否在这些形状内。优点计算快性能好。缺点形状匹配不精确对于不规则部位如飘动的头发效果差。像素级检测颜色回溯法在绘制时额外使用一个离屏Canvas为每个可交互部位填充唯一的颜色ID。点击时获取该坐标点在离屏Canvas上的颜色值通过颜色ID反推出点击了哪个部位。优点检测精确与视觉完全一致。缺点需要维护两套绘制逻辑内存占用稍高。在我们的项目中为了平衡精确度和复杂度推荐使用几何形状检测。我们可以为灵梦的“头”、“脸”、“手”等部位定义圆形或矩形区域这足以实现“摸头”、“握手”等基础互动。3. 开发环境准备我们使用最纯粹的前端技术栈无需复杂构建工具即可开始。操作系统Windows 10/11, macOS, 或 Linux 均可。浏览器推荐最新版的 Chrome、Edge 或 Firefox以支持完整的 Canvas 和 ES6 特性。代码编辑器VS Code、Sublime Text、WebStorm 任选。本地服务器由于涉及文件加载建议使用一个简单的本地HTTP服务器来打开项目而不是直接双击HTML文件。安装 Node.js 后可以使用http-server或live-server。使用 VS Code 的 “Live Server” 插件是更便捷的选择。4. 项目结构与核心流程拆解让我们从零开始构建这个项目。首先创建项目文件夹结构touch-spirit-miko/ ├── index.html # 主页面 ├── style.css # 样式文件 ├── script.js # 主逻辑代码 ├── assets/ # 资源文件夹 │ ├── images/ # 存放角色图片素材 │ └── sounds/ # (可选)存放音效 └── README.md整个应用的运行流程可以拆解为以下几步初始化阶段创建Canvas元素获取绘图上下文加载所有必需的图片和音效资源。绘制阶段在每一帧中根据当前角色状态如位置、表情、动作帧将所有部件按正确顺序绘制到Canvas上。交互阶段监听Canvas上的鼠标/触摸事件计算坐标进行命中检测。反馈阶段根据命中结果改变角色的状态如切换表情、播放动画、播放音效并触发重绘。5. 完整代码实现与分步解析5.1 HTML 骨架 (index.html)!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title摸个灵梦 - 创意交互Demo/title link relstylesheet hrefstyle.css link relicon hrefassets/images/favicon.ico typeimage/x-icon /head body div classcontainer header h1✨ 世界は可愛く出来ている ✨/h1 p classsubtitle点击灵梦的不同部位看看她会有什么反应/p /header main !-- Canvas 绘图区域 -- canvas idgameCanvas width800 height600 您的浏览器不支持Canvas请升级以获得完整体验。 /canvas !-- 状态提示 -- div idstatus等待互动中.../div !-- 控制面板 -- div classcontrol-panel button idbtnReset重置状态/button button idbtnChangeBg切换背景/button label input typecheckbox idtoggleSound checked 开启音效 /label /div /main footer p这是一个使用原生Canvas API实现的创意交互Demo。技术栈HTML5, Canvas 2D, JavaScript./p /footer /div !-- 预加载音效 (可选) -- audio idsoundTouch preloadauto source srcassets/sounds/touch.mp3 typeaudio/mpeg /audio audio idsoundReact preloadauto source srcassets/sounds/react.mp3 typeaudio/mpeg /audio script srcscript.js/script /body /html关键点canvas元素的id和预设宽高。引入了样式和脚本。添加了简单的控制UI和状态提示提升用户体验。使用audio标签预加载音效避免交互时延迟。5.2 基础样式 (style.css)* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Segoe UI, Microsoft YaHei, sans-serif; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; color: #333; line-height: 1.6; } .container { max-width: 900px; width: 95%; background-color: rgba(255, 255, 255, 0.9); border-radius: 20px; box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); padding: 30px; margin: 20px auto; text-align: center; } header h1 { color: #6a11cb; margin-bottom: 10px; font-size: 2.5rem; text-shadow: 2px 2px 4px rgba(0,0,0,0.1); } .subtitle { color: #666; margin-bottom: 30px; font-size: 1.1rem; } #gameCanvas { display: block; margin: 0 auto 25px; border-radius: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.05), 0 5px 15px rgba(0,0,0,0.1); background-color: #f8f9fa; cursor: pointer; /* 提示此处可点击 */ max-width: 100%; height: auto; /* 保持响应式 */ } #status { min-height: 24px; margin: 15px 0; padding: 10px; background-color: #e9ecef; border-radius: 8px; font-weight: 500; transition: all 0.3s ease; } #status.active { background-color: #d4edda; color: #155724; } .control-panel { display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; margin-top: 20px; } button, .control-panel label { padding: 10px 20px; border: none; border-radius: 8px; background: linear-gradient(to right, #6a11cb 0%, #2575fc 100%); color: white; font-weight: bold; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } button:hover { transform: translateY(-2px); box-shadow: 0 7px 14px rgba(106, 17, 203, 0.3); } .control-panel label { display: flex; align-items: center; gap: 8px; background: #6c757d; } input[typecheckbox] { width: 18px; height: 18px; cursor: pointer; } footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #dee2e6; color: #6c757d; font-size: 0.9rem; }关键点使用flex布局实现居中与响应式。为Canvas添加了内阴影和光晕提升质感。状态提示 (#status) 设计了样式变化用于反馈交互结果。控制按钮使用了渐变和悬停效果增强交互感。5.3 核心交互逻辑 (script.js)这是项目的核心我们将分模块实现。// script.js (function() { use strict; // 1. 全局变量与状态定义 const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const statusEl document.getElementById(status); const btnReset document.getElementById(btnReset); const btnChangeBg document.getElementById(btnChangeBg); const toggleSound document.getElementById(toggleSound); const soundTouch document.getElementById(soundTouch); const soundReact document.getElementById(soundReact); // 角色状态机 const STATE { IDLE: idle, // 待机 TOUCHED_HEAD: touched_head, TOUCHED_HAND: touched_hand, TOUCHED_BODY: touched_body }; let currentState STATE.IDLE; let stateTimer null; // 用于状态恢复计时 // 可交互区域定义 (几何形状检测) const interactiveAreas { head: { type: circle, x: 400, y: 200, radius: 60, desc: 头部 }, leftHand: { type: circle, x: 300, y: 350, radius: 30, desc: 左手 }, rightHand: { type: circle, x: 500, y: 350, radius: 30, desc: 右手 }, body: { type: rect, x: 350, y: 250, width: 100, height: 150, desc: 身体 } }; // 资源管理 const assets { bg: null, characterIdle: null, characterBlush: null, // 脸红表情 characterAngry: null // 生气表情 }; let assetsLoaded 0; const totalAssets Object.keys(assets).length; // 背景切换 const backgrounds [#f8f9fa, #e3f2fd, #fff3e0]; let bgIndex 0; // 2. 资源加载 function loadAssets() { const loadImage (key, src) { const img new Image(); img.onload () { assets[key] img; assetsLoaded; if (assetsLoaded totalAssets) { init(); // 所有资源加载完成后初始化 } }; img.onerror () { console.error(Failed to load image: ${src}); // 加载失败时可以创建一个替代的彩色方块 assets[key] createPlaceholder(key); assetsLoaded; if (assetsLoaded totalAssets) { init(); } }; img.src src; }; // 在实际项目中这里替换为你的图片路径 loadImage(bg, assets/images/bg.png); loadImage(characterIdle, assets/images/reimu_idle.png); loadImage(characterBlush, assets/images/reimu_blush.png); loadImage(characterAngry, assets/images/reimu_angry.png); } function createPlaceholder(key) { // 创建一个纯色Canvas作为占位图 const canvas document.createElement(canvas); canvas.width 200; canvas.height 300; const ctx canvas.getContext(2d); ctx.fillStyle key.includes(character) ? #ffcccb : #c3cfe2; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #999; ctx.lineWidth 2; ctx.strokeRect(5, 5, canvas.width - 10, canvas.height - 10); ctx.fillStyle #666; ctx.font 14px Arial; ctx.textAlign center; ctx.fillText(Placeholder: ${key}, canvas.width / 2, canvas.height / 2); const img new Image(); img.src canvas.toDataURL(); return img; } // 3. 初始化与游戏循环 function init() { updateStatus(资源加载完毕可以开始互动了。); draw(); // 绘制第一帧 // 绑定事件 canvas.addEventListener(click, handleCanvasClick); canvas.addEventListener(mousemove, handleMouseMove); btnReset.addEventListener(click, resetState); btnChangeBg.addEventListener(click, changeBackground); // 启动一个简单的循环用于支持未来可能的自动动画 requestAnimationFrame(gameLoop); } function gameLoop(timestamp) { // 这里是游戏主循环目前我们依赖事件驱动重绘但保留循环以备扩展如飘动的发带 // 如果需要持续动画可以在这里更新状态并调用 draw() requestAnimationFrame(gameLoop); } // 4. 绘制函数 function draw() { // 1. 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 绘制背景 ctx.fillStyle backgrounds[bgIndex]; ctx.fillRect(0, 0, canvas.width, canvas.height); if (assets.bg) { // 如果背景图存在绘制它可以调整透明度等 ctx.globalAlpha 0.7; ctx.drawImage(assets.bg, 0, 0, canvas.width, canvas.height); ctx.globalAlpha 1.0; } // 3. 根据状态绘制角色 let characterImg assets.characterIdle; // 默认 switch(currentState) { case STATE.TOUCHED_HEAD: characterImg assets.characterBlush; break; case STATE.TOUCHED_HAND: case STATE.TOUCHED_BODY: characterImg assets.characterAngry; break; } if (characterImg) { // 将角色绘制在画布中央偏上位置 const charX (canvas.width - characterImg.width) / 2; const charY (canvas.height - characterImg.height) / 2 - 50; ctx.drawImage(characterImg, charX, charY); } else { // 如果图片未加载绘制一个占位角色 drawPlaceholderCharacter(); } // 4. (调试用) 绘制可交互区域轮廓 drawInteractiveAreasDebug(); } function drawPlaceholderCharacter() { ctx.fillStyle #ffebee; ctx.beginPath(); ctx.arc(400, 250, 60, 0, Math.PI * 2); // 头 ctx.fill(); ctx.fillStyle #bbdefb; ctx.fillRect(340, 310, 120, 100); // 身体 ctx.fillStyle #333; ctx.font 20px Arial; ctx.textAlign center; ctx.fillText(灵梦, 400, 260); } function drawInteractiveAreasDebug() { // 仅在开发时启用正式发布可注释掉 // return; ctx.strokeStyle rgba(255, 0, 0, 0.5); ctx.lineWidth 2; for (const key in interactiveAreas) { const area interactiveAreas[key]; ctx.beginPath(); if (area.type circle) { ctx.arc(area.x, area.y, area.radius, 0, Math.PI * 2); } else if (area.type rect) { ctx.rect(area.x, area.y, area.width, area.height); } ctx.stroke(); // 标注区域名称 ctx.fillStyle red; ctx.font 12px Arial; ctx.fillText(area.desc, area.x, area.y - 10); } } // 5. 交互与命中检测 function handleCanvasClick(event) { const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; // 实际像素与CSS像素的缩放比 const scaleY canvas.height / rect.height; const x (event.clientX - rect.left) * scaleX; const y (event.clientY - rect.top) * scaleY; const hitArea getHitArea(x, y); if (hitArea) { triggerInteraction(hitArea); } else { updateStatus(点击了背景区域。); if (toggleSound.checked) { soundTouch.currentTime 0; soundTouch.play().catch(e console.log(音效播放被阻止:, e)); } } } function handleMouseMove(event) { // 可以用于实现鼠标悬停效果这里简单改变光标样式 const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const x (event.clientX - rect.left) * scaleX; const y (event.clientY - rect.top) * scaleY; const hitArea getHitArea(x, y); canvas.style.cursor hitArea ? pointer : default; } function getHitArea(x, y) { for (const key in interactiveAreas) { const area interactiveAreas[key]; let isHit false; if (area.type circle) { const distance Math.sqrt((x - area.x) ** 2 (y - area.y) ** 2); isHit distance area.radius; } else if (area.type rect) { isHit x area.x x area.x area.width y area.y y area.y area.height; } if (isHit) { return { key, ...area }; // 返回命中的区域信息 } } return null; } function triggerInteraction(area) { // 根据点击部位改变状态 let newState STATE.IDLE; let message 你点击了灵梦的${area.desc}; let soundToPlay soundReact; if (area.key.includes(head)) { newState STATE.TOUCHED_HEAD; message 她好像有点害羞...; } else if (area.key.includes(Hand)) { newState STATE.TOUCHED_HAND; message 她看起来不太高兴。; } else if (area.key body) { newState STATE.TOUCHED_BODY; message 喂别随便碰那里; soundToPlay soundTouch; // 使用不同的音效 } // 更新状态 currentState newState; updateStatus(message, true); // 播放音效 if (toggleSound.checked soundToPlay) { soundToPlay.currentTime 0; soundToPlay.play().catch(e console.log(音效播放被阻止:, e)); } // 3秒后自动恢复待机状态 if (stateTimer) clearTimeout(stateTimer); stateTimer setTimeout(() { if (currentState ! STATE.IDLE) { currentState STATE.IDLE; updateStatus(灵梦恢复了平静。); draw(); } }, 3000); // 重绘画布 draw(); } // 6. 工具与辅助函数 function updateStatus(text, isActive false) { statusEl.textContent text; if (isActive) { statusEl.classList.add(active); } else { statusEl.classList.remove(active); } } function resetState() { currentState STATE.IDLE; if (stateTimer) clearTimeout(stateTimer); updateStatus(状态已重置。); draw(); } function changeBackground() { bgIndex (bgIndex 1) % backgrounds.length; draw(); } // 7. 启动 // 页面加载完成后开始加载资源 window.addEventListener(DOMContentLoaded, loadAssets); })();代码核心逻辑解析状态管理使用STATE枚举和currentState变量管理角色状态这是实现不同反应的基础。资源加载loadAssets函数使用Image对象异步加载图片并提供了加载失败的回退方案 (createPlaceholder)增强了健壮性。命中检测getHitArea函数是核心。它遍历预定义的interactiveAreas圆形和矩形计算鼠标点击坐标是否落在这些几何区域内。坐标转换handleCanvasClick中scaleX和scaleY的计算至关重要。它解决了 Canvas 的实际绘制尺寸 (canvas.width/height) 与 CSS 显示尺寸可能不同的问题确保点击坐标映射准确。交互反馈链click-getHitArea-triggerInteraction- 更新状态、播放音效、更新UI、重置计时器 -draw重绘。这是一个清晰的事件驱动流程。调试支持drawInteractiveAreasDebug函数可以直观地看到可交互区域的边界开发时非常有用。6. 运行结果与效果验证将上述三个文件 (index.html,style.css,script.js) 和assets资源文件夹需自行准备或使用占位图放在同一目录。使用 VS Code 的 Live Server 插件打开index.html或在项目根目录下运行npx http-server。在浏览器中访问http://localhost:8080端口可能不同。页面加载后你将看到一个居中显示的 Canvas 画布其中绘有角色或占位图形。交互验证点击角色头部角色表情应变为“脸红”状态提示显示害羞文本并播放反应音效。3秒后自动恢复。点击手部角色表情应变为“生气”提示相应文本。点击身体触发更强的反应文本和音效。点击背景播放一个通用的触摸音效提示点击了背景。鼠标移动当鼠标移动到可交互区域时光标应变为“手型”。测试按钮点击“重置状态”按钮角色应立即恢复待机状态。点击“切换背景”按钮画布背景色应循环变化。如果效果不符合预期请首先打开浏览器的开发者工具 (F12)查看Console面板是否有报错如图片加载失败并检查Elements和Network面板确保资源加载正确。7. 常见问题与排查思路问题现象可能原因排查方式解决方案页面空白Canvas不显示1. JS 报错导致脚本未执行。2. Canvas 宽高为0。3. 资源加载失败阻塞。1. 查看浏览器控制台 (Console) 报错。2. 检查 Elements 面板中 Canvas 标签的样式是否被覆盖如display: none。3. 查看 Network 面板图片资源是否 404。1. 根据控制台错误修改代码。2. 确保CSS未隐藏Canvas。3. 检查图片路径是否正确或使用createPlaceholder函数确保有占位图。点击无反应1. 事件监听未绑定。2. 坐标计算错误。3. 命中检测区域定义错误。1. 在init函数开始处加console.log确认事件绑定成功。2. 在handleCanvasClick中打印x, y坐标确认数值合理。3. 启用drawInteractiveAreasDebug()函数查看区域绘制是否与角色位置匹配。1. 确认DOMContentLoaded事件已触发。2. 仔细检查scaleX/Y的计算公式。3. 调整interactiveAreas中的坐标和大小使其对准角色相应部位。角色图片不显示只有占位图1. 图片路径错误。2. 图片服务器跨域问题如果使用在线图床。3. 图片格式不支持或损坏。1. Network 面板查看图片请求状态码。2. Console 查看是否有跨域错误。3. 直接浏览器打开图片链接测试。1. 使用相对路径并确保文件在正确位置。2. 将图片放在项目本地目录或确保图床允许跨域。3. 转换图片格式为 PNG/JPG。音效无法播放1. 浏览器自动播放策略限制。2. 音效文件路径错误。3. 用户交互前尝试播放。1. Console 查看是否有play()被拒绝的警告。2. Network 面板检查音频文件加载。3. 确认音效是在用户点击事件中触发播放。1. 确保音效播放代码在click等用户手势事件回调中。2. 为audio标签添加muted属性并在播放前取消或提供明确的“开启音效”按钮让用户主动交互。动画卡顿1. 绘制逻辑过于复杂或每帧绘制太多内容。2. 未使用requestAnimationFrame。3. 内存泄漏。1. 使用浏览器 Performance 面板录制分析。2. 检查draw函数中是否有不必要的重复创建对象如new Path2D()。1. 优化绘制代码例如将静态背景缓存到离屏Canvas。2. 确保动画更新在requestAnimationFrame回调中进行。3. 清除不再使用的定时器和事件监听。8. 最佳实践与进阶扩展建议一个可用的Demo完成了但要将其打磨成一个更像样的项目还需要考虑更多工程化和体验细节。8.1 代码组织与模块化当前所有逻辑在一个文件中。对于更复杂的项目应考虑拆分assetLoader.js专门负责资源加载与管理。stateManager.js管理游戏状态和状态机。inputHandler.js处理所有输入事件。renderer.js负责所有绘制逻辑。main.js作为入口初始化并协调各模块。8.2 性能优化离屏渲染将不常变化的背景、静态角色部件绘制到离屏Canvas主循环中直接drawImage减少重复绘制开销。避免在draw中创建对象如new Path2D()应在初始化时创建并复用。节流与防抖对mousemove等高频事件进行节流处理。8.3 体验增强更精细的命中检测实现上文提到的像素级颜色回溯法用于需要精确到像素的交互如点击飘带。动画系统引入一个简单的补间动画库如Tween.js或自己实现让状态切换如表情变化、位置移动更加平滑。粒子效果点击时在鼠标位置产生心形、星星等粒子特效使用 Canvas 的fillRect或drawImage配合简单物理模拟实现。本地存储使用localStorage记录用户偏好如是否开启音效、喜欢的背景。移动端适配完善touchstart,touchmove,touchend事件处理并考虑移动端性能特点。8.4 项目工程化使用构建工具引入 Webpack 或 Vite 管理依赖、打包压缩代码、处理资源。使用 TypeScript为状态、区域定义等添加类型提升代码可维护性。添加单元测试为getHitArea,triggerInteraction等核心函数编写测试。这个“摸个灵梦”的项目麻雀虽小五脏俱全。它从兴趣出发串联起了前端开发中 Canvas 绘图、事件处理、状态管理、资源加载等多个核心技能点。通过实现它你不仅收获了一个有趣的玩具更完成了一次扎实的前端小项目实战。你可以基于这个框架替换成任何你喜欢的角色或形象添加更复杂的动画和交互创造出独一无二的数字互动作品。
返回列表