Technically Impossible

Lets look at the weak link in your statement. Anything "Technically Impossible" basically means we haven't figured out how yet.

Xanadu Clone - ザナドゥ クローン(ザナクロ)のソースを読む、その見どころ

f:id:espio999:20210718220850p:plain
これまで3回の投稿*1を通じて、『ザナドゥ クローン』(ザナクロ)のコードを読むための環境を用意した。今回の投稿は、読んだコードのまとめだ。どのような構造をしており、どこに何が定義されているかをまとめている。

ザナドゥ・データブック VOL.1』*2に掲載されている計算式と比較すると、ザナクロの処理実装は、同じ計算式に基づいたものであることが分かった。全ての計算処理を追跡、比較したわけではないのだが、ザナクロは、かなり真面目にオリジナルを再現しようとしていたことが伝わる内容だった。

全体構造

ファイル構造

まず、ファイルごとに構造化されていることに気づく。

ゲーム全体 xanadu
ゲーム中の環境 field
フィールド中の地形やモンスター dungeon
フィールド中の塔 tower
武装
魔法
アイテム
goods

などなど。
C言語オブジェクト指向言語ではないが、オブジェクト指向的にファイル名が指す事象を抽象化している。そのため、それぞれのファイルに記述されているソースの担当領域と、その内容を容易に想像することができる。

ロジックとデータ

興味深いのは、マップ+モンスターと、各種武装、魔法、アイテムの定義方法だ。いずれの設定も「データ」なのだが、マップとモンスターは差し替え可能に定義されている一方、そのほかのデータはゲーム・プログラムに組み込まれている。
例えば、モンスターの処理は”monster.c”として抽象化されているのだが、モンスター個別の情報はmonファイルに定義されている。一方、武装などは抽象化されておらず、”goods.c”中に定義されている。
言い換えれば、モンスターの処理はゲーム・システムの一部ではありながらも、モンスターそのものはゲーム・システムではない。登場する武装、魔法、アイテムの諸設定はゲーム・システムの一部だと解釈できる。そのため、各種武装などの設定値は、そのシステム全体を成立させるために調整済みのものが定義されていると考えられる。

差し替え可能なのはモンスターだけではなく、各ダンジョン・レベルを構成するマップも同様だ。つまり『XANADU scenario 2』のような「シナリオ」とは、マップ+モンスターの定義に若干のシステム変更(アイテムや地形、諸条件の追加、変更など)を加えたものを指している。
マップ、モンスターはそれぞれ、次のように外部ファイルにてバイナリ・データとして定義されており、”dungeon.c”にて、シナリオとして定義、呼び出しされる。

  • データ
mapファイル ダンジョン・レベルごとのフィールド
monファイル ダンジョン・レベルごとのモンスター

シナリオ定義

/* scenario 1 */
static const dungeon_level_data_t dungeon1[MAX_DUNGEON_LEVEL] = {
  { "monst_0.mon", "monst_0.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_1.mon", "monst_1.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_2.mon", "monst_2.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_3.mon", "monst_3.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_4.mon", "monst_4.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_5.mon", "monst_5.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_6.mon", "monst_6.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_7.mon", "monst_7.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_8.mon", "monst_8.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_9.mon", "monst_9.bmp", "xa1/field.bmp", &scenario1_db },
  { "monst_a.mon", "monst_a.bmp", "xa1/train.bmp", &training_db }
};

/* scenario 2 */
static const dungeon_level_data_t dungeon2[MAX_DUNGEON_LEVEL] = {
  { "monst_0.mon", "monst_0.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_1.mon", "monst_1.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_2.mon", "monst_2.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_3.mon", "monst_3.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_4.mon", "monst_4.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_5.mon", "monst_5.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_6.mon", "monst_6.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_7.mon", "monst_7.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_8.mon", "monst_8.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_9.mon", "monst_9.bmp", "xa2/field.bmp", &scenario2_db },
  { "monst_a.mon", "monst_a.bmp", "xa2/field.bmp", &scenario2_db },
};


