一周7天完成2个Mod,霸占创意工坊首页整周。作为第一次制作完整游戏系统的尝试,这篇文章描述了我个人在开发结束之后的复盘与反思

上周,我给游戏《潜渊症》(Barotrauma)做的Mod“原版枪械改装”和“武器XML快速生成器”完成了,登顶首页长达一周。

Snipaste_2025-07-24_23-14-16.png

这个mod说大不大说小不小:相当于给这游戏远程武器提供了一套完整的配件改装系统模板——你知道的,就是多数FPS做过的装个镜子、换个枪托这样的玩意,并且还把性能优化到了当前我力作能及的最大极限。我不好说它是不是还能再优化,但是整个游戏因为一把枪装了消音器就从60帧掉到30帧这种事情应该是不会出现了(点名这个游戏的另一个Mod:Combat Extended,有很长一段时间都有这种问题)。不过我并没有覆盖到游戏里的所有武器,目前只有三分之二。

至于另一个Mod“武器XML生成器”,基本就是开发配件系统时的附带产品。

在这之前,我尚未接触过完整的游戏开发,虽然参与过3、4次gamejam,但总的来说我没有感觉到自己以前的开发很有组织性,更多时候就像《游戏制作指南》里说的那样:在Deadline的三分之二时间里磨洋工、出于“必要混乱”之中,直到剩下一周或者几天时,“哦,糟糕”,发现自己要赶不上Deadline,才火速从“必要混乱”当中脱出身来,开始给自己的一团浆糊各种找补,最后端上来个四不像。

先从开发日志开始吧。

开发日志

Day1

  • 初步上手
  • 成功实现更为优雅简洁的配件机制

我需要开始考虑一下接下来怎么做战术插件.目前是考虑改造分为两类:战术化和暴力改造,前者就是原武器增加多个插件槽位,而后面的改造则是直接重写一个新武器出来.

对于插件槽位,目前的想法如下:

能影响的属性:

  • reload & reloadnoskill 射速与无技能射速 (需要reloadskillrequirement)
  • weapondamagemodifier 武器伤害倍率
  • penetration 护甲/结构伤害乘数
  • barrelpos 枪管位置
  • spread & unskilledspread 散布和无技能散布
  • crosshairscale 十字准星大小
  • 移动速度

具体见 https://regalis11.github.io/BaroModDoc/ItemComponents/RangedWeapon.html

  1. 上插件 | 镭射/灯光/发光管/瞄准镜等 | smallScopeVGM,bigScopeVGM
  2. 枪管 | 消声器/消焰器/长枪管 | smallMuzzleVGM,bigMuzzleVGM
  3. 下挂件 | 除瞄准镜以外的所有上挂件+握把 | smallAccessoryVGM,bigAccessoryVGM
  4. 枪托 | 去除枪托/轻枪托/重枪托 | smallStockVGM,bigStockVGM
  1. 内机构 | 灵活度较大 | smallMechanicsVGM,bigMechanicsVGM
  2. 弹匣 | 不算在插件系统内,短弹匣/长弹匣/弹鼓 | smallMagVGM,bigMagVGM,drumMagVGM

就先做这些吧, 都是些数值增减, 应该不成问题的

明天得开始处理XML烧火棍生成器了,这下确实有实在的需求在了.

Day2

模板初步实现,做贴图时发现一个问题:配件和枪最好统一配色,不然装上去会显得突兀(尤其是握把部分),嗯,之后所有战术武器全变成暗蓝配色好了

生成器初步完工,不过还需要不少改进,目前生成的代码手动改的部分还挺多,另外最好能把贴图扔到右边,左边一栏右边一栏方便操作

发现枪握把有个问题:握把的实现略有难度:

  1. 需要独立调整显示层级,得覆盖在枪握把的上面
  2. 所有组件都最好比原枪配件大一码,以覆盖原枪

后来尝试了一下, subcontainer不响应containedspritedepth这个标签,故因握把贴图实现难度过大的原因,实现得弃用

Day3

枪械配件列表:

