Skip to content

MCphone NeoForge 1.21.1 v1.9.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 08:50
· 40 commits to main since this release

MCphone v1.9.0

美西螈能发照片和表情了 —— 输入栏左边一个「+」,图片与表情各一页,点一下就到对方手机上 从拍照到发出去、到对方点开看大图、再存回自己相册,中间不再需要任何别的模组或外部工具。

运行环境: Minecraft 1.21.1 · NeoForge 21.1.200 或更高 · 客户端与服务端都需安装
可选: Curios —— 装了多一个手机槽
可选: Waystones(传送石碑)—— 装了多一个「传送石」App
可选: MCEF —— 装了多一个「浏览器」App
可选: NetMusic(网络音乐机)—— 装了唱片仓还收它刻的 CD
可选: Patchouli(教程手册)—— 装了多一个「阅读」App
可选: FTB Quests —— 装了多一个「任务书」App
可选: Integrated Dynamics —— 装了多任何东西,只让崩溃报告指认对人

从 1.8.x(含 1.8.20)升级: 直接替换 jar。存档、好友关系、聊天记录、购买记录、App 安装状态、主屏图标位置、手机里的唱片全部原样保留。

聊天记录的存档格式动了,但两边都兼容:老存档照常读;新版本写下的文字消息仍是老格式,所以万一要把模组降回 1.8.x,文字聊天记录一条都不会丢(只有图片消息那几条读不出来)。这一点有断言测试守着,见 docs/ChatMessageCodecTest.java

服务端必须一起换。 图片走的是新增的网络包,也存在服务端;客户端新、服务端旧的话,那个图片键点了不会有任何反应。


➕ 输入栏左边那个「+」

点开是一张小菜单:图片表情

为什么是「+」而不是直接一个图片键:能发的东西不止一种,每多一种就在输入栏挤一个键的话,那条栏很快就没地方打字了。菜单是按枚举画出来的,往后再多一种(分享物品、发个坐标)只要加一项,高度、悬停、点击分派都不用改。

📷 图片 —— 从相册挑一张

「图片」点开就是相册的网格——相机 App 拍的、按 F2 随手截的,都在里面,点一张就发出去了。

也可以直接把图片文件拖进游戏窗口。 正开着会话时拖进来,等同于选了这张图发出去。从相册选图的前提是那张图已经在截图目录里,而想发的常常是刚从别处存下来的一张——拖进来一步到位。

😀 表情 —— 自己的表情包,导进来就能一直用

「表情」那一页盯着 config/mcphone/stickers/,那就是你自己的表情包。它跟着客户端走,换服务器、换存档都在,与书架收藏同一个道理。

导入两条路,都不弹系统窗口

  • 把图片拖进游戏窗口 —— 表情页开着时,拖进来是"收进目录"而不是"发出去",而且一次能拖一整包,全收
  • 点右上角「打开文件夹」 —— 用系统自己的文件管理器打开那个目录,你想丢多少丢多少

(不做 Java 的文件选择器是有理由的:那要初始化 AWT,而 AWT 在 macOS 上要和游戏抢主线程,是有名的崩溃源。)

导入之后点一下就发。同一张表情反复发,服务器上只存一份——图片是按内容存的(文件名是"会话键 + 图片字节"的哈希),第二次发连写盘都省了,也只占"每对会话 20 张"里的一个名额。表情就是靠这一条才发得起,它天生要被反复发。

png / jpg / jpeg / gif / bmp 都收,GIF 是动的(见下一节)。

收到的图直接就看得见,不是一行"[图片]"要你再点。点一下放大,铺满整块屏幕;再点一下(或者按导航栏的返回)收回去。手机屏幕只有 120×200,消息里那张最宽 80 像素出头——认得出是什么,看不清写了什么,所以放大这一手是必须有的。

图片不套气泡。 文字消息有气泡底,图片没有:真实的聊天软件里图片就是图片本身,谁发的靠左右对齐看得出来。套一层等于在图周围多画一圈没有意义的色块,而这块屏幕只有 120 宽,那一圈还要从图身上扣——现在那 6 个像素还给图了。

放大之后右上角有「保存」,存进相册(也就是截图目录)。存的是收到的原始 PNG 字节,不是屏幕上那张贴图——贴图是解码放大过的像素,从它反推不回原文件。存完立刻出现在相册里,也就能再转发给别人。

