ARTICLE DETAIL

资讯详情

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

纯Web开发恋爱视觉小说:不用游戏引擎,手写剧情引擎与好感度系统

纯Web开发恋爱视觉小说:不用游戏引擎,手写剧情引擎与好感度系统 恋爱题材的Web交互页真的需要游戏引擎吗很多内容创作者想做一款恋爱视觉小说时第一反应是去学 RenPy、Twine或者干脆上手 Unity。但如果你仔细想一想“彩彩与千圣水蜜桃恋色物语”这类作品的核心是什么不是复杂的物理引擎不是 3D 渲染而是“剧情 分支选项 好感度 视觉反馈”的轻量交互组合。在这个领域纯 Web 技术HTML CSS JavaScript反而是一套被低估的方案。你不需要安装厚重的游戏引擎不需要编译打包一个几十 MB 的客户端只需要一套静态页面和一份结构清晰的剧情数据就能做出一部可以随时分享给朋友、点开即玩的恋爱题材互动页。这篇文章会把“彩彩与千圣水蜜桃恋色物语”当作一个示例项目从零手写一个完整的文字冒险/视觉小说网页。你会看到剧情如何在 JSON 数据里组织打字机效果如何实现分支选项和好感度系统是怎么挂到剧情引擎上的以及部署到公网前需要处理哪些细节。读完这篇你不仅能跑通一个可直接演示的恋爱交互页还会理解背后这套“数据驱动剧情”的设计思路以后换任何题材、换任何角色都能复用同样的框架。1. 为什么做恋爱互动页不需要先学游戏引擎先说一个容易走偏的判断不少开发者在做“彩彩与千圣”这类私人项目时会把技术选型搞得非常重。但实际上文字冒险类游戏的技术难度远低于大多数游戏品类。文字冒险/视觉小说的核心流程可以抽象成一句话读剧情、渲染文字、等用户选择、跳转下一段剧情。这个过程不涉及物理碰撞、实时联网、复杂动画甚至不需要帧循环。常见实现方案对比方案学习曲线产出形态适合场景RenPy中等PC 客户端想做完整独立游戏、发布到 SteamTwine较低网页快速原型、纯剧情实验Unity 插件偏高PC/移动端想加复杂演出、3D、养成系统纯 Web 手写中低网页/H5轻量作品、嵌进个人站点、快速分享我的判断是如果内容量在几千行剧情以内、场景数量不多、不依赖复杂美术演出纯 Web 是性价比最高的方式。它的好处很直接零安装成本朋友圈发一个链接就能打开代码量可控一个 HTML 文件加两个 JS 文件就能撑起完整的剧情引擎剧情与代码分离后续换故事只需要改 JSON不用重写逻辑移动端适配容易响应式布局做一版就能兼顾手机和桌面。所以下面我们会坚持“数据驱动”的思路所有剧情、选项、好感度变化都放在一份 JSON 数据里页面只是解释和执行这份数据的运行时。2. 核心概念先把剧情引擎的四个模块拆清楚在写代码之前先弄清楚这个项目由哪几部分组成。这不是为了堆概念而是为了后面不把代码写成一团浆糊。2.1 剧情数据Story Data文本游戏里最基本的单元是“节点”Node。一个节点可以包含说话角色对话文本是否展示选项背景图、立绘好感度影响下一节点指向。用 JSON 表示就是一份结构化的“故事剧本”。前端引擎只认这份剧本不关心具体剧情内容。2.2 渲染层Renderer渲染层负责把剧情数据显示到页面对话文本、角色名、背景图、按钮。这一层最核心的交互反馈是“打字机效果”也就是文本逐字出现。很多初次做这类项目的同学会把渲染层和逻辑层混在一起后面加需求时会非常痛苦。2.3 状态管理State状态管理记录当前进行到哪个节点、累计好感度是多少、哪些条件锁定了选项。没有状态管理的互动页本质上等于“自动翻页 PPT”但有了它故事才具备分支和可变结局。2.4 控制器Controller控制器接收用户的点击事件推动剧情前进计算状态变化再把新节点交给渲染层。整个引擎就是一个“状态机”点击 → 更新状态 → 渲染新内容。分层思路可以避免最常见的代码灾难把上千行剧情塞进事件回调里后续改需求只能重写。3. 环境准备与前置条件好的消息是这个项目几乎不需要什么环境操作系统Windows / macOS / Linux 均可浏览器推荐最新版 Chrome / Edge / Safari编辑器VS Code 或其他代码编辑器即可运行环境如果你只打开本地 HTML不需要 Node.js如果你希望使用自动刷新和构建工具安装 Node.js 即可版本以当前 LTS 为准。目录结构建议如下cai-cai-qian-sheng/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── data.js │ └── game.js └── assets/ ├── bg/ └── audio/assets目录存放背景图和音频本文示例中为了让你直接跑通使用 CSS 颜色渐变替代图片也可以换成自己的图片路径。4. 核心流程拆解从一个最小剧情出发先不急着写完整代码我们来拆步骤。每一步都动手在本地新建文件验证最终会得到一个完整的互动页面。4.1 定义剧情数据结构剧情数据是整个项目的核心。我给每个节点设计了这样的结构{ id: scene_open, character: 千圣, text: 你终于来了我等了很久。, background: #f9c9c0, options: [] }id节点唯一标识character当前说话角色text对话内容background背景色或背景图地址options选项数组空数组表示这是剧情中的普通推进节点。如果用户点击后需要跳转到另一个节点就在控制器里配置一个nextId字段。如果当前节点有多个分支就把它写进options每个选项可以带好感度增减。下面是一个带分支的节点示例{ id: scene_choice, character: 彩彩, text: 桌上的水蜜桃汽水只有一瓶你想让给谁, background: #f9c9c0, options: [ { text: 让给千圣, nextId: scene_after_choice_qian, affection: 1 }, { text: 留给自己, nextId: scene_after_choice_self, affection: 0 } ] }这种结构非常直观写剧情的人不需要懂代码只需要按照格式维护 JSON这就可以和文案同学分工协作。4.2 实现打字机效果打字机效果是文字冒险类页面最常见的交互反馈。实现思路有几种最简单的是“定时截取字符串”拿到完整文本设置一个计数器每隔 30ms 增加 1用substring(0, count)更新当前显示文本当计数等于文本长度时停止计时并允许点击跳转。这里有个容易被忽略的细节打字机播放期间不应该允许用户跳过对话否则可能出现点击冲突。我采用的做法是在打字时点击只触发“完成打字”打字结束后点击才触发“下一句”。这也更符合玩家的直觉。4.3 分支选项与好感度分支选项并不是独立的系统它只需要读取当前节点的options渲染成按钮再在点击对应按钮时更新affection和nextId。好感度的计算不需要复杂公式。通常用一个对象来保存let state { currentSceneId: scene_open, affection: 0, sceneHistory: [] };在演示项目中好感度只是一个数字但在真实作品中你可能会遇到“达到好感度 5 才能解锁某个节点”这种条件那时可以在节点上加{ id: scene_after_choice_qian, requireAffection: 3, text: 千圣轻轻接过汽水眼神有了变化。 }控制器在跳转前检查requireAffection是否满足。如果满足就进入该节点不满足就跳转到另一个兜底节点。4.4 背景切换与角色关系这个项目的名字有“水蜜桃恋色”的意象所以视觉上我选择以水蜜桃粉橙色系为主。背景并不一定需要图片使用 CSS 渐变就能做出非常柔和的效果。background字段可以直接写颜色值也可以写图片 URL。控制器在每次渲染节点时检查新的background与当前是否一致不一致就做一次渐变切换。这样比直接跳变更有“物语”的氛围。4.5 存档与历史回看文字冒险类页面后期一定会被问到“我能不能刷新后继续上次的进度”答案是启用localStorage自动保存。在每次剧情推进时把state序列化后写入localStorage页面启动时尝试读取。这个实现成本很低但对用户体验的提升非常明显。不过要提醒一点不要把过于敏感的信息存到localStorage因为前端存储任何人都可以读。对这个项目来说好感度、当前节点这类游戏进度数据完全够用但涉及用户隐私的数据不要放。5. 完整示例代码实现接下来是完整可运行的代码。为了让示例没有图片资源也能跑背景采用渐变实现音频部分只做预留接口。你在实际项目中替换成自己的素材即可。5.1 创建项目入口 index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title彩彩与千圣水蜜桃恋色物语/title link relstylesheet hrefcss/style.css /head body div idgame-container div idbg-layer/div div iddialogue-box div idcharacter-name千圣/div div iddialogue-text/div div idclick-hint点击继续 ▲/div /div div idoptions-box/div /div script srcjs/data.js/script script srcjs/game.js/script /body /html5.2 水蜜桃恋色主题样式 css/style.css这里用 CSS 变量统一管理主题色方便后续快速换配色。整个页面做成移动端优先在桌面端用 max-width 限制宽度模拟手机屏幕视觉这也是 H5 互动页常用的布局方式。:root { --peach-primary: #f9c9c0; --peach-deep: #f0a4a0; --peach-light: #fff3ec; --text-dark: #5a3e3b; --white: #ffffff; --shadow: rgba(90, 62, 59, 0.15); } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--peach-light); font-family: PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; color: var(--text-dark); } #game-container { position: relative; width: 100%; max-width: 420px; height: 720px; overflow: hidden; border-radius: 24px; box-shadow: 0 20px 40px var(--shadow); background: var(--peach-primary); } #bg-layer { position: absolute; inset: 0; background: linear-gradient(160deg, #fde3dc 0%, #f7b7aa 60%, #f0a4a0 100%); transition: background 1s ease; } #dialogue-box { position: absolute; left: 20px; right: 20px; bottom: 26px; background: rgba(255, 255, 255, 0.92); border-radius: 18px; padding: 18px 20px; box-shadow: 0 8px 20px var(--shadow); cursor: pointer; min-height: 140px; } #character-name { display: inline-block; background: var(--peach-deep); color: var(--white); font-size: 14px; padding: 4px 12px; border-radius: 20px; margin-bottom: 12px; } #dialogue-text { font-size: 17px; line-height: 1.7; min-height: 80px; } #click-hint { position: absolute; right: 18px; bottom: 12px; font-size: 12px; color: var(--peach-deep); opacity: 0.7; } #options-box { position: absolute; left: 20px; right: 20px; bottom: 190px; display: flex; flex-direction: column; gap: 12px; } .option-btn { background: rgba(255, 255, 255, 0.9); border: 2px solid var(--peach-primary); color: var(--text-dark); border-radius: 40px; padding: 12px 20px; font-size: 16px; cursor: pointer; transition: all 0.2s ease; } .option-btn:hover { background: var(--peach-primary); transform: translateY(-2px); box-shadow: 0 8px 16px var(--shadow); }这段样式的核心要点对话盒固定在底部呈半透明白色选项按钮出现在对话盒上方避免遮挡文字宽高固定为 420 × 720在页面居中显示像一台手机屏幕使用transition处理背景和按钮的视觉反馈。5.3 剧情数据 js/data.js下面这份 JSON 结构简化了“彩彩与千圣”的开场片段。这里只保留了两个分支和两个结局示例用来演示完整循环。真实项目中直接把数组扩充到几十个节点即可。const STORY_DATA [ { id: scene_open, character: 千圣, text: 已经是三月末了校门口那棵桃树还是没开花。你答应过要陪我看花总不能反悔吧, background: linear-gradient(160deg, #fde3dc 0%, #f7b7aa 60%, #f0a4a0 100%), options: [] }, { id: scene_open_2, character: 彩彩, text: 现在说这些还太早啦。我买了水蜜桃汽水你尝尝看像不像夏天提前到的味道, background: linear-gradient(160deg, #fff0ea 0%, #f9c9c0 60%, #f0a4a0 100%), options: [] }, { id: scene_choice, character: 彩彩, text: 汽水只剩最后一瓶了。你自己喝还是分给千圣, background: linear-gradient(160deg, #fff0ea 0%, #f9c9c0 60%, #f0a4a0 100%), options: [ { text: 分给千圣, nextId: scene_qian_route, affection: 1 }, { text: 自己先喝一口, nextId: scene_self_route, affection: 0 } ] }, { id: scene_qian_route, character: 千圣, text: ……谢谢。原来水蜜桃味的汽水是这种味道。她低下头耳根有一点红。, background: linear-gradient(160deg, #fde3dc 0%, #eebdb3 60%, #e09e94 100%), options: [ { text: 继续追问她的心事, nextId: scene_qian_ending, affection: 1 } ] }, { id: scene_self_route, character: 彩彩, text: 啊汽水气太足了我呛了一下。千圣在旁边忍不住笑出声。, background: linear-gradient(160deg, #fff0ea 0%, #f9c9c0 60%, #f0a4a0 100%), options: [ { text: 尴尬地挠挠头, nextId: scene_self_ending, affection: 0 } ] }, { id: scene_qian_ending, character: 千圣, text: 花还没开但今天好像也不是很糟。如果我们约好下个月还来这里你会来吗, background: linear-gradient(160deg, #fed7ce 0%, #f2a89e 60%, #e08e8a 100%), options: [] }, { id: scene_self_ending, character: 彩彩, text: 等夏天真正来的时候我们再买两瓶汽水坐在树下喝完它。约好了, background: linear-gradient(160deg, #fff0ea 0%, #f9c9c0 60%, #f0a4a0 100%), options: [] } ];注意这份数据里没有写“是否通关”的标记。判断结尾的方式很简单如果一个节点没有options且没有显式的nextId就认为它是暂时性结尾节点。在实际项目中你可以在节点上增加isEnding: true来区分普通节点和结局节点。5.4 剧情引擎 js/game.js这是项目的核心文件。它实现节点查找、打字机效果、选项渲染、好感度累积、自动存档。const dialogueText document.getElementById(dialogue-text); const characterName document.getElementById(character-name); const optionsBox document.getElementById(options-box); const bgLayer document.getElementById(bg-layer); const clickHint document.getElementById(click-hint); let state { currentSceneId: scene_open, affection: 0 }; let typingTimer null; let isTyping false; function loadScene(sceneId) { const scene STORY_DATA.find((item) item.id sceneId); if (!scene) { console.error(场景不存在, sceneId); return; } stopTyping(); optionsBox.innerHTML ; clickHint.style.opacity 0.6; dialogueText.textContent ; characterName.textContent scene.character || ; if (scene.background) { bgLayer.style.background scene.background; } startTyping(scene.text); if (scene.options scene.options.length 0) { renderOptions(scene.options); } } function startTyping(fullText) { isTyping true; let index 0; typingTimer setInterval(() { index; dialogueText.textContent fullText.substring(0, index); if (index fullText.length) { stopTyping(); clickHint.style.opacity 1; } }, 30); } function stopTyping() { if (typingTimer) { clearInterval(typingTimer); typingTimer null; } isTyping false; } function renderOptions(options) { options.forEach((option) { const button document.createElement(button); button.className option-btn; button.textContent option.text; button.addEventListener(click, (event) { event.stopPropagation(); if (option.affection) { state.affection option.affection; } state.currentSceneId option.nextId; saveState(); loadScene(option.nextId); }); optionsBox.appendChild(button); }); } function handleDialogueClick() { const scene STORY_DATA.find((item) item.id state.currentSceneId); if (!scene) return; if (scene.options scene.options.length 0) { return; } if (isTyping) { stopTyping(); dialogueText.textContent scene.text; clickHint.style.opacity 1; return; } const currentIndex STORY_DATA.findIndex((item) item.id state.currentSceneId); const nextScene STORY_DATA[currentIndex 1]; if (nextScene !scene.nextId) { state.currentSceneId nextScene.id; saveState(); loadScene(nextScene.id); } } function saveState() { try { localStorage.setItem(caiqian_save, JSON.stringify(state)); } catch (e) { console.warn(存档失败可能是隐私模式或浏览器限制, e); } } function loadState() { try { const saved localStorage.getItem(caiqian_save); if (saved) { const parsed JSON.parse(saved); if (parsed parsed.currentSceneId) { state parsed; } } } catch (e) { console.warn(读取存档失败使用默认进度, e); } } document.getElementById(dialogue-box).addEventListener(click, handleDialogueClick); loadState(); loadScene(state.currentSceneId);这段代码的关键逻辑我拆开讲loadScene(sceneId)是页面的“单入口”无论从哪个地方跳转最终都会走这个函数它负责重置界面、切背景、启动打字机打字机用setInterval实现每次间隔 30ms 增加一个字符达到全文长度后自动停止renderOptions遍历options数组动态生成按钮点击后立即更新好感度并跳转点击对话盒时如果打字没结束就跳过打字如果打字结束就尝试进入下一个节点localStorage的自动存档放在每次剧情推进之后页面刷新后能恢复进度。这个设计最舒服的一点场景管理逻辑和具体剧情完全解耦。你以后换一个故事只需要替换data.js不需要改game.js。6. 运行结果与效果验证在本地新建好index.html、css/style.css、js/data.js、js/game.js后有两种启动方式。方式一直接双击打开 HTML用浏览器打开index.html可以看到页面显示开场对话“已经是三月末了校门口那棵桃树还是没开花……”文本逐字出现。如果出错按 F12 打开开发者工具查看 Console 是否有报错。方式二用静态服务器启动虽然直接打开 HTML 也能运行但模块化和资源加载迟早会把浏览器逼到跨域限制上。建议用静态服务器# Python 3 python -m http.server 8080然后访问http://localhost:8080。验证重点打字机效果打开页面后文字是否逐字出现在文字未打完时点击对话盒是否立刻显示全文。分支跳转剧情推进到“汽水只剩最后一瓶了”时页面是否出现两个选项点击后是否进入对应分支。好感度累积点击“分给千圣”后在控制台执行localStorage.getItem(caiqian_save)能看到affection变为 1。存档恢复选择任意分支后刷新页面确认重新打开时回到该分支而不是从头开始。移动端适配用浏览器的设备模拟器切到 iPhone 或 Android 尺寸确认对话盒、按钮没有遮住文字。7. 常见问题与排查思路本地跑通后最容易出问题的不是代码逻辑而是环境和数据细节。下面按实际出现频率整理排查清单问题现象可能原因排查方式解决方案页面空白没有对话文本JS 报错通常是数据文件没加载打开 F12 Console查看报错路径确认 index.html 引入 js/data.js 后再引入 game.js点击选项没反应事件冒泡导致对话盒也触发了 click控制台打印按钮点击事件是否触发在按钮事件里加 event.stopPropagation()修改 data.js 后页面还是旧剧情浏览器缓存了旧文件清缓存或使用无痕窗口开发时开启自动刷新或强制刷新文字瞬间全部出现setInterval 失效或被覆盖查看是否有多个 game.js 实例检查是否重复引入脚本刷新后进度重置localStorage 被禁用或报错控制台执行 localStorage.setItem(test,1)改用 sessionStorage 或做降级处理背景不切换background 字段拼写错误打印 scene.background 的值确认 data.js 中字段名为 background选项按钮遮挡对话绝对定位 bottom 值不合适调整 options-box 的 bottom根据对话框高度改为 190px 或更高这里最隐蔽的一个坑是事件冒泡你点击选项按钮时事件会冒泡到对话盒上如果对话盒的点击事件恰好触发了“进入下一段”就可能出现“按钮没生效但剧情跳走了”的错乱。所以示例中专门调用了event.stopPropagation()。8. 最佳实践与工程建议把这个小项目推向“真正可维护、可发布”的状态有几个经验值得记下来。8.1 剧情数据先定 Schema再写剧情现实项目里写剧情的人和写代码的人通常是两个人。如果代码已经写完了才去定数据字段很容易出现“剧情按自己的习惯写代码按另一种结构解析”的尴尬。建议开工前先把节点结构定义好哪怕只是一个字段说明/** * 剧情节点 Schema * id: 字符串节点唯一标识 * character: 说话角色名 * text: 对话文本无需包含换行 * background: 背景颜色 / 图片 URL * options: 选项数组 * text: 选项显示文本 * nextId: 选项跳转节点 * affection: 好感度变化可为负数 * requireAffection: 可选字段前置好感度门槛 * isEnding: 是否为结局节点 */8.2 状态管理不要只放在全局变量里Demo 里的state是一个全局对象演示足够但项目复杂后会失控。更稳妥的做法是把状态读写封装成独立模块提供getState、updateState、resetState三个方法避免散落在各个函数里。8.3 音频素材注意版权和自动播放限制很多恋爱题材项目会加入背景音乐。这里有两个现实问题自动播放限制浏览器通常不允许页面加载就自动播放音频需要用户在页面上有过一次点击后才能播放。通常做法是进入第一个场景时弹一个“开始游戏”启动层用户点击后同时启动音频和剧情。版权风险不要随意使用商业作品的 BGM推荐使用开源免费音频库或购买授权素材。8.4 移动端优先注意刘海屏底部安全区域互动页大部分流量来自手机。除了响应式布局还要考虑 iPhone 的safe-area-inset-bottom。建议给对话盒增加padding-bottom: calc(18px env(safe-area-inset-bottom));这样可以避免底部被手势条遮挡。8.5 内容安全与部署边界这个项目本质是纯前端任何“解锁剧情”“隐藏彩蛋”逻辑在浏览器里都是透明的用户可以通过控制台修改localStorage或直接在代码里看剧情数据。所以不要在前端存放真正需要保密的内容如果只是分享一个治愈系恋爱互动页透明反而不是问题如果要做付费解锁或私密剧情必须引入后端鉴权不能依赖前端代码保密。8.6 发布前测试完整流程发布前建议走完一遍“新用户路径”清空 localStorage → 打开链接 → 从第一个节点读到结局 → 中途刷新一次 → 验证进度恢复 → 用手机流量再打开一次。9. 总结与后续可以继续深入的方向从“彩彩与千圣水蜜桃恋色物语”这个示例项目里我们做了一件很轻但很完整的事用纯 HTML/CSS/JavaScript 搭出了一个数据驱动的文字冒险引擎剧情在 JSON 里交互在事件里状态在 localStorage 里。这个框架的价值不在于它有多复杂而在于它把恋爱题材互动页最常见的共性需求全部覆盖了分节点剧情、打字机、分支选项、好感度、背景切换、自动存档。你以后换任何角色、任何故事只需要往STORY_DATA里加对象就能立刻获得一个可玩的新页面。如果还想继续深入我建议按这个顺序推进把核心逻辑从全局函数重构为 ES Module方便单测给节点增加条件分支表达式比如requireAffection 3时才展示某个选项增加简单的淡入淡出动画用 CSS transition 替代直接切换引入 Vue 或 React把渲染层和逻辑层分得更彻底适合长期维护的项目加入后端存档方案让跨设备进度同步成为可能。最后提醒一句这类轻量互动项目最关键的是内容质量和叙事节奏技术只是把故事包装成可交互的载体。不要过度设计代码结构先把一个完整的小故事跑通再考虑工程化。如果你想把这个模板直接拿去用记得把data.js里的示例剧情替换成自己的文本再配一套专属的 CSS 变量配色就能快速生成一个新的恋爱互动页了。
返回列表