今日回顾与计划
在今天的直播中,我们将继续进行游戏的开发工作,目标是完成资产文件(pack file)的测试版本。目前,游戏的资源(如位图和声音文件)是直接从磁盘加载的,而我们正在将其转换为使用自己创建的资产文件格式。我们已经编写了大部分的代码,现在的任务是完成写出资产文件的测试代码,这样我们就可以将其加载到游戏中。
在昨天的代码中,我们通过测试资产构建器(test asset builder)已经实现了写出大部分的资产文件头部,唯一缺失的部分是资产数组。今天,我们的目标是继续完成写出资产数组的工作。除了资产数组,我们还需要将实际的资源内容(如位图和声音文件)也写入文件中。这样做是为了确保资产文件不仅记录了资源的基本信息,还能正确包含资源的实际数据。
资产文件格式的设计如下:我们将每个资产(无论是位图还是声音)作为一个单独的条目存储,并通过一个数组记录它们的位置信息、标签以及数量。在资产的具体信息中,位图将包括其尺寸和对齐方式,而声音则会包含样本数量及是否与其他声音资源关联等信息。
接下来,我们需要完成这些数据的写入工作。我们之前已经完成了其他部分的编写,只剩下将资产数组写入文件了。对于其他数组的写入,我们采用了将数据保存在内存中的方式,并且确保这些数据的存储格式与文件中的格式一致。因此,现在我们只需要按照相同的方式将资产数组写入文件,确保它们与其他数据项的格式保持一致。
整体来说,这部分工作非常直接,主要是确保数据的正确存储与输出。
文件是存储在磁盘上的内存副本
文件和内存其实是非常相似的,文件只是内存的一种永久存储版本。它们本质上都是一串字节,唯一不同的是,文件是被保存在硬盘或固态硬盘等设备上的,而内存则是暂时存储在计算机的RAM中。当我们进行写操作时,实际上就是将内存中的一块数据写入到硬盘上。之后,游戏中需要用到这些数据时,可以再从硬盘中读取回来,恢复成原本的内容。
在写入数据时,我们并没有做特别复杂的操作,简单地将内存中的一块数据写入到磁盘上。在资产文件的写入过程中,我们的目标是将这些数据按顺序存储到文件中,这样就能在以后根据需要将它们加载回内存。对于每个资源(如位图),它们的存储位置是相互依赖的,因为每个资产在文件中的位置是根据之前写入的数据大小来确定的。如果第一个位图占用了100个字节,那么第二个位图的数据就会紧接着存储在第一个位图后面,而第三个位图的位置又会依赖于前两个位图的大小。因此,在写入资源之前,必须先计算出每个资源在文件中的具体位置,这样才能确保它们按照正确的顺序存储。
目前,还没有准备好资产数组,因为尚未处理所有资产的存储位置。为了确定每个资产的存储位置,需要遍历所有资产并计算它们在文件中的偏移量。因此,处理这些文件写入的方式有两种选择。最简单的做法是按照顺序写入资源数据,然后在写完所有资源之后,再更新文件中的资产数组,记录每个资产的偏移量。这种方法比较直观且易于实现。
存储资产数组和资产的两种方式:
有两种方法可以处理这个问题。一种方法是先将所有资产写入文件,但在文件中留出空间以便之后写入资产数组。然后再回到这个位置,将资产数组作为一个整体写入。我觉得这个方法比较麻烦,可能不会使用。我打算采用更简单的方法,就是在写入资源数据的同时,逐个写入资产数组。
具体做法是,我们已经知道了资源从文件中的哪个位置开始,因此可以根据这个位置加上资产数组的大小,计算出下一个可用的文件字节位置。这样,在写入资产的同时,就可以同步写入资产数组,因为我们已经明确了每个资产在文件中的存储位置。
1) 在写入每个资产后来回处理
打算采用手动遍历资产集的方法,从资产索引0开始,逐一遍历所有资产。每次遍历时,使用seek
操作来定位文件中的当前位置。虽然这种方式效率较低,但考虑到这是测试代码,且只会在本地运行,我们并不关心它的效率。如果以后成为生产环境中的问题,再考虑优化也不迟。因此,这段代码的效率并不重要。
在操作中,需要自己跟踪文件流的位置。对于每个资产,先跳转到文件中相应的部分,然后写入该资产的实际数据。如果是位图,就写入像素数据;如果是音效,则写入声音样本数据。完成后,再跳回资产数组部分,将相关信息写入数组中。
2) 写入所有资产,然后返回写入整个资产数组
在讨论如何处理资产数组时,最初的想法是先跳过资产数组的位置,通过一个 fseek
跳到文件的适当位置,然后再去写入资产数组。然而,在重新考虑之后,发现其实这样做并不比原来的方式更简单。实际上,最合适的方式是通过一次 fseek
将文件指针移动到资产数组的位置,然后顺序写入所有资产数据,再返回到资产数组的位置并将它写入。
这样做的原因是,当通过 fseek
调整文件指针时,不必担心给定确切的写入位置,因为C语言的文件操作API(如 fwrite
)会自动在当前文件指针的位置继续写入数据。这种方式简化了写入过程,并且避免了提前告知操作系统写入位置的麻烦。
具体操作流程是,首先跳过资产数组的位置,然后依次写入每个资产数据(如位图或音频等)。写入完所有资产后,再返回到原来的位置,将资产数组写入文件中。这样做可以避免不必要的文件操作,同时效率更高。
总之,通过这种方法,首先写入所有资产,之后返回到资产数组的位置进行写入,而不必提前占用不必要的空间。这种处理方式简化了文件操作,也符合我们对效率的要求。
C运行时库函数:fseek
fseek
是一个用于移动文件指针的函数,它可以在不写入任何数据的情况下调整文件的位置。通过 fseek
,可以指定文件指针的移动方式,确保数据按正确的位置读写。具体来说,fseek
有三个参数,首先是文件句柄,指定当前操作的是哪个文件流;其次是希望移动的字节数;最后是移动方式的解释参数,有三种方式:
- SEEK_SET:表示从文件的开头开始,移动到指定的绝对字节位置。例如,如果传入值为5,则文件指针会移动到文件的第5个字节。
- SEEK_CUR:表示从当前文件指针的位置开始,按给定字节数进行偏移。如果传入的是正数,文件指针会向后移动;如果是负数,则会向前移动。
- SEEK_END:表示从文件的末尾开始,向文件的起始方向进行偏移。这通常用于在文件末尾进行操作,或者回退文件中的数据。
在实际操作时,首先需要知道资产数组的大小,利用这个大小来计算跳过资产数组所需的字节数。然后,通过 fseek
从当前文件指针的位置开始,跳过指定的字节数,接着继续写入每个资产数据。完成所有资产的写入后,再通过 fseek
将文件指针返回到资产数组的位置,继续进行资产数组的写入。
这样,文件的写入过程就能够按预定顺序进行,同时避免了不必要的重复计算和文件操作。文件指针的精确控制确保了数据的顺利存储。
CRT的fseek不支持64位偏移量
在处理文件写入时,遇到一个问题,即文件的偏移量需要处理大文件。C语言的标准库中并不直接支持64位的文件偏移量,导致无法处理非常大的文件。不过,由于当前的应用场景并不涉及大文件,因此可以使用标准的偏移量处理方式。
对于大文件,可能需要使用 lseek
等系统调用来处理64位的文件偏移量。尽管如此,由于这是测试代码,实际在读取时不会使用这种方式,而是通过Windows的I/O完成端口(IOCP)来进行数据读取,根本不涉及这种偏移量问题。
在处理文件时,发现一个额外的问题:资产数组(AssetArray)与实际文件中存储的数据不同。最初创建的资产集合仅仅是一个读取文件的记录,但并不是真正要存储在文件中的数据。因此,需要将资产集合拆分为两部分:资产来源(AssetSources)和资产数据(AssetData)。资产来源部分仅仅是用于文件的元数据,不需要实际保存到文件中。
通过这种拆分的方式,可以让资产源(如文件名、类型等信息)存储在一个数组中,而实际的资产数据(如位图数据或声音数据)存储在另一个数组中。然后,文件的写入流程就是先写入资产数据,再将资产源的相关信息存储到适当位置。
代码中的具体实现细节包括:
- 将资产集合拆分成两个部分:资产源和资产数据。
- 使用
fseek
定位到文件中的适当位置,跳过资产源部分的数据,直接写入资产数据。 - 通过
fseek
返回到资产数组的起始位置,填充资产源的相关数据。 - 在写入时,需要注意一些必要的检查,确保没有空的资产索引,并进行必要的断言。
这种方法虽然简单,但确保了文件结构的正确性,避免了写入错误。最终目标是能够正确生成包含所有资产的文件,文件大小也需要检查,确保生成的数据符合预期。
如果一切都按预期执行,测试文件 test.HHA
可能会被正确生成,尽管文件的大小和内容还需要进一步验证。


