加载中...
不想等待可以点我关掉

背景

面试完了没事干,刷到学院推文想着水下学分
要求如下:

要求内容
要求内容

说实话第一眼我还以为他要搞AR,想了下应该是用虚拟文化的元素来装饰网页就够了。
参考里提到了《罗小黑战记2》,之前看过动漫,但是更新太慢了后面就没看了,电影的话可能是因为之前那些事没有大力宣发,看到推文才知道,下载下来看看,确实不错

内容上

电影看完了,想想怎么实现比较好。往年的作品很多都是富媒体的,或者说是用大量的文字、图片、视频来客观的叙述一部作品,内部细分到每个人物等等。

不对,这么玩我剪素材得累死,而且单纯的堆积素材,我觉得没有多大意义。所以我决定以小黑的第一视角来展开叙述,同时限定时间线为电影一二部。

既然是第一视角,那就简单自我介绍加叙述经历,省事

技术上

比赛是叫设计大赛,但我也不是学平面设计的,想专业做UI/UX肯定不行。干脆参考国外的一些设计回国内降维打击了(最明显的反差可能是南孚电池官网了吧)

考虑能不能炫点技,好歹是计院的比赛,有技术含量的好点

点击访问:

设计

事后诸葛亮嘛,直接放我当时答辩ppt

简单来说就是全屏滚动+横向滚动+视频播放绑定

经历有删改,不然篇幅太长太累赘,配文就随便写点了,刻意模仿角色的性格来写,很难做到完全贴合,写多错多。而且人家就几岁能说啥长篇大论,有点OOC

因为中途解决了个问题,改了下方向,导致经历页面的背景不太合适现在这样,不过p半天了懒得改

结尾不知道放什么,就放个符合氛围的MV收尾吧

技术

预览:

  1. 使用Live2d进行渲染人物模型
  2. 使用Matter.js模拟背景中“精灵”的移动
  3. 使用Gapless-5实现背景音乐无缝循环播放
  4. 使用Gsap提供良好的动画反馈及横向滚动效果
  5. 使用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 平滑滚动
lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
smoothTouch: true,
})

// 连接 Lenis 和 GSAP ScrollTrigger
lenis.on('scroll', ScrollTrigger.update)

gsap.ticker.add((time) => {
lenis.raf(time * 1000)
})

gsap.ticker.lagSmoothing(0)

// 获取所有 section
const sections = gsap.utils.toArray('.fp-section')

// 为每个 section 设置 ScrollTrigger
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'
},
// markers: true
})
})
})

onBeforeUnmount(() => {
// 清理 Lenis
if (lenis) {
lenis.destroy()
}

// 清理所有 ScrollTrigger 实例
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%;
}

/* 全局样式,确保每个 section 占满视口 */
: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 = [] // 存储精灵数据供 Live2D 追踪
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
})
}

// Wrap-around 边界穿越
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()

// 窗口大小改变时更新 canvas
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>

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 // 记录 DPlayer 播放前的状态

// 状态图片
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 5
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)
}

// 监听 DPlayer 播放/暂停事件
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)

// 监听 LivePhoto 播放/暂停事件
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)

// 清理 Gapless 5
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同步滚动,且变成另一个状态

处理素材
  1. 使用PS抠出主体
  2. 使用AI的图像临摹转换为svg
原图
原图
PS大法
PS大法
图像描摹
图像描摹
调用
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

// 滚动开始时切换为奔跑 logo
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'
}

// 根据滚动距离计算旋转角度
// 每滚动 300px 旋转 360 度
logoRotation.value = (currentScrollY / 300) * 360

// 清除之前的定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}

// 停止滚动 500ms 后切换回静态 logo
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): 记录“与前一帧和后一帧相比的变化”。因此严谨来说可能还需要后面的帧
处理素材
  1. 黑边

不知道为什么,下载的第二部有几个像素高的黑边,第一部更是大黑框,使用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 5M `
-c:a aac -b:a 192k "罗小黑战记_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 5M `
-c:a aac -b:a 192k "罗小黑战记2_10-20_h264_cuda.mp4"

1-5分钟,使用N卡加速
mkv转为mp4,不然导不进pr

  1. 剪辑&导出
