每日一题、免注册、多选:Atlas式轻量网页游戏的纯前端实现

每日一题、免注册、多选:Atlas式轻量网页游戏的纯前端实现 在 Hacker News 的 Show HN 栏目里每天都有大量新项目出现大部分看一眼就关掉了。但 Atlas 这个项目的一句话标题足够特别like GeoGuessr but multiple choice, daily, no sign-up。这句话把产品形态、使用节奏、注册门槛一次说完了看到它的技术人很容易追问一句这东西到底是怎么实现的不注册怎么保存进度每天一题又是怎么保证不重复的如果我也想做一款类似的轻量网页游戏需要准备哪些东西先给一个明确判断这类项目真正值得关注的不是“猜地点”这个玩法本身而是它用三个非常克制的产品决策把一个原本偏硬核的地理挑战游戏做成了普通人愿意每天点开、愿意分享给朋友的小型 Web 应用。多选代替自由输入降低了答题门槛每日更新代替无限对局制造了长期回访理由免注册代替账号体系让传播路径缩短到近乎为零。对于做前端、做独立开发、做小程序或轻应用的技术人来说这套思路完全可以迁移到自己的题目型产品里。这篇文章会从 Atlas 的产品形态出发分析它的设计逻辑再拆解实现一个同类型应用需要涉及的技术模块最后用一段可以直接运行的 HTML JavaScript 代码演示一个“多选地点猜谜 每日一题 localStorage 记录进度”的最小实现。读完你可以照着做也可以把同样思路套用到成语、历史、建筑、食物等任何题目类场景中。1. 这篇文章真正要解决的问题先回到一个现实场景很多开发者看到 GeoGuessr 类游戏会觉得很有意思但想做一个类似的体验时会在第一步就遇到成本问题。街景数据从哪来全景图怎么加载地图平台怎么选如果还要做账号系统、排行榜、连续答题开发量会迅速膨胀。结果就是项目还没开始就已经放弃了。Atlas 这种产品的价值恰恰在于它用“多选题”这个动作把地理猜谜从“重度体验”降级成了“轻量问答”。用户不再需要拖动地图、放大街景、估算坐标只需要看着一张图片和几个选项做一道选择题。这个改变看似很小实际影响非常大。它意味着数据来源可以只是普通图片而不是全景街景地图 SDK 也可以完全不用整个应用甚至不需要后端也能跑。所以这篇文章要解决的问题不是“如何复制一个 GeoGuessr”而是“如何用最小的技术成本做出一款有传播能力的题目型 Web 应用”。谁最应该读这篇文章第一对地理、旅行、地图类内容感兴趣想做站点或小游戏的前端开发者。第二想把某个知识领域做成每日答题产品的独立开发者。第三想理解“免注册应用如何保存状态”“每日内容如何保证稳定变化”这类工程问题的技术读者。如果你只是想要一个能直接部署的成品模板这篇文章也提供了完整代码。2. Atlas 是什么一个标题拆出三个产品决策Show HN 是 Hacker News 上个人开发者展示自己作品的固定栏目。Atlas 这个项目标榜自己是 GeoGuessr 的变体但它的标题没有用“更好的地图”“更强的技术”这类说法而是用了三个用户能直接感知的词multiple choice、daily、no sign-up。这三个词分别代表了三个产品决策。第一multiple choice。传统 GeoGuessr 是把用户扔到世界的某个角落让他判断自己在哪里然后在地图上点击猜测位置越接近得分越高。这个体验很有趣但它的门槛很高。用户要有基本的地理直觉要能识别地域性建筑、植被、路牌、文字还要适应全景视角。如果对目的地不熟悉挫败感会很强烈。改成多选题之后游戏目标从“在世界地图上定位”变成“根据线索选择正确地名”。玩家不再需要理解经纬度和视角只需要调用自己的知识储备和观察力。第二daily。每日一题不是限制用户而是在帮用户建立访问习惯。很多小游戏的问题是“内容太多反而不知道从哪开始”或者玩了一次就再也不会回来。每日一题通过时间维度制造稀缺感。今天错过就没了明天又会有一个新的题目。这种机制在 Wordle 等游戏里已经被验证得非常充分它让分散的用户在同一时间面对同一道题形成话题集中点。第三no sign-up。这一个决策可能是产品能传播起来的最关键因素。注册是转化率最大的杀手。用户进入页面如果先看到注册表单心理上就已经开始计算成本。免注册意味着用户从点开链接到完成第一次答题中间没有强制跳转、没有邮箱验证、没有密码确认。路径越短分享率越高。从一个产品比较的角度看过去常见的地理猜谜应用和 Atlas 代表的轻量题目应用之间存在明显差异。比较维度经典开放式猜地点Atlas 类多选每日猜谜交互方式观看街景后在地图上标记看图片线索后选择一个选项用户门槛需要空间感、地理知识只需要常识判断和排除能力单局时长较难控制可能超过几分钟通常几十秒内可完成数据依赖需要街景、地图 SDK普通图片 题目配置即可账号体系通常需要保存历史记录可完全用本地存储代替回访机制一般依赖自由游玩每日一期形成固定节奏社交传播分享复杂结果图分享结果文案或打卡记录这里有一个容易被误读的点多选题不是玩法的退化而是把乐趣从“空间推理”转移到了“视觉识别和知识判断”。它对大众更友好也更适合在今天的信息流环境中快速传播。3. 为什么这种“轻量题目型网页游戏”值得关注从技术开发者的视角看Atlas 这类产品背后有一个明显的趋势很多小而美的 Web 游戏正在把后端复杂度压缩到极致甚至完全去掉后端。过去做一个问答游戏需要数据库、账号体系和后端接口现在完全可以用静态托管加本地存储来实现。传统方案里用户数据必须由服务端保存。用户登录后服务器要记录他的答题历史、正确率、连续打卡天数。至少需要一个数据库、一组用户认证接口、一组业务接口。遇到并发需求还要做缓存。为了一个轻量级答题产品搭建完整后端投入产出比并不高。Atlas 这类 no-sign-up 产品选择绕开整个体系把用户状态放在浏览器本地。localStorage 虽然不跨设备但足以支撑大部分单人答题场景。这类产品的另一个技术价值在于它把“每日更新”变成了一种前端可控的确定性任务。服务端每天发布题目的做法需要定时任务前端根据日期生成题目则要简单得多。只要题目库足够大再配合一个稳定的哈希函数就可以让每一天固定对应一道题。用户在任意时间打开应用看到的都是属于当天的题目。再看部署和分发。静态页面意味着可以放到任何静态站点托管服务上不需要维护服务器也不需要处理用户数据隐私合规的复杂问题。用户在完成答题后把结果复制到聊天工具里其他用户点击链接即可开始挑战。整个信息传播链里没有注册、没有短信验证码、没有第三方登录授权传播阻力降到了历史最低点。从独立开发的角度看这个模式还有一层价值它把“内容运营”和“代码开发”脱耦了。代码只需要维护一个固定的题目库和一套渲染逻辑新内容就是向库中增加数据。也就是说在 Atlas 类产品里编辑器才是真正的增长引擎开发者能集中精力做好答题体验剩下的交给内容质量。4. 实现一个 Atlas 类网页应用需要拆解的技术模块由于手头没有 Atlas 的实际源码下面这部分不是对某个特定项目的逐行分析而是根据这类“图片多选 每日一题 免注册”产品的常见实现方式给出技术模块拆解。如果你自己动手实现大概率也会落到这些模块上。4.1 题目数据的组织方式题目是整个产品的地基。一条典型的题目数据至少需要包含图片地址、答项列表、正确答案索引、文字线索以及用于展示和分享的名称信息。如果需要未来扩展地图模式还可以预留给坐标字段但最小版本并不需要。一个合理的 JSON 结构长这样。{ id: temple-of-heaven, title: 北京天坛, image: https://example.com/images/temple-of-heaven.jpg, hint: 明清两代皇家祭天场所标志建筑是祈年殿。, options: [故宫, 北京天坛, 曲阜孔庙, 西安鼓楼, 沈阳故宫], answer: 1 }这里要注意两个容易出错的地方。第一options里的干扰项不是越多越好。选项太多会加重阅读负担对每天一题的产品来说四个到六个选项通常最合适。第二干扰项要有迷惑性不能明显错误。想要让玩家有“不确定但觉得合理”的思考过程就需要把选项控制在知识相关的范围内。4.2 每日题目的稳定性生成很多人的第一反应是“每日一题很容易从题库里随机取一道就行。”但实际上随机在浏览器里是不可控的刷新页面题目就会变化。如果你希望所有人都能在同一天看到同一道题就需要一个基于日期字符串的确定性算法。具体做法是先用当前日期生成一个类似于2026-05-27的字符串然后对这个字符串做一次稳定哈希再用哈希值对题库长度取模得到当天的题目索引。这个过程的优点是不需要后端。只要题库内容没有变化任何人在同一天访问都会得到同一道题。这里有几个细节。时区会直接影响“今天”的定义。如果使用本地时间生成日期东八区和 UTC 或者美国西海岸用户看到的“今天”会不同。对于面向全球用户的产品最好统一使用 UTC 日期或者指定某个目标时区。如果是面向国内用户的小游戏使用本地日期问题不大但仍建议在工作区里明确规定避免以后因为时区产生不同步。4.3 免登录下的用户状态保存免注册最大的技术问题是如何记住用户今天是否答过题以及他连续答对了几天。最简单的方案是把状态写入 localStorage。每次答题结束后把当天的日期、答题结果、累计答题次数和连续天数序列化后保存。localStorage 能存字符串、结构简单、同步读取非常适合这类小游戏。但它的边界同样明显用户清空浏览器数据后记录丢失换设备后记录不可同步隐身模式下可能无法持久保存。所以在产品设计上不应让 localStorage 承担账号系统才应该承担的重任。它适合记录单机状态不适合做跨端排行榜。如果想要更轻的跨设备回访可以使用 URL 参数分享当前日期和题目编号。分享文案里带上日期参数后朋友打开链接就能直接进入当天的题目而不必经过首页跳转。这种方式比登录状态更有利于传播。4.4 分享链路与静态部署分享是免注册产品的增长来源。实现分享并不复杂只需要在答题结束后生成一段简短文本包含日期、题目名称和用户结果。用户点击复制然后粘贴到社交平台即可。为了让这段文本看起来像“内容卡片”可以给它一个固定的文本模板。静态部署是这个模式的收尾。整个应用是 HTML、CSS、JavaScript 和一组图片没有动态请求所以可以部署到任何静态资源托管服务上。图片走 CDN页面走 CDN用户打开页面的速度就容易得到保证。5. 从零实现一个“多选地点猜谜”的最小 Demo在理解了上面的模块后下面我用一个单文件实现最小可运行的版本。为了便于复制整个 Demo 只有index.html题目和逻辑都在文件内部。因为是示例图片使用了随机占位图服务不要把占位图当作真实地点图片。正式使用时每一道题都要配一张有授权或版权合规的地点照片。5.1 目录结构与运行工具先准备好目录可用如下命令。mkdir atlas-demo cd atlas-demo # 在 atlas-demo 中创建 index.html内容见 5.2 python3 -m http.server 8000如果本机没有 Python 3也可以使用 Node.js 生态的静态服务工具比如npx serve。启动后浏览器访问http://127.0.0.1:8000即可。5.2 前端完整代码!-- index.html -- !DOCTYPE html html langzh-CN head meta charsetutf-8 / meta nameviewport contentwidthdevice-width, initial-scale1 / titleAtlas Demo - 每日地点挑战/title style body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background-color: #f5f7fa; color: #1f2329; } .container { max-width: 720px; margin: 32px auto; background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); } h1 { font-size: 24px; margin: 0 0 4px; } .subtitle { color: #656d76; font-size: 14px; margin: 0 0 20px; } .hint { background: #fff8c5; border-radius: 8px; padding: 8px 12px; font-size: 14px; margin-bottom: 14px; } .media { margin-bottom: 14px; } .media img { width: 100%; height: auto; border-radius: 12px; display: block; background: #eaeef2; } .fallback { display: flex; align-items: center; justify-content: center; min-height: 160px; background: #eaeef2; border-radius: 12px; color: #57606a; font-size: 14px; } .clue { margin: 0 0 16px; padding: 12px; background: #f6f8fa; border-radius: 8px; line-height: 1.6; } .options { display: grid; gap: 10px; margin-bottom: 16px; } .options label { display: block; padding: 12px 14px; border: 1px solid #d0d7de; border-radius: 10px; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease; } .options label:hover { background: #f6f8fa; } .options input { margin-right: 8px; } button { border: none; border-radius: 10px; padding: 12px 18px; background: #1f883d; color: #ffffff; font-size: 15px; cursor: pointer; } button:hover { background: #1a7f37; } button:disabled { opacity: 0.6; cursor: not-allowed; } button.secondary { background: #0969da; margin-top: 12px; } button.secondary:hover { background: #0b57b5; } .feedback { margin: 16px 0 0; font-weight: 600; line-height: 1.5; } .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 20px 0 0; } .stats div { background: #f6f8fa; border-radius: 8px; padding: 10px; text-align: center; } .stats dt { font-size: 12px; color: #656d76; } .stats dd { margin: 4px 0 0; font-size: 22px; font-weight: 700; } /style /head body main classcontainer h1每日地点挑战/h1 p classsubtitleAtlas Demo多选题版地点猜谜无需注册/p div idpreviewHint classhint styledisplay: none; 预览模式当前展示的是 URL 参数 date 指定的题目不会写入统计。 /div section idquestionPanel div classmedia img idlocationImage alt待猜地点图片 onerrorhandleImageError() / div idimageFallback classfallback styledisplay: none; 图片加载失败请根据下方文字线索作答 /div /div p idclue classclue/p form idquizForm div idoptions classoptions/div button idsubmitBtn typesubmit提交答案/button /form div idfeedback classfeedback/div button idcopyBtn classsecondary styledisplay: none; typebutton 复制挑战结果 /button /section section idresultPanel styledisplay: none; h2今日已完成/h2 p idtodayResultText classfeedback/p div classstats div dt累计参与/dt dd idstatPlayed0/dd /div div dt累计正确/dt dd idstatCorrect0/dd /div div dt连续打卡/dt dd idstatStreak0/dd /div div dt历史最佳/dt dd idstatBestStreak0/dd /div /div button idcopyBtnOnResult classsecondary typebutton 复制挑战结果 /button /section /main script // 示例题库。正式场景中image 应替换为真实地点图片。 // 当前使用 picsum.photos 的随机图片仅用于演示页面机制。 const QUESTIONS [ { city: 北京天坛, hint: 始建于明永乐年间是明清两代皇帝祭天祈谷的场所标志性建筑为祈年殿。, image: https://picsum.photos/seed/temple-of-heaven/800/450, options: [故宫, 北京天坛, 曲阜孔庙, 西安鼓楼, 沈阳故宫], answer: 1 }, { city: 意大利罗马斗兽场, hint: 古罗马时期建造的大型椭圆形竞技场可容纳数万观众位于意大利首都。, image: https://picsum.photos/seed/colosseum/800/450, options: [西班牙塞维利亚斗牛场, 法国尼姆竞技场, 意大利罗马斗兽场, 希腊雅典卫城, 土耳其以弗所], answer: 2 }, { city: 日本东京塔, hint: 位于日本东京是一座红白相间的电波塔灵感来自埃菲尔铁塔。, image: https://picsum.photos/seed/tokyo-tower/800/450, options: [日本东京塔, 日本晴空塔, 中国广州塔, 法国埃菲尔铁塔, 美国西雅图太空针塔], answer: 0 }, { city: 澳大利亚悉尼歌剧院, hint: 位于悉尼港畔白色贝壳形屋顶是世界著名的建筑符号之一。, image: https://picsum.photos/seed/sydney-opera/800/450, options: [丹麦哥本哈根歌剧院, 澳大利亚悉尼歌剧院, 英国伦敦塔桥, 美国纽约自由女神像, 阿联酋迪拜帆船酒店], answer: 1 } ]; const STORAGE_KEY atlas-demo:v1; const urlParams new URLSearchParams(window.location.search); const todayKey formatDateKey(new Date()); const requestedDate urlParams.get(date) || ; const previewDate /^\\d{4}-\\d{2}-\\d{2}$/.test(requestedDate) ? requestedDate : ; const isPreview Boolean(previewDate previewDate ! todayKey); const currentKey previewDate || todayKey; const questionPanel document.getElementById(questionPanel); const resultPanel document.getElementById(resultPanel); const previewHint document.getElementById(previewHint); let latestResult null; if (isPreview) { previewHint.style.display block; } function defaultState() { return { lastPlayedDate: , lastResult: null, playedCount: 0, correctCount: 0, streak: 0, bestStreak: 0 }; } function loadState() { try { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return defaultState(); const parsed JSON.parse(raw); return Object.assign(defaultState(), parsed); } catch (error) { return defaultState(); } } function saveState(state) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (error) { // localStorage 不可用时不阻塞页面 } } function formatDateKey(date) { const y date.getFullYear(); const m String(date.getMonth() 1).padStart(2, 0); const d String(date.getDate()).padStart(2, 0); return y - m - d; } function addDays(dateKey, delta) { const parts dateKey.split(-).map(Number); const date new Date(parts[0], parts[1] - 1, parts[2]); date.setDate(date.getDate() delta); return formatDateKey(date); } function hashSeed(text) { let hash 0; for (let i 0; i text.length; i) { hash (hash 5) - hash text.charCodeAt(i); hash hash | 0; } return Math.abs(hash); } function getTodayQuestion() { const seedText atlas-demo: currentKey; const index hashSeed(seedText) % QUESTIONS.length; return QUESTIONS[index]; } function handleImageError() { const img document.getElementById(locationImage); const fallback document.getElementById(imageFallback); img.style.display none; fallback.style.display flex; } function renderQuestion(question) { const img document.getElementById(locationImage); img.src question.image; img.alt 猜一猜这是哪里; document.getElementById(clue).textContent 文字线索 question.hint; const optionsDiv document.getElementById(options); optionsDiv.innerHTML ; question.options.forEach(function (option, index) { const label document.createElement(label); const radio document.createElement(input); radio.type radio; radio.name answer; radio.value String(index); label.appendChild(radio); label.appendChild(document.createTextNode(option)); optionsDiv.appendChild(label); }); } function renderStats(state) { document.getElementById(statPlayed).textContent state.playedCount; document.getElementById(statCorrect).textContent state.correctCount; document.getElementById(statStreak).textContent state.streak; document.getElementById(statBestStreak).textContent state.bestStreak; } function buildResultText(result) { if (!result) return ; const status result.correct ? 答对 : 答错; return [ Atlas Demo 每日地点挑战, 日期 result.date, 地点 result.city, 结果 status ].join(\\n); } async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (error) { const textarea document.createElement(textarea); textarea.value text; document.body.appendChild(textarea); textarea.select(); document.execCommand(copy); textarea.remove(); return true; } } function showResult(result) { latestResult result; document.getElementById(todayResultText).textContent 今日地点是 result.city 你 (result.correct ? 答对了 : 答错了) 。; document.getElementById(copyBtnOnResult).style.display inline-block; } function init() { const state loadState(); if (!isPreview state.lastPlayedDate currentKey state.lastResult) { questionPanel.style.display