将BMP代码移入测试资产构建器
接下来,进行资产处理的实现。首先,在 game_asset
文件中,现阶段仍然保留一些已经不再使用的代码,这些代码最终会被删除。比如之前处理位图(BMP)文件的代码,虽然现在它仍然存在,但在未来的版本中,这部分功能将不再需要,因此可以逐步删除。
在实现过程中,首先把已有的代码段复制进来,并在确认一切设置正确之后,从 game_asset
文件中移除这些已经不再使用的代码。目前,项目中的位图相关代码仍然存在并未删除,只是暂时保留,待确认之后再进行清理。
然后进行编译,查看缺少的部分或者错误。检查一些不再需要的部分,例如 AlignPercentage
,这部分数据从未实际使用过,因此可以删除。类似地,loaded_bitmap
也可以继续使用,因为它在渲染器中依然有效,但不再需要与位图文件的直接关联。
接下来,开始处理文件读取部分。需要实现 debug_latform_read_entire_file
函数,用来读取整个文件。这部分将返回包含文件内容及文件大小的结构体,可以叫做 entire_file
。ReadEntireFile
函数将接收文件名,并返回包含文件内容和文件大小的数据结构。
为了能够正确读取文件,还需要做一些处理。包括引入一些必要的内联函数(如位图扫描),可能还需要一些额外的库(例如 V4
和 srgb
映射相关的函数)。这些部分在读取文件时会用到,所以必须确保这些函数已经准备好。
综上,主要步骤如下:
- 移除不再需要的代码,比如旧的位图文件加载代码。
- 编译和调试,检查缺失的功能或者错误。
- 实现新的文件读取函数
ReadEntireFile
,该函数返回文件的内容和大小。 - 确保所有依赖的功能(如位图扫描、颜色映射等)已经正确实现。
此过程有助于逐步清理和重构代码,最终实现更简洁、更高效的文件读取和处理方式。


最终包含数学头文件
在实现过程中,最初可能觉得不需要涉及数学计算,但实际上发现需要一些数学操作来处理颜色转换。具体来说,需要实现两个函数:Linear1ToSRGB255
和 SRGB255ToLinear1
,这些函数用于颜色空间的转换。
考虑到这些数学函数可能会在不同地方被使用,最好将这些函数提取出来并使其可以被整个项目共享。这样可以避免重复代码,也能提高代码的可维护性。
一开始没有包含这些数学计算函数,可能会让人觉得有点后悔,但现在考虑到项目的需求,加入这些计算并不会太麻烦,反而可以让代码更加清晰和高效。因此,决定将这些数学操作函数集成到项目中,虽然最初没有想到这一点,但现在看来这样做会更加合理。
总结来说,主要内容包括:
- 实现颜色空间转换的数学函数,如
Linear1ToSRGB255
和SRGB255ToLinear1
。 - 将这些数学函数提取并使其可以在项目中其他部分共享使用。
- 重新审视最初的设计决策,意识到加入数学计算是必要的,并且可以提高代码的整体结构。
将声音代码移入测试资产构建器
在实现音频加载时,首先需要确保能够加载声音文件,并处理不同类型的资产。我们通过调用 read entire file
函数来读取完整的音频文件,然后判断当前处理的是哪种类型的资产。如果是声音文件,就会调用 load wave
函数来加载它。对于位图文件,调用 load BMP
函数来加载。加载完成后,我们需要处理文件中的数据,并确保数据的格式与预期一致。
特别是在加载声音文件时,需要处理样本的数量、通道数量等信息。有时样本数量可能未指定,这时会从文件中读取实际的样本数量;如果指定为零,则会使用文件中提供的数据。此外,需要关注音频文件的通道数量,这在处理多通道音频时非常重要。我们从文件中读取通道数量,并将其存入相应的数据结构中。
在加载完成后,数据会被写入一个数组中,这个数组会存储音频文件的所有相关信息。然后,音频的实际样本数据会被块写入到文件中。为了支持多通道音频,数据写入过程会使用循环处理每个通道的数据,每个通道的数据大小由样本数量和单个样本的大小决定。
最后,所有数据都会被写入到指定的位置。需要确保在写入时,所有的音频数据和相关信息都能够正确地被保存到文件中,以便后续使用。通过这种方式,确保了音频文件的加载、处理和保存过程能够按照预期进行,并且支持不同数量的通道。