导出时设置
导出时设置

注:由于我在解决卡顿问题前用的是序列帧(空间占用太恐怖了),为了沿用之前的剪辑成果,我使用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>
/**
* 横向滚动卡片组件
*
* 使用 GSAP ScrollTrigger 实现垂直滚动驱动横向内容移动
* 支持视频随滚动进度播放
*
* @example
* <SecondCard :slides="slidesData" />
*
* slidesData 格式:
* [
* {
* id: 1, // 唯一标识
* title: '标题', // 可选,标题文字
* description: '描述', // 可选,描述文字
* image: '/path/to/bg.jpg', // 背景图片路径
* video: '/path/to/video.mp4' // 视频路径
* }
* ]
*/
import { ref, onMounted, onBeforeUnmount } from 'vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)

// 定义 props
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)
}
}
}

// 更新当前 slide 和视频进度
const updateSlideAndVideos = (progress) => {
const slideCount = props.slides.length
const slideIndex = Math.floor(progress * slideCount)
currentSlide.value = Math.min(slideIndex, slideCount - 1)

// 为每个slide更新视频进度
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'
})
}
}

// 初始化 ScrollTrigger
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, // 添加唯一 ID
onUpdate: (self) => {
updateSlideAndVideos(self.progress)
},
onRefresh: () => {
calculateDistance() // 刷新时重新计算距离
},
invalidateOnRefresh: true
})
}

// 处理窗口大小变化
const handleResize = () => {
if (scrollTriggerInstance) {
scrollTriggerInstance.kill()
}
initScrollTrigger()
}

onMounted(() => {
// 延迟初始化,确保 DOM 已渲染
setTimeout(() => {
console.log('初始化 ScrollTrigger...', {
wrapper: wrapperElement.value,
cardsbox: cardsboxElement.value,
scrollWidth: cardsboxElement.value?.scrollWidth,
innerWidth: window.innerWidth
})

initScrollTrigger()

// 监听窗口大小变化
window.addEventListener('resize', handleResize)

// 使用 ResizeObserver 监听容器大小变化
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%;
}

/* 横向滚动容器 - sticky 定位 */
.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版本也就多了个播放时与背景音乐联动,全屏查看(移动端),后续更新到库里

整理素材
  1. 简单挡一下片头字幕,不然有点出戏
image.png
image.png
  1. 对部分片段手动调整了音量增益

对比页

沿用上面的视频滚动绑定逻辑,加入了文字渐变
主要是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 = []

// 为每个 slide 创建 ScrollTrigger
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)
}



// 如果是最后一个 slide,添加淡出效果
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
Snipaste_2025-11-05_11-00-20.jpg

字体

使用小赖字体,并使用fonttools压缩

安装fonttools:pip install fonttools

  1. 计算子集:全选页面内容后进行字符去重,将结果保存到一个txt文件
  2. 根据子集提取:fonttools subset ".\Xiaolai-Regular.ttf" --text-file=".\words.txt" --output-file=".\Xiaolai-Regular1_subset.ttf"
  3. 压缩为woff2格式:fonttools ttLib.woff2 compress ".\Xiaolai-Regular_subset.ttf" -o .\Xiaolai-Regular.woff2

搁置

搁置了部分功能没有实现:

  1. 视觉跟踪:上文提到的“长时间不活动自动盯这精灵看”,本来计划调用摄像头实现视觉追踪,考虑到实际运行的软硬件环境就没有做
  2. 图片转换:整体开发轻功能性,且与主题联系不大

AI

AI太好用了你知道吗
项目里一些我可能一辈子用不上几次的库(比如Matter.js)就直接让AI写了
一些库国内资料不多,但国外资料还是很详尽的
文中代码有疑问也可以直接找AI

开源及版权相关

虽然本文也差不多开源完了,但是整体不开源,原因如下:

  1. 版权问题:尽管文中没有涉及Live2D LPK解密的具体实现、模型内容等信息,开源仍会带来不必要的纷争
  2. 实际问题:如果完全开源,开箱即用,那明年比赛上可不就神仙大乱斗了吗,给评委找麻烦

本项目版权相关参阅版权声明,感谢各位作者的辛勤付出