ダンジョン・レベル=map読込

  /* レベル情報の読み込み */
  if (dir) {
    sprintf(path, "%s/level_%x.map", dir, level);
  } else {
    /* デフォルトディレクトリ */
    sprintf(path, "%s/%s/level_%x.map", LEVEL_DIR, subdir, level);
  }

既存のモンスター、画像情報を流用する前提で、新たなシナリオを加えるならば、既存のmapファイルを上書きするのがお手軽だ。あえて「scenario3」に相当するものを付け加えるにしても、既存の構成をお手本に、新シナリオ用の構造体を2つ定義すればよい。

強くてニューゲーム的改変

いわゆる「強くてニューゲーム」的な改変を望むならば、ソースを編集する必要はない。最も簡単なのは、希望する状態のキャラクタを「隠しネーム」ユーザーとして定義することだ。
「隠しネーム」は、フォルダ”users”に保存されているファイル”thanks.txt”に定義されている。このファイルの冒頭に記述されている書式に従って、名前、所持金、ステータス、装備品などを定義する。ゲーム開始後にお城を訪問して、定義した名前を入力すれば、希望の状態でゲームを始めることができる。

ソースコードの見どころ

プログラムを改変する場合、改変後に予期せぬ障害を避ける為の定石は、ロジックを改変せず、問題のない範囲でパラメータだけを操作することだ。それが計算によって得られるものならば、それが決まった値を返すように変更するとか、処理そのものを飛ばして、パラメータを定数化するとか。
そのような改変を行うためのポイントとなる個所を紹介する。

boss.c デカキャラの設定
equip.c アイテムの在庫数
初めて装備するアイテムの初期熟練度付与
field.c 各種ゲーム環境の初期化
各種ゲーム環境の設定(例:重力、加速度、ジャンプ力)
goods.c 武装、アイテム、魔法などの設定
monster.c 攻撃、防御、魔法の倍率
numinous.c プレイヤーの攻撃力、防御力
モンスターの攻撃力、防御力
shop.c 各種施設(お城、訓練所も含む)
Dragon Slayerへの地図
use_item.c アイテム使用、効果、持続時間
user.c 戦士、魔法使いのランク定義
時間経過
食料消費
鍵消費
user_dead.c エリクサー消費
user_io.c ユーザー情報のセーブ、ロード
隠しネーム処理

無限アイテム、効果持続時間の延長、食料消費しない、等々の改変を行うには、特定の処理を書き換えて、プログラムをリビルドすればよい。

もし改変を繰り返すならば、いっそ、次のようにプログラムを作り変えても良いと思う。前述の「隠しネーム」が実践していることと同じように、今回の目的に合致する処理を追加しても良いし、「隠しネーム」対応そのものに追記することもできる。

  • 編集したいパラメータ、あるいは状態を特定する
  • パラメータや状態を、プログラムとは別の設定フィルに定義する。
  • 実行ファイルが、設定ファイルを読み込み、パラメータや状態を反映する。

オリジナルとの比較

ザナドゥ・データブック VOL.1』には、オリジナルで実装されている各種処理の計算式が掲載されている。例えば、次のような具合だ。

KEY購入価格=(ファイター、ウィザードで高い方のレベル+1)×価格変動率%×100
FOOD消費量=Max.Hit-point×0.1%
W・S=使用武器本来の攻撃力×STR%×使用武器のW-EXP%×(90~120%RND)

「W・S」、「W-EXP」、「RND」とは、それぞれ「武器攻撃力」、「武器経験値」、「乱数補正」を意味している。
また「価格変動率%」とは、CHRによる補正だ。おなじくデータブックによると、それは次のように定義されている。

カリスマ値 価格変動率 CHR1当たりの変動率
0~49 200%~102% 2%ダウン
50~99 100%~75.5% 0.5%ダウン
100~199 75%~50% 0.25%ダウン
200 50%

