IFAWL开发日志

关于本地存储

Posted by Miso on February 5, 2026

0. 前言 - 关于IFAWL


IFAWL_PROJECT是一款基于Python IDLE Shell的回合制太空对战游戏,由Miso、Csugu和Tecrel创作。截至发稿,最新版本为1.3.1。可在GitHub下载其源码并开始体验。

IFAWL创作的初衷是:逃离困倦的高三生活。在一间没有网络的教室里,我们急需一种不枯燥、有乐子的娱乐方式。正好,我们当时找到了一些素材,罗列如下:

Csugu:

  • 一本Python早教教材
  • 从计算机课堂上弄到的Python 3.8

Miso:

  • 父亲教自己的一种石头剪刀布纸面游戏

Tecrel:

  • 极其精通于科幻创作的头脑

那还说什么了,没有什么比把纸面游戏弄到python里更有乐子的了!

IFAWL计划于焉开始。

1. 我们需要存储,我们需要存储


没有战利品的游戏是不完整的。我们在IFAWL(O. E. ,老版本)2.0里加入了战利品掉落系统,这直接引申出一个重要的需求:

我们需要把数据保存到硬盘里,在关闭游戏后这些数据仍然保存,并在用户下一次登录时读取

这是整个存储开发计划的最核心需求。我们当时习惯把开发日志写在草稿纸上,我读了读,细节需求有这么几个:

  1. 登录机制,通过不同的username : str来区分不同玩家的不同仓库
  2. 仓库内容多样,除了记录各种仓库物资数量外,还要记录玩法最高分等数据
  3. 版本更新适应,在仓库物资种类随着版本更迭发生变化时,与旧版本相同的物资应当自动迁移,本地仓库的键名应当立即更新

这是老IFAWL的需求。新IFAWL提出了更多要求:

  1. 革除重复性工作,比如终焉结们”3”到”43”的键名应当由程序生成,而非人手打出
  2. 新版本添加新数据项的工作流程应尽可能简单
  3. 提供一套简洁的外部接口

带着这些需求,让我们看看新IFAWL是怎么实现它们的。

2. 技术栈-不要重复造轮子,更不要重新发明轮子


我们最终采用的方法是使用shelve模块来存储数据。让我们来喊出那句妙妙咒语:

1
import shelve

曾经有一段时间我们挣扎于这样的想法:把每个玩家的仓库 : dict序列化为字符串,把字符串写进一个.txt文件里,读取时再反序列化。我和Csugu甚至已经做出了序列化的原型机。天哪,杀了我吧。这个想法真是太痛苦了。

简单回顾一下shelve的用法。

方法shelve.open('userdata/game_save',writeback=True)将返回一个shelve对象,这个对象可以像字典一样嵌套地塞入数据,语法和字典一模一样。

1
2
3
4
5
shelve_obj = shelve.open('userdata/game_save',writeback=True)

shelve_obj["item1"] = 10 # 设置值

shelve_obj.sync()

最后一行的sync()方法可以使对象中的数据保存到本地。下次只需打开同一路径的shelve对象即可重新读出数据。

3. 存储模板 读取 回灌 以及其它我们发明的垃圾术语


什么是存储模板

存储模板是一个巨大的字典(: dict[str, Any]),其键为IFAWL一切需要保存到本地的数据键名,值为对应的值,可为整数、浮点数、字符串甚至字典。

读取

在用户登录之后,我们尝试读取这个用户在硬盘中的各个键名,将值填充到模板中。若硬盘中键名在模板中没有(版本更新删除物资),则静默跳过。若模板中键名在硬盘中没有(版本更新新增物资),则模板保持原始状态作为默认值。

若没有这个用户,直跳下一步回灌。

回灌

在读取完成之后,模板已经填入了用户的有效数据。此时,我们把整个模板直接保存到硬盘中,覆盖用户的旧有数据。这就完成了硬盘中键名的更新工作。

这一系统的好处

版本兼容性与数据迁移自动化是最核心的优势。

通过将硬盘数据读取到最新的模板中,系统自动完成了新旧版本数据的“对齐”。旧版本中存在的、新版本已删除的数据项会被安全地忽略;而新版本新增的数据项,则会自动使用模板中预设的默认值进行初始化。

推进了开发流程的简化与标准化。

要添加一个新的可存储数据项,我们只需做一件事:在中央存储模板字典中添加对应的键和默认值。系统会自动处理该数据项的创建、读取、保存和版本迁移。这完全满足了“新版本添加新数据项的工作流程应尽可能简单”的需求,并杜绝了因手动管理键名而可能导致的拼写错误或遗漏。

4. 工程代码实现环节


我们首先来规定存储模板的格式