上插件:
步枪瞄准镜(原版) 增加武器的最远可视距离(cameraaimoffset)500单位,只有部分武器可用
ACOG瞄准镜 增加武器的最远可视(cameraaimoffset)距离250单位
全息瞄准镜 增加武器的最远可视100单位
- 变种:红点瞄准镜
- 变种:紧凑瞄准镜
狙击瞄准镜 增加武器最远可视距离700单位,只有部分武器可用,影响持握时步行速度

上下兼容插件:
激光瞄准器
手电筒
照明棒
信号弹
-以上物品安装在上插件时会导致准星消失

下插件:
垂直握把 变为战术持握(枪口向下)
直角握把 变为战术持握(枪口向上)

枪管:
消声器 修改武器枪管位置,减少5%散布,增加消音能力,增加5%伤害
消焰器 减少15%散布
长枪管 修改武器枪管位置,增加10%伤害,减少10%移动速度,增大枪口火焰

枪托:
无枪托(默认) 增加30%散布,增加15%移动速度
轻型枪托 有托武器的默认配置
重型枪托 减少5%移动速度,减少5%散布
射手枪托 减少15%移动速度,减少30%散布
(手枪枪托 减少20%散布,但是变为只能双手持握)

第一批贴图完成,只做了一半,主要是确认一下当前这套工作流可行,晚上准备开工处理代码
我把以前不知道在哪里找到的旧式工作台搬来了,现在当作这个mod的用好了

哈,幸好只做了一半,果然有坑:
xml原生支持增减这些操作,但是不支持TMD逆向操作!啥意思?老子消音器捅上去之后再拔下来,桶上去会给数值,拔下来数值没法变回去!笑死,好吧是可以变回去,用setvalue就行,但是如果一个属性被两种不同的插件影响(比如常见的,散布同时被握把和枪托影响),那逻辑上就会出现问题.

我想了想,这样的话得减一些特性:一个插件可以影响,每个属性只能固定被一种插件定义,

至于剩下的,我想之后学lua的时候试试好了…如果我还有余力去学lua而不是专注Godot的话

我就把话放在这儿了:如果不使用Lua重写的话,这个东西的拓展会很困难.所以等第一版出来以后,尽早用Lua把逻辑重写一遍

