Cocos2d-x数据模块教程05:CSV文件解析

2015年03月24日 09:25 0 点赞 0 评论 更新于 2025-11-21 14:50

在游戏开发中,通常会涉及大量的怪物、关卡、技能等数据信息。为了提高代码的灵活性和可维护性,这些数据一般不会直接硬编码在代码里,而是使用配置文件进行保存,在需要使用时再加载到内存中。

CSV(逗号分隔值)文件由于编辑简单,常被用于配置游戏的数据信息。本篇文章将详细介绍如何读取CSV文件格式的数据。

CSVParser解析器下载

下载地址:https://github.com/shahdza/Cocos_LearningTest/tree/master/CSVParser

CSV简介

什么是CSV?

CSV,即逗号分隔值(Comma-Separated Values),有时也称为字符分隔值,因为分隔字符不一定是逗号,也可以是分号(;)。其文件以纯文本形式存储表格数据(包括数字和文本)。

这种文件格式常用于不同程序之间的数据交互。CSV格式数据的结构类似表格,不同的记录占用一行,一行中的字段用逗号(,)分隔。

例如:

Name,Age,City
John,25,New York
Jane,30,Los Angeles

编辑CSV文件

  • Mac OS系统:可以使用Numbers创建表格文件,然后保存为CSV格式。
    1. 使用Numbers软件编辑表格数据。
    2. 将编辑好的表格保存为CSV格式文件。
    3. 打开导出的CSV文件,会发现每条数据占一行,且每一行的数据用逗号分割。
  • Windows系统:可以使用Excel编辑CSV文件,当然也可以使用其他CSV编辑软件。

CSV格式规则

  1. 开头不留空:以行为单位。
  2. 记录分隔:每条记录占一行,以逗号为分隔符。即使列为空,也要表达其存在。
  3. 列名规则:可含或不含列名,如果包含列名,则列名位于文件第一行。
  4. 数据完整性:一行数据不跨行,无空行。
  5. 特殊字符处理 - 逗号:字段中包含逗号时,该字段必须用双引号括起来。
  6. 特殊字符处理 - 换行符:字段中包含换行符时,该字段必须用双引号括起来。
  7. 特殊字符处理 - 空格:字段前后包含空格时,该字段必须用双引号括起来(例如:a b c 应表示为 "a b c")。
  8. 特殊字符处理 - 双引号:字段中的双引号,用两个双引号表示(例如:我说:"abc" 应表示为 我说:""abc"")。
  9. 特殊字符处理 - 含双引号字段:字段中如果有双引号,该字段必须用双引号括起来(例如:我说:"abc" 应表示为 "我说:""abc""。")。

注意:中文的逗号、双引号不需要用双引号包起来。

以下是一个符合CSV格式规则的例子:

"Name","Age","City"
"John","25","New York"
"Jane","30","Los Angeles"

为什么使用CSV?

  1. 空间占用小:CSV文件格式是文本文件,占用空间比较小。
  2. 编辑方便:可以用记事本打开,编辑修改方便,同时也可以用Excel打开。
  3. 与策划工作适配:在游戏项目中,策划通常喜欢用Excel做数值和配置,而Excel可以直接另存为CSV文件。
  4. 相比XML的优势:配置也可以使用XML,Excel同样可以导出XML文件格式。但C++的标准库没有XML的读取方法,通常C++项目读取XML需要依赖第三方库,如TinyXml之类的。

CSV解析

这里介绍C++版本的CSV解析。需要注意的是,CSV文件的编码格式在手游中一般使用UTF - 8编码格式。

另外,笨木头写过Lua版读取CSV数据,可参见《Cocos2d-x Lua 读取Csv文件,更方便的使用数据》。

以下的CSV解析代码是在上述文章的基础上进行修改的,使用C/C++标准库编写,不依赖Cocos2d - x里面的任何一个类或函数。这样做的好处是增强了CSV解析的通用性,即使在编写控制台应用程序时也可以正常使用。

CSVParser.h

#pragma once
#include <vector>
#include <string>
using namespace std;

