Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
550 changes: 106 additions & 444 deletions build.gradle

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions docs/AddonApiExamples.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Items;
import net.neoforged.neoforge.common.Tags;

import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -267,8 +266,10 @@ public Map<ResourceLocation, ICost> prices() {

static final ICost EMC = ICost.emc(1000);

// 原版是 Tags.Items.INGOTS(NeoForge 物品标签);Fabric 侧没有那套标签常量,
// 示例改用原版物品判定,语义不变("值 5 点的金属锭")
static final ICost INGOTS = ICost.matching(
stack -> stack.is(Tags.Items.INGOTS), 5,
stack -> stack.is(Items.IRON_INGOT) || stack.is(Items.GOLD_INGOT), 5,
Component.translatable("mymod.cost.ingots"));

public static final class MyWallet implements IEmcWallet {
Expand Down
76 changes: 76 additions & 0 deletions docs/AnimMotionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import com.november.mcphone.core.client.anim.Animator;
import com.november.mcphone.core.client.anim.Easing;
import com.november.mcphone.core.client.anim.UiMotion;

import java.util.ArrayList;
import java.util.List;

/**
* Easing / Animator / UiMotion 三个纯类的断言测试(从 AtomChat 移植时的行为钉死)。
*
* 跑法(这三个类不依赖 Minecraft,直接 javac/java 即可):
*
* javac -d /tmp/animtest \
* src/main/java/com/november/mcphone/core/client/anim/*.java \
* docs/AnimMotionTest.java
* java -cp /tmp/animtest AnimMotionTest
*/
public class AnimMotionTest {

static int checks = 0;
static final List<String> failures = new ArrayList<>();

static void check(boolean cond, String what) {
checks++;
if (!cond) failures.add(what);
}

static void near(float actual, float expected, float eps, String what) {
checks++;
if (Math.abs(actual - expected) > eps) {
failures.add(what + " 期望 " + expected + ",实际 " + actual);
}
}

public static void main(String[] args) {
// ---- Easing:端点与单调性 ----
near(Easing.linear(0f), 0f, 1e-4f, "linear(0)=0");
near(Easing.linear(1f), 1f, 1e-4f, "linear(1)=1");
near(Easing.easeOutCubic(0f), 0f, 1e-4f, "easeOutCubic(0)=0");
near(Easing.easeOutCubic(1f), 1f, 1e-4f, "easeOutCubic(1)=1");
near(Easing.easeInOutCubic(0.5f), 0.5f, 1e-4f, "easeInOutCubic(0.5)=0.5");
near(Easing.easeOutQuad(0f), 0f, 1e-4f, "easeOutQuad(0)=0");
near(Easing.easeOutQuad(1f), 1f, 1e-4f, "easeOutQuad(1)=1");
check(Easing.easeOutQuad(0.5f) < Easing.easeOutCubic(0.5f),
"easeOutQuad 在中点比 easeOutCubic 更柔和(进度更小)");

// ---- UiMotion.approach:总时长到点即钉死、不超调 ----
near(UiMotion.approach(0.5f, 1f, 90f, 90L), 1f, 1e-4f, "approach 走满时长直接到目标");
near(UiMotion.approach(0.8f, 0f, 100f, 100L), 0f, 1e-4f, "approach 回落走满直接到 0");
near(UiMotion.approach(0.5f, 1f, 45f, 90L), 1f, 1e-4f, "剩余距离 <= 步长时钉到目标");
float partial = UiMotion.approach(0f, 1f, 45f, 90L);
check(partial > 0f && partial < 1f, "approach 半程停在 (0,1) 区间");
near(UiMotion.approach(0.5f, 0.6f, 0f, 90L), 0.5f, 1e-4f, "approach 0ms 不动");

// ---- Animator:到达即 done、值精确落在终点 ----
Animator anim = new Animator(Easing::easeOutCubic);
anim.animateTo(100f, 5f);
anim.update(50f);
check(!anim.isDone(), "Animator 半程未 done");
anim.update(60f);
check(anim.isDone(), "Animator 累计 110ms 后 done");
near(anim.getValue(), 5f, 1e-4f, "Animator 终点值精确");
anim.setValue(2f);
check(anim.isDone(), "setValue 立即 done");
near(anim.getValue(), 2f, 1e-4f, "setValue 立即取值");

if (!failures.isEmpty()) {
System.out.println("FAIL " + failures.size() + " / " + checks);
for (String f : failures) {
System.out.println(" - " + f);
}
System.exit(1);
}
System.out.println("PASS " + checks + " checks");
}
}
126 changes: 126 additions & 0 deletions docs/ImagePickerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.november.mcphone.core.client;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import javax.imageio.ImageIO;

/**
* 文件选择器({@link ImagePicker})的断言测试 —— 守两件静默出错的判断。
*
* <h2>为什么这两件值得单独钉</h2>
*
* <b>一、收哪些后缀。</b>{@code isImageName} 决定玩家能不能在对话框里看见某个文件。
* 它多认一个 {@code .webp},玩家就会选中一张 {@code ImageIO} 根本读不了的图,
* 界面上表现为"选了但什么都没发生"——没有报错,只有一个沉默的空操作。
* 少认一个 {@code .jpeg},玩家会觉得"我的图明明在文件夹里,怎么挑不到"。
* 两头都不报错,所以只能靠断言钉住。
*
* <b>二、不许放大。</b>缩略图与预览都用 {@code fit} 收尾,而它最容易写错的一处是
* 把"按比例缩到框内"写成"拉满框"——那样小图会被拉糊。糊了也不报错,
* 只是看起来比原图差,玩家不会想到是这里的问题。
*
* 另外钉住 {@code decode} 对非图片文件返回 null 而不是抛异常:它跑在 EDT 与
* 缩略图线程上,抛出去就是对话框卡死或图标永远转圈。
*
* 跑法见 docs/ 下其它 *Test.java;`./gradlew check` 会自动带上。
*/
public class ImagePickerTest {

static int checks = 0;
static final List<String> failures = new ArrayList<>();

static void check(boolean ok, String what) {
checks++;
if (!ok) failures.add(what);
}

public static void main(String[] args) throws Exception {
// ---- 收哪些后缀 ----
check(ImagePicker.isImageName("a.png"), "小写 .png 应被接受");
check(ImagePicker.isImageName("A.PNG"), "大写 .PNG 应被接受(大小写不敏感)");
check(ImagePicker.isImageName("shot.jpeg"), ".jpeg 应被接受");
check(ImagePicker.isImageName("shot.jpg"), ".jpg 应被接受");
check(ImagePicker.isImageName("anim.gif"), ".gif 应被接受");
check(ImagePicker.isImageName("old.bmp"), ".bmp 应被接受");
check(ImagePicker.isImageName("我的 壁纸.png"), "带空格与中文的名字应被接受");

// webp 是刻意不收的:ImageIO 默认读不了,收了就是"选中了却没反应"
check(!ImagePicker.isImageName("x.webp"), ".webp 不应被接受(ImageIO 读不了)");
check(!ImagePicker.isImageName("a.txt"), ".txt 不应被接受");
check(!ImagePicker.isImageName("png"), "没有后缀不应被接受");
check(!ImagePicker.isImageName("png."), "只有点没有后缀不应被接受");
check(!ImagePicker.isImageName(null), "null 不应被接受");

// ---- decode:尺寸与不放大 ----
Path tmp = Files.createTempDirectory("mcphone-picker-test");

// 大图(400×200)缩到 100×100 以内 → 100×50,保持比例
File big = writePng(tmp.resolve("big.png"), 400, 200);
BufferedImage scaled = ImagePicker.decode(big, 100, 100);
check(scaled != null, "400×200 的 PNG 应能解码");
if (scaled != null) {
check(scaled.getWidth() == 100 && scaled.getHeight() == 50,
"400×200 缩到 100×100 框内应为 100×50,实际 "
+ scaled.getWidth() + "×" + scaled.getHeight());
}

// 小图(20×10)不许被放大 → 仍是 20×10
File small = writePng(tmp.resolve("small.png"), 20, 10);
BufferedImage kept = ImagePicker.decode(small, 160, 160);
check(kept != null, "20×10 的 PNG 应能解码");
if (kept != null) {
check(kept.getWidth() == 20 && kept.getHeight() == 10,
"小图不应被放大,实际 " + kept.getWidth() + "×" + kept.getHeight());
}

// 正方形图缩进正方形框 → 正好填满
File square = writePng(tmp.resolve("square.png"), 300, 300);
BufferedImage squared = ImagePicker.decode(square, 48, 48);
check(squared != null && squared.getWidth() == 48 && squared.getHeight() == 48,
"300×300 缩到 48×48 应为 48×48");

// ---- decode:不是图片时返回 null,而不是抛异常 ----
Path notImage = tmp.resolve("not-an-image.txt");
Files.writeString(notImage, "这不是图片");
check(ImagePicker.decode(notImage.toFile(), 64, 64) == null,
"非图片文件应返回 null");

Path missing = tmp.resolve("does-not-exist.png");
check(ImagePicker.decode(missing.toFile(), 64, 64) == null,
"不存在的文件应返回 null");

// 清理
try (var stream = Files.walk(tmp)) {
stream.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (Exception ignored) { }
});
}

if (!failures.isEmpty()) {
System.out.println("FAIL " + failures.size() + " / " + checks);
for (String f : failures) System.out.println(" - " + f);
System.exit(1);
}
System.out.println("PASS " + checks + " checks");
}

/** 造一张纯色 PNG。颜色不重要,这里只关心尺寸 */
private static File writePng(Path path, int width, int height) throws Exception {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = image.createGraphics();
try {
g.setColor(new Color(0x33, 0x66, 0x99));
g.fillRect(0, 0, width, height);
} finally {
g.dispose();
}
ImageIO.write(image, "png", path.toFile());
return path.toFile();
}
}
134 changes: 134 additions & 0 deletions docs/fabric-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# MCphone Fabric Port (1.21.1)

