只有老天爷看得懂了——基于自己写的纯黑框旮旯引擎的反思

零:前言

项目地址:https://github.com/Schariac125/Gal-Engine-Try

我是第一次学Python,也是第一次学oop。你知道的,我以前只会那种面条式的C++代码和一些基础的不能再基础的算法了,上一次写项目还是写黑马的那个最基础的基于C语言的管理系统,只实现了增改查删,然后就啥都没了。

我花了两天时间写了西二AI第一轮的那俩task,就是那个奇怪的旮旯给木和宝可梦。然后的然后,我鬼点子生成了,变成了我噩梦的开端。

我当时觉得那个旮旯给木的框架耦合度也太高了,然后我就想着:

“诶!那我重新写一个旮旯给木的引擎不就好了,先在黑框里面跑起来,后面看看怎么实现可视化”

从这一刻起我的人生彻底被毁掉了(?)

一:基本介绍

最基本的介绍在项目地址的README文件里已经列的轻轻楚楚了,这里也不多说,就简单说说看这个东西到底是怎样的一大坨。

我第一天晚上写这个引擎基本上只实现了一个框架还有其他的几项功能:最基本的存档读档删档,角色基类初始化,游戏主菜单界面,游戏主体游玩界面,在共通线结尾检查好感度是否已经达到阈值可以进线等等等等。后来加上了查询历史进度,跳过剧情,跳转选项。原本是打算做可视化的,但现在看到这一大坨代码,我只想着全部推倒换一个架构然后重构。

二:痛点在哪里

说实在话,第一天晚上只写出来那个框架的时候我自我感觉其实还可以,也完全没有意识到我现在的这个架构有多么的逆天。我是什么时候发现的?我今天本来就打算加一个跳过剧情的功能,就像真的旮旯给木那样子。但实现这个东西需要我去编写三个子功能,就是查询并且记录全局历史进度,查询应该跳转的点,额外判断选项不可被跳过。

全局历史进度我想的非常简单,用json去记录呗,记录一下共通线的历史最长和每个角色单人线的历史最长,到最后不就和存档读档一个逻辑了?这么干我真觉得可行,但是后来我去真的开始做跳过功能的时候,才发现最逆天的根本不是这个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 实现记录历史最远点功能,为后续跳过历史已读剧情基础
def save_overall(self):
overall_filename = os.path.join("save", "save_overall.json")
overall_data = self.load_overall_history()
if not self.begin_love:
history_common_idx = overall_data.get("common", 0)
if self.screen_idx_common > history_common_idx:
overall_data["common"] = self.screen_idx_common
with open(overall_filename, "w", encoding="utf-8") as f:
json.dump(overall_data, f, ensure_ascii=False, indent=2)
return

if self.current_girl is None:
return

跳过功能其实也不难做,写一个接口,等着后面在共通线和个人线里面调用就可以了,那么具体怎么实现呢,因为我有一个全局记录处于哪一句话的变量(这个我感觉很抽象),如果要跳过就把json里面的数据去比对一下,修改为json里面的数据再交给控制剧情播放的函数就可以了。具体代码是这样子的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def skip(self, steps, current_idx, history_limit, stop_at_choice=False):
next_idx = current_idx
while next_idx < len(steps):
_, step_data = steps[next_idx]
if stop_at_choice and step_data.get("choice") == "True":
return next_idx
if next_idx >= history_limit:
return next_idx
next_idx += 1
return next_idx

def execute_skip(self, steps, current_idx, history_limit, stop_at_choice=False):
skip_target = self.skip(steps, current_idx, history_limit, stop_at_choice)
if skip_target > current_idx:
print(f"已跳过 {skip_target - current_idx} 条剧情。")
return skip_target
if self.auto_skip_enabled:
self.auto_skip_enabled = False
print("自动连续跳过已停止,前方没有可跳过的已读剧情。")
return current_idx

写完这个东西,我去改接口,后面才发现,最逆天的原来是在剧情播放的主流程里面去调用这个,因为我设计的json格式的原因,我还要做各种特判。在agent的帮助下我非常丑陋的写完了,但直接力竭了,我发现了我这个架构最逆天的地方,无论是用户交互还是和json文件交互还是更新游戏基本状态,我都放在了game.py这个文件里面去实现,导致这个文件后面的代码长度巨长,也巨难维护,你永远都不知道你加了这个新功能之后会有哪里出现一些意想不到的bug,然后调用一个函数的接口,去看一下具体怎么实现的,发现它里面在调用另一个函数的接口,然后再看再看,就直接嵌套了,这里调用来那里调用去,全部糊在一个文件里,我维护起来确实是要死了。