会话列表那一行显示成「[图片]」。这句话是客户端说的、不是服务端说的:服务端把那条消息原样发下来,显示成哪国语言由看的人的客户端决定——服主的服务端是英文的、玩家的客户端是中文的,这种事天天发生。

对方不在线也能发,与文字消息完全一样:图存进世界存档,他下次上线就看得见。

🎞 动图

拖一张 GIF 表情进来,发出去在对方手机上就是动的。

怎么传的:客户端把 GIF 拆成帧,拼成一张 PNG(所有帧排成方阵)发出去,消息里多带"几帧、每帧停多久"。这样一来上面所有的机制一个都不用改——还是一张 PNG、还是同一个体积上限、还是按内容去重(同一张动图表情反复发仍然只存一份)、服务端仍然不必解码任何图片。收件人也只上传一张贴图,播放就是按时间挑一个子矩形画出来,一张动图和一张静态图的开销几乎一样。

不转发原始 GIF 字节,是因为压不动:源文件常有一两百 KB,超了上限就得重新编码,而重编 GIF 要重新量化调色板,画质掉得比缩成 PNG 还厉害。

塞不下就一档档退:先降一帧的尺寸(160 → 128 → 96),再隔帧抽稀(每两帧、每三帧取一帧,剩下的每帧就多停几倍时间,不然会播成快进)。帧数封顶 36。抽到不成动画(少于 6 帧)就不硬撑了,退回去发第一帧——一张清楚的静态表情,好过一团看不出在动什么的马赛克。

拆帧不是"逐帧读出来"那么简单:GIF 的每一帧存的是与上一帧的差,可能只有画面的一小块,画完还要按 disposal 决定这一块留着、擦成透明、还是还原成上一帧。所以是自己铺一张画布一帧帧盖上去,每盖完拍一张快照(见 GifCodec)。

每帧不同的停留时间会被拉匀:取出现次数最多的那个值。GIF 允许逐帧不同,但把一串延迟塞进每条聊天消息会让消息本身胖一圈,而表情几乎都是匀速的。

表情页的格子里是第一帧,不动——一屏几十张同时动只会让人眼花。会话里才动。

动图存进相册的是第一帧:相册是给截图用的,那里躺一张雪碧图,别的软件都读不了。

📂 相册与壁纸页多了「打开文件夹」

点一下,用系统自己的文件管理器打开对应目录。要往相册里放几张图、想把刚存下来的那张翻出来发给别人、或者往 wallpapers/ 里丢一张壁纸,不必再自己一层层去找——表情那一页早就有这个键了,另外两页没有,是个疏漏。

目录不存在时先建出来再开:玩家点它的时候,相册与壁纸目录多半正是空的,而"路径不存在"这句话对他一点用都没有。

壁纸那一页还多一手:点过之后开始盯着目录,每秒重扫一次。不然这个键只完成一半——你点开文件夹、拖一张 PNG 进去、切回游戏,而这一页只在进来的时候扫过一次,那张图要退出去再进来才认,多半会以为没放成功。只在点过之后才盯:那是玩家说出"我要往里放东西"的唯一时刻。

两页的位置都是标题行右边,不另占一行。壁纸那一页尤其不能占:它的网格没有翻页,多一行正好把两行挤成一行,能选的壁纸从四张掉到两张。挤不下时截掉的是标题——玩家正是点着「更换壁纸」进来的,标题只是复述一遍,而这个键是这一页唯一的新功能。

🗜 发之前会先压

截图是全屏分辨率的,一张 1920×1080 的 PNG 常有两三 MB。原样发上去有两个问题:原版对客户端发给服务端的包有 32767 字节的硬上限,根本发不动;而且服主要替所有人存那么大的文件。

所以客户端先压:长边压到 512 像素,体积由服主定上限chatImageMaxKb,默认 512 KB),再切片按序发上去,服务端拼回来。

512 是照着"放大之后要多清楚"定的:消息里那张最宽 80 个 GUI 像素,只按它算 256 都嫌多;但图是点得开的,放大后铺满 112×170 个 GUI 像素,在 4 倍缩放下就是 448×680 个真实像素——到那儿 384 还要拉伸一点,512 则是原尺寸出头。