This directory is a standalone **Fabric 1.21.1** port of
[november521/mcphone](https://github.com/november521/mcphone) (NeoForge 1.21.1),
based on upstream `main` at v1.9.2 (`b394668`).

## What is included

- Full Fabric Loom build: `./gradlew build` produces `build/libs/mcphone-1.9.2-fabric.1.jar`.
- Uses **official Mojang mappings** (`loom.officialMojangMappings()`), so the Java
sources stay byte-for-byte close to the NeoForge upstream — only loader glue differs.
- Zero server/client split source sets: client-only code stays in `/client/` packages,
and a Gradle `verifyDistIsolation` task enforces that dedicated servers never load it.
- SPI app system (`META-INF/services`) works unchanged (Java ServiceLoader).

## What changed vs upstream (summary)

| Area | NeoForge upstream | This Fabric port |
|---|---|---|
| Build | ModDevGradle + neoforge.mods.toml | Fabric Loom + `fabric.mod.json` |
| Entry | `@Mod` MCphone / `@Mod(dist=CLIENT)` MCphoneClient | `ModInitializer` / `ClientModInitializer` |
| Registry | `DeferredRegister` | `Registry.register(...)` at init |
| Networking | `PayloadRegistrar` + `PacketDistributor` | `PayloadTypeRegistry` + `ServerPlayNetworking` / `ClientPlayNetworking` |
| Config | NeoForge `ModConfigSpec` (TOML) | Lightweight JSON under `config/` + a server→client sync packet |
| Player data | NeoForge `AttachmentType` | Fabric `fabric-data-attachment-api-v1` (`AttachmentRegistry`) |
| Key bindings | NeoForge `KeyMapping` + `KeyConflictContext` | Vanilla `KeyMapping` + `KeyBindingHelper` |
| Key modifier | NeoForge `KeyModifier` | Vendored `core/client/KeyModifier.java` (same semantics) |
| Key events | NeoForge `InputEvent.Key` | `KeyboardHandlerMixin` forwarding raw GLFW |
| Custom audio stream | NeoForge `SoundInstance.getStream()` override | `NetSongSound` unique `Sound` path + `SoundEngineMixin` redirect |
| Optional deps | Curios, Waystones+Balm, MCEF, NetMusic, Patchouli, IntegratedDynamics, FTB Quests | Waystones+Balm, MCEF, NetMusic, Patchouli compile in; FTB Quests via reflection; **Curios and Integrated Dynamics omitted** (no 1.21.1 Fabric build) |

## Known differences / honest gaps

1. **Curios (belt slot) unavailable**: Curios has no Fabric build for 1.21.1.
`PhoneLocation` only supports hand/inventory; `PhoneItem.isCarriedBy` only checks inventory.
2. **Integrated Dynamics**: no Fabric build and its NeoForge-specific crash workaround is gone.
3. **Config files changed format** from NeoForge TOML to JSON (`config/mcphone-client.json`,
`<world>/serverconfig/mcphone-server.json`). Keys are unchanged.
4. **In-game NeoForge "Configuration" screen** does not exist on Fabric; all client config is
changed from inside the phone UI, same as NeoForge's gameplay paths.
5. **Back-navigation** upstream v1.9.2 already covers the newer pages; the earlier P0 static
route-table refactor was therefore **not** carried.
6. **Carried P0 value-add**: `core/client/anim/{Easing,Animator,UiMotion}.java` (pure classes)
+ `SettingsList` hover fade using `UiMotion.HOVER_MS` (90 ms). `docs/AnimMotionTest.java`
passes 18 checks.

## Build & verify

```bash
./gradlew build # compile + SPI verify + dist-isolation + remapJar
./gradlew runClient # interactive client (needs a display)
./gradlew runServer # dedicated server smoke (accept EULA in run/eula.txt first)
```

Pure animation unit test:

```bash
javac -d /tmp/animtest src/main/java/com/november/mcphone/core/client/anim/*.java docs/AnimMotionTest.java
java -cp /tmp/animtest AnimMotionTest
```

## Layout notes

- Shared networking classes contain only server (C2S) handlers; all S2C client receivers live
in `/client/` classes (`ClientNetworking`, `*NetworkingClient`) so dedicated servers never
load `ClientPlayNetworking`.
- Mixins (`KeyboardHandlerMixin`, `KeyMappingAccessor`, `SoundEngineMixin`) are client-only,
declared under `client` in `mcphone.mixins.json`.

## 1.10.1-beta.1 同步(2026-09-08,merge 6f4947a)

上游 v1.9.3→v1.10.1-beta.1 已合入(终端 App、副手 HUD、3D 模型、快捷键直达、界面大小页)。
上游 1.10.1 的加载器隔离重构(MCphoneNetwork / PhoneItemData / PhonePlayerData /
ModPresence 门面)把 1.9.2 移植时的接缝补丁收编了:调用面代码逐字取上游,Fabric 差异
只剩门面实现——

- `MCphoneNetwork.registerToClient`:候车室模式(共享阶段登记编解码 + 挂起接收器,
`core/client/ClientNetworking` 在客户端启动时领走注册)。S2C 处理函数回到共享
`*Networking` 类,四个 feature `*NetworkingClient` 类删除。
- `PhonePlayerData`:Fabric 附件直转(getAttachedOrCreate / setAttached);
PHONE_TERMINAL 的客户端同步 NeoForge 靠附件 sync(),这里手工补
`SyncPhoneTerminalPacket`(写入时发 + JOIN 推初值)。
- 终端 App 的 Fabric 真实后端是 **RS 与 Tom's Storage**(AE2/ae2wtlib 在 1.21.1
没有 Fabric 构建,其集成只是 compileOnly 死代码);TR Energy 4.1.0 取自 RS 发布包
内嵌 jar(libs/energy-4.1.0.jar)。
- 断言测试挂进 check(上游做法原样采纳,11 份随 build 跑)。