namespace CSVParser {
// 每一行的记录
class Row {
public:
Row() {}
~Row() {}
void push_back(const string& value) { m_values.push_back(value); }
void setHeader(const vector<string>* header) { m_header = header; }

// 每行数据有多少字段
unsigned int size() const { return m_values.size(); }

// 运算符 [] 重载
string& operator[](unsigned int key) {
if (key < size()) return m_values[key];
throw "can't return this value (doesn't exist)";
}

// 运算符 [] 重载
string& operator[](const string& key) {
vector<string>::const_iterator it;
int pos = 0;
for (it = (*m_header).begin(); it != (*m_header).end(); it++) {
if (key == *it) return m_values[pos];
pos++;
}
throw "can't return this value (doesn't exist)";
}

private:
const vector<string>* m_header;
vector<string> m_values;
};

class Csv {
public:
Csv(const string& filename);
~Csv();

// 解析csv文件
void Parse(const string& filename);

// 错误信息
const string& getErrorInfo() const { return m_strErrorInfo; }

// 获取列头字段
vector<string> getHeader() const { return m_header; }

// 获取总行数
unsigned int getRowCount() const { return m_content.size(); }

// 获取总列数
unsigned int getColumnCount() const { return m_header.size(); }

// 运算符 [] 重载
Row& operator[](unsigned int key);

private:
// 读取整个文件的数据
void Load(const string& filename, string& Data);

// 设置列头字段,用于[]运算符,以键值对方式获取数据值
void setHeader();

private:
// 原始表格数据
vector<Row> m_content;   // 所有行的数据(包含列头)
vector<string> m_header; // 列头字段

// 错误信息
string m_strErrorInfo;
};
}

代码说明:

  1. 命名空间:使用 CSVParser 命名空间。
  2. Row类:表示一行的数据记录,重载了 [] 运算符,可以通过“键值对”方式获取数据值。
  3. Csv类:用于解析CSV文件,也重载了 [] 运算符,可以像数组一样获取数据值。

CSVParser.cpp

#include "CSVParser.h"

namespace CSVParser {
Csv::Csv(const string& filename) {
Parse(filename);
}

Csv::~Csv() {}

void Csv::Load(const string& filename, string& Data) {
// 读取文件数据
FILE* pFile = fopen(filename.c_str(), "rb");
if (!pFile) {
return;
}

fseek(pFile, 0, SEEK_END);
long len = ftell(pFile);

char* pBuffer = new char[len + 1];

fseek(pFile, 0, SEEK_SET);
fread(pBuffer, 1, len, pFile);
fclose(pFile);

pBuffer[len] = 0;
Data.assign(pBuffer, len);

delete[] pBuffer;
}

void Csv::Parse(const string& filename) {
// 清除之前的数据
m_content.clear();
m_strErrorInfo.clear();

string text;
Load(filename, text);

if (text.size() == 0) {
return;
}

// 定义状态
enum StateType {
NewFieldStart,      // 新字段开始
NonQuotesField,     // 非引号字段
QuotesField,        // 引号字段
FieldSeparator,     // 字段分隔
QuoteInQuotesField, // 引号字段中的引号
RowSeparator,       // 行分隔符字符1,回车
Error,              // 语法错误
};

Row Fields = Row();
string strField;

// 设置初始状态
StateType State = NewFieldStart;

for (int i = 0, size = text.size(); i < size; ++i) {
const char& ch = text[i];

switch (State) {
case NewFieldStart: { // 新字段开始
if (ch == '"') {
State = QuotesField;
} else if (ch == ',') {
Fields.push_back("");
State = FieldSeparator;
} else if (ch == '\r' || ch == '\n') {
m_strErrorInfo = "语法错误:有空行";
State = Error;
} else {
strField.push_back(ch);
State = NonQuotesField;
}
}
break;

case NonQuotesField: { // 非引号字段
if (ch == ',') {
Fields.push_back(strField);
strField.clear();
State = FieldSeparator;
} else if (ch == '\r') {
Fields.push_back(strField);
State = RowSeparator;
} else {
strField.push_back(ch);
}
}
break;

case QuotesField: { // 引号字段
if (ch == '"') {
State = QuoteInQuotesField;
} else {
strField.push_back(ch);
}
}
break;

case FieldSeparator: { // 字段分隔
if (ch == ',') {
Fields.push_back("");
} else if (ch == '"') {
strField.clear();
State = QuotesField;
} else if (ch == '\r') {
Fields.push_back("");
State = RowSeparator;
} else {
strField.push_back(ch);
State = NonQuotesField;
}
}
break;

case QuoteInQuotesField: { // 引号字段中的引号
if (ch == ',') {
// 引号字段闭合
Fields.push_back(strField);
strField.clear();
State = FieldSeparator;
} else if (ch == '\r') {
// 引号字段闭合
Fields.push_back(strField);
State = RowSeparator;
} else if (ch == '"') {
// 转义
strField.push_back(ch);
State = QuotesField;
} else {
m_strErrorInfo = "语法错误: 转义字符 \" 不能完成转义 或 引号字段结尾引号没有紧贴字段分隔符";
State = Error;
}
}
break;

case RowSeparator: { // 行分隔符字符1,回车
if (ch == '\n') {
m_content.push_back(Fields);
Fields = Row(); // Fields.clear();
strField.clear();
State = NewFieldStart;
} else {
m_strErrorInfo = "语法错误: 行分隔用了回车 \\r。但未使用回车换行 \\r\\n ";
State = Error;
}
}
break;

case Error: { // 语法错误
return;
}
break;

default: break;
}
}
// end for

switch (State) {
case NewFieldStart: {
// Excel导出的CSV每行都以/r/n结尾。包括最后一行
}
break;

case NonQuotesField: {
Fields.push_back(strField);
m_content.push_back(Fields);
}
break;

case QuotesField: {
m_strErrorInfo = "语法错误: 引号字段未闭合";
}
break;

case FieldSeparator: {
Fields.push_back("");
m_content.push_back(Fields);
}
break;

case QuoteInQuotesField: {
Fields.push_back(strField);
m_content.push_back(Fields);
}
break;

case RowSeparator: {

}
break;

case Error: {

}
break;

default: break;
}

setHeader();
}

void Csv::setHeader() {
m_header.clear();
for (int i = 0; i < m_content[0].size(); i++) {
m_header.push_back(m_content[0][i]);
}
for (int i = 0; i < m_content.size(); i++) {
m_content[i].setHeader(&m_header);
}
}

Row& Csv::operator[](unsigned int key) {
if (key < m_content.size()) return m_content[key];
throw "can't return this row (doesn't exist)";
}
}

