EasyTeleport
[AI-generated] Vanilla-styled world-map buttons: teleport back to your bed, or click anywhere on the map to teleport there. Hold Ctrl to drag the buttons anywhere; position is saved. Safe landing: no burying, no fall damage, water is fine. Client-only.EasyTeleport
大地图上的传送按钮。 打开地图(M),画面左上角出现两个按钮 —— 外观默认与游戏原生控件同化(运行时继承原版面板底图),不会显得像外挂的贴纸。
| 按钮 | 做什么 |
|---|---|
| 传送回床 | 传送到你记录过的床(在床上睡过觉设下的出生点)。 |
| 任意传送 | 按一下 → 按钮变绿进入待选 → 再点地图上任意位置,就传送到那里。 再按一次按钮可取消。 |
挪位置:按住
Ctrl用左键拖拽按钮组,可以放到画面任意位置,松手自动保存。 按住 Ctrl 时按钮文字会变成淡黄色,表示"现在可以拖"。
传送落地是安全的:陆地自动落到地面上方、点水面会浮在水上游泳、永远不会摔死。 传送在任何状态下都能用 —— 游泳、奔跑、跳跃、在船上、在水里都行。
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 未命中时执行第 ④ 步校正。
把它关掉,跨半张地图传送就可能"人到了但地面还没来",或者停在未校正的高度上。
位置与外观
位置:按住 Ctrl 拖拽就行
按住 Ctrl + 左键拖动按钮组 → 放到画面任意位置 → 松手自动保存到
easyteleport.client.only.cfg 的 LeftAnchor / TopAnchor,不必手动改配置。
- 按住
Ctrl时按钮文字变淡黄,表示"现在可以拖"。 - 拖拽跟手:从按钮组的哪一点起拖,那一点就一直跟着鼠标(不会跳到鼠标中心)。
- 拖到屏幕外面会被夹在边界内,不会把按钮甩丢。
- 拖拽期间、以及松手后 0.25 秒内按钮功能被抑制 —— 不会"想挪位置却把自己传走了"。
- 不想要这个功能就把
AllowDrag设为false。
坐标用比例锚点(画面宽的 LeftAnchor、高的 TopAnchor),默认 0.084 / 0.392
= 1920×1080 下大约 (162, 423)。用比例而不是像素边距,是为了换分辨率时位置不会跑偏。
外观:继承游戏原生 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 底图(见下节) | |
AllowDrag |
true | 允许按住 Ctrl 拖拽调整位置 | |
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 下,点击传不进地图的输入处理。
Ctrl 拖拽是怎么实现的(5 个必须处理的点)
-
拖拽事件不带修饰键。Unity 的
IDragHandler只告诉你"在拖",不告诉你按没按 Ctrl ⇒ "按住 Ctrl 才允许拖"必须在回调里自己Input.GetKey(KeyCode.LeftControl)判断。 -
面板得先成为射线目标。面板本身是个空 GameObject,收不到任何事件 ⇒ 给它铺一张全透明 Image(
color = (0,0,0,0)、raycastTarget = true)。 uGUI 的Graphic.Raycast默认不看 alpha(除非设了alphaHitTestMinimumThreshold), 所以 alpha=0 照样能被命中 —— 这就是"透明挡板"的惯用做法。 因为 uGUI 射线按深度排序、子节点优先,按钮自己的点击不受影响。 -
事件会沿父级链冒泡。uGUI 执行拖拽事件时用的是
ExecuteEvents.GetEventHandler<IDragHandler>(target),它会从命中对象往上找第一个 实现了该接口的对象 ⇒ 在按钮上按下也能拖,事件最终落到面板的EventTrigger上, 不用给每个按钮单独挂。 -
必须抑制 Button 的 onClick。uGUI 的
Button走的是OnPointerClick—— 只要按下和抬起都落在按钮上就触发,中间拖了多远它不管 ⇒ 拖完松手会顺带把按钮点一次。 这里用"拖拽中 + 松手后 0.25 秒"的时间窗口来抑制,而不是一个布尔标志, 因为EventSystem与插件Update的先后顺序在 Unity 里没有保证, 靠标志会踩到"标志已清、onClick 还没跑"的时序问题。 -
拖拽过程中绝不写配置。BepInEx 的
ConfigEntry.Value赋值会立刻存盘, 拖拽时每帧写一次会把 cfg 写爆 ⇒ 拖拽中只改内存里的liveAnchorX/Y和RectTransform, 到OnEndDrag才写回一次。
坐标换算(屏幕坐标 → 锚点比例):
local = ScreenPointToLocalPointInRectangle(parentRt, mousePos, cam, out local)
ratio = (local - parentRect.rect.min) / parentRect.rect.size
rect.min 是矩形左下角相对 pivot 的位置,减掉它就把"相对 pivot 的坐标"转成"相对左下角的坐标",
再除以尺寸就是 0~1 的比例。
⚠️ ScreenPointToLocalPointInRectangle 的 camera 参数:Screen Space - Overlay 的 Canvas 要传 null,
Camera 模式才传 worldCamera —— 传错会得到全零或离谱的坐标。
⚠️ Canvas / RenderMode / RectTransformUtility 三个类型都在 UnityEngine.UIModule.dll
里(不是 UnityEngine.UI.dll),csproj 少引会报 CS1069/CS0103。
跟手性的充要条件:按下时记录"鼠标相对面板左上角的偏移" grab,
拖动时令 锚点 = 鼠标比例 - grab ⇒ 该偏移在整个拖拽过程中恒定,面板就不会往鼠标中心跳。
离线验证见 scripts/verify_drag.py(7 个场景:跟手性、上下界 clamp、100 步无漂移、配置往返)。
已知限制
- 只能在已探索的地图区域点选 —— 大地图本身只显示探索过的范围,点到未探索区域等于盲传 (游戏仍会正确落地,但你不知道那是哪)。
- 传送到床需要角色在床上睡过至少一次。
- 床的落点是上次睡觉时记录的那个位置;之后把床拆了 / 挪了,原版记录不会自动更新(要重新睡一觉)。
- 待选状态在关闭地图时会自动取消;退出游戏时会连同按钮状态一起重置(不落盘)。
- 极少数带反作弊的多人服务器可能拒绝客户端自行改位置;自用 / 自建服无此问题。
安装
r2modman「Install from file」选 EasyTeleport-1.1.0.zip,
或把 EasyTeleport.dll 丢进 BepInEx/plugins/。
前置:BepInExPack_Valheim 5.4.2350。
版本
- 1.3.0(2026-09-18):按钮支持按住 Ctrl 拖拽调整位置,松手自动写回配置(新增
AllowDrag)。 - 1.2.0(2026-09-18):按钮从左下角移到画面左上角(比例锚点定位),
外观改为继承游戏原生 UI 底图(
UseGameStyle),尺寸与字号按原版控件收窄。 - 1.1.0(2026-09-18):把「返回出发地」换成**「任意传送」**(地图上点哪去哪),
并接入原版的落地校正流程(水面高度 +
FindFloor/GetSolidHeight),永不埋地、不摔死。 - 1.0.0(2026-09-18):首个版本(回床 + 返回出发地)。