CRT的ftell不支持64位偏移量
在文件写入过程中,我们需要确保在写入数据之前,记录文件当前的偏移位置,以便在文件中正确存储数据偏移地址。由于我们使用的是标准文件操作函数 fseek
,但该函数在某些平台上可能不支持64位偏移,因此需要注意如果未来文件大小超过4GB时,可能会存在兼容性问题。尽管如此,为了当前的测试,我们选择使用32位偏移值,因为我们知道目前的数据量不会超过此限制。
首先,我们通过 fseek
获取当前文件的偏移位置,用于记录数据在文件中的起始地址。这对于位图和音频数据都是一样的,因此我们可以将该操作共享处理,不需要在两个分支中重复执行。获取到偏移位置后,我们立即将其记录到文件结构中,以便后续使用。
接下来,在处理位图文件时,我们需要将文件中的位图数据尺寸读取出来,包括宽度和高度,然后存储在文件结构中。文件中的 loaded_bitmap
结构仅包含宽度和高度,因此我们只需要直接读取并存储这两个值即可。在读取宽度和高度之后,我们需要确定数据的内存大小,以便进行写入。由于我们采用的是32位像素格式(每个像素占4个字节),因此数据大小计算公式为:宽度 * 高度 * 4。
数据写入时,我们再次使用 fseek
获取当前位置,并将其作为数据在文件中的偏移地址,然后将整个位图数据块写入文件中。我们通过直接写入内存数据的方式来确保所有数据连续存储,并保证文件的布局一致。
在处理音频文件时,操作流程基本相同。首先获取当前文件的偏移位置,记录在结构中,然后将所有音频数据写入文件。音频数据的大小由通道数量和样本数量决定,因此我们需要读取通道数量和样本数量,然后将其作为数据写入的依据。在多通道情况下,我们通过循环处理每个通道的数据,确保所有通道数据都能正确写入。
需要注意的是,在写入数据后,我们需要释放之前分配的内存以避免内存泄漏。这对于音频和位图数据都需要进行相同的内存释放处理,因此这部分操作也是通用的。
此外,我们还需要确保数据在文件中存储的连续性,因此所有的数据写入都会严格按照文件格式定义执行。音频数据的偏移地址和位图数据的偏移地址通过 fseek
获取,并确保写入的数据不会出现错位或重叠的情况。同时,数据写入时的大小也是严格根据数据结构计算得出的,避免写入错误导致文件格式异常。
总之,我们确保了在文件中准确存储了所有音频和位图数据,并记录了数据在文件中的偏移地址。通过通用的 fseek
获取偏移位置,并在两个分支共享该操作,从而避免重复代码。后续只需确保在加载数据时能够按照文件结构正确解析这些数据,即可实现文件的加载和存储功能。