興味本位で、オリジナルとザナクロの計算式を比べてみた。ザナクロは、かなり忠実にオリジナルを再現しようとしていたことが察せられる。

鍵価格

鍵の購入価格は主人公のレベルに比例する。そこに「価格変動率%」、つまりCHRにより補正され価格が決定する。ザナクロの場合、それは次のように実装されている。
オリジナルと同様の計算式で、価格決定されているのが分かる。

 /* 鍵の値段 */
shop_price = (user_higher_rank() + 1) * 100;
shop_price = shop_ask_price(shop_price);
int shop_ask_price(int price)
{
  int CHR = user_CHR();

  if (CHR <=  50)
    return price * (100 - CHR) /  50;
  if (CHR <= 100)
    return price * (250 - CHR) / 200;
  else
    return price * (400 - CHR) / 400;
}

食料消費の比較

一定時間ごとに、消費した食料分だけHPが回復する。『ザナドゥ・データブック VOL.1』に掲載されているオリジナルの計算式と比べると、ザナクロには補正が加えられているのが分かる。

decrement_food =(user.status.max_HP + 500) / 1000;

if (user.status.food < 0) {
  user.status.HP -= (user.status.max_HP + 50) / 100;
  bgm_random(1); /* BGM */
} else {
  user.status.HP += decrement_food;
  user.status.HP = min(user.status.max_HP, user.status.HP);
  user.status.food -= decrement_food;
  bgm_random(0); /* BGM */          
}

武器攻撃力

オリジナルでは、主人公のSTR、武器の攻撃力、経験値の積が乱数補正されるのだが、ザナクロでは攻撃力の計算と、補正が別々に行われている。まず攻撃力が求められ、それが戦闘中に補正されるのだ。

  • numinous.c
int STR = user_STR();
STR *= user.inventory[GOODS_WEAPON][weapon_id].skill;
STR = min(25500, STR) / 100;
return weapon_performance(STR, weapon_id);
  • numinous.c
#define weapon_performance(STR, weapon_id) \
  ((STR) * weapon_data()[weapon_id].performance)
  • battle.c
damage = user_error(user_attack_point())
      - monster_defend_point(monster_status, guard);
  • battle.c
/* 誤差: ユーザー: 30/32..38/32、モンスター: 12/16..20/16 */
#define user_error(p)		(((p) * (random_integer(8) + 30)) / 32)

Dragon Slayerへの地図

この表示条件はオリジナルとは異なっている。オリジナルではクラウンを揃えることが表示条件に含まれるのだが、ザナクロではクラウンの数は考慮されていない。ファイター、ウィザードのレベルだけが表示条件とされている。
とはいえクラウンの要素は無視されていない。それは最後の砦に通じる出入口への侵入条件として考慮されている。

  • shop.c
if (shop_id == SHOP_TEMPLE &&
    user.status.fighter.rank > 12 && user.status.wizard.rank > 12) {
  /* ヒント */
  strcpy(path, IMAGE_DIR "/picture/slayer.bmp");
  • field.c
/* シナリオ1: 最後の砦 */
if (map == tile_data.last_tower && !in_scenario2()) {
  if (user.status.CRN != 4 ||
      user.status.KRM != 0) {
    emit_message("Enter-Closed"); /* 本当は何て言うの? */
    return;
  }
}
  • field.c
/* scenario 2: 最下層に通じる扉? */
if (in_scenario2()) {
  if (user.equipment[GOODS_WEAPON    ] == MAX_GOODS - 1 &&
      user.equipment[GOODS_SCROLL    ] == MAX_GOODS - 1 &&
      user.equipment[GOODS_ARMOUR    ] == MAX_GOODS - 1 &&
      user.equipment[GOODS_SHIELD    ] == MAX_GOODS - 1 &&
      user.equipment[GOODS_MAGIC_ITEM] == MAX_GOODS - 1 &&
      user.status.CRN == 4 &&
      user.status.KRM == 0) {
    extend_context(init_cave(10));
    return;
  }
}