跳转到内容

使用游戏概念学习 C 语言/角色扮演游戏设计

来自 Wikibooks,开放世界中的开放书籍

现在我们已经涵盖了编译和模块化的基础知识(我们稍后会完善该部分),让我们继续进行游戏设计。我们的游戏将使用终端实现,并且不依赖于任何第三方库,除了 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”的新头文件中。这个新的头文件将包含所有与战斗相关的函数,并将包含在我们主程序的下一个迭代中。

华夏公益教科书