为了便于管理IFAWL存储的大量键名,模板采用二级键字典形式,即:

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
{
  "metadata": {
    "ship_name": "旅行者",
    "al_on_ship": ["","",""],
    "entry_rank": {},
    "max_infinity_round": 0,
    "max_disaster_point": 0,
    "plot_progress": {},
    "tracing_al": ""
  },
  "materials": {
    "三钛合金":10,
    "湿件主机":10,
    "重水":10,
    "高强度树脂":10,
    "锈蚀电路板":10,
    "激光准晶体":10,
    "黄铜差分机":10,
    "电子变频箱":10
  },
  "currency": {
    "联邦信用点":1000,
    "合约纪念点":0,
    "保险点":0
  },
  "als": {

  }
}

一级键为类别,如"metadata",二级键为有值键名,如"三钛合金"

对模板进行一些自动化的预处理

模板中某些值为空字典,接下来会对它们进行自动填充

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
class StorageManager:
    
    # ==================== 初始化与基础设置 ====================
    
    def __init__(self):
        self.username:str = ""
        # 建立模板
        self.template:dict[str,dict[str,str|int|dict[str,int]]] = json_loader.load("storage_template")
# 模板预处理开始
        for al_str in AL_META_DATA:
            self.template["als"][al_str] = 0
        for entry_str in ENTRY_META_DATA:
            self.template["metadata"]["entry_rank"][entry_str] = 0
        for session_str in ALL_POLT_DATA:
            self.template["metadata"]["plot_progress"][session_str] = False
# 模板预处理完毕
        # 建立空模板
        self.template_empty = deepcopy(self.template)
        # 建立映射表
        self.item_to_key_table:dict[str,str] = {}
        for key in self.template:
            for item in self.template[key]:
                self.item_to_key_table[item] = key
        # 建立统计字段
        self.total_materials_num = 0
        self.total_als_num = 0
        # 启动主仓库
        if not os.path.exists('userdata'):
            os.makedirs('userdata')
        self.repository_for_all_users = shelve.open('userdata/game_save',writeback=True)

从代码中可见,打开原始模板之后,立刻就会进行一次预处理,把一些来自其它源的键值对注入其中。

这一步满足了新IFAWL需求一,”革除重复性工作,比如终焉结们”3”到”43”的键名应当由程序生成,而非人手打出“

最后一步我们启动了包含所有用户数据的主仓库。

用户登录 - 读取与回灌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    # ==================== 用户管理 ====================
    
    def login(self):
        """
        登录|将硬盘数据写入模板|新建用户|重灌入硬盘
        :return: 无
        """
        self.username = Txt.input_plus("指挥官,请输入您的代号")
        if self.username in self.repository_for_all_users.keys():
            for key in self.repository_for_all_users[self.username]:
                if key not in self.template:
                    continue
                for item in self.repository_for_all_users[self.username][key]:
                    if item not in self.template[key]:
                        continue
                    self.template[key][item] = self.repository_for_all_users[self.username][key][item]
        else:
            Txt.print_plus("初次登录|欢迎指挥官")
            self.template = self.template_empty
        self.repository_for_all_users[self.username] = self.template
        # hello
        Txt.print_plus(f"指挥官代号识别成功·{self.get_value_of('ship_name')}号护卫舰正在启动·欢迎来到浅草寺")
        # 更新统计字段
        self.sync()

若用户名有本地存档,一连串的for会将二级键值对读入模板中,否则,什么也不干,保持空模板状态。接着,回灌整个模板到硬盘中。

方法的最后我们调用了self.sync(),它的实现如下:

1
2
3
4
5
6
7
    def sync(self):
        """
        将仓库同步至硬盘
        :return: 无
        """
        self.update_statistical_data()
        self.repository_for_all_users.sync()

一个函数最好不要同时做两件事,除非它们的关系真的很紧密。sync()方法同时承担了保存至硬盘和更新统计数据(如资产总量)的职责。

对外接口封装

