当《荒野大镖客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)不仅描述了人物,还要求将他们的内心世界——雪山、燃烧的营地、落日峡谷——融合进剪影之中。

有了这些高质量的图片,网页的设计原则就非常清晰了:

  1. 极简主义:UI 不能喧宾夺主,一切为了展示图片。
  2. 西部氛围:配色必须是经典的 RDR 红(#cc0000)与深邃黑。
  3. 沉浸交互:像翻阅一本精美的电子画册,而不是浏览普通的网页。

🤖 开发过程:与 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>
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