何为「Gamer」
一个基于Next.js开发的「游戏」主题个人网站,融合了现代 Web 技术与属于游戏的独特交互体验。
博客地址:hiripple.com
从零开始制作一个博客,困难的不是代码,而是最初的设计蓝图。工程进行到一半,却因为效果未达预期而推翻重来,没有什么比这更痛苦的了。
「Gamer」的开发历程就是如此,初期总是想得太杂太多,导致实现的页面毫无章法,各处修修补补,总 commit 数量达到了有些惊人的 200+。
不过最终,在潜心研究顶尖大佬的前端设计与交互,采纳各种反馈后,自认为已达到基本满意的水平。
Ripp 认为,「极简」与「极繁」都可以成为非常优秀的博客:前者意味着博客没有任何复杂组件,白底黑字,纯靠时间与内容的积淀,博客只是获取知识的一个路标;后者指的不是「用一个 live2d 小人挡住画面,进博文就开始下樱花雨、播放音乐……」,而是像 Antoine Wodniack 老师的个人页面那样,完全的秀肌肉作品,即便文本内容密度不大,视觉享受就令人叹为观止。
「Gamer」尚未积累足够的内容,也没有顶尖的技术与美术,在两者之间或许更偏向「极简」,并且附加上一些略显独特的创意与巧思。
基础功能
-
极致性能:在 Lighthouse、GTmetrix 测试中表现卓越,平均 Performance 95%,SEO 与 Best Practice 均接近 100%
-
游戏风设计:简约而不简单的游戏风 UI/组件设计,完美契合主题
-
细节至上:响应式设计、流畅且现代化的前端动画
-
深色/浅色模式:精心设计的深色/浅色模式,能够记住你的偏好
-
国际化支持:路由层面的国际化支持,SEO 友好、无感切换
-
实时状态:通过 WebSocket 连接,实时查看博主状态、在线人数、发送弹幕
-
语法支持:支持丰富的 Markdown、LaTeX 语法,满足多样化写作需求
-
扩展插件:可选择 Buttondown、kBar、Umami 等插件拓展功能
旧版本的介绍不再重复,你可以点击全新个人网站 Gamer 即将上线查看详情。
这里展示的是现代化博客几乎都具备的基本功能。开源的正式版相比之前,大幅优化了 PC 端与移动端网页的性能与速度表现,页面改为客户端组件,大型库全部应用了动态导入,静态资源经过压缩、转化为 AV1 编码格式。实测静止状态下 Safari 最低占用可达 1.6%,不用担心浏览网页成为电池杀手。
此外还有一些样式调整:主题色全部统一为紫色,包括搜索 kBar、滚动条等;深色/浅色模式全部单独设计了色调;为代码块、列表、图库、图片加载等格式加入了全新样式。
--color-primary-50: oklch(0.978 0.02 290);
--color-primary-100: oklch(0.953 0.04 290);
--color-primary-200: oklch(0.901 0.08 290);
--color-primary-300: oklch(0.835 0.12 289);
--color-primary-400: oklch(0.758 0.16 289);
--color-primary-500: oklch(0.681 0.19 289);
--color-primary-600: oklch(0.612 0.21 289);
--color-primary-700: oklch(0.534 0.19 289.1);
--color-primary-800: oklch(0.412 0.15 288.6);
--color-primary-900: oklch(0.334 0.12 288);
--color-primary-950: oklch(0.245 0.09 287);
特色功能
在大陆地区,独立博客的死亡趋势似乎已经不可避免:搜索引擎难以收录,流量几乎全部被商业平台垄断。一方面是国内政策与用户习惯,另一方面可能是市面上的很多博客都太千篇一律了,不论是审美奇怪的主题,还是复制粘贴、令人提不起兴趣的内容。
因此 Ripp 希望,「Gamer」有能力让访客在这里多待一会,可以有一种感觉:「这种风格还挺有意思,之前真没见过」。作为一名魂系列爱好者,喜欢魂系列不是因为难度高,而是初次开荒时那种探索未知的感觉。因此「Gamer」也在探索与互动这一块下了不少功夫。
全面的手柄/键盘支持
你可以像玩游戏 🎮 一样浏览博客!
从这个仿造某独立游戏的导航菜单就可以看出,用户不需要鼠标也可浏览博客(请无视 TAB 键),方向键选择、回车/空格确定。
更加无用少见的是,用户还可以用手柄访问博客,无论是浏览网页、点击按钮,还是聚焦文本框。而且博客还准备了精心设计的震动反馈。(你需要操控 niko 浏览网页)
const vibrateClick = useCallback(() => {
performVibration('dual-rumble', {
startDelay: 0,
duration: 100,
weakMagnitude: 0.6,
strongMagnitude: 0.9,
})
}, [performVibration])
const vibrateTyping = useCallback(() => {
performVibration('dual-rumble', {
startDelay: 0,
duration: 15,
weakMagnitude: 0.1,
strongMagnitude: 0.15,
})
}, [performVibration])
其实,Ripp 对震动的执念一直很深,玩到一款震动设计得很棒的游戏时会由衷地赞叹(宇宙机器人),而反之会认为缺乏细节。
因此最初对震动这一功能的要求有点高,希望实现 Switch Pro 手柄的 HD 震动与 DualSense 的触觉反馈。不过最终废弃了,高精度的震动不仅需要 WebHID API,而此 API 要求用户授权才可以连接上手柄,但是这一授权刚好打破「不用鼠标就可以浏览博客」的想法,当然另一个原因还是 Safari 根本不支持 WebHID(如果我的代码没写错的话)。
任天堂官方也没有公布 HD 震动的相关协议。Switch 出世的 2017 年,HD 震动让玩家们眼前一亮,当时居然可以通过手柄播放音乐。虽然声音的本质就是震动,但这也很好地体现了 PRO 手柄的强大。Ripp 最终尝试了 GitHub 的 Switch 逆向数据映射,然后用 FFmpeg 采样音频的参数(强度、频率等)作为震动数据,再用 WebHID 播放震动音乐。遗憾的是,最终只能感受到一些音乐的节奏,其他音色完全无法还原。
或许未来深入研究手柄的相关协议后,会进一步完善这一功能。
和 niko 一起探索博客
这个世界知道你的存在。
定下「游戏」主题之后,就一直想在网页里塞一个小游戏,但是普通的游戏和博客并没有任何关联,塞进去只会起到反效果。这时候突然想到了最喜欢的独立游戏之一,让 niko 和玩家一起冒险。
niko 的存在不仅仅是一个小游戏,还可以帮助导航博客、介绍内容,甚至……提供一些情绪价值。
最令我满意的一点是,niko 不仅仅拓展了博客的功能,还完美地契合了原作的世界观。作为 meta 游戏的主角,niko 不仅仅知道玩家/用户的存在,还可以穿过维度的限制,实现更深层次的互动。
在「Gamer」中,niko 被赋予了相当复杂的行为逻辑,许多致敬原作的桥段,不少隐藏的对话。niko 知道你在看哪一篇文章,知道现在的世界、地点、天气,甚至还可以透过摄像头看见你。
穿越至 Sparks
搭乘「海原電鉄」吧!
hiRipple 的博文一般以长文为主,一点小事或者想法就发一篇文章,未免过于敷衍。因此构建一个闪念页面,记录短暂的、临时的想法,就一直萦绕在心头。
如何去设计闪念呢?游戏的页面已经有了,不妨定一个「电影」主题的页面吧,于是就挑了最喜欢的「吉卜力」作为页面主题。呼应开头,博客往「极简」这一边靠,那么闪念亦可以尝试下「极繁」。
要繁复,就想到了 3D 渲染,对应的是 three.js,而 React 版本的渲染器是 react-three-fiber(R3F)。凭借自己的游戏经验感觉,最难渲染的莫过于光影与流体,Ripple 这个名字自然和水联系在一起,所以 Sparks 页面的基本效果就成为了涟漪,目标是尽可能模拟真实的水面涟漪。
最终的实现方式是:生成一张动态的"涟漪位移图",在一个看不见的、离屏的画布上,持续绘制出涟漪的产生、扩散和消失过程,使用每个像素的颜色值代表该位置涟漪的强度和方向。然后在需要的时候,应用位移效果,获取到第一步生成的"位移图",再根据这张图的信息,让像素从周围"抓取"颜色,形成一种视觉上的扭曲和流动。
在水面的中间,是海上列车「海原電鉄」,因为博主不会画画,这个列车的图片其实是 3D 模型 + AI 绘制 + Pixelmator 后期得到的结果。观察 3D 模型,不断给 AI 输入各个角度、微调细节,然后用后期软件调整色彩与拼接,最终得到满意的效果。
使用每一节车厢代表一段闪念,为了维持手绘风的效果,使用 SVG 绘制了边框,文本内容则是 Markdown 渲染,评论功能被简化为 emoji 回应,后端仍然是 Supabase。
目前仍然存在不少性能问题,但是有些找不着原因,只能等以后慢慢优化了。
找出所有彩蛋吧
玩家们喜欢魂系列的探索,当然也是为了探索后拿到对应的奖励,但「Gamer」只有探索,没有奖励(如果不算 Ripp 的夸赞……)。
但我还是很喜欢这个页面,仿佛这就是「游戏」主题的初衷,我相信喜欢游戏的玩家们一定会忍不住看看,到底可以认出多少游戏~
其实这个页面只是一个入口,藏满彩蛋更多是为了给玩家们在意想不到的地方放一些惊喜。没有任何刻意为难的要素,部分彩蛋甚至反复出现,提示也直接了当。
真心期待未来有一名玩家可以把它们全部找齐,拿到 hiRipple 的「白金奖杯」!
逃离 WordPress
WordPress 并不差,那一套系统虽然繁重,但是对个人博客这种数量级的文章来说,完全可以轻松应付。事实上,使用了类似「超级缓存」这类插件生成静态页面后,WP 的速度并不比 Next.js 网页慢多少。无奈那一套 PHP 开发实在玩不来,加一点小功能都尤其艰难,而且迁移起来极其繁琐(也可能是我改得太乱了),最大的原因是不想每个月给阿里云交钱了。
总而言之,三年多的时光,总计在 WordPress 写了八十多篇博文,收获上百条评论,一切并不是没有意义的,所以必须迁移到「Gamer」。
迁移博文
我的迁移方法是使用 WordPress 官方的 XML 导出,然后应用这个项目:wordpress-export-to-markdown 将 XML 转化为 MD。
下一步是撰写代码把 MD 转化为适合「Gamer」的 MDX,实例:
const fs = require('fs/promises')
const path = require('path')
const matter = require('gray-matter')
// --- 辅助函数 ---
/**
* 从 URL 中提取文件名 (例如: "https://.../foo.JPG?query=1" -> "foo.JPG")
* @param {string} url - 完整的 URL
* @returns {string} 文件名
*/
function getFilenameFromUrl(url) {
try {
const urlObject = new URL(url)
return path.basename(urlObject.pathname)
} catch (error) {
return path.basename(url)
}
}
/**
* 从文件名中提取名称部分 (例如: "foo.JPG" -> "foo")
* @param {string} filename - 文件名
* @returns {string} 文件名(不含扩展名)
*/
function getFilenameWithoutExtension(filename) {
return path.parse(filename).name
}
// --- 核心转换函数 ---
/**
* 转换 Markdown 主体内容(不包括 Frontmatter)
* @param {string} bodyContent - 原始 Markdown 文件主体内容
* @returns {string} 转换后的 MDX 主体内容
*/
function convertBody(bodyContent) {
let updatedContent = bodyContent
// 规则 1 & 2: 图片转换
updatedContent = updatedContent.replace(
/<figure>[\s\S]*?\[!\[.*?\]\((.*?)\)\]\(.*?\)\s*<figcaption>([\s\S]*?)<\/figcaption>[\s\S]*?<\/figure>/g,
(match, url, caption) => {
const alt = caption.trim().replace(/\s+/g, ' ')
return `<Image src="${url}" alt="${alt}" />`
}
)
updatedContent = updatedContent.replace(/\[!\[(.*?)\]\((.*?)\)\]\(.*?\)/g, (match, alt, url) => {
const filename = getFilenameFromUrl(url)
const finalAlt = alt.trim() || getFilenameWithoutExtension(filename)
return `<Image src="${url}" alt="${finalAlt}" />`
})
updatedContent = updatedContent.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => {
if (match.includes('<Image')) return match
const filename = getFilenameFromUrl(url)
const finalAlt = alt.trim() || getFilenameWithoutExtension(filename)
return `<Image src="${url}" alt="${finalAlt}" />`
})
// 规则 3: 音频转换
updatedContent = updatedContent.replace(
/\[(.*?)\]\((.*?\.(mp3|wav|ogg|m4a))\)/g,
(match, title, url) => {
const filename = getFilenameFromUrl(url)
return `<AudioPlayer src="/static/audio/${filename}" title="${title.trim()}" />`
}
)
// 规则 4: 视频转换
updatedContent = updatedContent.replace(
/\[(.*?)\]\((.*?\.(mov|mp4|webm))\)/g,
(match, title, url) => {
const filename = getFilenameFromUrl(url)
const posterFilename = `${getFilenameWithoutExtension(filename)}.avif`
return `<VideoPlayer src="/static/images/${filename}" poster="/static/images/${posterFilename}" />`
}
)
// 规则 5: 二级标题前加分割线
updatedContent = updatedContent.replace(/^(## .*)$/gm, '<DividerSmall />\n$1')
return updatedContent
}
/**
* 转换 Frontmatter 元数据
* @param {object} data - 从 gray-matter 解析出的元数据对象
* @returns {object} 转换后的新元数据对象
*/
function convertFrontmatter(data) {
const categoryMap = {
全部: '兴趣交流',
interest: '兴趣交流',
tech: '技术交流',
software: '软件交流',
relax: '生活杂谈',
}
const newFrontmatter = {}
newFrontmatter.title = data.title || ''
if (data.date) {
newFrontmatter.date = data.date.toISOString().split('T')[0]
}
newFrontmatter.draft = false
newFrontmatter.summary = ''
if (data.categories && Array.isArray(data.categories)) {
const tags = data.categories
.map((cat) => categoryMap[cat] || cat)
.filter((value, index, self) => self.indexOf(value) === index)
newFrontmatter.tags = tags
} else {
newFrontmatter.tags = []
}
return newFrontmatter
}
// --- 主执行函数 ---
async function processFolder(folderPath) {
console.log(`[INFO] 开始处理文件夹: ${folderPath}`)
try {
const files = await fs.readdir(folderPath)
const mdFiles = files.filter((file) => path.extname(file).toLowerCase() === '.md')
if (mdFiles.length === 0) {
console.warn(`[WARN] 在 "${folderPath}" 中没有找到 .md 文件。`)
return
}
for (const mdFile of mdFiles) {
const fullPath = path.join(folderPath, mdFile)
const mdxFilename = `${path.parse(mdFile).name}.mdx`
const mdxPath = path.join(folderPath, mdxFilename)
try {
console.log(`[INFO] 正在读取: ${mdFile}`)
const rawContent = await fs.readFile(fullPath, 'utf8')
console.log(`[INFO] 正在转换: ${mdFile} -> ${mdxFilename}`)
const { data: frontmatterData, content: bodyContent } = matter(rawContent)
const newFrontmatter = convertFrontmatter(frontmatterData)
const newBody = convertBody(bodyContent)
const finalMdxContent = matter.stringify(newBody, newFrontmatter, {
flowLevel: 1,
})
await fs.writeFile(mdxPath, finalMdxContent, 'utf8')
console.log(`[SUCCESS] 成功创建: ${mdxFilename}`)
} catch (err) {
console.error(`[ERROR] 处理文件 ${mdFile} 时出错:`, err)
}
}
console.log('\n[COMPLETE] 所有文件处理完毕。')
} catch (err) {
if (err.code === 'ENOENT') {
console.error(`[ERROR] 文件夹不存在: ${folderPath}`)
} else {
console.error('[ERROR] 读取文件夹时发生未知错误:', err)
}
}
}
// --- 脚本启动 ---
const targetDirectory = process.argv[2]
if (!targetDirectory) {
console.error('[ERROR] 请提供一个文件夹路径作为参数。')
console.log('用法: node convert.js /path/to/your/markdown/folder')
process.exit(1)
}
processFolder(path.resolve(targetDirectory))
迁移评论
迁移评论也是类似的方法,这里使用了 WordPress 的评论导出插件,导出评论为 CSV 文件。随后撰写脚本将评论一个个插入到 Supabase 数据库:
/**
* @file migrate-wp-comments.js
* @description A script to migrate WordPress comments from a CSV export to a Supabase table.
* @instructions
* 1. Clear your 'comments' table in Supabase to avoid duplicates from previous runs.
* 2. Update the 'slugMapping' object below with the post slugs you want to migrate in this batch.
* 3. Save your WordPress comment export as 'wp_comments.csv' in the same directory.
* 4. Create a '.env' file with your Supabase credentials.
* 5. Run dependencies: npm install csv-parse @supabase/supabase-js dotenv
* 6. Run the script: node migrate-wp-comments.js
*/
import fs from 'fs'
import { parse } from 'csv-parse'
import { createClient } from '@supabase/supabase-js'
import 'dotenv/config'
// --- 配置 ---
const CSV_FILE_PATH = './wp_comments.csv'
const SUPABASE_URL = process.env.SUPABASE_URL
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY
// --- 映射表 ---
const slugMapping = {
'hiripple-2024年度总结': '2024-summary',
}
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
console.error('错误:请确保在 .env 文件中设置了 SUPABASE_URL 和 SUPABASE_SERVICE_KEY')
process.exit(1)
}
// 初始化 Supabase 客户端
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
/**
* 主迁移函数
*/
async function migrateComments() {
console.log('开始迁移 WordPress 评论...')
// 1. 读取并解析 CSV 文件
const parser = fs.createReadStream(CSV_FILE_PATH).pipe(
parse({
columns: true,
skip_empty_lines: true,
})
)
const allRecords = []
for await (const record of parser) {
allRecords.push(record)
}
console.log(`从 CSV 文件中成功读取 ${allRecords.length} 条评论。`)
// --- 2. 新增:只处理在映射表中定义的文章的评论 ---
const mappedSlugs = Object.keys(slugMapping)
if (mappedSlugs.length === 0) {
console.log('slugMapping 为空,没有需要迁移的评论。脚本已退出。')
return
}
const recordsToMigrate = allRecords.filter((r) => mappedSlugs.includes(r.comment_post_name))
console.log(
`找到 ${recordsToMigrate.length} 条评论,这些评论属于在 slugMapping 中定义的文章,将被处理。`
)
// 3. 创建一个映射,用于存储旧的 WordPress comment_ID 和新的 Supabase UUID 的对应关系
const oldIdToNewUuidMap = new Map()
// 4. 第一遍:插入所有父评论 (comment_parent === '0')
console.log('\n--- 第一遍:正在插入父评论... ---')
const parentComments = recordsToMigrate.filter((r) => r.comment_parent === '0')
for (const record of parentComments) {
const postSlug = slugMapping[record.comment_post_name] || record.comment_post_name
const newComment = {
post_slug: postSlug,
content: record.comment_content,
author_name: record.comment_author,
author_email: record.comment_author_email,
author_website: record.comment_author_url,
ip_address: record.comment_author_IP,
status: record.comment_approved === '1' ? 'approved' : 'pending',
created_at: record.comment_date_gmt,
}
const { data, error } = await supabase.from('comments').insert(newComment).select('id').single()
if (error) {
console.error(`插入父评论 (旧ID: ${record.comment_ID}) 失败:`, error.message)
} else {
console.log(`成功插入父评论 (旧ID: ${record.comment_ID}) -> 新UUID: ${data.id}`)
oldIdToNewUuidMap.set(record.comment_ID, data.id)
}
}
// 5. 第二遍:插入所有回复评论
console.log('\n--- 第二遍:正在插入回复评论... ---')
const replyComments = recordsToMigrate.filter((r) => r.comment_parent !== '0')
for (const record of replyComments) {
const parentUuid = oldIdToNewUuidMap.get(record.comment_parent)
if (!parentUuid) {
console.warn(
`警告:找不到回复 (旧ID: ${record.comment_ID}) 的父评论 (旧父ID: ${record.comment_parent})。跳过此条评论。`
)
continue
}
const postSlug = slugMapping[record.comment_post_name] || record.comment_post_name
const newReply = {
post_slug: postSlug,
content: record.comment_content,
author_name: record.comment_author,
author_email: record.comment_author_email,
author_website: record.comment_author_url,
ip_address: record.comment_author_IP,
status: record.comment_approved === '1' ? 'approved' : 'pending',
created_at: record.comment_date_gmt,
parent_id: parentUuid,
}
const { data, error } = await supabase.from('comments').insert(newReply).select('id').single()
if (error) {
console.error(`插入回复 (旧ID: ${record.comment_ID}) 失败:`, error.message)
} else {
console.log(`成功插入回复 (旧ID: ${record.comment_ID}) -> 父UUID: ${parentUuid}`)
oldIdToNewUuidMap.set(record.comment_ID, data.id)
}
}
console.log('\n迁移完成!')
}
// 运行迁移脚本
migrateComments().catch(console.error)
结语
如果你觉得「Gamer」不错,请前往 GitHub 点一个 Star 吧,这会成为莫大的鼓励!