EasyWeapon
[AI-generated] Client-only weapon tweaks: faster reload (crossbows/grappling hook), reloads that survive run/jump/dodge, and a tuned chain lightning (weapon proc chance + ChainLightningRed effect parameters).EasyWeapon
AI 生成声明:本模组由 AI 智能体(WorkBuddy)在用户的明确需求与逐项验收下编写; 功能设计、真机测试与发布决定均由用户主导。
武器手感。 纯客户端模组。
功能(3 项)
| # | 功能 | 默认 | 配置节 |
|---|---|---|---|
| 1 | 装填时间倍率 —— 弩 / 钩索 | 0.5(= 装填时间减半) | 1 - Reload Speed |
| 2 | 装填不被打断 —— 奔跑 / 跳跃 / 闪避翻滚时不清掉装填 | 三项都开 | 2 - Uninterruptible Reload |
| 3 | 连锁闪电强化 —— 武器触发率统一 + ChainLightningRed 特效参数 |
见 3 - Chain Lightning 节 |
3 - Chain Lightning |
这两个需求在游戏里其实是同一套机制
反汇编确认(游戏 1.0.14,assembly_valheim.dll):
| 物品 | 本地化键 | m_requiresReload |
m_reloadTime |
装填动画 | 技能 |
|---|---|---|---|---|---|
| 钩索 | $item_graplinghook ⚠️ 游戏里就少一个 p |
true | 2.0 s | reload_crossbow |
无(不吃技能) |
| 弩(Arbalest / Ripper×4 / Gold×3 及其变体,共 8 把) | $item_crossbow_* |
true | 3.5 s | reload_crossbow |
Crossbows |
| 闪电法杖(默认不动) | $item_staff_lightning |
true | 1.9 s | recharge_lightningstaff |
ElementalMagic |
两者共用 ItemData.GetWeaponLoadingTime():
if (!m_requiresReload) return 1f; ← 该分支必须绕开
float skill = Player.m_localPlayer.GetSkillFactor(m_skillType);
return Mathf.Lerp(m_reloadTime, m_reloadTime * 0.5f, skill); ← 弩技能满级本来就已减半
所以「弩减半」和「钩索减半」是一个挂载点,不需要写两套。
配置
BepInEx/config/easyweapon.client.only.cfg
1 - Reload Speed
| 键 | 默认 | 说明 |
|---|---|---|
Enabled |
true | 总开关 |
ReloadTimeMultiplier |
0.5 | 时间倍率:0.5 = 装填时间减半(更快)。⚠️ 填 2 是变慢 |
Crossbows |
true | 弩 |
GrapplingHook |
true | 钩索 |
Staves |
false | 法杖(目前走装填的是闪电法杖,充能 1.9 s) |
OtherReloadWeapons |
false | 其它「需要装填」的武器(游戏更新新增、本模组还不认识的)。宁可不动,也不误伤 |
Verbose |
true | 首次遇到某件武器时记一条日志(含前后耗时),同一件只记一次 |
2 - Uninterruptible Reload
| 键 | 默认 | 说明 |
|---|---|---|
Enabled |
true | 总开关 |
OnRun |
true | 奔跑时不打断装填 |
OnJump |
true | 跳跃时不打断装填 |
OnDodge |
true | 闪避翻滚时不打断装填 |
Verbose |
true | 每次新的装填动作被保护时记一条(同一个动作只记一次,不刷屏) |
攻击仍然会打断装填 —— 那是原版设计,本模组不动。
3 - Chain Lightning
| 键 | 默认 | 说明 |
|---|---|---|
Enabled |
true | 总开关(武器侧 + 特效侧一起管) |
WeaponProcChance |
0.66 | 所有「命中时生成连锁预制体」的攻击槽,主/副攻击统一用这个几率。原版主攻击 0.25 / 副攻击 0.20 |
RedStartChance |
1.0 | ChainLightningRed 的起始判定(原版 0.9)。⚠️ 判定是每帧一次(存活期间),不是"每次挥击的触发率" |
RedChancePerTarget |
0.66 | 每个候选目标被链上的几率(原版 0.3),各目标独立判定 |
RedMinTargets |
4 | 一次连锁最少链几个目标(原版 2) |
RedMaxTargets |
6 | 一次连锁最多链几个目标(原版 4),实际个数在 Min~Max 之间随机 |
RedFalloff |
0.8 | 逐跳衰减(原版 0.6):下一跳几率 = 本跳几率 × 该值。填 1 = 不衰减 |
RedStartDelay |
0.18 | 特效生成后多久开始判定(秒) |
RedRadius |
12 | 连锁判定半径(米),原版 8 |
RedTtl |
-1(= 不改) | 特效存活时长。-1 = 保持游戏默认(3 秒),本模组完全不碰这个字段;填正数(0.1~30)才覆盖。⚠️ 它同时是连锁的判定窗口,填小了会明显削弱 |
RedLightningDamage |
90 | 每一跳的闪电伤害,原版 75 |
Verbose |
true | 打印每条改动的前后数值(便于自证生效) |
⚠️ 本模组只改
ChainLightningRed—— 那是「雷血 / BloodLightning」那一族武器 ($item_*_gold_bloodlightning)用的连锁特效。 另一族ChainLightning(闪电附魔$item_*_lightning)的参数不动, 但那些武器的触发几率同样受WeaponProcChance影响。
⚠️ 改完必须重启游戏(BepInEx 只在插件 Awake 读一次配置)。
实现要点
功能 1:装填倍率
- 挂载点:
ItemDrop.ItemData.GetWeaponLoadingTime()的 postfix,返回值 × 倍率。 - 这个方法是全程序集唯一调用点为
Player.QueueReloadAction()的纯计算函数 (asm_member_refs.py实测),返回值写进MinorActionData.m_duration(进度条文案$hud_reloading)。⇒ 影响面被天然圈死: 只影响本地玩家按下的那一次装填,不碰 NPC 的Attack.Start路径,也不改任何 prefab 数据。 - ⚠️ 必须绕开
!m_requiresReload的分支:那条路径原版返回常量1f, 对它乘倍率会破坏不装填武器的时长语义。代码里在分类之前就先判m_requiresReload。 - 幂等:纯计算、每次由原始值重算,无累计副作用 —— 所以不需要「记账 + 原值」那套(对比:改写 prefab 数据的模组必须记账)。
- 分类判据用本地化键(
SharedData.m_name),不是显示名(会随语言变)、也不是类名 (游戏里没有 Crossbow / GrapplingHook 类型,行为全挂在Attack+ItemDrop上)。 归一化:去$item_前缀 → 去_/空格/-/.→ 转小写。 弩与法杖用StartsWith(变体多),钩索用精确等值(避免误伤名字相近的物品)。
功能 2:装填不被打断
原版有四个地方把整个动作队列清空(asm_member_refs.py Humanoid::ClearActionQueue 给出):
| 来源 | 位置 | 本模组 |
|---|---|---|
| 奔跑 | Player.CheckRun(每帧) |
拦 |
| 跳跃 | Player.OnJump(第一条指令就是 callvirt ClearActionQueue) |
拦 |
| 闪避翻滚 | Player.UpdateDodge(偏移 0x0074) |
拦 |
| 攻击 | Humanoid.StartAttack |
不拦(保持原版) |
- 拦截点是
Player.ClearActionQueue(override,真正执行的那份),不是Humanoid.ClearActionQueue(基类是空实现,只有一句ret;四个调用点写的都是callvirt Humanoid::ClearActionQueue,虚拟分发到 Player 的重写)。 Player.ClearActionQueue的实现只有三条指令:ldarg.0 / ldfld m_actionQueue / callvirt List::Clear / ret, 没有任何其它副作用 —— 所以「只清空非装填动作」与「整体清空」在其它方面完全等价。- 队列里的动作类型:
Player.MinorActionData.ActionType=Equip(0)/Unequip(1)/Reload(2)。 本模组遍历m_actionQueue,移除除Reload外的所有条目,装填那条原地保留 ⇒ 装填进度(m_time)不丢、进度条不归零。 m_actionQueue是私有字段,用AccessTools.FieldRefAccess反射取(解析失败会在 启动自检里打LogError,不会静默失效)。- 来源判定用深度计数而非 bool:bool 一旦因异常卡住(prefix 跑了、postfix 没跑)
就会永久误保护;计数配合
> 0夹紧可以自愈。 - 标了
[HarmonyPriority(Priority.Low)](数值越小越后执行)—— 让别的模组的判定先做。
功能 3:连锁闪电强化
游戏里「闪电伤害」和「连锁闪电」是两件事 —— 不是所有带闪电伤害的武器都会连锁。 反汇编 + prefab 数据核对(游戏 1.0.14)得到的完整链路:
武器命中
└─ Attack.SpawnOnHit(): if (m_spawnOnHitChance > 0 && m_spawnOnHit != null)
if (Random.Range(0f,1f) < m_spawnOnHitChance) ← 功能 3 改这里
在命中点实例化 m_spawnOnHit(连锁特效预制体)
└─ 特效上的 Aoe.CustomFixedUpdate(): ← 功能 3 也改这里
延迟 m_chainStartDelay 后,每帧判一次:Random.value < m_chainStartChance
→ 找目标,个数 = Random.Range(m_chainMinTargets, m_chainMaxTargets + 1)
→ 每个目标:Random.value < m_chainChancePerTarget
→ 生成下一个连锁对象,并把 m_chainChance × m_chainStartChanceFalloff 传下去
- 武器侧挂载点:
ObjectDB.Awake的 postfix。遍历ObjectDB.m_items→ItemDrop.m_itemData.m_shared的m_attack/m_secondaryAttack。 ⚠️ 判据用与游戏完全相同的两个条件(m_spawnOnHitChance > 0且m_spawnOnHit != null) 再加一道「目标预制体名以ChainLightning开头」的过滤: 前者保证只改"原版真的会触发"的槽(否则会把"不触发"改成"触发"), 后者保证不误伤将来别的 hit-spawn 特效。不写死武器名单,游戏新增武器自动跟上。 当前版本命中 19 件武器(7 件闪电附魔 + 12 件雷血附魔)× 主/副槽。 - 特效侧挂载点:
ZNetScene.Awake的 postfix。ZNetScene.Awake开头就填m_namedPrefabs(GetStableHashCode做键),所以 postfix 时GetPrefab("ChainLightningRed")可用; 而 ZNetScene 没有"运行时注册预制体"的方法 ⇒ 名字表在 Awake 之后固定不变, 要改就得赶在实例化之前 —— 这个时机是唯一正确的窗口。 取不到时再遍历m_prefabs兜底(防止注册名与 GameObject 名不一致)。 - 幂等:全部是绝对值赋值(不是"原值 × 倍率")⇒ 重复执行结果不变。 对比功能 1 的纯计算写法,这里额外加了"已应用"标记,只为避免场景重建时重复刷日志。
- 影响面:只改 prefab 静态数据,不碰任何运行时逻辑、不碰存档、不加网络同步。 连锁的每一跳都是同一个预制体实例化出来的,所以自然继承以上全部参数。
- ⚠️ Host 端权威性:伤害由攻击者本地计算后同步,所以装了本模组的一方打出的连锁伤害确实变高; 但别人屏幕上看到的连锁范围/存活时长仍按他们自己的 prefab 数据。
与其它模组的关系
本模组不依赖任何其它模组 —— 拦截点直接落在 Player.ClearActionQueue,不假手上游。
历史上 EasyGear 曾有一项
「跑步时也能切换武器 / 工具」会拦同一个清空,两者当时可共存(谁先返回 false 都能保住队列);
该功能已在 EasyGear 1.0.5 整体剔除,所以这段共存逻辑现在只在将来出现同类模组时才有意义 ——
[HarmonyPriority(Priority.Low)] 就是为此保留的(数值越小越后执行,让别人的判定先做)。
启动自检里同时打印 Humanoid.ClearActionQueue(空实现)与 Player.ClearActionQueue
(实际执行的重写)的 patch 数,两条一对比就能看出补丁是否落在真正会被执行的方法上
(诊断用,不是判据 —— EasyGear 1.0.2/1.0.3 正是栽在这一点上,详见其 CHANGELOG)。
联机
纯客户端。 主机、其他玩家都不需要装。
不声明 NetworkCompatibilityAttribute(= NotEnforced,不参与连接校验),
不引用 Jotunn,不内嵌 ServerSync / CCS。
⚠️ 功能 3 改的是 prefab 静态数据 ⇒ 效果只体现在装了本模组的那一端: 连锁伤害由攻击者本地计算后同步(你打出的伤害确实更高), 但别人屏幕上看到的连锁范围 / 存活时长仍按他们自己的 prefab 数据。 三项功能都互不依赖,也可以只开其中一项。
真机验证怎么看日志
启动后 BepInEx/LogOutput.log 里应有:
[Info : EasyWeapon] ---- 补丁挂载自检 ----
[Info : EasyWeapon] ItemData.GetWeaponLoadingTime() prefix=0 postfix=1 OK
[Info : EasyWeapon] Player.CheckRun() prefix=1 postfix=1 OK
[Info : EasyWeapon] Player.OnJump() prefix=1 postfix=1 OK
[Info : EasyWeapon] Player.UpdateDodge() prefix=1 postfix=1 OK
[Info : EasyWeapon] Player.ClearActionQueue() prefix=1 postfix=0 OK
[Info : EasyWeapon] (若这一行下面还有「来自其它模组的 prefix:xxx」,说明那个模组的补丁
也挂在了**实际执行的重写**上;一条都没有则表示它的补丁挂在
Humanoid 的空实现上、运行时不会被执行 —— Harmony 不会把
「对虚方法的 patch」自动展开到派生重写。)
[Info : EasyWeapon] ObjectDB.Awake() prefix=0 postfix=1 OK
[Info : EasyWeapon] ZNetScene.Awake() prefix=0 postfix=1 OK
[Info : EasyWeapon] [诊断] Humanoid.ClearActionQueue()(空实现)prefix=?
游戏内装填一次后应出现(同一件武器只记一次):
[Info : EasyWeapon] [装填] $item_crossbow_arbalest(弩):3.50s → 1.75s(倍率 0.5)
[Info : EasyWeapon] [装填] $item_graplinghook(钩索):2.00s → 1.00s(倍率 0.5)
装填途中奔跑 / 跳跃 / 翻滚时应有:
[Info : EasyWeapon] [装填保护] 奔跑触发的清空:保留装填动作「$item_crossbow_arbalest」(进度 0.83s / 1.75s),移除其它动作 0 个,队列现有 1 条。
若自检里出现 !! 未挂上 !! 或 反射不到 Player.m_actionQueue,
说明游戏更新改了结构 —— 对应功能不会生效,需要重新核对。
功能 3 的启动日志(进世界时 ObjectDB / ZNetScene 各 Awake 一次):
[Info : EasyWeapon] [ObjectDB.Awake] 武器连锁触发率 → 0.66(主/副攻击统一):命中 38 个攻击槽 / 19 件武器(共扫 N 件物品)。
[Info : EasyWeapon] [ZNetScene.Awake] 连锁特效 ChainLightningRed 已改写:
起始判定 0.9 → 1
每目标几率 0.3 → 0.66
目标数 2~4 → 4~6
逐跳衰减 0.6 → 0.8
[Info : EasyWeapon] 起始延迟 0.18 → 0.18 s|半径 8 → 12 m|每跳闪电伤害 75 → 90|存活 3 s(保持游戏默认,未改动)
若出现 找不到预制体 ChainLightningRed 或 没有 Aoe 组件,说明游戏更新改了名字/结构 ——
特效侧参数不会生效(武器侧仍正常),需要重新核对。