体积上限为什么交给服主:它直接换成硬盘(每对好友 = 上限 × 20 张)。一台十个人的私服和一台两百人的公服,愿意为聊天记录付的硬盘差着两个数量级,而这件事只有服主知道。客户端读到的是服主那一份——服务端配置会在玩家连上来时同步过来。

压完仍然超上限的(雨天、树叶、粒子这类噪点多的截图,PNG 压不动)会自动降一档尺寸重压(384 → 320 → 256 → 192);实在压不下来会在动作栏上告诉你换一张,而不是静悄悄地什么都没发生。实测一张纯随机噪点的 1920×1080:默认 512 KB 下原样发出去(512×288),把上限调到 128 KB 就会自动降到 256×144。而 Minecraft 真实截图那种大片天空与地形,512 长边压出来只有 1 KB,两个上限下都一样。

透明底保得住。 留不留 alpha 通道是按图判的:表情几乎全是透明底的 PNG,一律按不透明编码的话,透明的地方会变成纯黑——表情页里看着好好的,发出去带一圈黑框。截图没有透明像素,仍然不留那个通道,这条路上每个字节都要过网络。

一张只解码一次。 降档重压时不重新读盘解码——那是整条路上最贵的一步,一张 4096 的 PNG 解一遍就是一秒出头,四档各解一遍等于让玩家干等三四秒。读盘、解码、缩到最大那一档只做一次,往下几档都从那一张再缩(降档是为了压体积,不是为了更清楚,而 512→384 只有 0.75 倍,一次插值就够)。实测 2048 的噪点图从 1.27 秒降到 0.28 秒,4096 的从 3.58 秒降到 0.90 秒。

同一张只压一次。 压出来的结果只取决于文件内容,所以按"文件 + 改动时间 + 大小"把最近 8 张记住。表情天生要反复发同一张,第二次起直接拿现成的字节走——压缩那一段是 0 毫秒。

一次只发一张,点快了的排队。 同时只允许一次上传:压缩是异步的,两次上传交错着发上去,而服务端按"片号必须连续"收,交错的结果是两张都发不成。但"这会儿不能发"不等于"当你没点过"——冷却期里点的那几张排着(至多 4 张),闸一开自己走。发送期间那个「+」画得淡一些(意思是上一张还在路上,你照样能点),排满了才真的点不动。

🚫 为什么不走图床

同类的聊天模组多半是另一条路:图片传到公网图床(uguu.se 之类),聊天里发一串
[[CICode,url=...]],收到的人按 URL 去下载。那条路有一个我们给不起的好处——不需要服务端
任何服务器都能用;也有三个我们不愿付的代价:

  • 图离开了游戏。传上去的是玩家的截图,落在一个谁都能读的公网地址上;那类模组的说明里
    通常直接写着"请勿发送敏感或私密内容"
  • 链接会过期(常见的免费图床是几小时)。聊天记录还在,图全成了死链——而我们这边过期的是
    像素、消息还在,且保留的是最近 20 张而不是三小时
  • 多一个必须能连上的第三方。图床挂了、或者玩家的网络到不了它,发不了也看不了;而 MCphone
    只依赖玩家此刻已经连着的那台服务器

我们本来就要服务端(好友关系、离线消息都在那儿),所以把图留在服务器里是顺理成章的,
换来的是隐私、持久、以及"只有会话双方要得到"的访问控制。

⏳ 每对会话只留最近 20 张【不同的】图

图片是留在存档里的,不像传送那样做完就完了。所以有一道上限:每对会话保留最近 20 张不同图的像素,再往前的图片消息留着那一行,但显示成「已过期」

数的是"不同的图"而不是"图片消息":同一张表情发二十次只占一个名额,因为它在硬盘上本来就只有一份。挤掉谁按"最久没再发过"算——刚刚还在发的那张表情永远是新的。

为什么不把整条消息删掉:删掉之后聊天记录会凭空少几句,而少的是什么谁也想不起来。显示成「已过期」至少说清了这里本来有张图。

文字消息不受这条限制,仍然是每对会话 100 条。

🖼 像素是"看到了才去要"的

