使用游戏概念学习 C 语言/角色扮演游戏设计
现在我们已经涵盖了编译和模块化的基础知识(我们稍后会完善该部分),让我们继续进行游戏设计。我们的游戏将使用终端实现,并且不依赖于任何第三方库,除了 C 默认提供的库。我们将采用自底向上的方法来创建我们的角色扮演游戏(以下简称 RPG)。
在进行一个复杂的新项目时,对程序的功能和实现方式进行一些头脑风暴非常重要。虽然这听起来很明显,但往往很容易直接跳入编码,并根据脑海中出现的想法来实现。直到代码变得数百行长,才会发现组织思路是必要的。最初的头脑风暴阶段会将主要思想(在本例中为 RPG)分解成更基本的要素。对于每个元素,我们要么将其分解成更小的元素,要么简要概述如何实现它。在商业环境中,这个头脑风暴会议会产生一份规格说明文档。
每个 RPG 的核心都是玩家。
玩家应该:
- 拥有描述其能力的属性。
- 能够彼此互动(例如,对话、战斗)。
- 能够从一个地点移动到另一个地点。
- 能够携带物品。
属性:简单。只是一个变量,告诉我们属性是什么(例如,生命值),并包含一个整数值。
对话:为了促进对话,玩家需要脚本化的对话。这些对话可以与主要角色一起存储,或者与主要角色将与其互动的人一起存储,并且在后一种情况下,主要角色必须能够访问这些对话。
战斗:一个函数,当给定一个玩家(进行攻击)时,会启动一个战斗序列,该序列会持续到有人撤退或某个玩家的生命值降至 0 为止。
地图:一次浩瀚而史诗般的旅程涉及许多地点。每个地点节点告诉我们玩家到达该地点后会看到什么,以及他可以从那里去哪里。每个地点节点都具有相同的结构。这个新的节点将与第一个节点具有相同的结构,但包含不同的信息。每个地点节点都分配了一个唯一的地址 ID,告诉计算机可以在哪里找到另一个地点节点。“他可以去哪里”传统上是一个最多包含 10 个地址 ID 的数组,以允许玩家最多向 10 个方向移动——上、下、北、南、东、东北等。
移动:玩家应该包含一个链表或二叉树的节点。第一个地址 ID 告诉我们玩家当前所在的位置。第二个地址 ID 告诉我们玩家来自哪里(这样玩家就可以说“返回”)。移动将涉及将玩家的位置(例如,沼泽)更改为另一个节点的地址 ID(例如,森林)。如果这听起来很令人困惑,别担心,我会画一些图来说明这个概念。ː)
物品栏:物品栏将从一个双向链表开始。一个物品节点包含一个物品(例如,生命药水)、该物品的数量、物品的描述以及两个链接。一个链接到列表中前一个物品,另一个链接到列表中下一个物品。
这个初步的规格说明充当下一阶段(实际编码部分)的蓝图。现在我们已经将主要思想分解成更小的元素,我们可以专注于创建单独的模块来实现这些功能。例如,我们将实现玩家和玩家函数在 Main 文件中进行测试。一旦我们确信我们的代码正常工作,我们就可以将我们的数据类型和函数迁移到 Header 文件中,并在我们想要创建和操作玩家时调用该文件。这样做将大大减少查看主文件中代码的数量,并且将玩家函数保存在 player 头文件中将为我们提供一个查找、添加、删除和改进玩家函数的逻辑位置。随着我们的进展,我们可能会想到在规格说明中添加新内容。
因为玩家过于复杂,无法用单个变量表示,所以我们必须创建一个结构体。结构体是复杂的数据类型,可以同时保存多个数据类型。下面是一个玩家结构体的基本示例。
struct playerStructure {
char name[50];
int health;
int mana;
};
使用关键字struct,我们声明了一个名为playerStructure的复杂数据类型。在花括号内,我们使用所有需要保存的数据类型来定义它。此结构体可用于创建与之相同的新结构体。让我们用它来创建一个英雄并显示他的属性。
player.c
#include <stdio.h>
#include <string.h>
struct playerStructure {
char name[50];
int health;
int mana;
} Hero;
// Function Prototype
void DisplayStats (struct playerStructure Target);
int main() {
// Assign stats
strcpy(Hero.name, "Sir Leeroy");
Hero.health = 60;
Hero.mana = 30;
DisplayStats(Hero);
return(0);
}
// Takes a player as an argument and prints their name, health, and mana. Returns nothing.
void DisplayStats (struct playerStructure Target) {
// We don't want to keep retyping all this.
printf("Name: %s\nHealth: %d\nMana: %d\n", Target.name, Target.health, Target.mana);
}
让我们回顾一下我们的代码做了什么。我们包含了一个名为<string.h>的新标准库,其中包含一些有助于处理字符串的函数。接下来,我们定义了复杂数据类型 playerStructure,并在其后立即声明了一个名为 Hero 的 playerStructure。请注意,在定义结构体后,分号始终是必需的。与高级语言不同,在 C 中不能使用赋值运算符 = 来分配字符串,只能分配构成字符串的单个字符。由于 name 长度为 50 个字符,假设我们有 50 个空格。要将“Sir Leeroy”分配给我们的数组,我们必须按顺序将每个字符分配给一个空格,如下所示
name[0] = 'S'
name[1] = 'i'
name[2] = 'r'
name[3] = ' '
name[4] = 'L'
name[5] = 'e'
name[6] = 'e'
name[7] = 'r'
name[8] = 'o'
name[9] = 'y'
name[10] = '\0' // 字符串结束标记
函数 Strcpy() 本质上会循环遍历数组,直到到达任一参数的字符串结束标记,并一次分配一个字符,如果字符串小于我们存储它的数组的大小,则用空格填充其余部分。
我们结构体 Player 中的变量称为成员,可以通过struct.member语法访问它们。
现在,如果我们的游戏只有英雄而没有敌人,那将是乏味和平淡的。为了添加更多玩家,我们需要键入“struct playerStructure variableName”来声明新玩家。这很繁琐且容易出错。相反,如果我们为玩家数据类型有一个特殊的名称,我们可以像使用 char、int 或 float 一样随意地调用它,那就好多了。这可以通过使用关键字typedef轻松实现!与之前一样,我们定义了复杂数据类型 playerstructure,但不是之后声明一个 playerStructure,而是创建了一个关键字,可以在任何时候声明它们。
player2.c
#include <stdio.h>
#include <string.h>
typedef struct playerStructure {
char name[50];
int health;
int mana;
} player;
// Function Prototype
void DisplayStats (player Target);
int main () {
player Hero, Villain;
// Hero
strcpy(Hero.name, "Sir Leeroy");
Hero.health = 60;
Hero.mana = 30;
// Villain
strcpy(Villain.name, "Sir Jenkins");
Villain.health = 70;
Villain.mana = 20;
DisplayStats(Hero);
DisplayStats(Villain);
return(0);
}
// Takes a player as an argument and prints their name, health, and mana. Returns nothing.
void DisplayStats (player Target) {
printf("Name: %s\nHealth: %d\nMana: %d\n", Target.name, Target.health, Target.mana);
}
仍然存在创建玩家的问题。我们可以在程序开始时定义游戏中将出现的所有玩家。只要玩家列表很短,这可能是可以忍受的,但所有这些玩家都会占用内存,无论它们是否被使用。从历史上看,由于旧计算机上的内存稀缺,这将是一个问题。如今,内存相对丰富,但为了可扩展性,并且因为用户在后台运行其他应用程序,所以我们希望有效地使用内存并动态地使用它。
动态分配内存是通过使用malloc来完成的,这是一个包含在<stdlib.h>中的函数。给定要返回的字节数,malloc 会找到未使用的内存并将该内存的地址交给我们。要使用此内存地址,我们使用一种称为指针的特殊数据类型,该类型旨在保存内存地址。指针的声明方式与其他数据类型相同,只是我们在变量名前面加了一个星号 (*)。考虑以下代码行:
player *Hero = malloc(sizeof(player));
这是声明指针并为其分配内存地址的标准方法。星号告诉我们,我们不想声明一个具有固定、不可更改的内存地址的玩家,而是想要一个可以指向任何玩家地址的变量。未初始化的指针的值为 NULL,这意味着它们不指向任何地址。由于很难记住单个数据类型中有多少个字节,更不用说我们的玩家结构体了,因此我们使用 sizeof 函数来为我们计算出来。Sizeof 返回 player 中的字节数给 malloc,malloc 会找到足够的空闲内存来容纳一个 player 结构体,并将地址返回给我们的指针。
如果 malloc 返回内存地址 502,Hero 现在将指向位于 502 的玩家。指向结构体的指针有一种独特的方式来调用成员。我们现在使用箭头 (->) 代替句点。
player *Hero = malloc(sizeof(player));
strcpy(Hero->name, "Leeroy");
Hero->health = 60;
Hero->mana = 30;
请记住,指针不包含像整数和字符这样的值,它们只是告诉计算机在哪里可以找到这些值。当我们更改指针指向的值时,我们是在告诉计算机“嘿,我想让你更改的值位于此地址 (502),我只是在指挥交通”。因此,当您想到指针时,请将其视为“指挥交通”。以下是一个表格,显示了各种类型的指针声明的含义:
声明 | 含义。 |
---|---|
char *variable | 指向 char 的指针 |
int *variable | 指向 int 的指针 |
float *variable | 指向 float 的指针 |
player *variable | 指向 player 的指针 |
player **variable | 指向指向 player 的指针的指针 |
现在我们正在使用指针,我们可以编写一个函数来动态分配玩家。同时,让我们在规格说明中添加一些新想法。
玩家应该:
- 拥有描述其能力的属性。已完成
- 能够彼此互动(例如,对话、战斗)。
- 能够从一个地点移动到另一个地点。
- 能够携带物品。
- 拥有职业(战士、法师、游侠、会计师)。新增
- 职业在创建时具有独特的属性。例如:战士拥有较高的生命值,法师拥有较低的生命值。新增
dynamicPlayers.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Classes are enumerated. WARRIOR = 0; RANGER = 1, etc.
typedef enum ClassEnum {
WARRIOR,
RANGER,
MAGE,
ACCOUNTANT
} class;
typedef struct playerStructure {
char name[50];
class class;
int health;
int mana;
} player;
// Function Prototypes
void DisplayStats(player *target);
int SetName(player *target, char name[50]);
player* NewPlayer(class class, char name[50]); // Creates player and sets class.
int main() {
player *Hero = NewPlayer(WARRIOR, "Sir Leeroy");
player *Villain = NewPlayer(RANGER, "Sir Jenkins");
DisplayStats(Hero);
DisplayStats(Villain);
return(0);
}
// Creates player and sets class.
player* NewPlayer(class class, char name[50]) {
// Allocate memory to player pointer.
player *tempPlayer = malloc(sizeof(player));
SetName(tempPlayer, name);
// Assign stats based on the given class.
switch(class) {
case WARRIOR:
tempPlayer->health = 60;
tempPlayer->mana = 0;
tempPlayer->class = WARRIOR;
break;
case RANGER:
tempPlayer->health = 35;
tempPlayer->mana = 0;
tempPlayer->class = RANGER;
break;
case MAGE:
tempPlayer->health = 20;
tempPlayer->mana = 60;
tempPlayer->class = MAGE;
break;
case ACCOUNTANT:
tempPlayer->health = 100;
tempPlayer->mana = 100;
tempPlayer->class = ACCOUNTANT;
break;
default:
tempPlayer->health = 10;
tempPlayer->mana = 0;
break;
}
return(tempPlayer); // Return memory address of player.
}
void DisplayStats(player *target) {
printf("%s\nHealth: %d\nMana: %d\n\n", target->name, target->health, target->mana);
}
int SetName(player *target, char name[50]) {
strcpy(target->name, name);
return(0);
}
在进入下一个主要开发阶段之前,你需要将已编写的代码模块化。首先创建两个头文件,一个名为“gameProperties.h”,另一个名为“players.h”。在游戏属性文件中,放置你的playerStructure和classEnum类型定义。此处定义的数据类型可能出现在我们可能创建的任何其他头文件中。因此,这将始终是我们调用的第一个头文件。接下来,所有与创建和修改玩家相关的函数,以及它们的原型,都将放在我们的players头文件中。
罗马不是一天建成的,优秀的战斗系统也不例外,但我们会尽力而为。现在我们有了敌人,我们有义务让他参与一场友好的拳击比赛。为了让玩家战斗,我们需要在玩家结构中包含两个额外的属性,攻击和防御。在我们的规范中,所有战斗函数都包含两个玩家的参数,但经过进一步思考,让我们根据有效攻击造成伤害,有效攻击等于攻击减去防御。
在gameProperties头文件中,为playerStructure修改两个新的整型变量,“attack”和“defense”。
gameProperties.h
// Classes are enumerated. WARRIOR = 0; RANGER = 1, etc.
typedef enum ClassEnum {
WARRIOR,
RANGER,
MAGE,
ACCOUNTANT
} class;
// Player Structure
typedef struct playerStructure {
char name[50];
class class;
int health;
int mana;
int attack; // NEWː Attack power.
int defense; // NEWː Resistance to attack.
} player;
在players头文件中,修改case语句以将值赋给attack和defense属性。
players.h
// Creates player and sets class.
player* NewPlayer(class class, char name[50]) {
// Allocate memory to player pointer.
player *tempPlayer = malloc(sizeof(player));
SetName(tempPlayer, name);
// Assign stats based on the given class.
switch(class) {
case WARRIOR:
tempPlayer->health = 60;
tempPlayer->mana = 0;
tempPlayer->attack = 3;
tempPlayer->defense = 5;
tempPlayer->class = WARRIOR;
break;
case RANGER:
tempPlayer->health = 35;
tempPlayer->mana = 0;
tempPlayer->attack = 3;
tempPlayer->defense = 2;
tempPlayer->class = RANGER;
break;
case MAGE:
tempPlayer->health = 20;
tempPlayer->mana = 60;
tempPlayer->attack = 5;
tempPlayer->defense = 0;
tempPlayer->class = MAGE;
break;
case ACCOUNTANT:
tempPlayer->health = 100;
tempPlayer->mana = 100;
tempPlayer->attack = 5;
tempPlayer->defense = 5;
tempPlayer->class = ACCOUNTANT;
break;
default:
tempPlayer->health = 10;
tempPlayer->mana = 0;
tempPlayer->attack = 0;
tempPlayer->defense = 0;
break;
}
return(tempPlayer); // Return memory address of player.
}
void DisplayStats(player *target) {
printf("%s\nHealth: %d\nMana: %d\n\n", target->name, target->health, target->mana);
}
int SetName(player *target, char name[50]) {
strcpy(target->name, name);
return(0);
}
最后,在主程序中包含你的头文件。我们不使用尖括号<>,而是使用双引号。如果头文件位于与可执行文件相同的文件夹中,则只需要提供文件名。如果你的头文件位于其他文件夹中,则需要提供文件位置的完整路径。
我们还将开发一个基本的战斗系统来利用攻击和防御属性。
player3.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "gameProperties.h"
#include "players.h"
// Function Prototype
int Fight (player *Attacker, player ̈*Target);
int main () {
player *Hero = NewPlayer(WARRIOR, "Sir Leeroy");
player *Villain = NewPlayer(RANGER, "Sir Jenkins");
DisplayStats(Villain); // Before the fight.
Fight(Hero, Villain); // FIGHTǃ
DisplayStats(Villain); // After the fight.
return(0);
}
int Fight (player *Attacker, player *Target) {
int EffectiveAttack; // How much damage we can deal is the difference between the attack of the attacker
// And the defense of the target. In this case 5 - 1 = 4 = EffectiveAttack.
EffectiveAttack = Attacker->attack - Target->defense;
Target->health = Target->health - EffectiveAttack;
return(0);
}
如果我们编译并运行它,我们会得到以下输出:
Name: Sir Jenkins
Health: 35
Mana: 0
Name: Sir Jenkins
Health: 34 // An impressive 1 damage dealt.
Mana: 0
待办事项:调整职业属性使其更加多样化。
现在我们已经弄清楚了如何造成伤害,让我们扩展我们之前的规范:
Fight() 应该:
- 循环,直到某人的生命值降至零、撤退或投降。
- 在获取输入之前向用户显示一个选项菜单。
- 攻击、防御、使用物品和逃跑应该是基本的选择。
- 告诉我们是否输入了错误的内容。
- 让双方都有机会行动。可能通过交换攻击者和目标的内存地址。
- 这意味着我们需要区分用户玩家和非用户玩家。
- 如果战斗涉及两个以上的角色,我们可能需要稍后修改交换的想法。然后我们可以使用某种列表轮换。
- 使用速度作为因素(敏捷性)的游戏,可能会在战斗前根据属性构建列表,以确定谁先行动。
我会尽量避免在可能的情况下发布整个程序,但我鼓励你继续进行增量更改,并在我们进行的过程中编译/运行主程序。对于战斗序列,我们将修改Fight函数,使其循环直到目标的生命值降至0,然后宣布获胜者。一个“战斗菜单”将提供用户界面,该菜单将一个数字与一个动作配对。我们有责任在添加新动作时修改此菜单,并确保在调用时每个单独的动作都能正常工作。
当用户选择一个动作时,关联的数字将传递给一个**Switch**,该**Switch**将给定变量与一系列**Cases**进行比较。每个case都有一个数字或字符(不允许使用字符串),用于上述比较。如果Switch找到匹配项,它将评估该Case中的所有语句。我们必须使用关键字**break**来告诉switch停止评估命令,否则它将移动到下一个case并执行这些语句(有时这很有用,但不是我们的目的)。如果Switch无法将变量与case匹配,则它会查找一个名为**default**的特殊case并对其进行评估。我们总是希望有一个default来处理意外输入。
int Fight(player *Attacker, player *Target) {
int EffectiveAttack = Attacker->attack - Target->defense;
while (Target->health > 0) {
DisplayFightMenu();
// Get input.
int choice;
printf(">> "); // Indication the user should type something.
fgets(line, sizeof(line), stdin);
sscanf(line, "%d", &choice);
switch (choice) {
case 1:
Target->health = Target->health - EffectiveAttack;
printf("%s inflicted %d damage to %s.\n", Attacker->name, EffectiveAttack, Target->name);
DisplayStats(Target);
break;
case 2:
printf("Running away!\n");
return(0);
default:
printf("Bad input. Try again.\n");
break;
}
}
// Victoryǃ
if (Target->health <= 0) {
printf("%s has bested %s in combat.\n", Attacker->name, Target->name) ;
}
return(0);
}
void DisplayFightMenu () {
printf("1) Attack\n2) Run\n");
}
测试程序的完整性需要在编译后运行几次。首先,我们可以看到,如果我们输入像“123”或“Fish”这样的随机输入,我们将调用default case并被迫选择另一个答案。其次,输入2将导致我们逃离战斗。第三,如果我们继续输入1,最终Sir Leeroy将削减Sir Jenkin的所有生命值并被宣布为获胜者。如果心急,可以修改Sir Leeroy的攻击值:)
然而,Sir Jenkins仍然无法自卫,这使得比赛非常不公平。即使给Sir Jenkins一个回合,系统仍然会提示用户代表他采取行动。回合制问题可以通过规范中提出的想法解决,即我们在每个循环中交换Attacker和Target指针的内存地址。解决自主权问题的方案是在我们的player结构中添加一个新属性,即一个bool。bool具有二进制值,true或false,对我们来说,它回答了一个简单的问题“是否自动驾驶?”。当自动驾驶bool设置为true时,Fight函数(在我们修改它以检查它时)将知道它们必须自动化这些角色的动作。要使用bool数据类型,我们需要包含一个名为**<stdbool.h>**的新头文件。bool使用**bool**关键字声明,并且只能分配true或false值。
在“gameProperties.h”中,将以下行添加到playerStructure中的int defense下方。
bool autoPilot;
接下来,将以下代码片段添加到“Players.h”中的NewPlayer函数,位于对SetName的调用下方。
static int PlayersCreated = 0; // Keep track of players created.
if (PlayersCreated > 0) {
tempPlayer->autoPilot = true;
} else {
tempPlayer->autoPilot = false;
}
++PlayersCreated;
上面的代码使用关键字**static**创建了一个持久变量。通常,一旦函数被调用,局部变量就会消失。相反,静态变量在函数的生命周期之外保持其值,并且当函数再次开始时,它的值不会被重置。仅为第一个主要角色之后的玩家开启自动驾驶。
完成此操作后,请考虑以下程序。我们添加了bool和IF语句来确定是否需要自动或提示玩家。胜利IF被移动到while循环内部,并在条件满足时宣布胜利,否则,它将交换玩家以进行下一个循环。
player4.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include "gameProperties.h"
#include "players.h"
// Function Prototype
void DisplayStats(player Target);
int Fight(player *Attacker, player *Target);
void DisplayFightMenu(void);
// Global Variables
char line[50]; // This will contain our input.
int main () {
player *Hero = NewPlayer(WARRIOR, "Sir Leeroy");
player *Villain = NewPlayer(RANGER, "Sir Jenkins");
DisplayStats(Villain); // Before the fight.
Fight(Hero, Villain); // FIGHTǃ
return(0);
}
int Fight(player *Attacker, player *Target) {
int EffectiveAttack = Attacker->attack - Target->defense;
while (Target->health > 0) {
// Get user input if autopilot is set to false.
if (Attacker->autoPilot == false) {
DisplayFightMenu();
int choice;
printf(">> "); // Sharp brackets indicate that the user should type something.
fgets(line, sizeof(line), stdin);
sscanf(line, "%d", &choice);
switch (choice) {
case 1:
Target->health = Target->health - EffectiveAttack;
printf("%s inflicted %d damage to %s.\n", Attacker->name, EffectiveAttack, Target->name);
DisplayStats(Target);
break;
case 2:
printf("Running away!\n");
return(0);
default:
printf("Bad input. Try again.\n");
break;
}
} else {
// Autopilot. Userless player acts independently.
Target->health = Target->health - EffectiveAttack;
printf("%s inflicted %d damage to %s.\n", Attacker->name, EffectiveAttack, Target->name);
DisplayStats(Target);
}
// Once turn is finished, check to see if someone has one, otherwise, swap and continue.
if (Target->health <= 0) {
printf("%s has bested %s in combat.\n", Attacker->name, Target->name) ;
} else {
// Swap attacker and target.
player *tmp = Attacker;
Attacker = Target;
Target = tmp;
}
}
return(0);
}
void DisplayFightMenu (void) {
printf("1) Attack\n2) Run\n");
}
现在我们已经创建了一个非常基本的战斗系统,是时候再次进行模块化了。获取Fight和DisplayFightMenu函数,并将它们放在一个名为“fightSys.h”的新头文件中。这个新的头文件将包含所有与战斗相关的函数,并将包含在我们主程序的下一个迭代中。