第08回 経験値と強化選択
武装をいつ使えるゲームにするか
武装を増やすと、何をいつ取得できるか、どの武装を組み合わせるかも作品のルールになります。この回では、経験値の回収から選択画面までの流れを読み、解放条件、所持上限、候補の選出を接続します。
第07回までに作った武装や画像、開始設定を引き継ぎます。例ではプレイヤーレベルによる解放と、候補を循環させる方法を使います。数値や選出方法は、作品の進行に合わせて設計できます。
配布版の強化選択画面
画像は配布版の見本です。配布版は武装レベル0を未取得、1以上を所持済みとして区別します。解放条件と、武装数とは別の所持上限はありません。候補は武装配列の先頭から最大3件を追加しています。
編集する場所
| 内容 | ファイル・処理 |
|---|---|
| 武装ごとの解放条件 | Source/AutoShooter/Objects/Weapon.h・.cpp |
| 所持上限と表示上限 | Source/AutoShooter/Tuning/GameTuning.h |
| 所持数、候補収集、適格性、選出 | Source/AutoShooter/GameModel.h・.cpp |
| 選択結果の適用と経験値の繰り越し | GameModel.cppのChooseUpgrade |
| カードと所持数の表示 | Source/AutoShooter/Resources/Renderer.cpp |
| 経験値アイテムの吸引と回収 | Source/AutoShooter/Objects/Pickup.cpp |
| 経験値アイテムの保持と生成 | Source/AutoShooter/Objects/PickupManager.cpp |
入力や描画の仕組みは選択画面でも再利用します。候補の内容はGameModelが作り、Rendererは取得した候補を表示します。候補を選ぶために、描画処理へ武装の取得条件を書く必要はありません。
撃破から経験値の回収まで
経験値アイテムを生成する
GameModel::DamageEnemyは、敵を倒したときに経験値アイテムを生成します。
pickups.Spawn(enemy.GetPosition(), enemy.GetExperience());経験値量はEnemy.cppの基本定義から取得します。配布版では通常敵が1、突進敵が3です。第03回で追加した敵も、その定義のexperienceを使えます。
PickupManagerは空き枠へアイテムを生成します。全枠が使用中なら、既存の先頭アイテムへ経験値量を合算します。アイテムの見た目の個数と、保持する経験値の合計は同じ意味ではありません。
吸引と回収を分ける
Pickup::Updateは、吸引範囲内でアイテムを主人公へ近づけます。
auto difference = target - position;
float distance = Length(difference);
if (distance < tuning::pickup::AttractRadius)
position = position + Normalize(difference) *
std::min(distance, tuning::pickup::Speed * seconds);移動量は残り距離までに制限します。回収するかどうかは、別のTryCollectで判定します。
int Pickup::TryCollect(Vector2 target)
{
if (!active || LengthSquared(target - position) >=
tuning::pickup::CollectRadius * tuning::pickup::CollectRadius)
return 0;
active = false;
return value;
}配布版は吸引開始半径70px、回収半径24px、吸引速度420px/秒です。GameTuning.hのpickup名前空間で変更できます。例えば吸引開始半径を120pxにするなら、AttractRadiusの1行を置き換えます。
constexpr float AttractRadius = 120;回収距離や画像サイズは、この変更だけでは変わりません。吸引は毎回距離を判定するため、範囲外へ離れたアイテムは追尾しなくなります。
プレイヤーレベルと武装レベル
プレイヤーレベルは、次の必要経験値や解放条件に使います。武装レベルは、その武装の性能段階です。別々の値として管理します。
必要経験値はGameModel::GetExperienceTargetで求めます。
int GameModel::GetExperienceTarget() const
{
return tuning::experience::FirstTarget +
(playerLevel - 1) * tuning::experience::TargetGrowth;
}配布版のFirstTargetは6、TargetGrowthは4です。レベル1では6、レベル2では10、レベル3では14が必要です。開始からの累計ではなく、次のレベルアップに使う量です。
経験値を加えると、Playing中で必要量へ達した場合にUpgradeへ移ります。
experience += amount;
if (state == State::Playing && experience >= GetExperienceTarget())
ChangeState(State::Upgrade);武装は、未所持のレベル0から取得するとレベル1になります。所持済みなら、その武装だけが1段階強化されます。
解放・取得・強化を区別する
今回の解放条件は「候補を選び終えた後のプレイヤーレベル」で判定する例です。BuildChoicesの呼び出し時点ではまだレベルを上げていないため、playerLevel + 1を使います。
| 状態 | 扱い |
|---|---|
| 未所持で解放条件未達 | 新規取得候補にしない |
| 未所持で解放済み | 所持枠があれば新規取得候補にする |
| 所持済みで最大レベル未満 | 強化候補にする |
| 所持済みで最大レベル | 強化候補から除く |
解放だけでは所持しません。選択してレベル1になった時点で、その武装を使い始めます。
所持上限が制限するのは新規取得です。所持枠が埋まっていても、所持済み武装の強化は残します。
開始時に所持させた武装も所持数に数えます。今回の条件は新規取得を判定するもので、開始設定の武装を削除する処理ではありません。
武装数・所持上限・表示件数を分ける
武装数はWeapon::Count、所持上限は追加するMaxOwnedWeapons、表示上限はChoiceCountです。
例えば所持上限3・表示上限3でも、定義する武装数とは独立しています。所持上限を武装数以上にすれば、その範囲では枠による取得制限は起こりません。
配布版のChoiceCountは3で、入力は1〜3キーに対応しています。表示件数を増やす場合は、候補配列だけでなく、Input.hのSelectionActions、Input.cppのBindings、カード幅や配置も対応させます。所持上限を変えるだけなら、入力件数を変える必要はありません。
1. 解放条件と所持上限を宣言する
第07回で方式を分離したWeapon.hのDefinitionに、unlockLevelを追加します。
struct Definition
{
Type type;
AttackType attack;
int unlockLevel;
const wchar_t* name;attackを追加していない構成なら、typeの後などにunlockLevelを置き、Rowsの並びをその宣言に合わせます。
GameTuning.hでは、ChoiceCountの直後へ所持上限を追加します。次の3は設計例です。
constexpr std::size_t ChoiceCount = 3;
constexpr std::size_t MaxOwnedWeapons = 3;GameModel.hのpublicで、GetChoiceCountの直前へ所持数を取得する関数を宣言します。
std::size_t GetOwnedWeaponCount() const;
int GetChoiceCount() const { return choiceCount; }privateではchoiceCountの前へ、候補の循環位置を追加します。
bool preview = false;
std::size_t choiceOffset = 0;
int choiceCount = 0;choiceOffsetは、次の選択画面で候補列のどこから表示を始めるかを保持する値です。乱数は使いません。
2. 各武装へ解放レベルを登録する
Weapon.cppのRowsで、attackと名前の間へ解放レベルを入れます。例えば既存3武装を最初から取得可能にする行頭は次の形です。
{ Weapon::Type::Bolt, Weapon::AttackType::Straight, 1, L"狙い撃ち", 0x75D8FF, {{{ Weapon::Type::Fan, Weapon::AttackType::Spread, 1, L"扇状発射", 0xFFCC70, {{{ Weapon::Type::Pulse, Weapon::AttackType::Wave, 1, L"衝撃波", 0xC5A0FF, {{RapidBoltを追加している場合、レベル3到達時の選択から取得可能にする例です。
{ Weapon::Type::RapidBolt, Weapon::AttackType::Straight, 3, L"速射弾", 0x80FF90, {{手元のすべての定義にunlockLevelを設定します。名前、色、方式、各レベルの性能は自分の設定を使います。
ValidDefinitionsでは、registered[index]への代入直後に解放レベルの検査を追加できます。
registered[index] = true;
if (definition.unlockLevel < 1)
return false;第07回で加えた攻撃方式の検査も残します。
3. 所持数の定義と初期化を接続する
GameModel.cppのBuildChoicesの直前へ、次の関数を追加します。
std::size_t GameModel::GetOwnedWeaponCount() const
{
std::size_t count = 0;
for (const auto& weapon : weapons)
if (weapon.GetLevel() > 0)
++count;
return count;
}
void GameModel::BuildChoices()const auto&は、各武装をコピーせず読み取る参照です。武装レベルの合計ではなく、レベル1以上の武装を1種類ずつ数えます。
GameModel::Startで、choiceCountを初期化する行の直後へ追加します。
choiceCount = 0;
choiceOffset = 0;
playTime = 0;新しくゲームを始めたときは、候補の表示開始位置も先頭へ戻ります。各管理クラスのReset、経験値や武装の初期化は、手元の開始ルールを引き続き使います。
4. 全候補から適格な武装を選ぶ
GameModel.cppのBuildChoicesを、関数全体で置き換えます。
void GameModel::BuildChoices()
{
std::array<UpgradeChoice, Weapon::Count> all{};
std::size_t allCount = 0;
for (const auto& definition : Weapon::Definitions())
all[allCount++] = { UpgradeKind::Weapon, definition.type };
std::array<UpgradeChoice, Weapon::Count> candidates{};
std::size_t candidateCount = 0;
const auto owned = GetOwnedWeaponCount();
const int nextLevel = playerLevel + 1;
for (std::size_t index = 0; index < allCount; ++index)
{
const auto& choice = all[index];
const auto& weapon = weapons[Weapon::Index(choice.weapon)];
if (weapon.GetLevel() >= tuning::MaxWeaponLevel)
continue;
if (weapon.GetLevel() == 0)
{
const auto& definition = Weapon::GetDefinition(choice.weapon);
if (nextLevel < definition.unlockLevel ||
owned >= tuning::MaxOwnedWeapons)
continue;
}
candidates[candidateCount++] = choice;
}
choiceCount = 0;
if (candidateCount > 0)
{
const auto start = choiceOffset % candidateCount;
const auto displayCount = std::min(candidateCount, choices.size());
for (std::size_t index = 0; index < displayCount; ++index)
choices[static_cast<std::size_t>(choiceCount++)] =
candidates[(start + index) % candidateCount];
choiceOffset = (start + displayCount) % candidateCount;
}
else
{
choiceOffset = 0;
}
if (choiceCount < static_cast<int>(choices.size()))
choices[static_cast<std::size_t>(choiceCount++)] =
{ UpgradeKind::Recovery, Weapon::Type::Bolt };
}全武装の収集、適格性の判定、表示件数への選出を順に行います。表示枠が埋まったことを理由に、武装全体の走査を打ち切りません。
allは全武装、candidatesは条件を満たした武装、choicesは画面へ渡す選択肢です。固定長配列には未使用部分もあるため、allCountやcandidateCountで有効な件数を管理します。
最大レベルの武装は除外します。未所持の場合だけ解放条件と所持枠を判定し、所持済みなら最大レベル未満を候補に残します。
表示開始位置を循環させる
%は割った余りを求める演算子です。候補列の末尾を超えたら先頭へ戻る添字に使っています。
例えば適格候補がA・B・C・D、表示上限3なら、最初はA・B・C、次はD・A・Bとなります。候補集合が同じなら、後方の武装にも表示の順番が回ります。
新規取得や最大レベル到達、解放によって候補集合が変わると、その新しい列に対して循環位置を適用します。この方法は出現確率を設定する抽選ではありません。ランダム選出や取得優先度を使いたい場合は、全適格候補を作った後の選出部分を変更できます。
同じ画面の描画中にBuildChoicesを呼ばないため、選択を待つ間は候補が変わりません。
候補不足時の回復
武装候補が表示上限より少ない場合、回復を1件だけ加えます。表示上限3の例なら、武装候補が2件・1件・0件のとき、総表示数は3件・2件・1件です。
回復候補のweaponに入れたBoltは、回復の適用には使いません。kindがRecoveryかを先に判定するためです。
すべての枠を埋めるために、同じ武装や回復を重複して登録する処理にはしていません。
5. 候補を作る呼び出しを確認する
GameModel::ChangeStateは、Upgradeへ入るときにBuildChoicesを呼びます。既存の接続を使えます。
if (state == State::Upgrade)
{
BuildChoices();
events.level = true;
}GameModel::UpdateのUpgrade分岐は、選択入力だけを受け付けてreturnします。
case State::Upgrade:
if (input.selection >= 0)
ChooseUpgrade(input.selection);
return;この状態で迎えた更新はUpdatePlayingへ進まないため、敵、弾、主人公の移動とゲーム内時間が停止します。
経験値回収の途中でUpgradeへ変わった更新では、すでに実行中のUpdatePlayingの残り処理は続きます。回収ループ内のほかの経験値も加算でき、次の更新から選択待ちになります。
6. 選択適用と経験値の繰り越しを確認する
ChooseUpgradeは、候補の適用とレベルアップを担当します。配布版の処理を使う場合は、次の順序です。
bool GameModel::ChooseUpgrade(int index)
{
if (state != State::Upgrade || index < 0 || index >= choiceCount)
return false;
const auto& choice = choices[static_cast<std::size_t>(index)];
if (choice.kind == UpgradeKind::Recovery)
player.Recover();
else
if (!weapons[Weapon::Index(choice.weapon)].Upgrade())
return false;
experience -= GetExperienceTarget();
++playerLevel;
events.level = true;
if (experience >= GetExperienceTarget())
BuildChoices();
else
ChangeState(State::Playing);
return true;
}選択を適用した後、現在レベルの必要経験値を減らしてからplayerLevelを上げます。先にレベルを上げると、新しい必要量を差し引いてしまいます。
配布版の必要経験値で、レベル1・経験値20なら、最初の選択後はレベル2・経験値14です。次の必要量10にも達しているため、もう一度選択します。2回目の選択後はレベル3・経験値4となり、戦闘へ戻ります。
連続する選択ではBuildChoicesを直接呼びます。状態がすでにUpgradeでも候補を作り直せます。各選択で新しいplayerLevel + 1を求めるため、次に到達するレベルで解放を判定します。
回復も1回の選択として経験値を使い、プレイヤーレベルを上げます。配布版のRecoverは最大HPを5増やし、現在HPを25回復します。
void Player::Recover()
{
maxHp += tuning::player::RecoveryMaxHp;
hp = std::min(maxHp, hp + tuning::player::RecoveryHp);
}効果量はGameTuning.hのplayer名前空間で変更できます。
7. 所持数とカード表示を接続する
Renderer.cppのDrawOverlayで、武装名を表示する行の直後へ追加します。
Text(x + 22, 330, definition.name, definition.color, 1);
Text(x + 22, 300, L"所持 " + std::to_wstring(game.GetOwnedWeaponCount()) +
L" / " + std::to_wstring(tuning::MaxOwnedWeapons), Muted);
bool acquired = weapon.GetLevel() == 0;所持数は武装カード内の表示です。回復カードはその前の分岐で処理するため、この表示を通りません。
既存の「獲得 / LV 1」とレベル上昇の表示は、そのまま新規取得と強化を区別できます。実際に描く件数はGetChoiceCountを使います。
選択入力もchoiceCount未満だけを受け付けます。表示が1件なら、2番や3番の入力でレベルが上がることはありません。
解放条件を画面へ示す場合も、定義のunlockLevelを参照できます。今回の候補画面は未解放武装を表示しないため、解放予定を見せる一覧を作る場合は、全定義を読む表示として分けられます。
自分の成長ルールを確かめる
解放レベルの前後、新規取得の前後、所持上限へ達した後を遊び、想定した候補になっているか確認します。解放済みでも、所持枠や選出方法によって表示されない場合があるため、候補資格と今回の表示は分けて考えます。
所持武装を最大レベルへ進めたときの除外、後方IDの武装が表示されること、候補不足時の回復も確認できます。候補が変わる場面では、循環位置と新しい候補集合の関係をコードから追えます。
RキーでStartを呼ぶと、手元の開始武装、経験値、プレイヤーレベルとともにchoiceOffsetも初期化されます。同じ選択列をたどれば、この循環方式の候補列を再現できます。
武装の数や強化段階だけでなく、経験値を取りに行く動き、取得を待つ時間、所持する組み合わせも作品の遊びです。作った条件を引き継ぎ、第09回では敵の出現計画と成長を結び付けます。