背景面试完了没事干,刷到学院推文想着水下学分 要求如下:
说实话第一眼我还以为他要搞AR,想了下应该是用虚拟文化的元素来装饰网页就够了。 参考里提到了《罗小黑战记2》,之前看过动漫,但是更新太慢了后面就没看了,电影的话可能是因为之前那些事没有大力宣发,看到推文才知道,下载下来看看,确实不错
电影看完了,想想怎么实现比较好。往年的作品很多都是富媒体的,或者说是用大量的文字、图片、视频来客观的叙述一部作品,内部细分到每个人物等等。
不对,这么玩我剪素材得累死,而且单纯的堆积素材,我觉得没有多大意义。所以我决定以小黑的第一视角来展开叙述,同时限定时间线为电影一二部。
既然是第一视角,那就简单自我介绍加叙述经历,省事
比赛是叫设计大赛,但我也不是学平面设计的,想专业做UI/UX肯定不行。干脆参考国外的一些设计回国内降维打击了(最明显的反差可能是南孚电池官网了吧)
考虑能不能炫点技,好歹是计院的比赛,有技术含量的好点
点击访问:
设计事后诸葛亮嘛,直接放我当时答辩ppt 吧
简单来说就是全屏滚动+横向滚动+视频播放绑定
经历有删改,不然篇幅太长太累赘,配文就随便写点了,刻意模仿角色的性格来写,很难做到完全贴合,写多错多。而且人家就几岁能说啥长篇大论,有点OOC 了
因为中途解决了个问题,改了下方向,导致经历页面的背景不太合适现在这样,不过p半天了懒得改
结尾不知道放什么,就放个符合氛围的MV收尾吧
技术预览:
使用Live2d 进行渲染人物模型 使用Matter.js 模拟背景中“精灵”的移动 使用Gapless-5 实现背景音乐无缝循环播放 使用Gsap 提供良好的动画反馈及横向滚动效果 使用Lenis 提供平滑滚动 全屏滚动FullPageScroll.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 <script setup > import { onMounted, onBeforeUnmount } from 'vue' import Lenis from 'lenis' import gsap from 'gsap' import { ScrollTrigger } from 'gsap/ScrollTrigger' gsap.registerPlugin (ScrollTrigger ) let lenis = null onMounted (() => { lenis = new Lenis ({ duration : 1.2 , easing : (t ) => Math .min (1 , 1.001 - Math .pow (2 , -10 * t)), smoothWheel : true , smoothTouch : true , }) lenis.on ('scroll' , ScrollTrigger .update ) gsap.ticker .add ((time ) => { lenis.raf (time * 1000 ) }) gsap.ticker .lagSmoothing (0 ) const sections = gsap.utils .toArray ('.fp-section' ) sections.forEach ((section ) => { ScrollTrigger .create ({ trigger : section, start : 'top top' , end : 'bottom top' , snap : { snapTo : 1 , duration : { min : 0.2 , max : 0.6 }, ease : 'power2.inOut' }, }) }) }) onBeforeUnmount (() => { if (lenis) { lenis.destroy () } ScrollTrigger .getAll ().forEach (st => st.kill ()) gsap.ticker .remove ((time ) => { if (lenis) lenis.raf (time * 1000 ) }) }) </script > <template > <div class ="fp-container" > <slot /> </div > </template > <style scoped > .fp-container { width : 100% ; } :deep (.fp-section) { width : 100% ; height : 100vh ; display : flex; align-items : center; justify-content : center; position : relative; } </style >
调用时:
HomePage.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ...... <template > <FullPageScroll > <section class ="fp-section" > <FirstCard /> </section > <SecondCard :slides ="secondCardSlides" section-id ="second-card-1" /> <section class ="fp-section" > <ThirdCard /> </section > <SecondCard :slides ="FourthCardSlides" section-id ="second-card-2" /> <FifthCard :slides ="FifthCardSlides" /> <SixthCard :slides ="SixthCardSlides" /> </FullPageScroll > </template >
对于需要实现全屏吸附的页面,需用<section class="fp-section"></section>包裹
首页 背景起初背景打算用一个视频解决,后来觉得视频首末帧衔接太突兀就打算实现前端实时渲染
“灵”,或者说“精灵 ”,表现为球状,能自运动,速度不一,会受阻力影响变为椭球状,不受重力影响,可融合/分裂
投影到平面上时呈发光圆环状,内部半透明,外发光效果
不考虑其他形态,简单实现样式:
1 2 3 4 5 ctx.fillStyle = 'rgba(254, 254, 252, 0.6)' ctx.strokeStyle = 'rgba(255, 255, 255)' ctx.lineWidth = 1 ctx.shadowColor = '#b8d0bd' ctx.shadowBlur = 10
使用Matter.js 进行2D物理模拟
FirstCard.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 <script setup > import { ref, onMounted, onBeforeUnmount } from 'vue' import Matter from 'matter-js' let live2dInitialized = false let cellAnimation = null let cells = [] let targetCellIndex = null let eyeTrackingInterval = null let idleTimer = null let isTracking = false const initCellAnimation = async ( ) => { const canvas = document .getElementById ('cell-canvas' ) canvas.width = window .innerWidth canvas.height = window .innerHeight const ctx = canvas.getContext ('2d' ) const { Engine , World , Bodies , Body , Vector } = Matter const engine = Engine .create () engine.gravity .scale = 0 engine.gravity .x = 0 engine.gravity .y = 0 const world = engine.world let numCells = 15 let speedModifier = 0 try { const response = await fetch ('https://generate-cloud-image.hzchu.top/v1/image?format=json' ) const data = await response.json () const weatherCode = data.weather_code || 0 console .log (`天气代码: ${weatherCode} ` ) numCells = Math .max (0 , 15 - weatherCode) speedModifier = 0.03 * weatherCode console .log (`精灵数量: ${numCells} , 速度修正: +${speedModifier.toFixed(3 )} ` ) } catch (error) { console .warn ('天气数据获取失败,使用默认值:' , error) } cells = [] const radius = 10 for (let i = 0 ; i < numCells; i++) { const body = Bodies .circle ( Math .random () * canvas.width , Math .random () * canvas.height , radius, { frictionAir : 0 , friction : 0 , restitution : 1 , } ) const speed = 0.001 + Math .random () * 0.2 + speedModifier const angle = Math .random () * Math .PI * 2 Body .setVelocity (body, { x : Math .cos (angle) * speed, y : Math .sin (angle) * speed }) World .add (world, body) cells.push ({ body, speed, angle }) } const updateMovement = ( ) => { cells.forEach (cellData => { const cell = cellData.body const currentSpeed = Vector .magnitude (cell.velocity ) if (currentSpeed < cellData.speed * 0.9 ) { const normalizedVel = Vector .normalise (cell.velocity ) Body .setVelocity (cell, { x : normalizedVel.x * cellData.speed , y : normalizedVel.y * cellData.speed }) } if (cell.position .x < -radius) Body .setPosition (cell, {x : canvas.width + radius, y : cell.position .y }) if (cell.position .x > canvas.width + radius) Body .setPosition (cell, {x : -radius, y : cell.position .y }) if (cell.position .y < -radius) Body .setPosition (cell, {x : cell.position .x , y : canvas.height + radius}) if (cell.position .y > canvas.height + radius) Body .setPosition (cell, {x : cell.position .x , y : -radius}) }) } const render = ( ) => { ctx.clearRect (0 , 0 , canvas.width , canvas.height ) cells.forEach (cellData => { const cell = cellData.body const vx = cell.velocity .x const vy = cell.velocity .y const speed = Math .sqrt (vx*vx + vy*vy) const stretch = 1 + Math .min (speed / 5 , 0.3 ) const angle = Math .atan2 (vy, vx) ctx.save () ctx.translate (cell.position .x , cell.position .y ) ctx.rotate (angle) ctx.beginPath () ctx.ellipse (0 , 0 , radius * stretch, radius / stretch, 0 , 0 , Math .PI * 2 ) ctx.fillStyle = 'rgba(254, 254, 252, 0.6)' ctx.strokeStyle = 'rgba(255, 255, 255)' ctx.lineWidth = 1 ctx.shadowColor = '#b8d0bd' ctx.shadowBlur = 10 ctx.fill () ctx.stroke () ctx.restore () }) } const update = ( ) => { updateMovement () Engine .update (engine, 1000 / 60 ) render () cellAnimation = requestAnimationFrame (update) } update () const handleResize = ( ) => { canvas.width = window .innerWidth canvas.height = window .innerHeight } window .addEventListener ('resize' , handleResize) return () => { window .removeEventListener ('resize' , handleResize) if (cellAnimation) { cancelAnimationFrame (cellAnimation) } } } </script >
同时补充了个设定:天气恶劣时精灵数量减少,运动速度加快,符合逻辑一点。天气信息获取参考使用小米天气接口获取天气信息
FirstCard.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <template > <div class ="video-background" > <img v-webp src ="/assets/background.png" alt ="背景" class ="background-image" /> <canvas id ="cell-canvas" class ="cell-canvas" > </canvas > <div class ="video-overlay" > </div > </div > </template >
样式
FirstCard.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 <style scoped > .video-background { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; z-index : 0 ; overflow : hidden; } .background-image { position : absolute; top : 50% ; left : 50% ; min-width : 100% ; min-height : 100% ; width : auto; height : auto; transform : translate (-50% , -50% ); object-fit : cover; } .cell-canvas { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; z-index : 1 ; pointer-events : none; } .video-overlay { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; background : rgba (255 , 255 , 255 , 0.90 ); } :global (.dark) .video-overlay { background : rgba (0 , 0 , 0 , 0.85 ); } </style >
PS:其实写的时候考虑过鸟群算法,后来发现效果不太好就去掉了
Live2D起初是想加点互动性,在考虑到实际情况后用了Live2D,模型来自盒装现烤奕潞 和躺师傅轻食小炒 ,感谢
这部分还是搞了挺久,首先模型是经过Live2DViewerEX 打包后的lpk文件,加密后无法直接使用,解密后模型是Live2D 3.0 版本,网上大多数都是2.0为基础的。找了好久找到LSTM-Kirigaya/Live2dRender ,但是不太符合我的实际需求就二次开发了一些功能
具体就不展开讲了,前几天土土 推荐了个guansss/pixi-live2d-display ,好家伙又白造轮子了 效果可以参考学游渊的博客
LPK解密相关:尽管网上确实能找到相关资料,为了保护版权,这里不展开叙述
有个小彩蛋:长时间无动作时人物会盯着页面里的“精灵”看。计算坐标有点麻烦,我简单实现了下,没过多琢磨
背景音乐同样考虑到衔接问题,使用了regosen/Gapless-5 来实现无缝播放音频,同时使用AU 处理音频得到可循环音乐片段
具体操作参考:
首先要选一个纯音乐,有人声的话不太好,我这里选的是嘿咻狂想曲 前半段
注意与其他播放事件联动,避免一起播放影响听感
GlobalMusicPlayer.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 <script setup > import { ref, onMounted, onBeforeUnmount } from 'vue' import { Gapless5 } from '@regosen/gapless-5' const isPlaying = ref (false )let gapless = null let wasPlayingBeforeLivePhoto = false let wasPlayingBeforeDPlayer = false const imgIdle = 'https://emoticons.hzchu.top/emoticons/luo-xiao-hei/10.png' const imgPlaying = 'https://emoticons.hzchu.top/emoticons/shen-tan-luo-xiao-hei/4.png' const togglePlay = ( ) => { if (!gapless) return if (isPlaying.value ) { gapless.pause () } else { gapless.play () } } onMounted (() => { gapless = new Gapless5 ({ tracks : ['/assets/sound/background-single.wav' ], loop : true , loadLimit : 1 , useHTML5Audio : true }) gapless.onplayerload = () => { console .log ('Gapless 5 加载完成' ) } gapless.onplay = () => { isPlaying.value = true } gapless.onpause = () => { isPlaying.value = false } gapless.onerror = (error ) => { console .error ('Gapless 5 播放错误:' , error) } const onDPlayerPlay = ( ) => { if (gapless && isPlaying.value ) { wasPlayingBeforeDPlayer = true gapless.pause () console .log ('DPlayer 播放,暂停背景音乐' ) } } const onDPlayerPause = ( ) => { if (gapless && !isPlaying.value && wasPlayingBeforeDPlayer) { wasPlayingBeforeDPlayer = false gapless.play () console .log ('DPlayer 暂停,恢复背景音乐' ) } } window .addEventListener ('dplayer-play' , onDPlayerPlay) window .addEventListener ('dplayer-pause' , onDPlayerPause) const onLivePhotoPlay = ( ) => { if (gapless && isPlaying.value ) { wasPlayingBeforeLivePhoto = true gapless.pause () console .log ('实况照片播放,暂停背景音乐' ) } } const onLivePhotoPause = ( ) => { if (gapless && !isPlaying.value && wasPlayingBeforeLivePhoto) { wasPlayingBeforeLivePhoto = false gapless.play () console .log ('实况照片结束,恢复背景音乐' ) } } window .addEventListener ('livephoto-play' , onLivePhotoPlay) window .addEventListener ('livephoto-pause' , onLivePhotoPause) onBeforeUnmount (() => { window .removeEventListener ('dplayer-play' , onDPlayerPlay) window .removeEventListener ('dplayer-pause' , onDPlayerPause) window .removeEventListener ('livephoto-play' , onLivePhotoPlay) window .removeEventListener ('livephoto-pause' , onLivePhotoPause) if (gapless) { gapless.stop () gapless = null } }) }) </script > <template > <div class ="global-music-player" > <button class ="music-btn" @click ="togglePlay" :title ="isPlaying ? '暂停背景音乐' : '播放背景音乐'" > <img :src ="isPlaying ? imgPlaying : imgIdle" alt ="音乐状态" class ="music-status" /> </button > </div > </template > <style scoped > .global-music-player { position : fixed; left : 10px ; bottom : 10px ; z-index : 100 ; display : flex; align-items : flex-end; } .music-btn { background : none; border : none; padding : 0 ; cursor : pointer; outline : none; } .music-status { height : 2.5rem ; width : auto; display : block; } </style >
顶栏小彩蛋:滚动页面时icon同步滚动,且变成另一个状态
使用PS抠出主体 使用AI 的图像临摹转换为svg HeaderToolBar.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <script setup > import { ref, onMounted, onBeforeUnmount } from 'vue' const logoSrc = ref ('/assets/logo-256.svg' )const logoRotation = ref (0 )const isMobileMenuOpen = ref (false )let scrollTimeout = null let isScrolling = false const handleScroll = ( ) => { const currentScrollY = window .scrollY if (currentScrollY > 50 && !isScrolling) { isScrolling = true logoSrc.value = '/assets/logo-run.svg' } else if (currentScrollY <= 50 && !isScrolling) { logoSrc.value = '/assets/logo-256.svg' } else if (currentScrollY > 50 ) { logoSrc.value = '/assets/logo-run.svg' } logoRotation.value = (currentScrollY / 300 ) * 360 if (scrollTimeout) { clearTimeout (scrollTimeout) } scrollTimeout = setTimeout (() => { isScrolling = false if (window .scrollY > 50 ) { logoSrc.value = '/assets/logo-256.svg' } }, 500 ) } const openMobileMenu = ( ) => { isMobileMenuOpen.value = true } const closeMobileMenu = ( ) => { isMobileMenuOpen.value = false } onMounted (() => { window .addEventListener ('scroll' , handleScroll, { passive : true }) }) onBeforeUnmount (() => { window .removeEventListener ('scroll' , handleScroll) if (scrollTimeout) { clearTimeout (scrollTimeout) } }) </script >
故事页这部分耗时最久是排查“滚动页面时视频联动卡顿,要等运动完全静止才加载的出来”的问题
最后在youtube评论里找到了答案
想想也是,视频压缩算法中,非关键帧(预测帧)只记录画面中变化的部分,导致在读取该帧时需要依赖上一个关键帧到该预测帧间的所有消息。可能变化速度超过了解码速度或者是浏览器出于性能考虑优化了吧
注
在 H.264/H.265 等压缩编码中,视频帧分为三种类型:
I帧(关键帧/Intra-frame): 它是唯一包含完整画面信息 的帧。你可以把它看作一张完整的 .jpg 图片。P帧(预测帧/Predicted frame): 它不包含 完整的画面,只记录了“与前一帧相比变化了哪里”。B帧(双向预测帧/Bi-directional predicted frame): 记录“与前一帧和后一帧相比的变化”。因此严谨来说可能还需要后面的帧黑边 不知道为什么,下载的第二部有几个像素高的黑边,第一部更是大黑框,使用ffmpeg裁剪
1 2 3 4 5 6 7 8 9 ffmpeg -ss 00 :01 :00 -to 00 :05 :00 -i "罗小黑战记.mkv" ` -vf "crop=in_w:in_h-276:0:138" `-c :v h264_nvenc -preset slow -rc vbr -cq 18 -b :v 5 M `-c :a aac -b :a 192 k "罗小黑战记_1-5min_h264_cuda.mp4" ffmpeg -ss 00 :10 :00 -to 00 :20 :00 -i "罗小黑战记2.mkv" ` -vf "crop=in_w:in_h-12:0:12" `-c :v h264_nvenc -preset slow -rc vbr -cq 18 -b :v 5 M `-c :a aac -b :a 192 k "罗小黑战记2_10-20_h264_cuda.mp4"
1-5分钟,使用N卡加速 mkv转为mp4,不然导不进pr
剪辑&导出 注:由于我在解决卡顿问题前用的是序列帧(空间占用太恐怖了),为了沿用之前的剪辑成果,我使用pr导出序列帧再使用ffmpeg合成
导出时可压低分辨率,再高的分辨率缩放后也没用
ffmpeg命令:
1 ffmpeg -hwaccel cuda -framerate 5 -i "%d.jpg" -c :v h264_nvenc -bf 0 -g 2 -forced-idr 1 -pix_fmt yuv420p output.mp4
将此文件夹下所有jpg图片按照顺序拼接-g 2 关键帧距离为2(解决报错:Gop Length should be greater than number of B frames + 1)-forced-idr 1 使用IDR帧 (Instantaneous Decoding Refresh,即时解码刷新帧)-bf 0 禁用 B 帧(双向预测帧) 使几乎每一帧都接近关键帧
使用GSAP ScrollTrigger 实现横向滚动
SecondCard.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 <script setup > import { ref, onMounted, onBeforeUnmount } from 'vue' import gsap from 'gsap' import { ScrollTrigger } from 'gsap/ScrollTrigger' gsap.registerPlugin (ScrollTrigger ) const props = defineProps ({ slides : { type : Array , required : true , validator : (value ) => { return value.every (slide => slide.id !== undefined && slide.image !== undefined && slide.video !== undefined ) } }, sectionId : { type : String , default : () => `section-${Math .random().toString(36 ).substr(2 , 9 )} ` } }) const currentSlide = ref (0 )const videoRefs = ref ({})const wrapperElement = ref (null )const cardsboxElement = ref (null )let scrollTriggerInstance = null let resizeObserver = null let distance = 0 const calculateDistance = ( ) => { if (cardsboxElement.value ) { distance = cardsboxElement.value .scrollWidth - window .innerWidth if (wrapperElement.value ) { wrapperElement.value .style .height = `${distance + window .innerHeight} px` console .log ('横向滚动距离:' , distance, 'wrapper高度:' , wrapperElement.value .style .height ) } } } const updateSlideAndVideos = (progress ) => { const slideCount = props.slides .length const slideIndex = Math .floor (progress * slideCount) currentSlide.value = Math .min (slideIndex, slideCount - 1 ) props.slides .forEach ((slide, index ) => { const slideProgress = (progress * slideCount) - index const clampedProgress = Math .max (0 , Math .min (1 , slideProgress)) const videoElement = videoRefs.value [slide.id ] if (videoElement && videoElement.duration ) { const targetTime = clampedProgress * videoElement.duration if (Math .abs (videoElement.currentTime - targetTime) > 0.1 ) { videoElement.currentTime = targetTime } } }) } const goToSlide = (index ) => { if (scrollTriggerInstance) { const targetProgress = index / props.slides .length const scrollPosition = scrollTriggerInstance.start + (scrollTriggerInstance.end - scrollTriggerInstance.start ) * targetProgress window .scrollTo ({ top : scrollPosition, behavior : 'smooth' }) } } const initScrollTrigger = ( ) => { if (!wrapperElement.value || !cardsboxElement.value ) return calculateDistance () const animation = gsap.to (cardsboxElement.value , { x : () => -distance, ease : 'none' }) scrollTriggerInstance = ScrollTrigger .create ({ trigger : wrapperElement.value , start : 'top top' , end : 'bottom bottom' , animation : animation, scrub : 1 , id : props.sectionId , onUpdate : (self ) => { updateSlideAndVideos (self.progress ) }, onRefresh : () => { calculateDistance () }, invalidateOnRefresh : true }) } const handleResize = ( ) => { if (scrollTriggerInstance) { scrollTriggerInstance.kill () } initScrollTrigger () } onMounted (() => { setTimeout (() => { console .log ('初始化 ScrollTrigger...' , { wrapper : wrapperElement.value , cardsbox : cardsboxElement.value , scrollWidth : cardsboxElement.value ?.scrollWidth , innerWidth : window .innerWidth }) initScrollTrigger () window .addEventListener ('resize' , handleResize) if (cardsboxElement.value ) { resizeObserver = new ResizeObserver (handleResize) resizeObserver.observe (cardsboxElement.value ) } }, 100 ) }) onBeforeUnmount (() => { if (scrollTriggerInstance) { scrollTriggerInstance.kill () } window .removeEventListener ('resize' , handleResize) if (resizeObserver) { resizeObserver.disconnect () } }) </script > <template > <div ref ="wrapperElement" class ="horizontal-scroll-wrapper" :data-section-id ="props.sectionId" > <div class ="horizontal-scroll-container" > <div ref ="cardsboxElement" class ="cardsbox" > <div v-for ="slide in props.slides" :key ="slide.id" class ="slide-item" > <div class ="slide-background" > <img v-webp :src ="slide.image" :alt ="slide.title" class ="background-image" /> <div class ="background-overlay" > </div > </div > <div class ="slide-content" > <div class ="w-1/2 px-6 flex items-center" > <div class ="dialog-box" > <h1 v-if ="slide.title" class ="mb-4 text-4xl font-bold leading-tight md:text-5xl lg:text-6xl text-neutral-700 dark:text-neutral-200" v-html ="slide.title" > </h1 > <p v-if ="slide.description" class ="text-lg md:text-xl text-neutral-600 dark:text-neutral-300 leading-relaxed" v-html ="slide.description" > </p > </div > </div > <div class ="w-1/2 h-full flex items-center justify-center p-6" > <video :ref ="el => { if (el) videoRefs[slide.id] = el }" :src ="slide.video" class ="max-w-full max-h-full object-contain rounded-lg shadow-2xl" muted playsinline preload ="auto" /> </div > </div > </div > </div > </div > <div class ="scroll-indicator" > <div v-for ="(slide, index) in props.slides" :key ="slide.id" class ="indicator-dot" :class ="{ active: currentSlide === index }" @click ="goToSlide(index)" > </div > </div > <div class ="scroll-hint" > <span class ="text-sm text-neutral-500 dark:text-neutral-400" > 滚动查看更多</span > </div > </div > </template > <style scoped > .horizontal-scroll-wrapper { position : relative; width : 100% ; } .horizontal-scroll-container { position : sticky; top : 0 ; display : flex; align-items : center; justify-content : flex-start; width : 100% ; height : 100vh ; overflow : hidden; background : transparent; } .cardsbox { display : flex; align-items : center; height : 100% ; will-change : transform; } .slide-item { flex-shrink : 0 ; width : 100vw ; height : 100vh ; display : flex; align-items : center; justify-content : center; position : relative; } .slide-background { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; z-index : 0 ; overflow : hidden; } .background-image { width : 100% ; height : 100% ; object-fit : cover; object-position : center; } .background-overlay { position : absolute; top : 0 ; left : 0 ; width : 100% ; height : 100% ; background : rgba (255 , 255 , 255 , 0.80 ); } :global (.dark) .background-overlay { background : rgba (0 , 0 , 0 , 0.80 ); } .slide-content { position : relative; z-index : 1 ; width : 100% ; height : 100% ; display : flex; align-items : center; justify-content : center; } .dialog-box { position : relative; background : rgba (255 , 255 , 255 , 0.95 ); border-radius : 20px ; padding : 2rem 2.5rem ; box-shadow : 0 10px 40px rgba (0 , 0 , 0 , 0.15 ); border : 2px solid rgba (255 , 255 , 255 , 0.8 ); backdrop-filter : blur (10px ); max-width : 600px ; } :global (.dark) .dialog-box { background : rgba (30 , 30 , 30 , 0.95 ); border-color : rgba (60 , 60 , 60 , 0.8 ); } .scroll-indicator { position : absolute; bottom : 2rem ; left : 50% ; transform : translateX (-50% ); display : flex; gap : 0.5rem ; z-index : 10 ; } .indicator-dot { width : 8px ; height : 8px ; border-radius : 50% ; background-color : rgba (163 , 163 , 163 , 0.5 ); transition : all 0.3s ease; cursor : pointer; } .indicator-dot .active { width : 24px ; border-radius : 4px ; background-color : rgba (82 , 82 , 82 , 0.9 ); } :global (.dark) .indicator-dot { background-color : rgba (163 , 163 , 163 , 0.3 ); } :global (.dark) .indicator-dot.active { background-color : rgba (229 , 229 , 229 , 0.9 ); } .scroll-hint { position : absolute; bottom : 4rem ; left : 50% ; transform : translateX (-50% ); z-index : 10 ; animation : fadeInOut 3s ease-in-out infinite; } @keyframes fadeInOut { 0% , 100% { opacity : 0.3 ; } 50% { opacity : 1 ; } } @media (max-width : 768px ) { .slide-item { padding : 1rem 0 ; } .slide-content { flex-direction : column; } .slide-content > div { width : 100% !important ; max-height : 50% ; } .dialog-box { padding : 1.5rem ; font-size : 0.9rem ; } .scroll-hint { bottom : 3rem ; } .scroll-indicator { bottom : 1.5rem ; } } </style >
相册页之前在测试不基于 LivePhotoKit JS 实现实况照片 - 陪她去流浪 看到了个简单的实况图片示例,我借来用一下
具体实现逻辑我整理好了一个独立的库,vue版本也就多了个播放时与背景音乐联动,全屏查看(移动端),后续更新到库里
简单挡一下片头字幕,不然有点出戏 对部分片段手动调整了音量增益 对比页沿用上面的视频滚动绑定逻辑,加入了文字渐变 主要是CSS的linear-gradient 函数实现
FifthCard.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 <script setup > import { ref, onMounted, onBeforeUnmount } from 'vue' import gsap from 'gsap' import { ScrollTrigger } from 'gsap/ScrollTrigger' gsap.registerPlugin (ScrollTrigger ) const props = defineProps ({ slides : { type : Array , required : true , validator : (value ) => { return value.every (slide => slide.id !== undefined && slide.title !== undefined && slide.description !== undefined && slide.video !== undefined ) } } }) const wrapperElement = ref (null )const videoRefs = ref ({})const textRefs = ref ({})let scrollTriggers = []const initScrollTriggers = ( ) => { if (!wrapperElement.value ) return const slides = props.slides const slideCount = slides.length slides.forEach ((slide, index ) => { const slideElement = wrapperElement.value .querySelector (`[data-slide-id="${slide.id} "]` ) if (!slideElement) return const videoElement = videoRefs.value [slide.id ] if (videoElement) { const videoTrigger = ScrollTrigger .create ({ trigger : slideElement, start : 'center bottom' , end : 'bottom top' , scrub : 1 , onUpdate : (self ) => { if (videoElement.duration ) { const targetTime = self.progress * videoElement.duration if (Math .abs (videoElement.currentTime - targetTime) > 0.1 ) { videoElement.currentTime = targetTime } } } }) scrollTriggers.push (videoTrigger) } const textElement = textRefs.value [slide.id ] if (textElement) { const textTrigger = ScrollTrigger .create ({ trigger : slideElement, start : 'top bottom' , end : 'bottom top' , scrub : 1 , onUpdate : (self ) => { const progress = self.progress * 100 const isDark = document .documentElement .classList .contains ('dark' ) const color1 = isDark ? '#9ca7bb' : '#2d3e4f' const color2 = isDark ? '#2d3e4f' : '#9ca7bb' textElement.style .background = `linear-gradient(to right, ${color1} 0%, ${color1} ${progress} %, ${color2} ${progress} %, ${color2} 100%)` textElement.style .webkitBackgroundClip = 'text' textElement.style .webkitTextFillColor = 'transparent' textElement.style .backgroundClip = 'text' } }) scrollTriggers.push (textTrigger) } if (index === slideCount - 1 ) { const fadeOutTrigger = ScrollTrigger .create ({ trigger : slideElement, start : 'center top' , end : 'bottom top' , scrub : 1 , onUpdate : (self ) => { slideElement.style .opacity = 1 - self.progress } }) scrollTriggers.push (fadeOutTrigger) } }) } onMounted (() => { setTimeout (() => { initScrollTriggers () }, 100 ) }) onBeforeUnmount (() => { scrollTriggers.forEach (st => { if (st) st.kill () }) }) </script >
视频页用DPlayer 好看一点,同时和背景音乐联动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 const dp = new DPlayer ({ container : container, video : { url : slide.video , type : 'auto' }, autoplay : false , loop : false , preload : 'auto' , volume : 0.7 , mutex : true , theme : '#3b82f6' , lang : 'zh-cn' }) dplayers.push (dp) dp.on ('play' , () => { window .dispatchEvent (new CustomEvent ('dplayer-play' )) }) dp.on ('pause' , () => { window .dispatchEvent (new CustomEvent ('dplayer-pause' )) })
杂项 鼠标指针使用由漓翎_cub 制作的鼠标指针,并使用Axialis CursorWorkshop 调整大小
Snipaste_2025-11-05_11-00-20.jpg
字体使用小赖字体 ,并使用fonttools 压缩
安装fonttools:pip install fonttools
计算子集:全选页面内容后进行字符去重 ,将结果保存到一个txt文件 根据子集提取:fonttools subset ".\Xiaolai-Regular.ttf" --text-file=".\words.txt" --output-file=".\Xiaolai-Regular1_subset.ttf" 压缩为woff2格式:fonttools ttLib.woff2 compress ".\Xiaolai-Regular_subset.ttf" -o .\Xiaolai-Regular.woff2 搁置搁置了部分功能没有实现:
视觉跟踪:上文提到的“长时间不活动自动盯这精灵看”,本来计划调用摄像头实现视觉追踪,考虑到实际运行的软硬件环境就没有做 图片转换:整体开发轻功能性,且与主题联系不大
AIAI太好用了你知道吗 项目里一些我可能一辈子用不上几次的库(比如Matter.js)就直接让AI写了 一些库国内资料不多,但国外资料还是很详尽的 文中代码有疑问也可以直接找AI
开源及版权相关虽然本文也差不多开源完了,但是整体不开源,原因如下:
版权问题:尽管文中没有涉及Live2D LPK解密的具体实现、模型内容等信息,开源仍会带来不必要的纷争 实际问题:如果完全开源,开箱即用,那明年比赛上可不就神仙大乱斗了吗,给评委找麻烦 本项目版权相关参阅版权声明 ,感谢各位作者的辛勤付出