原版突击步枪的弹匣怎么贴图位置不对? 这个做完了帮官方修一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<Item identifier="tacAssaultRifleVGM" name="tacAssaultRifleVGM" tags="smallitem,weapon" category="Weapon" scale="0.5" impactsoundtag="impact_metal_light">
<!-- interactdistance="50" interactpriority="1.0" interactthroughwalls="false" focusonselected="false" offsetonselected="0" showcontentsintooltip="false" scale="1.0" spritecolor="1.0,1.0,1.0,1.0" health="100" indestructible="false" fireproof="false" waterproof="false" impacttolerance="10.0" maxstacksize="1" allowasextracargo="false" allowrotatingineditor="true" linkable="false"这些都不需要 -->
<Price baseprice="666">
<Price storeidentifier="merchantoutpost" sold="false" multiplier="1.5" />
<Price storeidentifier="merchantcity" multiplier="1.25" minavailable="1" sold="false" />
<Price storeidentifier="merchantresearch" sold="false" multiplier="1.25" />
<Price storeidentifier="merchantmilitary" multiplier="0.9" minavailable="1" />
<Price storeidentifier="merchantmine" sold="false" multiplier="1.25" />
<Price storeidentifier="merchantarmory" multiplier="0.9" minavailable="1" />
<!-- price需要增加storeidentifier作为子标签 -->
</Price>
<Fabricate requiredtime="35" suitablefabricators="fabricatorVGM">
<RequiredItem identifier="assaultrifle" amount="1" mincondition="0" />
<RequiredItem identifier="steel" amount="3" mincondition="0" />
</Fabricate>
<Deconstruct time="10">
<Item identifier="plastic" />
<Item identifier="titaniumaluminiumalloy" />
</Deconstruct>
<Body width="160" height="60" density="25" />
<RangedWeapon reload="0.24" weapondamagemodifier="1.0" penetration="" holdtrigger="true" barrelpos="75,-20" spread="6" unskilledspread="20" combatpriority="80" drawhudwhenequipped="true">
<Crosshair texture="Content/Items/Weapons/Crosshairs.png" sourcerect="0,256,256,256" scale="0.2" />
<CrosshairPointer texture="Content/Items/Weapons/Crosshairs.png" sourcerect="256,256,256,256" />
<RequiredItems items="smgammo" type="Contained" msg="ItemMsgAmmoRequired" />
<RequiredSkill identifier="weapons" level="60" />
<!-- <StatusEffect type="OnUse" target="This" Condition="-0.01" /> -->
<!-- <StatusEffect type="OnUse" target="Contained" Condition="-0.1" /> -->
<!-- <StatusEffect type="OnUse" target="Character" DisableDetachment="true">
<Explosion force="0" smoke="true" flames="false" shockwave="false" sparks="false" flash="false" sound="SmallWeaponShoot" />
</StatusEffect> -->
<!-- 上面这三行都不需要,请修改成下面这些 -->
<StatusEffect type="OnUse" target="This">
<ParticleEmitter particle="casingfirearm" particleamount="1" anglemin="90" anglemax="150" velocitymin="50" velocitymax="250" CopyEntityAngle="true" />
<Explosion range="150.0" force="1.5" shockwave="false" smoke="false" flames="false" sparks="false" underwaterbubble="false" camerashake="12.0" />
</StatusEffect>
<StatusEffect type="OnUse" target="Contained">
<Use />
</StatusEffect>
<StatusEffect type="OnSecondaryUse" target="This" Condition="-0.1" stackable="true" disabledeltatime="true" setvalue="false" >
<RequiredItem items="flashlight" type="Contained" />
</StatusEffect>
</RangedWeapon>
<ItemContainer capacity="1" maxstacksize="1" hideitems="false" containedstateindicatorslot="0" containedstateindicatorstyle="bullet" containedspritedepth="0.56">
<!-- <SlotIcons>
<SlotIcon slot="0" texture="Content/Items/InventoryIconAtlas.png" sourcerect="832,830,64,64" />
<SlotIcon slot="1" texture="Content/UI/StatusMonitorUI.png" sourcerect="320,448,64,64" />
<SlotIcon slot="3" texture="%ModDir%/UI/icons.png" sourcerect="0,64,64,64" />
<SlotIcon slot="4" texture="%ModDir%/UI/icons.png" sourcerect="64,0,64,64" />
<SlotIcon slot="5" texture="%ModDir%/UI/icons.png" sourcerect="128,0,64,64" />
</SlotIcons> -->
<!-- sloticons和index是错误的写法,正确写法请参考如下: -->
<SlotIcon slotindex="0" texture="Content/UI/StatusMonitorUI.png" sourcerect="256,448,64,64" origin="0.5,0.5" />
<SlotIcon slotindex="1" texture="Content/UI/StatusMonitorUI.png" sourcerect="320,448,64,64" origin="0.5,0.5" />
<SlotIcon slotindex="2" texture="%ModDir%/UI/icons.png" sourcerect="192,0,64,64" origin="0.5,0.5" />
<SlotIcon slotindex="3" texture="%ModDir%/UI/icons.png" sourcerect="256,0,64,64" origin="0.5,0.5" />
<SlotIcon slotindex="4" texture="%ModDir%/UI/icons.png" sourcerect="64,0,64,64" origin="0.5,0.5" />
<SlotIcon slotindex="5" texture="%ModDir%/UI/icons.png" sourcerect="128,0,64,64" origin="0.5,0.5" />
<!-- <Contained items="smgammo,smallMagVGM,bigMagVGM,drumMagVGM" hide="false" itempos="-19,1" rotation="-30" /> -->
<!-- 请把Contained换成Containable -->
<!-- <SubContainer capacity="1" maxstacksize="1" allowitems="smallAccessoryVGM,bigAccessoryVGM,light" hideitems="false" itempos="22,-2" setactive="true" type="LowerAccessory">
<StatusEffect type="OnContained" target="This" CheckConditionalAlways="true">
<Conditional Is=!"tacAssaultRifleVGM" />
<Remove />
</StatusEffect>
</SubContainer> -->
subcontainer的写法存在错误,这里我给你留着第一行,你按照我的写法来做,所有的StatusEffect不需要生成,需要containable即可
<SubContainer capacity="1" maxstacksize="1">
<Containable items="smallAccessoryVGM,bigAccessoryVGM,light" hide="false" itempos="22,-1" setactive="true" >
</Containable>
</SubContainer>
<SubContainer capacity="1" maxstacksize="1">
<Containable items="smallHandVGM,bigHandVGM" hide="false" itempos="22,-1" setactive="true" >
</Containable>
</SubContainer>
<SubContainer capacity="1" maxstacksize="1">
<Containable items="smallMuzzleVGM,bigMuzzleVGM" hide="false" itempos="22,-1" setactive="true" >
</Containable>
</SubContainer>
<!-- 枪托 -->
<SubContainer capacity="1" maxstacksize="1">
<Containable items="smallStockVGM,bigStockVGM" hide="false" itempos="22,-1" setactive="true" >
</Containable>
</SubContainer>
<!-- 上挂件 -->
<SubContainer capacity="1" maxstacksize="1">
<Containable items="smallScopeVGM,bigScopeVGM" hide="false" itempos="22,-1" setactive="true" >
</Containable>
</SubContainer>
</ItemContainer>
<Sprite texture="Content/Items/Weapons/weapons_new.png" sourcerect="94,8,161,59" depth="0.55" origin="0.5,0.5" scale="0.5" />
<InventoryIcon texture="Content/Items/InventoryIconAtlas.png" sourcerect="94,8,161,59" origin="0.5,0.5" />
<Holdable slots="Any,RightHand+LeftHand" controlpose="true" holdpos="40,-10" aimpos="45,-10" handle1="-30,-15" handle2="26,5" holdangle="-35" msg="ItemMsgPickUpSelect" />
</Item>