一条消息里只有图片的 id,不含像素。玩家翻到哪一张,客户端才去要哪一张——一个会话最多 100 条消息,一进去就全下载等于几百 KB 的突发流量,而他多半只看得见最后两三张。

要的时候是攒一批一次要(至多 4 张):服务端对拉取类的包有 500 毫秒限流,一次一张地要,一屏三张图要一秒半才凑齐。要过没回音的会隔 6 秒再要一次——包可能正好撞上限流被丢掉,而客户端无从知道。

自己刚发出去的那张不会再下回来:上传的那几个字节直接进客户端缓存,回声一到就用上了。

贴图缓存最多 24 张,超出按最久没显示的逐出并归还显存;手机一关全部释放。

🔒 服主那一头

新增一个配置项serverconfig/mcphone-server.toml):

默认 关掉之后
allowChatImages true 输入栏那个图片键不再显示,服务端也拒收上传的图。已经发过的图不删,仍然看得见
chatImageMaxKb 512 一张图最多多少 KB(64–768)。客户端按它压、服务端按它收,直接决定硬盘

硬盘怎么算chatImageMaxKb × 20(每对会话至多 20 张不同的图)。默认 512 KB 就是每对常聊的好友封顶 10 MB,200 对是 2 GB 上限——那是最坏情况,正常截图离它很远(真实截图压出来只有 1 KB 量级)。嫌大就把这个数调小:客户端会自动降一档尺寸重压,动图先掉帧再退成静态图,功能不坏,只是画质让路。

图片存在世界存档的 mcphone/chat-images/,一张一个文件——不放进聊天记录那份 SavedData 里,是因为那份东西整份常驻内存、每次保存整份重写,而图片只在有人正好翻到那条消息时才被读一次。

服务器启动时扫一遍孤儿文件:图写完了、消息还没落盘就崩了的话,那个文件再没人认领。一次崩溃留一两个文件不算什么,但服务器会崩很多次,而没人会记得去清。

几道闸,都在服务端:

  • 发图片每人 2 秒一张RequestThrottle)。这不是"防手快",是防有人拿聊天当上传通道;被拦下时会明说"缓一下",不静默丢弃
  • 只有好友之间能发,与文字消息同一道校验,手机也得在身上
  • 要一张图时校验的是"这张图出现在你和他的那段记录里"——知道 id 也要不走别人的图
  • 服务端不解码图片(解码是客户端的事),但会看一眼文件头是不是 PNG、它自己声明的宽高在不在上限之内。真正的解码防线在客户端,用的是纯 Java 的 ImageIO 而不是原生解码器——那段字节是别的玩家发来的
  • 越界的宽高一律夹到合法区间而不是抛异常:抛的话,伪造客户端发一个宽 20 亿的包就能让收件人掉线,挨罚的是无辜的那一方

写盘与读盘都在后台线程,主线程只做校验与落消息。

🧱 顺带整理的几处

  • 消息正文抽成了 MessageBody(文本 / 图片各一个实现,种类登记在 MessageKind)。再加一种消息——分享物品、发个坐标——只要写一个实现类、在那张表上登记一行,存档、网络包、会话列表预览、通知横幅全都自动认得它,已有的两种一个字节都不用动
  • 读图、缩图、上传贴图收进 ImageCodec 一份。相册读的是硬盘上的截图,美西螈读的是网络上来的字节,中间那几步(逐级减半缩放、ARGB→ABGR、尺寸要在 register 之前取)一模一样,而每一步都有不写下来就会踩的坑
  • 照片网格收进 PhotoGridPainter 一份,相册与"选一张发给好友"那一页共用:格子多宽、几列、缓存要顶到多大,都是"改一处必须同时改另一处"的数
  • 照片网格与目录扫描又抽出一层ImageFolder(一个"装着图的目录":扫描、缩略图、LRU、导入、删除)现在同时给相册和表情用,两页共用一份实现;「挑一张发出去」那一页也是同一个类的两个实例,只是盯着不同的目录
  • 新增换肤位 chat/attach.png(9×9,缺图时画 + 字符)

给服主

这一版新增两个配置项allowChatImageschatImageMaxKb)、新增三个网络包(上传一片、要图、发图),新增一个存档目录mcphone/chat-images/)。聊天记录的格式向前向后都兼容,见开头那一段。服务端与客户端必须同时更新。