完整决策与遗留见 `D:\Claude_ds\mcphone-fabric-handoff-1.10.1-beta.1-fabric.1.md`。

## 1.10.1-beta.1-fabric.2:两处修复 + 文件选择器

- **NetMusic 1.2.x 放不出声**:1.2.x 的客户端播放类在 `netmusic.audio`,1.5.x 挪到了
`netmusic.client.audio`;我们按 1.5.2 编译,写死了新包名,于是 1.2.x 上
`MusicPlayManager` 抛 NoClassDefFoundError(只有日志里看得见)。`NetMusicPlayback`
改为反射依次探测两代包名(两代签名一致)。服务端的 `ItemMusicCD` 两代同路径,
所以 CD 认得出、只是不响——这条差异正好解释了症状。
- **Windows 上「打开文件夹」无反应**:`Util.getPlatform().openPath` 走
`rundll32 url.dll,FileProtocolHandler`,对目录静默失败(实测:0 个资源管理器窗口,
而 `explorer.exe` 打开 1 个)。上游同一行代码,非移植引入。新增 `FolderOpener`
在 Windows 走 explorer.exe、其他平台保留 openPath、兜底 AWT Desktop;
壁纸/相册/表情三处统一走它。
- **新功能:文件选择器**(移植 AtomChat):系统原生选择器抬不到 Minecraft 全屏窗口之上,
所以用 `JFileChooser` + 自带 always-on-top `JFrame` + FlatLaf(内嵌 jar-in-jar),
带行内缩略图与右侧实时预览。接在壁纸页的「选择图片」上,
`WallpaperStore.importFile` 复制进目录且不覆盖同名文件。
`docs/ImagePickerTest.java`(19 项)钉住后缀白名单与"不放大"两条静默规则。

