当《荒野大镖客2》遇上前端美学:我用 Gemini 打造了一座“双重曝光”的人物纪念馆
这篇文章分享了作者如何借助AI工具(ChatGpt和Gemini)开发一个致敬《荒野大镖客2》的沉浸式前端网页项目。作者通过三次迭代最终实现了全屏垂直滚动的电影级体验,运用CSS Scroll Snap、动态粒子特效和IntersectionObserver等技术,为游戏角色创建了一个带有双重曝光艺术效果的数字纪念馆。项目展示了AI如何帮助开发者专注于创意而非代码细节,实现了极简主义设计、西部氛围
当《荒野大镖客2》遇上前端美学:我用 Gemini 打造了一座“双重曝光”的人物纪念馆
“We’re more ghosts than people.” — Arthur Morgan

《荒野大镖客2》(Red Dead Redemption 2)不仅仅是一款游戏,它是一部关于时代终结、忠诚与救赎的互动史诗。作为一名开发者,同时也是一名被范德林德帮的故事深深打动的玩家,我一直想为这款游戏做点什么。
最近,我使用 ChatGpt 生成了一组极具艺术感的“双重曝光(Double Exposure)”风格人物剪影。手里有了素材,我便萌生了一个想法:为什么不写一个沉浸式的前端网页,来承载这些厚重的灵魂呢?
但我不想把时间花在繁琐的 CSS 布局调试上。于是,我找来了 Gemini(Google 的 AI 模型),充当我的“结对编程伙伴”。
今天就来分享一下,如何用 AI 快速实现一个电影级、全屏滚动、带有动态粒子特效的人物志网页。
🎨 灵感与素材:当代码遇上艺术
项目的核心视觉基于一组 ChatGpt 生成的图片。提示词(Prompt)不仅描述了人物,还要求将他们的内心世界——雪山、燃烧的营地、落日峡谷——融合进剪影之中。
有了这些高质量的图片,网页的设计原则就非常清晰了:
- 极简主义:UI 不能喧宾夺主,一切为了展示图片。
- 西部氛围:配色必须是经典的 RDR 红(
#cc0000)与深邃黑。 - 沉浸交互:像翻阅一本精美的电子画册,而不是浏览普通的网页。
🤖 开发过程:与 Gemini 的三次迭代
在开发过程中,我并没有一开始就写代码,而是扮演了“产品经理 + 艺术总监”的角色,向 Gemini 提出需求。有趣的是,这个项目经历了三个版本的迭代,才达到了最终的效果。
V1.0:手风琴画廊
最初,我设想的是一个横向的“手风琴”效果。点击某个人物,卡片展开。
- Gemini 的表现:迅速生成了 Flexbox 布局,交互逻辑没有问题。
- 问题:当人物多达 14 个时,收缩状态下的卡片太窄,且无法完美展示双重曝光图中丰富的细节(如亚瑟体内的雪山纹理)。
V2.0:图鉴目录式
接着,我尝试模仿游戏内的菜单,左侧是列表,右侧是大图。
- Gemini 的表现:实现了一个带有磨砂玻璃质感(Backdrop-filter)的侧边栏,点击列表切换右侧大图。
- 问题:虽然信息清晰,但缺乏“叙事感”,更像是一个资料库,而不是艺术展。
V3.0:全屏沉浸式滚动(最终版)
最后,我决定致敬现代高端 Web 设计,采用全屏垂直滚动(Vertical Scroll Snap)。每一屏只讲一个人的故事,配上他们最经典的台词。
- Gemini 的表现:这一版最为惊艳。它不仅写出了
scroll-snap的核心逻辑,还自动补全了 Intersection Observer API 来实现文字和图片的入场动画。
💻 技术亮点解析
虽然代码是由 AI 生成的,但其中的前端技术细节非常值得玩味:
1. CSS Scroll Snap 实现“吸附感”
为了让滚动体验像幻灯片一样“一页一停”,我们使用了 CSS 的 Scroll Snap 属性。这比传统的 JS 监听滚动要流畅得多,且不占用主线程资源。
.scroll-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory; /* 强制垂直吸附 */
scroll-behavior: smooth;
}
.section {
height: 100vh;
scroll-snap-align: start; /* 对齐视口顶部 */
}
2. 动态余烬(Embers)特效
为了营造那种“余烬将熄”的悲壮氛围,页面背景中不断有红色的火星升起。这是通过简单的 DOM 操作配合 CSS 动画实现的,既保证了视觉效果,又避免了引入庞大的 Canvas 库。
3. 电影级入场动画
当用户滚动到新的一页时,图片和文字不是生硬地出现,而是带有节奏感:
- 图片:从缩小状态缓慢放大并浮现(模拟双重曝光的显影过程)。
- 文字:从底部错落升起,名字、台词、描述依次出现。
这是通过 IntersectionObserver 监听元素是否进入视口,动态添加 .active 类来触发 CSS Transition 实现的。
4. 字体美学
网页引入了 Google Fonts 中的 Rye(西部通缉令风格)和 Cinzel(古典衬线体),配合金色的经典台词(Quote),瞬间拉满“大表哥”的味道。
🎬 最终效果
现在的网页,不再是一个简单的图片列表。
当你向下滚动,看到亚瑟·摩根在雪山背景中浮现,下面写着 “I gave you all I had…”;接着滑到下一页,看到达奇在燃烧的营地中说 “Have some faith!”。
这种视觉与听觉(脑补配音)的双重冲击,正是我想要的效果。
📝 结语
这次尝试让我深刻体会到,AI 并不是来取代开发者的,而是来解放创造力的。 我不需要纠结于 div 怎么居中,也不用手写繁琐的动画关键帧,我只需要专注于审美、构思和对故事的理解。
如果你也是《荒野大镖客2》的粉丝,或者对这种全屏交互的前端实现感兴趣,可以查看下方的完整代码。你可以替换成自己的图片,为你喜爱的角色建立一座数字纪念馆。
🤠
附:项目完整源代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RDR2: Legends of the West</title>
<!-- 引入西部字体和手写体 -->
<link href="https://fonts.googleapis.com/css2?family=Rye&family=Cinzel:wght@400;700&family=Playfair+Display:ital@1&family=Noto+Serif+SC:wght@300;500;700&display=swap" rel="stylesheet">
<style>
:root {
--rdr-red: #cc0000;
--rdr-black: #0a0a0a;
--text-gold: #d4af37;
--text-white: #e0e0e0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background-color: var(--rdr-black);
color: var(--text-white);
font-family: 'Noto Serif SC', serif;
overflow: hidden; /* 隐藏默认滚动条,完全由容器接管 */
}
/* --- 动态背景层 (固定不动) --- */
#bg-layer {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 1;
background: radial-gradient(circle at center, #1a0505 0%, #000 90%);
pointer-events: none;
}
/* 粒子容器 */
#embers-container {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 2;
pointer-events: none;
}
.ember {
position: absolute;
bottom: -20px;
background: orangered;
border-radius: 50%;
box-shadow: 0 0 15px 2px rgba(255, 69, 0, 0.6);
animation: floatUp linear infinite;
opacity: 0;
}
@keyframes floatUp {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0; }
10% { opacity: 0.8; }
100% { transform: translateY(-110vh) translateX(20px) scale(0.5); opacity: 0; }
}
/* --- 滚动主容器 --- */
.scroll-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory; /* 核心:强制垂直吸附 */
scroll-behavior: smooth;
position: relative;
z-index: 10;
}
/* --- 单个屏/板块 --- */
.section {
height: 100vh;
width: 100%;
scroll-snap-align: start; /* 滚动停止时对齐顶部 */
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* 内容布局 */
.content-wrapper {
display: flex;
width: 90%;
max-width: 1600px;
height: 90%;
align-items: center;
justify-content: space-between;
}
/* 左侧/中间:图片区 */
.visual-area {
flex: 1.5;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* 红色光晕背景 */
.glow-bg {
position: absolute;
width: 60vh;
height: 60vh;
background: radial-gradient(circle, rgba(204,0,0,0.25) 0%, rgba(0,0,0,0) 70%);
border-radius: 50%;
opacity: 0;
transform: scale(0.5);
transition: all 1.5s ease;
}
.character-img {
max-height: 90vh;
max-width: 100%;
object-fit: contain;
filter: drop-shadow(0 0 40px rgba(0,0,0,0.8));
opacity: 0;
transform: scale(0.9) translateY(20px);
transition: all 1.2s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 右侧:文字区 */
.text-area {
flex: 1;
padding-left: 50px;
display: flex;
flex-direction: column;
justify-content: center;
}
.char-name {
font-family: 'Rye', serif;
font-size: 4rem;
line-height: 1.1;
color: var(--rdr-red);
text-transform: uppercase;
margin-bottom: 10px;
text-shadow: 4px 4px 10px #000;
opacity: 0;
transform: translateX(50px);
transition: all 0.8s ease 0.2s; /* 延迟0.2秒 */
}
.char-quote {
font-family: 'Playfair Display', serif;
font-style: italic;
font-size: 1.5rem;
color: var(--text-gold);
margin-bottom: 30px;
border-left: 4px solid var(--rdr-red);
padding-left: 20px;
opacity: 0;
transform: translateX(50px);
transition: all 0.8s ease 0.4s; /* 延迟0.4秒 */
}
.char-desc {
font-size: 1rem;
line-height: 1.8;
color: #ccc;
background: rgba(0, 0, 0, 0.6);
padding: 25px;
border-radius: 8px;
backdrop-filter: blur(5px);
opacity: 0;
transform: translateY(30px);
transition: all 0.8s ease 0.6s; /* 延迟0.6秒 */
}
/* --- 激活状态(当滚动到该屏时添加的类) --- */
.section.active .glow-bg {
opacity: 1;
transform: scale(1);
}
.section.active .character-img {
opacity: 1;
transform: scale(1) translateY(0);
}
.section.active .char-name,
.section.active .char-quote,
.section.active .char-desc {
opacity: 1;
transform: translateX(0) translateY(0);
}
/* 滚动提示箭头 */
.scroll-indicator {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-family: 'Rye', serif;
font-size: 0.8rem;
opacity: 0.5;
z-index: 20;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {transform: translateX(-50%) translateY(0);}
40% {transform: translateX(-50%) translateY(-10px);}
60% {transform: translateX(-50%) translateY(-5px);}
}
/* --- 手机适配 --- */
@media (max-width: 900px) {
.content-wrapper { flex-direction: column; justify-content: flex-end; height: 100%; width: 95%; }
.visual-area { flex: unset; height: 55%; width: 100%; align-items: flex-end; }
.character-img { max-height: 100%; }
.text-area { flex: unset; height: 45%; padding: 20px 10px 50px 10px; justify-content: flex-start; }
.char-name { font-size: 2.5rem; margin-bottom: 5px; text-align: center; transform: translateY(30px); }
.char-quote { font-size: 1rem; margin-bottom: 15px; text-align: center; border-left: none; border-bottom: 2px solid var(--rdr-red); padding-bottom: 10px; padding-left: 0; transform: translateY(30px); }
.char-desc { font-size: 0.85rem; padding: 15px; max-height: 200px; overflow-y: auto; }
}
</style>
</head>
<body>
<!-- 固定背景元素 -->
<div id="bg-layer"></div>
<div id="embers-container"></div>
<div class="scroll-indicator">SCROLL DOWN</div>
<!-- 滚动容器:内容由JS动态生成 -->
<div class="scroll-container" id="container">
<!-- Sections will be injected here -->
</div>
<script>
// --- 人物数据 (包含路径、名字、台词、描述) ---
const characters = [
{
name: 'Arthur Morgan',
img: 'images/Arthur Morgan.png',
quote: "I gave you all I had... I did.",
desc: "亚瑟·摩根的轮廓中融合了寒冬边境那令人惊叹的崎岖地貌。白雪皑皑的松林、霜冻的山峰,以及一匹穿越小径的孤独马匹,在他的身影中回响,诉说着无尽的孤独。单色的背景保持着锐利的对比,所有的焦点都汇聚在这位在救赎之路上挣扎的亡命之徒身上。"
},
{
name: 'Dutch van der Linde',
img: 'images/Dutch van der Linde.png',
quote: "I have a plan! You just have to have some faith.",
desc: "达奇正带着狂野的眼神挑衅地凝视着前方。在他的剪影中,是一个被混乱吞噬的燃烧营地——帐篷在烈火中崩塌,黑烟在松林间盘旋,金币散落在肮脏的泥泞里。那迷雾与火花,象征着他那被贪婪与偏执逐渐腐蚀的雄心壮志与崩塌的乌托邦。"
},
{
name: 'John Marston',
img: 'images/John Marston.png',
quote: "People don't forget. Nothing gets forgiven.",
desc: "约翰·马斯顿凝视着远方,轮廓内是落日余晖下的峡谷骑行。骑手在悬崖边留下的剪影、风化的篝火在身后阴燃、尘土在风中飞扬,这一切都交织在灰烬般的纹理中,勾勒出一个即使想金盆洗手却仍被江湖纠缠的灵魂。"
},
{
name: 'Sadie Adler',
img: 'images/Sadie Adler.png',
quote: "Nobody's taking anything from me ever again.",
desc: "莎迪·阿德勒侧身回首,目光如炬。她的剪影中是一条风暴肆虐的沙漠小径。在深红色的背景映衬下,这幅画面充满了激烈的对抗与不屈的女性力量,她已不再是那个无助的寡妇,而是复仇风暴本身。"
},
{
name: 'Charles Smith',
img: 'images/Charles Smith.png',
quote: "The amount of hell we've raised, we're owed some back.",
desc: "查尔斯的轮廓中展开了广阔的平原,野牛群平静地迁徙,仪式篝火的烟雾缓缓升腾。背景是宁静的迷雾森林绿,透着古老的智慧与静谧。他是帮派中为数不多仍保持着与大地、与灵魂深刻连接的战士。"
},
{
name: 'Micah Bell',
img: 'images/Micah Bell.png',
quote: "Survivors, that's all there is! Living and dying.",
desc: "迈卡带着那标志性的凶险笑容。他的体内是一场肆虐山口的暴风雪,枪手们埋伏在岩石后,秃鹫在头顶盘旋。风暴般的灰色背景夹杂着深红色的闪电纹理,完美捕捉了他那混乱、背叛且冷血的本质。"
},
{
name: 'Hosea Matthews',
img: 'images/Hosea Matthews.png',
quote: "I wish I had acquired wisdom at less of a price.",
desc: "何西阿平静地坐着。在他的剪影里,是金色的秋日森林,远处小屋升起袅袅炊烟,一本打开的日记静静躺在落叶中。这充满回忆色调的画面,象征着他是帮派中理智的声音,向往着那些逝去的、充满秩序与和平的旧时光。"
},
{
name: 'Abigail Roberts',
img: 'images/Abigail Roberts.png',
quote: "We all make mistakes, but we pay for them.",
desc: "阿比盖尔在柔和的光线下双臂交叉。她的轮廓中充满了农舍厨房的温暖和在长草中奔跑的孩子。在红色的暖光晕映衬下,这幅画面讲述了一个关于母爱、家庭传承以及在动荡西部寻求安稳生活的平凡梦想。"
},
{
name: 'Jack Marston',
img: 'images/Jack Marston (Young).png',
quote: "Do you think I could be a gunslinger?",
desc: "年幼的杰克带着好奇仰望天空。盛开的草地、飞舞的蜻蜓、自由驰骋的马匹填充了他的身影。柔和的光晕与充满希望的色调,象征着那个尚未被残酷现实完全玷污的纯真童年,他是帮派所有人试图守护的未来。"
},
{
name: 'Uncle',
img: 'images/Uncle.png',
quote: "It's a serious medical condition! Lumbago!",
desc: "大叔懒洋洋地向后靠着,轮廓里是摇晃的门廊椅、布满灰尘的旋转风车和啤酒瓶。在热浪滚滚的红色背景下,这幅画面充满了喜剧色彩与乡村的闲散气息,生动演绎了那著名的“腰痛”借口。"
},
{
name: 'Bill Williamson',
img: 'images/Bill Williamson.png',
quote: "I'm loyal to the boss, that's all.",
desc: "比尔·威廉姆森正在发起冲锋。在他的轮廓中,是爆炸的TNT炸药、坍塌的矿坑入口以及硝烟中呐喊的人群。粗糙的纹理与激烈的砂砾感背景,描绘了一个混乱的战场场景,这是他那盲目忠诚的性格写照。"
},
{
name: 'Reverend Swanson',
img: 'images/Reverend Swanson.png',
quote: "I have lost my way...",
desc: "斯旺森牧师双手合十跪地祈祷。然而在他的剪影中,却是破碎的彩色玻璃、散落的威士忌酒瓶和从枯树上惊飞的乌鸦。在漂浮的烟雾背景中,这幅画面展示了他信仰崩塌与寻求救赎之间的痛苦挣扎。"
},
{
name: 'The Camp Ladies',
img: 'images/Mary-Beth Karen Susan Grimshaw.png',
quote: "We are a family. We protect our own.",
desc: "玛丽-贝斯的日记、凯伦的威士忌、苏珊的步枪。从烛火的琥珀色到月光的银色,她们三种不同的人生轨迹在同一个营地中交织,支撑着这个流浪之家的运转。"
},
{
name: 'Eagle Flies & Rains Fall',
img: 'images/Eagle Flies Rains Fall.png',
quote: "My son is too proud for his own good.",
desc: "飞鹰与落雨背靠背的双重剪影。他们的形态中融合了正在推进的骑兵队、被撕毁的条约卷轴以及暮色下的圣山。在部落图腾纹理与战争烟雾的红色背景下,这是一场关于激进与传统、战争与和平的代际冲突。"
}
];
// --- 1. 动态生成页面内容 ---
const container = document.getElementById('container');
characters.forEach((char, index) => {
// 创建 Section
const section = document.createElement('div');
section.className = 'section';
// 为了便于观察器识别,添加一个ID(可选)
section.dataset.index = index;
section.innerHTML = `
<div class="content-wrapper">
<div class="visual-area">
<div class="glow-bg"></div>
<img src="${char.img}" alt="${char.name}" class="character-img" loading="lazy">
</div>
<div class="text-area">
<h2 class="char-name">${char.name}</h2>
<div class="char-quote">"${char.quote}"</div>
<p class="char-desc">${char.desc}</p>
</div>
</div>
`;
container.appendChild(section);
});
// --- 2. Intersection Observer 实现滚动触发动画 ---
const observerOptions = {
root: container, // 监听的滚动容器
threshold: 0.5 // 当50%的元素可见时触发
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 进入视野:添加 active 类触发动画
entry.target.classList.add('active');
} else {
// 离开视野:移除 active 类(这样往回滚时也能重新触发动画)
entry.target.classList.remove('active');
}
});
}, observerOptions);
// 监听所有 section
document.querySelectorAll('.section').forEach(section => {
observer.observe(section);
});
// --- 3. 背景粒子特效 (Embers) ---
const embersContainer = document.getElementById('embers-container');
function createEmber() {
const ember = document.createElement('div');
ember.className = 'ember';
const size = Math.random() * 4 + 1;
ember.style.width = `${size}px`;
ember.style.height = `${size}px`;
ember.style.left = `${Math.random() * 100}vw`;
// 随机动画时间和延迟
const duration = Math.random() * 5 + 4;
ember.style.animationDuration = `${duration}s`;
embersContainer.appendChild(ember);
setTimeout(() => ember.remove(), duration * 1000);
}
// 持续生成粒子
setInterval(createEmber, 200);
</script>
</body>
</html>
更多推荐

所有评论(0)