我最喜欢的环节。

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
    def show_assets(self) -> dict[str:int]:
        """
        展示现有仓库资产
        :return: 字典,键为所有的materials|currency|Als,值为它们的数目
        """
        out = {}
        for key in ["materials","als","currency"]:
            for item in self.repository_for_all_users[self.username][key]:
                out[item] = self.repository_for_all_users[self.username][key][item]
        return out
    
    def show_assets_except_al(self) -> dict[str:int]:
        """
        展示现有仓库资产,Als除外
        :return: 字典,键为所有的materials|currency,值为它们的数目
        """
        out = {}
        for key in ["materials","currency"]:
            for item in self.repository_for_all_users[self.username][key]:
                out[item] = self.repository_for_all_users[self.username][key][item]
        return out
    
    def modify(self,item:str,delta:int):
        """
        增减仓库物品数量|核心业务封装
        :param item:需要改变数量的物品
        :param delta:物品数量改变量
        :return: 无
        """
        key:str = self.item_to_key_table[item]
        self.repository_for_all_users[self.username][key][item] += delta
        self.sync()
    
    def get_value_of(self,item:str) -> int|str:
        """
        查找仓库物品数量或元数据值|核心业务封装
        :param item: 需查找的物品或键
        :return: 物品在仓库中的数量或元数据值
        """
        key: str = self.item_to_key_table[item]
        return self.repository_for_all_users[self.username][key][item]
    
    def set_value_of(self,item:str,val):
        """
        设置仓库内物品或元数据的值|核心业务封装
        :param item: 需设置的物品或键
        :param val: 需设置的值
        :return: 无
        """
        key: str = self.item_to_key_table[item]
        self.repository_for_all_users[self.username][key][item] = val
        self.sync()
    
    def transaction(self,give_list:dict[str,int],get_list:dict[str,int]):
        """
        进行一次交易|核心业务封装
        :param give_list: 交付 字典
        :param get_list: 得到 字典
        :return: 无
        """
        for item in give_list:
            self.modify(item,-give_list[item])
        for item in get_list:
            self.modify(item,get_list[item])
        self.sync()

核心数据操作接口可概括如下

  • show_assets()
    • 功能:获取玩家当前所有资产(材料、货币、AI)的数量。
    • 返回:一个字典,键为资产名称,值为其数量。
  • show_assets_except_al()
    • 功能:获取玩家当前所有资产,但排除AI(Als)。
    • 返回:一个字典,键为资产名称(仅材料和货币),值为其数量。
  • modify(item: str, delta: int)
    • 功能:核心业务封装。修改仓库中指定物品的数量。
    • 参数:
      • item:需要改变数量的物品名称。
      • delta:物品数量的改变量(正数为增加,负数为减少)。
    • 效果:直接更新仓库数据并同步到硬盘。
  • get_value_of(item: str)
    • 功能:核心业务封装。查找仓库中指定物品的数量或元数据的值。
    • 参数:item - 需要查找的物品名称或元数据键名。
    • 返回:该物品的数量或元数据的值(整数或字符串)。
  • set_value_of(item: str, val)
    • 功能:核心业务封装。设置仓库内指定物品或元数据的值。
    • 参数:
      • item:需要设置的物品名称或元数据键名。
      • val:需要设置的新值。
    • 效果:直接更新数据并同步到硬盘。
  • transaction(give_list: dict[str, int], get_list: dict[str, int])
    • 功能:核心业务封装。执行一次原子交易,即同时交付一批物品并获取另一批物品。
    • 参数:
      • give_list:一个字典,表示要交付的物品及其数量。
      • get_list:一个字典,表示要获取的物品及其数量。
    • 效果:依次执行give_list中所有物品的减少和get_list中所有物品的增加,并同步到硬盘。

特点总结

  1. 高度抽象:上层游戏逻辑只需调用这些简单的接口,无需关心底层是使用shelve、模板还是回灌机制。
  2. 业务聚焦:modify, get_value_of, set_value_of, transaction 被明确标注为“核心业务封装”,是IFAWL存储机制的基石。
  3. 数据安全:所有修改数据的方法(modify, set_value_of, transaction)在操作后都会自动调用sync(),确保更改立即持久化到硬盘。
  4. 查询便利:show_assets系列方法提供了快速查看玩家资产概况的途径。

这套接口设计满足了新IFAWL的第三个核心需求——“提供一套简洁的外部接口”,使得游戏其他模块能够以安全、一致的方式与玩家数据交互。

5. 结语

回顾IFAWL的本地存储开发历程,我们从最初笨拙的手动序列化构想,到最终建立起一套基于“模板-读取-回灌”的自动化系统,这不仅是技术上的迭代,更是开发思维的转变。

这套系统成功地将一个繁琐、易错的后端问题,转化为一个声明式、可维护的基础设施。它让我们得以专注于游戏玩法本身的创新,而无需在数据持久化的泥潭中反复挣扎。每当新版本需要添加一种资源、记录一项成就或调整一个默认值时,我们只需在存储模板的json中轻点一笔,剩下的工作——创建、迁移、兼容——系统都已为我们妥善处理。

更重要的是,它为IFAWL未来的扩展奠定了坚实的基础。无论游戏内容如何丰富,玩家数据如何增长,这套存储架构都能提供稳定、可靠的支持。它或许不是最复杂或最高效的方案,但却是最适合我们这个小团队、这个特定项目的优雅解。

在高三那间没有网络的教室里,在高考后那个炎热的暑假,我们不仅创造了一个游戏,更学会了一种构建复杂系统的方法论。

这,或许比游戏本身更让我们感到自豪。

浅草寺的大门永远敞开,指挥官的旅程仍在继续。

欢迎大家游玩IFAWL!