ftell
函数是 C 语言标准库 (stdio.h
) 提供的一个函数,用于获取文件流的当前位置(也就是文件指针的位置)。在文件读写过程中,我们可以通过 ftell
获取当前文件指针在文件中的偏移量,从而用于记录数据的起始位置或计算文件大小等用途。
✅ ftell函数的原型
c
long int ftell(FILE *stream);
✅ 参数
FILE *stream
:文件流指针,表示当前操作的文件。
✅ 返回值
- 成功时 :返回文件流指针当前位置相对于文件开头的偏移量(以字节为单位)。
- 失败时 :返回
-1L
,表示出现错误,比如:- 文件流未打开。
- 文件指针非法。
- 文件出现 IO 错误。
✅ 说明
ftell
返回的是 long 类型的值 ,因此在32位系统中最大只能表示2GB
或4GB
的偏移量(long
在32位系统中最大表示2,147,483,647
或4,294,967,295
字节)。- 在64位系统 上,
ftell
可能仍然只能表示4GB
范围内的文件位置。因此如果要处理超过 4GB 的文件 ,需要使用ftello
或ftell64
等函数。
✅ 使用场景
📌 场景1:获取当前文件偏移位置
如果我们想要记录当前数据在文件中的起始地址 ,可以使用 ftell
:
c
#include <stdio.h>
int main() {
FILE *file = fopen("example.txt", "r");
if (file == NULL) {
printf("文件打开失败\n");
return 1;
}
// 获取文件起始位置,应该是0
long pos = ftell(file);
printf("文件指针初始位置: %ld\n", pos);
// 读取几个字符
char buffer[10];
fread(buffer, 1, 5, file);
// 获取当前文件指针的位置
pos = ftell(file);
printf("文件指针当前位置: %ld\n", pos);
fclose(file);
return 0;
}
输出示例:
文件指针初始位置: 0
文件指针当前位置: 5
解释:
- 文件指针在读取5个字节后,偏移量变为5。
- 可以通过
ftell
确定文件中的任何数据的起始位置。
✅ 场景2:计算文件大小
可以通过 ftell
配合 fseek
计算文件的大小:
c
#include <stdio.h>
int main() {
FILE *file = fopen("example.txt", "rb");
if (file == NULL) {
printf("文件打开失败\n");
return 1;
}
// 将文件指针移动到文件末尾
fseek(file, 0, SEEK_END);
// 获取文件末尾的偏移量(文件大小)
long size = ftell(file);
printf("文件大小: %ld 字节\n", size);
fclose(file);
return 0;
}
解释:
fseek(file, 0, SEEK_END)
:将文件指针移动到文件末尾。ftell(file)
:获取文件末尾的偏移量,相当于文件大小。
✅ 场景3:获取二进制数据的起始位置
假设我们在文件中写入了音频数据和图像数据,我们想记录:
- 音频数据在文件中的起始位置。
- 图像数据在文件中的起始位置。
我们可以这样做:
c
#include <stdio.h>
int main() {
FILE *file = fopen("data.bin", "wb");
if (file == NULL) return 1;
// 写入音频数据
long audioOffset = ftell(file);
fwrite(audioData, 1, audioDataSize, file);
// 写入图像数据
long imageOffset = ftell(file);
fwrite(imageData, 1, imageDataSize, file);
printf("音频数据起始位置: %ld\n", audioOffset);
printf("图像数据起始位置: %ld\n", imageOffset);
fclose(file);
return 0;
}
解释:
- 在写入音频数据之前,通过
ftell
获取音频数据在文件中的偏移位置。 - 在写入图像数据之前,通过
ftell
获取图像数据在文件中的偏移位置。 - 后续在加载文件时,可以通过偏移地址直接跳转到指定的数据。
✅ 场景4:搭配fseek回到特定偏移位置
如果我们已经知道了数据的偏移地址,可以通过 fseek
配合 ftell
定位文件中的数据:
c
#include <stdio.h>
int main() {
FILE *file = fopen("data.bin", "rb");
if (file == NULL) return 1;
// 跳转到音频数据的位置
fseek(file, audioOffset, SEEK_SET);
// 读取音频数据
fread(audioBuffer, 1, audioDataSize, file);
fclose(file);
return 0;
}
fseek(file, audioOffset, SEEK_SET)
:将文件指针移动到audioOffset
处。fread
就能直接读取音频数据。- 同理,可以跳转到
imageOffset
处读取图像数据。
✅ ftell的局限性
📌 1. 32位平台最大只能读取4GB文件
在32位系统中:
c
long int ftell(FILE *stream);
long
类型最大表示 2,147,483,647 (2GB)或 4,294,967,295(4GB)。- 超过 4GB 的文件将导致
ftell
返回负数或异常值。
📌 2. 解决方法:使用64位ftell
如果处理大文件(超过4GB),需要使用:
平台 | 替代函数 | 类型 |
---|---|---|
Linux | ftello() |
off_t (64位) |
Windows | _ftelli64() |
__int64 (64位) |
示例:
c
#include <stdio.h>
int main() {
FILE *file = fopen("data.bin", "rb");
if (file == NULL) return 1;
// 获取64位文件偏移
long long pos = _ftelli64(file);
printf("文件偏移: %lld\n", pos);
fclose(file);
return 0;
}
✅ 总结
功能 | ftell | 替代方案 (64位) |
---|---|---|
获取文件偏移地址 | long int ftell(FILE*) |
_ftelli64() |
最大支持文件大小 | 2GB 或 4GB | 理论无限 |
通用性 | 适用于小文件 | 大文件推荐使用 |
常用场景 | 记录数据偏移、计算文件大小 | 处理超大文件 |
✅ 建议
- 如果文件小于4GB :直接使用
ftell
即可。 - 如果文件超过4GB :请使用:
- Windows :
_ftelli64()
或fseek64()
。 - Linux :
ftello()
或fseeko()
。
- Windows :
- 在处理资源文件(如音频、图像)时,建议提前记录数据的 偏移地址,以便快速定位和加载数据。
💡 适用场景
使用场景 | 推荐函数 |
---|---|
记录文件偏移 | ftell |
获取文件大小 | ftell + fseek |
加载文件中特定资源 | ftell + fseek |
读取超大文件(>4GB) | _ftelli64() |
💡 如果需要,我还可以给你演示如何用ftell
处理自定义文件格式(如游戏资源打包文件)! 🚀😎
防止多个位图的pitch问题
我们需要确保位图(bitmap)的stride(步幅)和 width(宽度)是相等的。具体来说,位图的步幅 应该等于宽度乘以4 (因为每个像素占用4个字节),这样数据才会是连续的、无填充的,方便加载和使用。
如果我们遇到不符合这种格式 的位图(即每行数据存在额外填充),那么我们就需要在这里编写代码 来移除这些填充,以保证我们加载的数据格式始终一致。
我们不希望在游戏运行时加载数据时还需要处理各种不同的步幅 ,因为这会增加加载和处理的复杂性。因此,我们希望所有位图的数据都是连续排列 ,即每一行数据长度 = 宽度 × 4 ,中间无填充。
如果未来我们需要将位图填充到特定的边界对齐 (比如对齐到16字节或32字节),那么我们也应该在这里添加代码 来处理这种填充逻辑,而不是把这种复杂性推迟到加载时再处理。
将资产数据加载到可释放内存中
我们现在的任务是确保加载的文件数据 在使用完之后,可以通过释放内存(free)的方式进行回收,避免内存泄漏 。目前,我们的LoadWav
和LoadBitmap
函数在读取文件数据时,还没有正确地提供可释放的内存块,因此我们需要调整一下它们的实现方式。
首先,我们需要实现一个通用的读取文件内容的函数 ,我们将其命名为ReadEntireFile
,它的职责是读取整个文件的内容 并返回一块内存 ,这块内存是可以被释放的,这样在加载资源时,就不会出现内存泄漏的风险。
✅ 实现可释放的内存块
我们定义了一个函数ReadEntireFile
,它接收一个文件路径,然后读取文件的全部内容,并将内容存储在一块内存 中返回。同时,我们还需要确保这块内存可以被释放 ,因此我们会在文件读取完成后,提供一个Free
指针 ,用于释放内存。
我们打算设计类似这样的结构:
c
struct FileReadResult
{
void* Contents;
uint32 ContentsSize;
};
Contents
是指向文件数据的指针,ContentsSize
是文件数据的大小。
该函数的实现逻辑如下:
- 打开文件,并确定文件大小。
- 分配一块内存,大小等同于文件大小,用于存放文件内容。
- 读取文件内容并存放到内存中。
- 返回文件数据的指针,供调用者使用。
- 提供一个
Free
指针,用于在使用完成后释放内存。
如果文件打开失败或者内存分配失败,则返回一个空的FileReadResult
,表示读取失败。

