个人网站「Gamer」完工/开源了,一个简洁现代的游戏主题网站

139 阅读12分钟

何为「Gamer」

Gamer

一个基于Next.js开发的「游戏」主题个人网站,融合了现代 Web 技术与属于游戏的独特交互体验。

项目地址:github.com/CelestialRi…

博客地址: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

更加无用少见的是,用户还可以用手柄访问博客,无论是浏览网页、点击按钮,还是聚焦文本框。而且博客还准备了精心设计的震动反馈。(你需要操控 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 的存在不仅仅是一个小游戏,还可以帮助导航博客、介绍内容,甚至……提供一些情绪价值。

最令我满意的一点是,niko 不仅仅拓展了博客的功能,还完美地契合了原作的世界观。作为 meta 游戏的主角,niko 不仅仅知道玩家/用户的存在,还可以穿过维度的限制,实现更深层次的互动。

在「Gamer」中,niko 被赋予了相当复杂的行为逻辑,许多致敬原作的桥段,不少隐藏的对话。niko 知道你在看哪一篇文章,知道现在的世界、地点、天气,甚至还可以透过摄像头看见你。

穿越至 Sparks

搭乘「海原電鉄」吧!

Sparks

hiRipple 的博文一般以长文为主,一点小事或者想法就发一篇文章,未免过于敷衍。因此构建一个闪念页面,记录短暂的、临时的想法,就一直萦绕在心头。

如何去设计闪念呢?游戏的页面已经有了,不妨定一个「电影」主题的页面吧,于是就挑了最喜欢的「吉卜力」作为页面主题。呼应开头,博客往「极简」这一边靠,那么闪念亦可以尝试下「极繁」。

要繁复,就想到了 3D 渲染,对应的是 three.js,而 React 版本的渲染器是 react-three-fiber(R3F)。凭借自己的游戏经验感觉,最难渲染的莫过于光影与流体,Ripple 这个名字自然和水联系在一起,所以 Sparks 页面的基本效果就成为了涟漪,目标是尽可能模拟真实的水面涟漪。

最终的实现方式是:生成一张动态的"涟漪位移图",在一个看不见的、离屏的画布上,持续绘制出涟漪的产生、扩散和消失过程,使用每个像素的颜色值代表该位置涟漪的强度和方向。然后在需要的时候,应用位移效果,获取到第一步生成的"位移图",再根据这张图的信息,让像素从周围"抓取"颜色,形成一种视觉上的扭曲和流动。

Train

在水面的中间,是海上列车「海原電鉄」,因为博主不会画画,这个列车的图片其实是 3D 模型 + AI 绘制 + Pixelmator 后期得到的结果。观察 3D 模型,不断给 AI 输入各个角度、微调细节,然后用后期软件调整色彩与拼接,最终得到满意的效果。

Sparks

使用每一节车厢代表一段闪念,为了维持手绘风的效果,使用 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 吧,这会成为莫大的鼓励!