只能说,我没有软件工程思维和面向对象思维,而且这才哪到哪。

更逆天的是,我实现角色类的那个文件竟然把个人写写在了主角具体的子类里面,然后后知后觉发现不对,这个不应该是基类实现的吗,子类每个都去实现一遍这个,那代码又要变成一坨了,然后被我改成了这样

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
class Character:
def __init__(self,name,role):
self.name=name
self.role=role

class MainCharacter(Character):
def __init__(self,name,role="MainGirl",affinity=0):
super().__init__(name,role)
self.affinity=affinity

def change_affinity(self,change_amount):
self.affinity+=change_amount
print(f"{self.name}的好感度上升了{change_amount}点,目前好感度为{self.affinity}点。")

def get_affinity(self):
return self.affinity

def is_love(self) -> bool:
if self.affinity >= 100:
return True
else:
return False

def get_story_filename(self):
return os.path.join("story", "girls", f"{self.name}.json")

def girl_story(self,screen_idx,game=None):
idx=screen_idx
story_filename=self.get_story_filename()
try:
with open(story_filename,'r',encoding="utf-8") as f:
story_data=json.load(f)
steps=sorted(story_data.items(), key=lambda kv:int(kv[0].replace("step","")))
history_limit = idx
if game is not None:
history_limit = game.get_history_limit()
if idx>=len(steps):
print("剧情已经结束了")
return screen_idx
else:
while screen_idx < len(steps):
_,story_text = steps[screen_idx]
print(f"{story_text['speaker']}:{story_text['text']}")
screen_idx+=1
if game is not None:
game.save_overall()
history_limit = game.get_history_limit()
if game.auto_skip_enabled:
screen_idx = game.execute_skip(steps, screen_idx, history_limit)
continue
command_result=game.prompt_story_command()
if command_result=="menu":
game.open_story_menu()
return screen_idx
if command_result=="skip":
screen_idx = game.execute_skip(steps, screen_idx, history_limit)
return screen_idx
except FileNotFoundError:
print("剧情文件不存在。")
return screen_idx

def girl_end(self):
print(f"{self.name}的个人线已经结束了!")

从这个代码其实也能看出很多东西了,那就是剧情播放的那个逻辑真的是做的一坨,无穷无尽的if和else嵌套在一起,无穷无尽的各种奇怪调用,读起来难受的很。

也有可能是我变量名取得抽象吧,我有自知之明的。

三:反思

我后来彻底放弃了用pygame做可视化的想法,因为比起可视化,我觉得重构是更重要的。

我和哈基米讨论了一圈,它告诉我我现在架构的问题就是game.py这个文件里塞的东西实在是太多了,它说这个叫做上帝类隐患,到后面会让我的主文件越来越臃肿难以维护,而且程序对于剧情的逻辑控制分布在非常多不同的角落。根本拆不开。

后来我问,更优秀的架构应该是什么样子的,它告诉我要强调演出和逻辑分离,引入剧情解释器,不要让角色和主程序本身去解析json文件,建立一个命令表等等等等。

简单来说就是,数据,逻辑,表现这三者必须分离开来,不然迟早会爆。我现在的项目就是把逻辑和表现塞在一起了。

我细想,有道理。反思了一下自己发现自己还是那种面条式编程的思维,好像自己在做oop,实际上也只不过是写了一大堆轮子然后各种缝合,最后继续面条式。

嵌了一大堆if和else就是最明显的体现了。

我说作业的那个架构耦合度太高,实际上我自己写的耦合度也是爆炸级的,大哥不说二哥了这下。其实应该做到一点就是

“不要让数据知道自己在被谁使用”

我总是过度关注现在数据到哪了,数据到底要怎样流动。这估计是我以前刷题思维的后遗症,因为刷题的时候我必须完全掌控数据的流动,数据必须要被知道它被谁调用了,感觉还是面条思维。

或许真的工程环境不需要非常关注?不过我也不知道就是了,但我现在就是这么觉得的。

四:结语

干了一件蠢事也不能说完全没有收获,现在想想或许让我意识到这种架构有多逆天,好的架构有多重要也是有意义的。

然后确实印证了一件事,大多数时间不是花在写代码上,而是思考架构和设计上。

那个项目估计也不会再更新了, 因为过两天,那里的代码只剩下老天爷看得懂了。

毕竟糊代码糊到最后就是输输输