C运行时库版ReadEntireFile
我们当前的任务是实现一个通用的文件读取函数 ,其功能是读取磁盘上的文件 ,将文件的全部内容 加载到动态分配的内存 中,并且保证内存是可释放的,防止内存泄漏。这个文件读取函数是核心基础设施之一,因为我们后续加载的所有资源(比如音频、图片、数据文件等),都需要通过它来进行文件读取。
✅ 文件读取的基本流程
我们的思路是这样的:
- 打开文件 :使用
fopen
函数以二进制模式 (rb
)打开文件,如果文件不存在或者无法打开,直接报错并返回空内容。 - 获取文件大小 :使用
fseek
和ftell
确定文件大小,然后将文件指针复位到起始位置。 - 动态分配内存 :根据文件大小,使用
malloc
函数分配一块与文件大小完全匹配的内存。这块内存将存储文件的全部内容。 - 读取文件内容 :使用
fread
函数将文件内容全部读取到内存中。 - 关闭文件:读取完成后立即关闭文件,避免文件句柄泄漏。
- 返回内存地址 :将动态分配的内存地址和大小打包成一个
FileReadResult
结构体返回,供调用者使用。 - 释放内存 :调用者使用完数据后,必须手动调用
free
函数释放内存,否则将发生内存泄漏。
✅ 具体的函数设计
我们先定义一个结构体,用来表示文件的读取结果:
c
struct FileReadResult
{
void* Contents; // 文件内容指针
uint32 ContentsSize; // 文件大小
};
这个结构体会在读取文件后返回,调用者可以通过Contents
指针获取文件内容,通过ContentsSize
获取文件大小。
✅ 打开文件的操作
我们使用fopen
函数以二进制模式 (rb
)打开文件:
c
FILE* In = fopen(FileName, "rb");
if(!In)
{
printf("Error: Cannot open file %s\n", filePath);
return {};
}
如果文件无法打开,直接返回一个空的FileReadResult
结构体,并在控制台打印错误信息。
✅ 获取文件大小
我们需要确定文件大小,以便分配合适的内存。这里我们使用fseek
和ftell
:
c
fseek(inputFile, 0, SEEK_END);
size_t fileSize = ftell(inputFile);
fseek(inputFile, 0, SEEK_SET);
- fseek(inputFile, 0, SEEK_END):将文件指针移动到文件末尾。
- ftell(inputFile):获取当前文件指针的偏移量(即文件大小)。
- fseek(inputFile, 0, SEEK_SET):将文件指针复位到文件起始位置,准备开始读取内容。
通过这种方式,我们获取到了文件的总大小。
✅ 分配内存
接下来,我们需要为文件内容动态分配内存,内存大小等于文件大小。
c
Result.Contents = malloc(Result.ContentsSize);
✅ 读取文件内容
我们使用fread
函数将文件内容一次性读入内存中:
c
fread(Result.Contents, Result.ContentsSize, 1, In);
Result.Contents
是内存地址;Result.ContentsSize
是要读取的数据大小;1
表示读取1个完整的文件;
✅ 关闭文件
无论成功与否,我们都需要关闭文件句柄,避免文件句柄泄漏:
c
fclose(In);
确保文件句柄不被占用。
✅ 返回结果
c
return Result;
这样,调用者就可以获取到文件内容以及文件大小。

意图 | 正确的写法 |
---|---|
✅ 获取文件大小 | fseek(In, 0, SEEK_END); |
✅ 获取当前大小 | size_t fileSize = ftell(In); |
✅ 重置指针到开头 | fseek(In, 0, SEEK_SET); |
❌ 保持指针不变(错误) | fseek(In, 0, SEEK_CUR); ❌ |
记住:如果你想读取文件,从头开始读取,就必须 fseek(In, 0, SEEK_SET);

跳过第一个故意留空的资产
在文件中,有些内容是故意留空的,因此不能假设文件名一定可以被加载。在最初的值中,文件名可能为null,因此不能直接使用第一个索引的文件名。为了避免这个问题,可以选择从索引1开始处理文件,因为除了第一个索引外,其余的文件名应该都是非null的。这种方法应该是可行的。接下来可以进行测试,验证这种方法是否有效。具体操作是进入系统或程序,尝试加载文件,看是否会出现问题。
调试ReadEntireFile
接下来,通过读取文件并执行比特扫描等操作,我们逐步分析文件的内容。此时,我们检查了文件的偏移量、标签和图像尺寸等信息,结果显示文件的数据偏移量、尺寸和其他数据与预期一致。这时,我们确认了文件的格式基本正确,并将其释放掉。
接下来,我们编写代码,将这些处理过的数据写入到新的文件中,这样就生成了一个包含所有游戏资源的打包文件。虽然这个文件的大小约为17MB,看起来是合理的,但我们并没有进行完全验证,因此不敢完全确定文件的内容是否正确。
通过生成的这个打包文件,我们替换了之前的测试文件,并将所有数据合并为一个名为 test.HHA
的文件。该文件包含了游戏的所有数据。接下来需要验证这个文件能否在实际的游戏中正常运行。
虽然我们还未进行完全的验证,但到目前为止的进展显示,整个过程是可行的,文件结构和数据格式应该是正确的。接下来的步骤将包括进一步的验证和测试,以确保所有功能都能正常工作。


切换到从资产包文件加载所有资产
现在我们要做的第一件事是将游戏切换为使用打包文件。首先,我们需要明确哪些内容需要正确初始化,哪些内容则不重要。比如调试相关的代码大多数可以去掉,只保留一些关键的部分。所有的调试代码和不必要的部分会被移除掉,留下必要的初始化和处理逻辑。
在清理完这些不必要的代码后,接下来的步骤是使用现有的调试文件加载例程,将整个打包文件加载到内存中,先通过这种方式进行测试。之后,计划在Windows平台层添加一个路径,使用更高效的重叠I/O操作,以便更好地处理文件的加载。
为了实现这一过程,首先需要将不必要的代码全部清空,剩下的核心部分就是文件加载相关的代码。这一阶段,我们将通过现有的加载方式来验证文件是否能够正确加载到内存中,并且确保加载的过程没有出现问题。
接下来的步骤将会更复杂,需要实现一个支持高效I/O的加载机制,确保系统能够在实际运行时更高效地处理文件数据。