代码说明:

  • Load方法:用于读取整个CSV文件的数据。
  • Parse方法:解析CSV文件,通过状态机的方式处理不同的字符,确保正确解析CSV文件的格式。
  • setHeader方法:设置列头字段,以便通过“键值对”方式获取数据。

使用方法

1. csv文件数据

在Mac OS系统上,可以使用Numbers编辑表格,并导出为CSV格式。

2. 解析csv文件,获取数据

#include <stdio.h>
#include <iostream>
using namespace std;

//[1] 引入头文件、命名空间
#include "CSVParser.h"
using namespace CSVParser;

int main() {
//[2] csv文件完整路径
string path = "/soft/cocos2d-x-3.4/projects/Demo34/Resources/testCSV.csv";

//[3] 解析csv文件
Csv csv = Csv(path.c_str());

//[4] 获取总行数(包含列头)、总列数
printf("总共有 %d 行\n", csv.getRowCount());
printf("总共有 %d 列\n", csv.getColumnCount());

//[5] 获取所有数据(第0行为列头字段)
// csv.getRowCount() : 数据总行数(包含列头)
for (int i = 0; i < csv.getRowCount(); i++) {
// csv[i].size() : 每条数据有多少字段
for (int j = 0; j < csv[i].size(); j++) {
printf("%s,", csv[i][j].c_str());
}
puts("");
}

//[6] 也可以根据列头名称,获取数据
printf("%s\n", csv[2]["备注"].c_str());

//[7] 获取某一行数据
Row row = csv[4];
printf("%s\n", row["姓名"].c_str());

return 0;
}

代码说明:

  1. 引入头文件和命名空间:包含 CSVParser.h 头文件,并使用 CSVParser 命名空间。
  2. 指定文件路径:设置CSV文件的完整路径。
  3. 解析文件:创建 Csv 对象并传入文件路径进行解析。
  4. 获取基本信息:获取CSV文件的总行数和总列数。
  5. 遍历数据:通过两层循环遍历CSV文件的所有数据。
  6. 根据列头获取数据:可以根据列头名称获取特定的数据。
  7. 获取某一行数据:可以获取指定行的数据。

3. 运行结果

运行上述代码后,会输出CSV文件的总行数、总列数以及所有数据,同时可以根据列头名称获取特定的数据。