一个人做个人网站(八):打卡热力图与小游戏中心——把"坚持"和"好玩"做成产品
前面七篇都在讲"正经"的内容管理。这一篇换个口味,看两个纯取悦访客(和我自己)的模块:一个向内,记录日复一日的坚持;一个向外,请来访的人停下来玩两分钟。
它们技术栈完全不同——打卡热力图是"日历算法 + 统计口径"的冷静思考,小游戏是"物理引擎 + 并发防刷"的热烈调试——但有一个共同的产品内核:规则看似简单,真正难的是把边界情况一个个想清楚。
一、打卡模块:两张表,两种习惯模式
数据模型极简,习惯和记录两张表:
class CheckinHabit(db.Model):
name = db.Column(db.String(60))
emoji = db.Column(db.String(16), default='✅')
color = db.Column(db.String(16), default='#7c9a86') # 热力图主题色
mode = db.Column(db.String(10), default='simple') # simple 打勾 / numeric 带数值
unit = db.Column(db.String(10)) # 公里 / 分钟 / 页
target = db.Column(db.Float, nullable=True) # numeric 的每日目标
is_public = db.Column(db.Boolean, default=True)
archived = db.Column(db.Boolean, default=False) # 弃坑不删,历史保留
records = db.relationship('CheckinRecord', cascade='all, delete-orphan',
order_by='CheckinRecord.check_date.desc()', lazy='selectin')
class CheckinRecord(db.Model):
habit_id = db.Column(db.ForeignKey('checkin_habits.id', ondelete='CASCADE'))
check_date = db.Column(db.Date, nullable=False, index=True)
value = db.Column(db.Float, nullable=True) # simple 模式为空
__table_args__ = (db.UniqueConstraint('habit_id', 'check_date', name='uq_checkin_habit_date'),)
三个设计决策值得说:
(habit_id, check_date)唯一约束。一天一条记录,重复打卡天然变成"更新",不需要在应用层先查再判断,数据库兜底防并发重复;- simple / numeric 两种模式同表共存。"早起打卡"只要有没有,"跑步 5 公里"要记数值。
value为空即打勾,有值即计量,不搞两张表; - 弃坑用归档而不是删除。坚持了半年的习惯放弃了,历史热力图仍是自己的记录,
archived=True让它从页面消失但数据完整保留——级联删除只在真正删除习惯时发生。
二、热力图网格:GitHub 那张图到底怎么算
热力图的视觉参考是 GitHub contribution graph:每列一周、每周七行、周日开头、格子颜色深浅代表数值。看起来只是个 CSS Grid,真正的难点是怎么把一年的日期切成周列,并正确处理年初的空位:
const weeks = computed(() => {
const year = selectedYear.value
// 今年只画到今天;翻看历史年份时画完整一年
const endDate = year === Number(todayStr.value.slice(0, 4))
? parseIso(todayStr.value) : new Date(year, 11, 31)
const jan1 = new Date(year, 0, 1)
const cursor = new Date(year, 0, 1 - jan1.getDay()) // 回退到年初所属周的周日
const cols = []
while (cursor <= endDate) {
const col = []
for (let i = 0; i < 7; i++) {
col.push(cursor.getFullYear() === year && cursor <= endDate
? iso(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()) : null)
cursor.setDate(cursor.getDate() + 1)
}
cols.push(col)
}
return cols
})
几个关键处理:
- 年初对齐:1 月 1 日未必是周日,游标先回退到它所属周的周日,那几天属于上一年,渲染成
null(CSSvisibility:hidden占位而非空格,保证网格不错位); - 今年和历史年份终点不同:今年只画到今天,未来日期留空;翻看 2024 年则画满到 12 月 31 日;
- 月份标签按周列推断:某列第一次出现 1~7 号就在该列上方标注月份,和 GitHub 的标注节奏一致;
- 布局用纯 CSS Grid:
grid-template-rows: repeat(7, 13px); grid-auto-flow: column,七行固定、列自动向右生长,外层一个横向滚动容器,手机上可以左右滑动查看全年。
还有一个日期处理的经典坑我刻意避开了:全程用本地时区构造日期字符串,绝不使用 toISOString()。后者输出 UTC,东八区凌晨操作会把日期退回前一天,打卡"穿越"。iso() 用 getFullYear/getMonth/getDate 手工拼 YYYY-MM-DD,前后端口径完全一致。
格子颜色也有两套语义。simple 模式有记录即满色;numeric 模式按 value/target 分两档透明度(不足目标一半 0.35、接近目标 0.7、达标满色),用习惯自己的主题色 + 一个 hexA() 函数把十六进制转成带透明度的 rgba。今天那格额外画双层描边(白圈 + 主题色圈),在满屏格子里一眼定位"今天"。
三、统计口径:连续天数为什么"今天没打不算断"
页面顶部有四个数字:连续、本月、本月达标、累计。连续天数(streak)是最容易写错的:
# 今天已打就从今天往前数;今天没打则从昨天起算(当天还没打不算断卡)
cursor = today if today in dates else today - timedelta(days=1)
streak = 0
while cursor in dates:
streak += 1
cursor -= timedelta(days=1)
如果死板地"从今天往前数,今天没记录 streak 就是 0",那么每个坚持打卡的人在当天打卡之前打开页面,都会看到自己"连续 0 天"——明明昨天还在坚持,只是今天还没到打卡时间,这非常打击人。正确口径是:今天没打,就当今天还没开始,从昨天数。这个口径在访客侧 API、爬虫 SSR 页两处复用了同一个函数,保证分享卡片上的数字和页面一致。
四、补卡窗口:一个"不对称"的规则
补卡是这类产品的双刃剑:完全禁止,忘了一天就破功,挫败感强;随便补,数据失去真实感。我的方案是后台可配置"允许补最近 N 天"(默认 2 天),但规则是不对称的:
if record:
record.value = value # 已有记录=修正历史数值,任意过去日期都允许
else:
makeup = _checkin_config()['makeup_days']
if check_date < today - timedelta(days=makeup):
return fail(f'只能补最近 {makeup} 天的卡')
record = CheckinRecord(...) # 只有"新增"才受补卡窗口限制
新增打卡受窗口约束(防止三个月后伪造一条完美记录),但修改或撤销已有的历史记录不受限——那是站长对自己数据的订正权。前端格子的可点击判定严格复刻这条规则:空格子要看是否在补卡窗口内,已有记录的格子永远可点(改数值/撤销)。后端是唯一安全边界,前端只是提前给出"只能补最近 2 天"的提示,两边的判断函数我对着写、对着测。
五、小游戏中心:一个全局榜首,而不是一堆排行榜
游戏模块的产品决策先行:每款游戏只维护一个全站最高分,不做 Top 100、不做用户体系。访客打开即玩,结束时填个昵称就能挑战纪录。理由很简单——一个小站没有那么大的玩家基数,一百名排行榜大部分是空的;而"当前最高分:小明 · 23840,来打败他"这种单一目标,反而最能激发再来一局的冲动。
后端的游戏白名单是安全的第一道闸,前后端各登记一处,上报的 key 必须命中:
GAMES = {'2048': '2048', 'suika': '合成大西瓜'}
@public_bp.post('/<game_key>/record')
def submit_record(game_key):
if game_key not in GAMES:
return fail('游戏不存在', 404)
# ...限频、名字长度、分数范围校验
row = GameRecord.query.filter_by(game_key=game_key).first()
if row is not None and score <= row.score:
return ok({'broken': False, 'record': row.to_dict()}) # 幂等返回,不报错
# 严格更高才覆盖
破纪录的裁定权完全在服务端:前端说自己得了 99999 分不算数,后端取当前纪录比对,严格大于才覆盖。分数还限定了合理区间(0 到 10^9),名字限 2~10 字符,上报按真实 IP 限频(每分钟 10 次,进程内滑动窗口)。未破纪录的重复上报返回 broken:false 而不是报错——网络重试、快速连点时前端无感,接口天然幂等。
前端配套两个可复用的 composable,把"玩家身份"和"某款游戏的纪录"从具体游戏里抽离:
export function useGameRecord(gameKey) {
async function submitIfBest(score, name) {
const currentBest = record.value?.score ?? -1
if (score <= currentBest || submitting.value) return // 本地先拦一层
const res = await gameApi.submit(gameKey, { score, player_name: name })
if (res.data?.record) record.value = res.data.record // 以服务端裁定为准
}
}
玩家昵称存 localStorage,一次填写、全站三款游戏共用;本地先做一次"高于已知纪录才发请求"的粗筛减少无谓上报,但最终结果永远以后端返回的 record 为准(可能在你游玩期间别人已经刷新了纪录)。2048、合成大西瓜、恐龙跑酷各自只管游戏逻辑,排行榜代码一行都不用重复。
六、Matter.js 的 collisionStart 之坑:这一篇的重头戏
合成大西瓜的核心规则是"两个相同水果碰撞 → 合成更高级水果"。用 Matter.js 物理引擎,直觉写法是监听 collisionStart 事件:
Matter.Events.on(engine, 'collisionStart', onCollision)
function onCollision(event) {
const used = new Set() // 同一帧一个水果只能参与一次合成
for (const pair of event.pairs) {
const a = pair.bodyA, b = pair.bodyB
if (!canMergePair(a, b, used)) continue
used.add(a.id); used.add(b.id)
doMerge(a, b)
}
}
used 集合先解决了一个问题:物理引擎一帧内可能报出多对接触(A 同时碰到 B 和 C),不防重会导致一个水果同时被合两次、瞬间消失。
但真正的坑在联调时才暴露:两个同级水果有时候明明贴在一起,却永远不合成。复盘后理清了因果链——
- 两个水果合成时,新水果可能恰好生成在另一个同类水果旁边;
- 为了防止水果"刚投放/刚生成的瞬间"误触合并(比如新水果与下方静止的同类在生成帧重叠),我给每个水果加了 100ms 的新生保护期,保护期内
canMergePair返回 false; - 于是新水果与旁边同类接触开始的那一帧恰好落在保护期内,
collisionStart被跳过; - 等保护期结束,两个水果已经稳定贴合,
collisionStart只在"接触开始的瞬间"触发一次,贴合状态不会再触发——事件永远地错过了。
这是事件驱动模型的典型盲区:你只能收到"变化的边沿",收不到"持续的状态"。解决办法是增加一个状态轮询作为兜底——不依赖碰撞事件,每隔 60ms 主动扫描全场,按"同级 + 圆心距 ≤ 半径和"补判一次:
// 主循环里:物理步进之后定时扫描
Matter.Engine.update(engine, dt)
scanAccum += dt
if (scanAccum >= 60) { scanAccum = 0; scanMerges() }
function scanMerges() {
const fruits = Matter.Composite.allBodies(engine.world)
.filter((b) => b.label.startsWith('fruit:'))
const used = new Set()
const pending = []
for (let i = 0; i < fruits.length; i++) {
for (let j = i + 1; j < fruits.length; j++) {
const a = fruits[i], b = fruits[j]
if (!canMergePair(a, b, used)) continue
const rr = a.circleRadius + b.circleRadius
const dx = b.position.x - a.position.x
const dy = b.position.y - a.position.y
const touch = rr + 3 // 3px 容差
if (dx * dx + dy * dy <= touch * touch) pending.push([a, b])
}
}
pending.forEach(([a, b]) => doMerge(a, b)) // 先收集再执行,避免边遍历边删
}
这里又藏着两个细节:
- 3px 容差是必需的。物理求解器为了解决重叠,常会把轻微相交的两个圆推开 1~2px,零容差会造成"视觉上严丝合缝、数学上差 2px"的新死锁。用距离平方比较还省掉了开根号;
- 先收集 pending、扫描结束后再统一执行
doMerge。合并会从物理世界移除刚体,遍历时直接删体会让数组索引错乱。
最终形成"事件为主、轮询兜底"的双保险:collisionStart 负责绝大多数即时合成(手感跟手),定时扫描负责打捞所有被事件遗漏的稳定贴合(逻辑完备)。新生保护期、used 去重、距离容差、延迟执行四个手段缺一不可。
顺带一提,游戏循环我没有用 Matter 自带的 Runner.run,而是自己写 requestAnimationFrame 驱动 Engine.update(engine, dt),并把 dt 截断在 33ms——切到后台标签页再回来时,累计的巨大时间差不会让物理世界一步爆炸;粒子、飘字、连击判定、扫描都挂在这同一个循环里,节奏统一。
七、踩过的其他坑
- 热力图数据按年下发:一次只返回所选年份的日期 map(
{'2026-09-01': 5}),而不是把几年的记录全塞给前端;年份列表由后端DISTINCT(YEAR(date))得出并强制包含今年。 - 数值型习惯的"本月达标"只在查看今年时计算,翻历史年份时本月统计返回 0,避免语义错乱。
- 游戏纹理纯 Canvas 程序化绘制(11 级水果),不引用任何外部图片资源——和全站"不依赖外部素材即可部署"的理念一致,也避免了素材版权问题。
- 2048 的动画与合成是另一套坑(格子位移 vs 生成新格子的两阶段渲染),思路同样是"先结算逻辑状态,再播放视觉过渡",不要在 DOM 上直接推演游戏。
- 游戏页也要过 SEO 分流:第 7 篇的 SSR 为游戏列表和详情页输出了介绍文案和当前最高分,爬虫抓到的不是空白游戏壳。
八、小结
这两个模块教会我的是同一件事:简单规则的价值在于边界完备。
- 打卡的难点不在画格子,在时区、年初对齐、"今天算不算断"、补卡窗口的对称性这些口径——口径要在 API、SSR、前端三处共用同一份;
- 排行榜的难点不在展示分数,在"谁有权裁定纪录"——服务端白名单 + 严格比较 + 幂等响应 + IP 限频,让公开上报接口没法被轻易灌脏数据;
- 物理游戏的难点不在让球动起来,在事件驱动的固有盲区——边沿事件负责手感,状态轮询负责完备,再用保护期和容差处理真实物理引擎的不精确。
做"好玩"的功能还有个额外收益:它逼着你处理实时交互、动画时序、并发上报这些内容管理永远遇不到的问题,是成本极低的技术练兵场。
下一篇是收官篇:AI 网页宠物。看怎么在不引入任何 AI SDK 的前提下,用标准库给 DeepSeek 做后端代理,把一个有性格、有限流、有口令门禁的桌宠养在网站里,顺便给整个系列做一次复盘——第 9 篇:AI 网页宠物,与整个系列的复盘。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏(本文)→ ⑨ AI 网页宠物与收官