处理大量代码变更。一步一步来
在进行大规模的代码更改时,最好将任务分解成小的步骤(或称为"阶段"),而不是一次性完成所有的更改。这种方法有助于我们在每次更改后验证是否正确工作,这样可以及时发现问题,避免一次性更改过多内容导致难以调试。每次只做一个小的修改,有助于更容易定位问题所在,而不是一开始就替换掉所有的内容,这样调试起来会变得复杂且耗时。
根据这个方法,首先会处理调试加载的部分,比如加载位图(BMP)和音频文件(WAV)。我们会将这部分代码暂时替换为断言操作,确保它们不会继续执行,直到我们明确这些操作应该如何进行。具体来说,会用 assert
来替代实际的加载操作,并立即返回一个结果,这样代码执行时就不会继续进行错误的加载操作。
当这些更改完成并编译时,系统会提示缺少资源,因为没有加载资产。这时的行为是非常有趣的,系统并不会崩溃,而是尝试去加载图像和资源,发现没有相关资源后,它会继续执行,不会停止,这种行为也说明了系统的设计是健壮的,可以在缺少资源的情况下继续运行。
接下来,计划恢复这些资源。首先,我们将使用调试版的 DEBUGReadEntireFile
函数读取之前写入的 test.HHA
文件。通过读取这个文件,获取其中的所有资产,然后将它们加载到内存中,系统将通过这些资源来继续工作。具体步骤包括读取文件内容,并通过遍历所有资产,将其引用填充到内存中的相关位置。
引用加载到内存中的资产文件
我们已经将整个文件加载到内存中,接下来计划直接用指针覆盖内存中的其他内容。具体来说,我们将覆盖资产计数、资产本身以及相关的数据。这些内容将从文件中加载的数据进行替换。为了正确更新这些内容,我们需要对一些数据结构进行处理,特别是资产数量和标签数量等。
首先,我们会根据从 HHA
头部中获取的资产数量来设置新的资产计数,然后将这些资产数量推送到数组中。类似地,标签的数量也会根据 HHA
文件中的信息来更新。之后,我们将通过遍历标签数组、资产类型数组以及资产数组,依次将数据填充到新的位置。
对于标签数组,目前我们还没有明确的加载方式,可能会考虑将标签数据平铺加载,即将其直接指向内存中的相应位置。我们可能会在下一步中决定哪些数据需要平铺加载,哪些则不需要,而这部分内容会在之后的迭代中进一步优化。
在当前的实现中,我们将数据的源(即从文件中加载的标签)和目标(即我们最终使用的资产标签数组)进行匹配和复制。具体来说,我们会将标签的ID和对应的值从源复制到目标。这里的关键操作是确保从文件加载的数据能正确地映射到我们的数据结构中。
最后,我们已经知道从文件加载的数据结构是什么样的,下一步将是根据这些数据来正确地填充我们的内部结构,并确保数据能够顺利流动和使用。
直接将头文件强制转换为hha头文件结构,并检查魔法值和版本
首先,HHA头部是我们文件格式的第一个部分,因此我们可以通过将文件数据直接转换为对应的结构体来访问它。我们将文件中的数据直接转型为HHA头部结构,并验证它是否包含我们预期的魔法值。这样做可以确保我们读取到的文件是我们写入的文件,避免出现读取错误。
在调试过程中,我们还可以检查HHA文件版本,以确保文件格式是正确的。然后,我们可以通过读取资产数量和标签数量等信息,来确定文件中的数据布局。接下来,我们需要找到HHA标签的位置,来正确地读取标签数据。
标签数据的位置可以通过查看HHA头部中的位置指示来确定。通过将文件指针移动到标签数据的起始位置,我们就能访问这些标签数据。因此,我们需要在文件中找到标签部分的位置,并基于这个位置来读取数据。
在这一过程中,我们还注意到文件格式没有被包括在现有的代码中。为了修复这个问题,我们将需要将文件格式包含到代码中。这意味着要将必要的头文件和格式定义加入到项目中,这样我们就能正确地处理文件的加载和解析。
此外,关于数据类型的命名,也进行了些微的调整。在命名时,我们考虑了是否使用带有前导零的数字(如08
)或标准的数字(如8
)。虽然一些人在线表示应该避免使用带有前导零的命名方式,但有时为了对齐,我们还是会使用它,这样看起来更加整齐。
在所有这些调整完成后,我们会继续进行测试和验证,确保文件的正确加载和数据解析。虽然过程有些超时,但这也是正常的,特别是在调试和调整细节时。