Day4

好吧,Alpha阶段算是ok了,调试了2把武器和8个配件,接下来可以进入大批量生产阶段了,哦对了,我最好确认一下合成配方,Day6做差不多的时候再添加好了

Day5

大规模生产的一天,今明两天可以把所有武器做完,不过我好像忘了做延长弹匣和弹鼓,今天还是先完成这个,并且进一步优化我的生成器好了、

本来想给弹匣设置一下影响枪械属性的,但是看到弹匣的原料后觉得没有必要,后勤和最大携带量已经足够了

我忘记给能上消音器的上aitarget标签了
另外得另起一个override文件,重写一下步枪的配方(可以在VGM加工台制作) 完工
得给所有涉及步枪镜的加个statuseffect 完工

哪些做对了

在开发初期就认识到程序化生成对批量产出的重要性

简单算笔账:
在Day4的时候,我把这个系统的核心代码实现给架构完成,整个mod一共10把武器16个配件,当我没有这个生成器的时候,在Day4做两个武器其实就花掉我半天时间,

那个XML生成器,其实就是为了枪械改装这个模组准备的,幸好我这次提前开始同时做这两个玩意儿,如果没有生成器,光是做两把武器就得花掉我半天时间。

生成器是在第五天完成的,而当我有了专门的XML生成器后,一天时间就完成了剩下的8个武器还有7个配件。开发的架构阶段很重要,要是搭好了,堆量阶段的效率甚至可以增加5倍。

生成器和Mod同步开发修缮

说实话,虽然XML生成器是全AI完成开发,但是AI开发≠不需要调试,前几天出的乱子也挺不少的,比如凭空生成游戏解析不了的格式、JS构造函数老是这里那里出问题等等。这种并行开发和迭代修正的策略,确保了工具的实用性,也让它输出的内容准确无误,避免了工具问题阻碍后续开发。

先完成代码层面的机制,然后再进行大规模开发

这个实际上我之前在《远行星号》谈mod制作的时候有讲过,这里再放一下引用:

没有美工、策划的电子游戏顶多不好玩,但没有程序的电子游戏会变得不能玩。

做mod同理。建议上来就对着技术难关搞突破——哪一部分你觉得最难搞定,那一开始就搞它。别上来就搞策划案和文案,就我个人3次 gamejam 的经历,策划案与文案是最整个游戏开发最简单的内容。把最难的部分放到最后做会让mod的难产可能性直线上升。

我就算在这说了也会有人继续往这个坑踩,我以前也踩过:主要的客观原因是写策划是整个游戏开发中正反馈最即时的部分,其他开发环节通常会延后得到反馈——两案写爽的例子不在少数,但是写程序/美工写爽的倒是挺少听到(那种爽也和写策划的不一样,更多的是一种折磨结束后的狂喜,而不是创作的快乐)。

这回算是彻底避开了这个坑,没有又一次陷入策划写10w字,代码一点没动的问题当中

终于学会如何使用AI看文档了

以前听老程序员说,看文档不如看源代码。《潜渊症》的开发文档本身也是半程序化生成的,有很多细节照顾不到位。这回我尝试了使用哈吉米(谷歌Gemini 2.5 flash,主流大模型当中最容易和用户发生争论/突发恶疾的一个因此得名)让它来帮我写一部分内容。

image.png

注意哈,我不是让它帮我搜索《潜渊症》的文档,我选择了先把游戏源脚本文件喂给它(StatusEffect.cs、Condintional.cs等)然后结合文档,问它一些更为细致的问题:比如OnInsert和OnRemove只能写在哪里哪里,某些子容器是否有限制等,事实证明这样使用AI比直接问它文档要准确得多,不容易出现AI幻觉的问题。

哪些做错了

在Mod文件夹里初始化.git仓库

如果你的游戏有1G以上的大小,或者根本不在乎游戏的体积,git仓库那50mb的体积对大多数项目来说都不是个事儿。但是当你的mod本身只有2mb的时候,让其他人为了下2mb的东西就得顺带下个50m的.git就显得有些不厚道了。

所幸这个问题被我及时发现并替换掉,看来以后做Godot的时候也得注意一下这个问题:总不能打包成web发行版时顺带给版本管理程序打包进去不是,毕竟jam上支持在线试玩与否有时候是能决定试玩量的。

没有使用fork来分化开发版和发行版

在项目管理上,缺乏开发版和发行版的清晰分离是我需要改进的地方。我这次没有使用Git的fork机制,很可能意味着我的开发分支和主分支(或者说发行分支)没有明确的区分。这可能会导致一些问题:

  • 发布风险: 直接在开发分支上修改并发布,很容易把那些没完成、没测试的功能或者Bug一起打包发布出去,影响用户体验。

  • 版本混乱: 我很难清晰地追踪哪些提交是针对开发中的功能,哪些是已发布版本的修复或更新,导致版本回溯和管理变得复杂。

  • 协作不便: 如果未来有团队协作,这种模式会让多人开发变得一团糟,很容易产生代码冲突。

我以后应该这样做:保持一个稳定的**主分支(main/master)作为发行版本,而所有的功能开发、Bug修复都在开发分支(develop)**或者特性分支上进行。当开发内容成熟并经过充分测试后,再把开发分支合并到主分支,然后打上版本标签进行发布。这样可以确保发行版本的稳定性和质量,也方便我管理和回溯版本。

版本控制函待全自动化

尽管我已经使用了Git进行版本控制,但看起来我的**版本控制流程还不够自动化。在未来,最好尝试一下以下内容:

  • 在我把代码提交到特定分支后,自动触发构建、测试。

  • 在满足发布条件时(例如,合并到主分支),自动创建版本标签。

  • 自动把我编译好的Mod文件打包,并上传到指定的分发平台或存储位置。

自动化可以大大减少我的人为错误,提高发布效率和质量,让我能更专注于开发本身。

(开发日志写得比较混乱)

我不知道这算不算个问题,不过你也能看到我这一周基本是把开发日志当作随手笔记来写,我好像听说开发日志最好正规并且八股文一点?