### fabric.3:壁纸页按钮失效的真根因

`WallpaperPicker.render()` 先算好标题行与按钮的命中(局部 `hovered`),随后"目录为空"
分支在 `return` 前写了 `this.hoveredIdx = -1`,把本帧的命中结果覆盖掉了。壁纸目录为空时
(玩家还没放图),「打开文件夹」与「选择图片」的点击判定永远不成立——不弹窗、不打日志。
已改为 `this.hoveredIdx = hovered`。

注意 `FolderOpener` 本身没问题:实测 Java 进程启动 `explorer.exe` 会让资源管理器窗口数 +1,
之前的验证只看了退出码(explorer 对已存在的窗口会返回 1),那是盲区。

同类排查:`ChatMediaPicker` 的空分支只清 `hoveredIdx`、不动 `openFolderHovered`;
`Gallery` 在空分支之前就完成了 hover 判定——两者都没有这个 bug,只有壁纸页有。

## PR 已提交(2026-09-08)

- **PR**:https://github.com/november521/mcphone/pull/2
- **分支**:`1.21.1-Fabric`(fork `E33EPUS/mcphone`),base `main`
- **规模**:+3044/-2474,92 个文件
- **提交邮箱**:`1683427466@qq.com`(已验证关联 E33EPUS 账号,能进贡献表)
- **重建说明**:PR 分支是用 `commit-tree` 从上游 `main` 线性重建的(保留上游 SHA),
只含我们的 10 个提交;`git filter-branch` 会级联重写上游 20 个提交的 SHA,不要用它做这个仓库的作者修正。
- **CI**:`action_required` —— 首次贡献者需维护者批准后才跑,不是失败。
批准后会触发 `guard-version`(上游禁止人手改 `mod_version`);PR 正文已说明移植分支
需要独立版本号(同 `1.20.1-forge` 的 `0.12.0-beta.1`),建议合并后由 CI 接管。
- **正文要点**:移植工作 / 修的三个上游 bug(Windows 打开文件夹、空目录按钮失效、
NetMusic 1.2.x 播放)/ 新增图片选择器 / 联动 mod 差异(Curios 与 AE2 在 1.21.1 Fabric
无构建)/ FlatLaf Apache-2.0 声明 / 分支命名。
Loading
Loading