什么是"平面加载"?
"Flat Loaded"通常有两种意思,具体含义取决于上下文。第一种解释是指直接将数据文件作为一个整体加载到内存中,然后程序直接从内存中读取数据并运行,而不对数据进行进一步的修改或分配内存空间。也就是说,这种方式加载的内存是一个连续的块,游戏或程序可以直接从这个块中读取并执行数据,而不需要重新调整数据的位置或者分配新的内存区域。
这种加载方式被称为"Flat Loaded",因为数据是直接一次性加载进内存中,没有经过额外的处理和修改。相比之下,另一种常见的数据加载方式则可能需要额外的步骤,比如先将整个文件加载到内存中,然后根据需要解析出数据结构并为每个数据部分分配内存,进行更为细致的操作。这种方式通常较为繁琐且低效,因为需要在加载过程中做很多额外的工作,因此尽量避免使用。
尽管"Flat Loaded"方式通常用于更高效的游戏或程序中,但它并不是唯一的方式。在实际应用中,加载数据时还可能会涉及解压缩步骤。即首先将数据作为一个块加载到内存中,然后进行解压,再将解压后的数据用于程序。这种方式与"Flat Loaded"类似,但因为多了一步解压处理,因此并不完全相同。通过这种方式,程序仍然可以利用内存中的数据块,但会先对数据进行必要的解压操作,以便更高效地使用。
总的来说,Flat Loaded方式能够有效减少不必要的内存分配和数据重排,因此被认为是一种性能较好的数据加载方式。
为什么将所有资产放在一个文件中比将不同类型的资产分开到不同文件中更有好处?
有人提到了将所有资源放在一个文件中而不是为每种资源类型创建不同文件的好处。这个问题需要澄清一下,因为它可以有两种不同的理解。
第一种理解是,为什么要将所有资源放在一个大的文件中,而不是为每个资源(例如每个位图)创建单独的文件?这种问题的重点在于资源如何存储在文件中,是否通过将不同的资源类型(如位图、音效等)分别存储在不同的文件中,或者将所有资源集中在一个大的文件中。
第二种理解是,为什么要选择将资源都放在一个文件里,而不是选择多个文件,例如一个文件专门存储音效,另一个文件专门存储位图?这涉及到将不同资源按类别分开存储和管理,还是将所有资源都统一放在一个文件中。
这两个问题虽然看起来相似,但实际上是不同的,因此需要确保理解清楚提问者的真正意思,以便做出合适的回答。
如果查询没有匹配的资产,是否应该有错误断言?
如果查询没有找到匹配的资源,是否应该使用断言(assert)来进行处理呢?对此不确定,可能并不需要使用断言。更可能的做法是设置一种方式来记录这种情况,例如通过日志记录。建议采取一种等待和观察的方式,看看是否会遇到这种情况,如果确实出现了问题,再做进一步的处理。
我们的单一文件HHA文件格式
目前的文件结构大致是这样的,它由几个部分组成。首先是文件的头部,头部里面包含了一个前导部分,这部分其实没有什么具体内容。接下来有一个魔法值(Magic Value),表示一些特殊的标识,还有一些计数和地址信息。然后是一些数组,比如标签、资源类型和具体的资源。这些数组之间是相互关联的,数组指向特定的数据,指示各个资源的具体位置。
当前的文件中,资源按定义的顺序排列,通常是位图(Bitmap)资源先列出,接着是声音(Sound)资源。但这些资源的顺序并不是固定的,我们可以在定义时调整顺序,比如可以将位图和声音资源交替排列,这样仍然是有效的。所以,资源的顺序只是当前定义方式的结果,并不意味着文件格式本身要求按照这个顺序排列。
为什么要拆分我们的HHA文件?
为什么不将资源分成两个文件,一个存储位图(比如BMP文件),另一个存储声音(比如WAV文件),然后每个文件的开头都加上一个头部信息,这样就有两个文件,一个专门存位图,一个专门存声音。
针对这个问题,首先想到的理由是,为什么要这么做呢?增加文件数量有什么好处?如果我只使用一个文件,这个文件包含了所有数据,当我打开这个文件时,只需要检查是否能成功打开这个文件,就知道可以获取到所有的资源了。如果文件打不开,唯一需要担心的就是文件损坏或丢失的情况。
但是如果使用两个文件,就需要处理更多的工作。在启动时,除了要处理位图文件,还需要处理声音文件,这样就多了一个解析路径,意味着更多的工作、更多的潜在错误和更多的代码。所以,最主
要的原因就是没有必要这样做,为什么不将文件简化,合并成一个文件呢?没有找到将它们分开存储的明显好处,因此更倾向于将所有资源都存储在一个文件中。
游戏是多人还是单人?会不会像这个名字所暗示的那样让玩家自己编程角色?
游戏将是单人游戏,而不是多人游戏。虽然游戏的最终版本将是单人游戏,但为了展示如何编程,我们在代码中支持了多人游戏的功能。例如,在引擎测试代码中,如果插入一个Xbox手柄并按下按钮,就会添加第二个角色,这表明代码是支持多人游戏的。
然而,游戏的设计并不适合多人游戏,所以虽然代码本身可以支持多人同时游玩,但游戏的玩法和体验并不是为了多人互动而设计的,也不会以多人游戏的形式发布。我们确保代码可以支持多人功能,但最终发布的游戏将专注于单人游戏,因为多人模式在这种设计下不会带来有趣的游戏体验。
为什么你的个人资料网页在没有JavaScript的情况下无法查看?
我的网页之所以需要JavaScript才能查看,是因为我对传统的网页技术(HTML、CSS和JavaScript)并不喜欢,觉得它们都很糟糕。出于这个原因,我做了一些实验,其中之一就是制作一个不依赖这些技术的网站。
我的网页实际上是一个C程序,它负责布局,并将JavaScript作为后端输出,就像一个编译器一样,生成我在C程序中设计的布局。这是我在业余时间做的一个项目,虽然它并不完美,但我从中学到了一些东西。
在实现这个过程中,我还与Firefox的开发者讨论,如何使这个过程更加高效。因为JavaScript和HTML非常慢,甚至做一些基本的布局都几乎不可能,除非掌握一些特定的技巧。所以,我的网页设计方法实际上是绕过了这些常见的网页技术,尝试用一种不同的方式实现网页的布局和展示。
什么是位图?
位图(Bitmap)这个词其实是一个误用,虽然它在早期的定义中更为准确。在最初的计算机图形中,位图确实是指"比特的图",也就是说,它是由0和1构成的图像,每一个0或1代表图像中的一个位置的状态。如果在早期的显示器上使用这种方式,比如黑白显示,0表示不绘制,1表示绘制,这样就能够构成图像。
比如,假设要绘制字母"H",可以通过在一个矩阵中使用1来表示字母的部分,0表示背景部分。这个过程实际上就是将一个字符的图像存储为"比特图"(bitmap)的形式。在那时,位图的定义是相对准确的,因为显示器通常是单色的,一个比特可以表示一个像素的开关状态。
然而,随着显示技术的发展,尤其是彩色显示的出现,仅仅用0和1来表示一个像素显然不够用了。现在,位图更多的是指一种每个像素存储多个值的数据结构。现代的位图文件通常会在每个像素位置存储完整的RGBA(红色、绿色、蓝色、透明度)颜色值,每个颜色通道占8位,从0到255之间的值,用来表示颜色的强度。
因此,位图这个词的定义已经发生了变化,虽然它仍然是由比特组成的,但现在的位图实际上可以表示更多的信息,比如彩色和透明度。尽管如此,位图这个术语依然存在,它的起源实际上是指每个像素只有一个比特,用于黑白显示,随着技术进步,才逐渐扩展为现在的彩色位图。
什么是重叠I/O?
重叠I/O(Overlapped I/O)是Windows操作系统中的一种机制,允许对硬盘进行异步读取操作。通过这种方式,可以在等待数据从硬盘读取的同时,继续执行其他任务,而不需要阻塞程序的运行。这个机制允许更高效地管理I/O操作,因为它使得程序可以在执行其他工作时,仍然能够并行进行数据读取。
在接下来的几天里,将详细介绍如何使用重叠I/O来进行资源加载的过程。
在这种情况下,为什么其他开发者将资产拆分到不同的文件中?
有些开发者会将资源分成多个文件,比如把声音文件和位图文件分别存储在不同的文件中。对于这个问题,我不太清楚为什么会这么做,因为我个人没有遇到过这样的做法,也不认识有开发者这样做。这并不代表没有这样的做法,只是我没有亲自见过。因此,我不能解释为什么他们会这样做。
如果问题是问为什么很多开发者把每个资源单独存储为一个文件,可能是因为他们没有使用打包文件(pack files),或者因为不想处理打包文件的复杂性。打包文件可以将多个资源合并成一个文件,但可能会让一些开发者觉得麻烦,所以他们选择保持每个资源单独存储为一个文件。
拥有单独的文件是否有助于游戏的修改或补丁?
将资源分成不同的文件并不一定有助于修改或打补丁。因为我们设定的资源文件格式允许添加尽可能多的文件,所以始终可以根据需要增加新的资源。因此,从资源的管理角度来看,是否将资源分开并不会带来太大差异。
至于打补丁,如果我们需要更新或替换现有的资源,可能需要考虑补丁的方式。但实际上,如果打补丁的机制设计得当,可能能够自动处理这些更新,所以不一定需要额外的工作。总的来说,这个过程可能会比较简单,打补丁的需求并不会因为资源是否分开存储而产生太大不同。
面向数据设计和压缩导向编程有什么不同吗?
压缩导向编程和数据导向设计是不同的概念,尽管它们有些地方可能会重叠。数据导向设计关注的是如何在编写代码时,首先聚焦于数据的处理方式。它强调编写代码的首要目标是根据需要改变数据的结构,无论是将数据移动到另一个位置,还是直接在原地进行修改。简单来说,数据导向设计的核心是围绕数据的操作和变化来组织代码,这是计算机在处理数据时的基本任务。
而压缩导向编程则是处理更高层次的结构,它关注如何通过抽象和重用代码来提高效率。压缩导向编程并不是改变数据的操作方式,而是通过提取出类似的部分,将其构建成可重用的模块,并确保这些模块仍然遵循数据导向设计的原则,避免改变其基本功能。其目标是保持数据处理的一致性和正确性,同时通过合理的结构化,能够在多个场景中复用相同的代码。
总之,数据导向设计关注的是如何高效地操作和处理数据,而压缩导向编程则是如何优化和重用代码,保证设计在数据处理的基础上更加灵活和高效。
拆分文件的一个原因是为了绕过FAT32的4GB限制。另一个原因是,如果你的工作流程允许音频开发人员更新他们的包文件,而不需要更改其他游戏部分(如模型、纹理),这也是一个好处
将文件拆分的一个原因是为了绕过FAT32的4GB限制。另一个原因可能是在开发过程中,如果音频开发人员能够上传和更新他们的包文件,而不需要修改游戏的其他部分,这样可以避免影响游戏的其他资源。然而,这属于开发阶段的事情,与最终游戏的发布方式是不同的。
关于FAT32的限制,现在可能已经不太常见了。即使在传输数据时使用U盘,可能还会遇到FAT32的4GB文件大小限制,但这通常不再是常见的问题。
至于在哪里可以阅读到相关的实际内容,这里并没有明确的资料来源,但可以通过查找有关FAT32文件系统的文档或关于开发过程中的资源管理资料来了解更多。
游戏是否需要等到所有资产都解压完成后才能启动?
游戏并不需要等到所有资产都解压缩完毕才能启动。处理方式是将每个资源单独压缩,而不是将所有资源作为一个整体块压缩。这样,当需要加载某个特定的资源时,游戏只需要解压该资源,而不是解压所有资源。因此,解压的过程是有选择性的,只针对需要的资源进行解压,以支持随机访问。
为什么你使用Windows而不是Linux?
选择使用Windows而不是Linux作为主要开发平台的原因,主要是因为Windows在PC游戏市场中占据了绝大多数份额。根据Steam硬件调查,约96%的玩家使用Windows系统,而使用MacOS和Linux的玩家分别只有不到4%。因此,作为游戏开发者,选择Windows作为主要开发平台是为了能够覆盖大多数潜在玩家,确保游戏能够顺利销售和获得成功。如果没有支持Windows,游戏可能会面临无法顺利发布、玩家反馈大量问题的风险。
尽管如此,开发者仍然会考虑将游戏移植到Linux和MacOS等其他平台。首先,编写跨平台代码是一个有价值的技能,能帮助开发者准备应对未来可能的需求,比如移植到主机平台(例如PS4)。此外,微软对游戏开发者的态度并不友好,他们不断推行有利于自己利益的政策,比如限制旧版Windows对Direct3D的支持,强制要求开发者使用最新版本的操作系统。这种行为令开发者在Windows平台上的工作变得更加困难。
因此,尽管Windows仍然是主流平台,开发者将游戏移植到Linux也有其战略意义。随着Linux平台在Steam上的逐步崛起,开发者通过支持Linux可以增加市场竞争力,为玩家提供更多选择,甚至在未来能够减少对微软平台的依赖,避免成为单一平台的"俘虏"。通过这一举措,也有可能推动Steam Linux等替代平台的成功,从而增加整个游戏行业的多样性。
你能在直播中说点什么让reddit生气吗?他们已经在抱怨你关于OOP的言论了
对于Reddit的反应,似乎不需要刻意去做什么让他们生气,因为Reddit上的用户总是容易抱怨和发火,所以不管做什么,他们似乎都会有意见。
但是你可以在Linux上为Windows编程,对吧?
在Linux上为Windows进行开发并不算非常容易。虽然可以通过Wine来模拟运行并进行开发,但这种方法并不可靠,因为Wine与真实的Windows环境有很大的不同。所以不建议通过模拟器来测试主要平台,最好在真实的Windows平台上进行测试。虽然在Linux上开发是可行的,但由于Linux的调试工具相较于Visual Studio较为差劲,因此也不推荐将Linux作为主要的开发平台。尽管Visual Studio也有其缺点,但与Linux的调试工具相比,它依然更为有效。因此,目前不建议将Linux作为主力开发平台。