
EasyTeleport
[AI-generated] Top-left world-map buttons styled after the vanilla UI: teleport back to your recorded bed, or click anywhere on the map to teleport there. Landing uses the vanilla flow - never buried, no fall damage, water is fine. Client-only.EasyTeleport
大地图上的传送按钮。 打开地图(M),画面左上角出现两个按钮 —— 外观默认与游戏原生控件同化(运行时继承原版面板底图),不会显得像外挂的贴纸。
| 按钮 | 做什么 |
|---|---|
| 传送回床 | 传送到你记录过的床(在床上睡过觉设下的出生点)。 |
| 任意传送 | 按一下 → 按钮变绿进入待选 → 再点地图上任意位置,就传送到那里。 再按一次按钮可取消。 |
传送落地是安全的:陆地自动落到地面上方、点水面会浮在水上游泳、永远不会摔死。 传送在任何状态下都能用 —— 游泳、奔跑、跳跃、在船上、在水里都行。
AI 生成声明:本模组由 AI 智能体(WorkBuddy)在用户的明确需求与逐项验收下编写; 需求描述、交互设计与发布决定均由用户主导。
用法
传送回床
- 先在床边上按 E 睡一觉(这一步会在角色档案里记下出生点;不睡就点按钮会提示"还没有记录过床")。
- 按
M打开大地图 → 右下角点 「传送回床」 → 黑屏淡入淡出 → 人已经在床边。
任意传送(地图上点哪去哪)
- 按
M打开大地图。 - 点 「任意传送」 —— 按钮变成绿色并显示「取消 · 点地图选点」,同时左上角弹一条提示。
- 在地图上点任意位置 → 直接传送过去。
- 点偏了?再点一次地图就行(待选状态会保留到你成功传送或主动取消)。
- 不想传了?再点一次按钮取消,或者按
M关掉地图(也会自动取消)。
⚠️ 游戏对传送有 2 秒冷却(原版传送门同款)。刚传送完立刻再传会被拒绝, 屏幕上会提示"传送被游戏拒绝 —— 通常是因为刚传送过"。此时待选状态不会丢,等一两秒直接再点地图即可。
落点是怎么保证安全的
这一块没有自己写地形算法 —— 而是利用了原版 Player.UpdateTeleport 里本就有的一套落地校正流程,
再喂给它一个"在什么地形上都成立"的高度值。所有结论都来自对 assembly_valheim.dll 的反汇编。
原版的流程(Player.UpdateTeleport,distantTeleport = true 时)
① player.position = 我们给的 (x, Y, z) ← 这里 Y 是我们唯一能控制的
② 等 ZNetScene.IsAreaReady(target) ← 远处传送就是在这里等区域加载
③ if (ZoneSystem.FindFloor(target, out floor)) ← Raycast(target + up×1, down, 1000 m)
return; ← ★ 命中就结束,玩家高度 = 我们给的 Y(不再校正)
④ else(没命中)
player.y = ZoneSystem.GetSolidHeight(target) + 0.5
← Raycast(target + up×1000, down, 2000 m)
同时,在搬运玩家的那一刻原版会执行 Character.m_maxAirAltitude = transform.position.y;
摔落伤害 = 当前高度 − 历史最高滞空高度,所以传送落地时该差值恒为 0 ⇒ 不会摔伤。
我们取的 Y = ZoneSystem.c_WaterLevel
ZoneSystem.c_WaterLevel 是一个 public const,从 DLL 的 Constant 表里读出来是 30.0f(Valheim 海平面)。
取这个值,两条分支在地形上的表现是:
| 地形 | ③ FindFloor | 最终落点 |
|---|---|---|
| 陆地(地面高于水面) | 射线从水面上方往下打够不到地面 → 未命中 | 走 ④ → 精确落到地面 +0.5 米 |
| 海洋 / 湖泊(水底低于水面) | 射线命中水底 → 命中 | 高度保持水面 → 玩家浮在水面游泳 |
离线场景矩阵验证(scripts/verify_landing.py,12 个场景)全部通过:
高山(Deep North 峰) 地面 420.0 → GetSolidHeight 校正 → 落点 420.5 OK 落在地面 +0.5 米
山地 地面 180.0 → GetSolidHeight 校正 → 落点 180.5 OK 落在地面 +0.5 米
平原 / 草原 地面 50.0 → GetSolidHeight 校正 → 落点 50.5 OK 落在地面 +0.5 米
略高于水面的海岸 地面 32.5 → GetSolidHeight 校正 → 落点 33.0 OK 落在地面 +0.5 米
刚好在水面 地面 30.0 → FindFloor 命中 → 落点 30.0 OK
浅滩(水下 5 米) 地面 25.0 → FindFloor 命中 → 落点 30.0 OK 落在水面(可游泳)
沼泽低地(水下 12 米) 地面 18.0 → FindFloor 命中 → 落点 30.0 OK 落在水面(可游泳)
浅湖底(水下 25 米) 地面 5.0 → FindFloor 命中 → 落点 30.0 OK 落在水面(可游泳)
深海海床 地面 -55.0 → FindFloor 命中 → 落点 30.0 OK 落在水面(可游泳)
最深海床 地面 -95.0 → FindFloor 命中 → 落点 30.0 OK 落在水面(可游泳)
世界最高峰(极端假设) 地面 900.0 → GetSolidHeight 校正 → 落点 900.5 OK 落在地面 +0.5 米
超出校正范围的极端高 地面1100.0 → 两条都失败 → 落点1030.0 FAIL(Valheim 无此地形)
⚠️ 这也解释了为什么 UseDistantTeleport 必须保持 true:
只有远距离传送模式才会在 FindFloor 未命中时执行第 ④ 步校正。
把它关掉,跨半张地图传送就可能"人到了但地面还没来",或者停在未校正的高度上。
位置与外观
位置:画面左上角,用比例锚点
按钮组的左上角钉在画面的 (LeftAnchor, TopAnchor) 处,默认 0.084 / 0.392,
也就是 1920×1080 下大约第 (162, 423) 像素 —— 位于地图上方那片空白区域,
不会压到地图本身的内容、图例文字、左边和右边的两列图钉图标。
用比例而不是像素边距,是为了换分辨率时位置不会跑偏(像素边距在 4K 下会显得贴边)。
嫌位置不合适就改这两个值:TopAnchor 越小越靠上,LeftAnchor 越小越靠左。
外观:继承游戏原生 UI
UseGameStyle = true(默认)时,模组会在运行时从大地图已有的控件里翻一个"面板/按钮底图" sprite 过来复用:
| 优先级 | 来源 |
|---|---|
| ① | 名字里带 button / panel / background / _bg / frame 的 Image |
| ② | 有九宫格边界(Sprite.border != 0)的 Image —— 这种才是可拉伸的面板底图 |
| ③ | 任意带图的 Image |
| ④ | 都没有 → 退回纯色半透明深灰 |
拿到之后就按九宫格(Image.Type.Sliced)铺满按钮,所以圆角、渐变、描边都跟原版控件一致。
文字用米白色 + 1 像素黑描边,和原版 UI 的文字处理方式一致。
⚠️ 开着 Verbose 可以在日志里看到底图具体取自哪个控件(按钮底图取自 … sprite=… 九宫格=True)。
万一某次更新后取到的图不合适(比如拿到一个小图标铺开变形),把 UseGameStyle 改成 false
即可退回干净的纯色底 —— 这时按钮的长相由 ArmedColor 等配置控制。
为什么中文字体不能跟着游戏走
游戏内置字体 AveriaSansLibre 不含 CJK 字形,用它画中文会是一排方块。
所以字体默认走系统字体(微软雅黑),并从系统字体清单里按名字找。
观感上和原版 UI 已经很接近(都是无衬线体),但确实不是同一个字体 —— 这是中文环境下的必然取舍。
配置
BepInEx/config/easyteleport.client.only.cfg
| 分区 | 键 | 默认 | 说明 |
|---|---|---|---|
1 - General |
Enabled |
true | 总开关 |
2 - Buttons |
BackToBedText |
传送回床 | 按钮文字 |
AnywhereText |
任意传送 | 按钮平时显示的文字 | |
ArmedText |
取消 · 点地图选点 | 待选状态显示的文字 | |
ArmedColor |
深绿 | 待选状态下按钮的绿色调(UseGameStyle = false 时作为底色) |
|
UseGameStyle |
true | 按钮外观继承游戏原生 UI 底图(见下节) | |
LeftAnchor |
0.084 | 按钮组左上角的横向比例位置(0=最左,1=最右) | |
TopAnchor |
0.392 | 按钮组左上角的纵向比例位置(0=最上,1=最下) | |
ButtonWidth / ButtonHeight |
190 / 40 | 按钮尺寸(像素) | |
Spacing |
6 | 两按钮间距 | |
FontSize |
16 | 字号 | |
FontName |
Microsoft YaHei UI |
字体(见下) | |
3 - Teleport |
CloseMapOnTeleport |
true | 传送后自动关掉大地图(回到小地图) |
UseDistantTeleport |
true | 远距离传送 —— 落地安全的前提,别关 | |
ShowMessages |
true | 屏幕左上角提示 | |
4 - Debug |
Verbose |
false | 详细日志 |
关于字体:游戏内置字体(AveriaSansLibre)不含中文字形,直接画中文会变方块,
所以默认走系统字体(微软雅黑)。查找顺序:
配置里的名字 → 一组常见中文字体 → 游戏内置字体 → 已加载的任意字体。
想让按钮跟游戏 UI 完全统一(但只能显示英文),把 FontName 改成 AveriaSansLibre,
同时把按钮文字改成英文即可。
⚠️ 改完配置必须重启游戏(BepInEx 只在插件 Awake 里读一次)。
装了 BepInEx.ConfigurationManager 的话可以按 F1 直接改,那种是即时生效的。
兼容性
- 纯客户端:无 Jotunn、无 ServerSync、无
NetworkCompatibilityAttribute(= NotEnforced)。 - 联机:主机和其他玩家都不需要装;没装的人完全不受影响。
本模组只调用游戏自己的接口(
Player.TeleportTo),不改任何主机权威数据。 - 与地图类模组(如 BetterMap)不冲突 —— 按钮挂在游戏自己的大地图根节点下,
只占屏幕右下角一块;位置撞了改
MarginBottom/MarginRight即可。 - 传送门附近也可以用,本模组与本机已装的
QuickTeleport无交互。
实现要点(全部来自 IL 实证,2026-09-18)
床的坐标从哪来
PlayerProfile.HaveCustomSpawnPoint() → bool (public)
PlayerProfile.GetCustomSpawnPoint() → Vector3 (public)
两者的 IL 都只有三行:ZNet.instance.GetWorldUID() → GetWorldData(uid) →
读 WorldPlayerData.m_haveCustomSpawnPoint / m_spawnPoint。
⇒ 按世界 UID 分开存,换世界不会串坐标。
原版 Minimap.UpdateProfilePins(地图上那个"床"图标)用的也是这两个方法。
⚠️ 没有 Player.m_spawnPoint 这种字段(Player 里跟 spawn 有关的只有 m_spawnEffects)。
地图点击 → 世界坐标
原版 Minimap.OnMapLeftClick(public)里的两步:
ZInput.pointerPosition → Vector3(屏幕坐标)
Minimap.ScreenToWorldPoint(屏幕坐标) → Vector3(世界坐标,Y 恒为 0,只有 XZ 有效)
ScreenToWorldPoint 是 private —— 本模组用反射调用它(和原版走完全相同的换算:
RectTransformUtility.ScreenPointToLocalPointInRectangle → Rect.PointToNormalized →
按 RawImage.uvRect 映射 → MapPointToWorld)。
ZInput 定义在 assembly_utils.dll 里(不是 assembly_valheim),
用 AccessTools.TypeByName("ZInput") 反射取 pointerPosition,拿不到时回退 Input.mousePosition。
怎么"吞掉"那次点击
Minimap.OnMapLeftClick 是 public,直接挂 Prefix:
[HarmonyPatch(typeof(Minimap), "OnMapLeftClick")]
internal static class MinimapLeftClickPatch
{
[HarmonyPrefix]
private static bool Prefix() => MapTeleportButtons.HandleMapLeftClick();
}
返回 false 时原版不会继续走"选中 / 取消选中点到的图钉"那段逻辑。
顺带一提:原版放图钉是中键(Minimap.OnMapMiddleClick),所以待选状态下按中键不受影响。
传送走哪条路
Player.TeleportTo(Vector3 pos, Quaternion rot, bool distantTeleport) (public,返回 bool)
IL 分两支:
if (!m_nview.IsOwner()) {
m_nview.InvokeRPC("RPC_TeleportTo", pos, rot, distantTeleport);
return false; // ← RPC 已发出去了,false 不代表失败
}
if (IsTeleporting()) return false;
if (m_teleportCooldown < 2f) return false; // ← 2 秒冷却在这里
m_teleporting = true;
m_teleportFromPos = transform.position;
m_teleportTargetPos = pos;
return true;
⇒ 本模组的返回值处理:自己是主人时 true = 真的开始传送了;
不是主人时忽略返回值。玩家自己的角色对象正常就是自己拥有的,这条只是兜底。
按钮挂在哪
Minimap.m_largeRoot : GameObject (public)
Minimap.IsOpen() 的 IL = s_instance != null && (m_largeRoot.activeSelf || m_hiddenFrames <= 2)
挂在 m_largeRoot 下的好处:地图关掉 → 根节点失活 → 按钮自动隐藏,
不用自己监听地图开关。
⚠️ 三个已经踩过的坑:
MapMode是Minimap的嵌套类型(和PinData/PinType一样), 写裸的MapMode会 CS0103「当前上下文中不存在名称 MapMode」—— 看着像"类型不存在",其实是嵌套类型没加外部类名。- 不用 IMGUI 画按钮:大地图是全屏 uGUI Canvas,
OnGUI画的东西会被整个盖住 (看不见也点不到)。所以走原生 uGUI。 Minimap.m_nameInput的类型GuiInputField继承TMP_InputField(TextMeshPro), 在gui_framework.dll里,想从它拿字体会连锁引gui_framework+Unity.TextMeshPro, 而且它没有.textComponent(那是 uGUI InputField 的)—— 不值得,改用系统字体。
为什么按钮不会误触发地图操作
原版全程序集里 EventSystem.IsPointerOverGameObject 只有 FejdStartup.UpdateCharacterRotation
一个调用者 —— 地图自己不做"鼠标是否落在 UI 上"的判断。
所以防误触靠的是层级隔离:地图的可点击区域(UIInputHandler)挂在 m_mapLarge 上,
按钮挂在它的兄弟节点 m_largeRoot 下,点击传不进地图的输入处理。
已知限制
- 只能在已探索的地图区域点选 —— 大地图本身只显示探索过的范围,点到未探索区域等于盲传 (游戏仍会正确落地,但你不知道那是哪)。
- 传送到床需要角色在床上睡过至少一次。
- 床的落点是上次睡觉时记录的那个位置;之后把床拆了 / 挪了,原版记录不会自动更新(要重新睡一觉)。
- 待选状态在关闭地图时会自动取消;退出游戏时会连同按钮状态一起重置(不落盘)。
- 极少数带反作弊的多人服务器可能拒绝客户端自行改位置;自用 / 自建服无此问题。
安装
r2modman「Install from file」选 EasyTeleport-1.1.0.zip,
或把 EasyTeleport.dll 丢进 BepInEx/plugins/。
前置:BepInExPack_Valheim 5.4.2350。
版本
- 1.2.0(2026-09-18):按钮从左下角移到画面左上角(比例锚点定位,可配置),
外观改为继承游戏原生 UI 底图(
UseGameStyle),尺寸与字号按原版控件收窄。 - 1.1.0(2026-09-18):把「返回出发地」换成**「任意传送」**(地图上点哪去哪),
并接入原版的落地校正流程(水面高度 +
FindFloor/GetSolidHeight),永不埋地、不摔死。 - 1.0.0(2026-09-18):首个版本(回床 + 返回出发地)。

