‹ 返回博客

人网站(八):打卡热力图与小游戏中心——把"坚持"和"好玩"做成产品

2026-09-20 · 约 5 分钟读完 · #编程

一个人做个人网站(八):打卡热力图与小游戏中心——把"坚持"和"好玩"做成产品

前面七篇都在讲"正经"的内容管理。这一篇换个口味,看两个纯取悦访客(和我自己)的模块:一个向内,记录日复一日的坚持;一个向外,请来访的人停下来玩两分钟。

它们技术栈完全不同——打卡热力图是"日历算法 + 统计口径"的冷静思考,小游戏是"物理引擎 + 并发防刷"的热烈调试——但有一个共同的产品内核:规则看似简单,真正难的是把边界情况一个个想清楚

一、打卡模块:两张表,两种习惯模式

数据模型极简,习惯和记录两张表:

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'),)

三个设计决策值得说:

  1. (habit_id, check_date) 唯一约束。一天一条记录,重复打卡天然变成"更新",不需要在应用层先查再判断,数据库兜底防并发重复;
  2. simple / numeric 两种模式同表共存。"早起打卡"只要有没有,"跑步 5 公里"要记数值。value 为空即打勾,有值即计量,不搞两张表;
  3. 弃坑用归档而不是删除。坚持了半年的习惯放弃了,历史热力图仍是自己的记录,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
})

几个关键处理:

还有一个日期处理的经典坑我刻意避开了:全程用本地时区构造日期字符串,绝不使用 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),不防重会导致一个水果同时被合两次、瞬间消失。

但真正的坑在联调时才暴露:两个同级水果有时候明明贴在一起,却永远不合成。复盘后理清了因果链——

  1. 两个水果合成时,新水果可能恰好生成在另一个同类水果旁边;
  2. 为了防止水果"刚投放/刚生成的瞬间"误触合并(比如新水果与下方静止的同类在生成帧重叠),我给每个水果加了 100ms 的新生保护期,保护期内 canMergePair 返回 false;
  3. 于是新水果与旁边同类接触开始的那一帧恰好落在保护期内,collisionStart 被跳过;
  4. 等保护期结束,两个水果已经稳定贴合,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))  // 先收集再执行,避免边遍历边删
}

这里又藏着两个细节:

最终形成"事件为主、轮询兜底"的双保险:collisionStart 负责绝大多数即时合成(手感跟手),定时扫描负责打捞所有被事件遗漏的稳定贴合(逻辑完备)。新生保护期、used 去重、距离容差、延迟执行四个手段缺一不可。

顺带一提,游戏循环我没有用 Matter 自带的 Runner.run,而是自己写 requestAnimationFrame 驱动 Engine.update(engine, dt),并把 dt 截断在 33ms——切到后台标签页再回来时,累计的巨大时间差不会让物理世界一步爆炸;粒子、飘字、连击判定、扫描都挂在这同一个循环里,节奏统一。

七、踩过的其他坑

八、小结

这两个模块教会我的是同一件事:简单规则的价值在于边界完备

做"好玩"的功能还有个额外收益:它逼着你处理实时交互、动画时序、并发上报这些内容管理永远遇不到的问题,是成本极低的技术练兵场。

下一篇是收官篇:AI 网页宠物。看怎么在不引入任何 AI SDK 的前提下,用标准库给 DeepSeek 做后端代理,把一个有性格、有限流、有口令门禁的桌宠养在网站里,顺便给整个系列做一次复盘——第 9 篇:AI 网页宠物,与整个系列的复盘

系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏(本文)→ ⑨ AI 网页宠物与收官