ヒラリラーのブログ

ゲームレビュー・旅行記ブログです。

Immortal Spiral - 不死の螺旋 - システム徹底解説

この記事は「Immortal Spiral」(以下、通称いもスパ)の徹底解説記事となります

昨日のアルクスさんの「『Episode D.S.』ができるまでの話 」ですがとても読み応えのある長編でしたね。 ・・・今日もそれに劣らずの長文となっております。

note.com

いもスパは裏では複雑なシステムを組んでいるのですが表に見えるのは一部に過ぎず、プレイしている分には良くわからないけどステータスが下がった or 上がったと思われてしまいがちですので、裏で動いている処理について解説していきたいと思います。

この記事をあくまで解説記事なので直接の攻略方法は紹介しないのですが、読んだうえでゲームをプレイするとうまく立ち回れるかもしれないので、ある意味では攻略には役立つかもしれません。
基本的にゲームのコンセプトの話とかはせずに、あくまで技術的な話に特化した内容になるかなと思います。 具体的には「どのようなロジックで仲間は死ぬのか」「どのような仕組みで老化が進むのか」「どのような計算でステータスが求まるのか」の3つを中心にお話していこうと思います。

ちょっと退屈な話になっちゃうかもしれないのですが、お時間のある方はお付き合いください。

plicy.net

システムの深掘りをする観点から、ストーリーの重大なネタバレを含んでいる場合があります!未クリアの方はお気をつけください

この記事は「WWA Advent Calendar 2025」22日目の記事となります。

adventar.org

この記事で書くことについて

さて、冒頭でもお話しましたがゲームコンセプトやらダンジョン生成アルゴリズムはゼロディングや15日目の記事でお話しましたので、この記事ではある程度省略して書いていきます。
ダンジョン生成アルゴリズムについて知りたい方はこちらの記事を参考にしてください。

hirarira.hatenablog.jp

具体的にはシステム面に注目して、何故仲間は死ぬのか?何故ステータスがこんなに頻繁に変動するのか?のロジック部分の解説をしていきます。

「難易度」について

さてさて、難易度についてはゲーム開始画面で怒涛の長文でお伝えはしているんですが、開発者用ドキュメントにも難易度表がありましたのでそのまま引っ張ってきました。

基本は書いたとおりなんですが、一部解説が必要な項目もありますのでちょっと説明していきます。

途中開始の健康度ペナルティ

途中開始の階層/難易度分だけ参加メンバーの健康度が減った状態で開始します。
例えば50階開始の場合、ノーマルだと ( 50 / 4 ) = 12 削れた状態で開始します。
ちなみにこのように途中開始だとペナルティが課されるシステムは「メイドインアビス 闇を目指した連星」が元ネタです。

hirarira.hatenablog.jp

敵を倒した時の減少健康度

敵を倒したときに健康度が減少しますが、戦闘にかかったターン数に依存して健康度が減っていくようになります。
この仕様のため、1ターンで倒せる場合には健康度があまり減らず、ゴーレム系などの固いモンスター相手だとより多く疲労する・・・というシステムになっています。
このシステムはAokashi氏制作の「RAIL LAND DUNGEON」をテストプレイで遊んだときに良いな!と思って採用したシステムになります。

plicy.net

ラスボスリリィ判定レベル

ちょっとネタバレになりますが、本作のラスボスはリリィのレベルに依存して強さが決まります。
具体的にはリリィのレベルを10の倍数で繰り上げた強さになっています。
実際のステータス値については後述しますが、「ハード」「異端」の場合には実際のレベルより一段階・二段階上の強さに設定されます。
つまりラスボス戦でリリィのレベルが55だとすると、「ノーマル」ではLv60、「ハード」ではLv70、「異端」ではLv80相当のステータスの強さになります。
ちなみにリリィのレベルは最大99まで・・・それを超えるとどうなるかと言うと、設定上はステータステーブルは100以上も用意してまして、レベル110、120相当のステータスの強さとなりますw

ダンジョンサイズ

基本的にダンジョンは深く潜るほど広くなります。
具体的な広さは以下のとおりです。

階数 ハード以下 異端
1F - 29F 30 x 30 30 x 30
31F - 49F 30 x 30 40 x 40
51F - 59F 40 x 40 40 x 40
61F - 89F 40 x 40 50 x 50
91F - 99F 50 x 50 60 x 60

制作中の広さは異端モード準拠だったんですが、テストプレイ中にあまりに広大だと思ったので引き下げた経緯があります。
ちなみにシステム的には 80 x 80まで拡大できるようには作っているのですが、 70 x 70 でやるとダンジョン生成処理が重くなってしまってプレイ体験が悪くなってしまったのと、単純に広すぎてイライラしてしまうと思って最大でも 60 x 60で抑えました。

「1年経過時」の処理について

大まかには「初期化処理」→「事前集計処理」→「全仲間の状態更新ループ」→「年末処理と進行」
各仲間に対しては「健康度変動計算」→「ステータス上昇処理(訓練時)」→「収入処理(労働時)」→「疫病処理」→「貧困ダメージ」→「ストレス処理」→「死亡処理」→「ステータス再計算」という形で処理していきます。
個別で何をしているかは後述するので、ここでは全体の流れはこうなってるんだなあってことを大まかに感じてもらえればと

「死亡判定」の仕組みについて

日常での死亡判定

さて、みなさんが一番気になるであろう死亡判定についてです。

寿命での死亡

まずは寿命による死亡処理は、以下のどちらかの条件を満たした場合です。
なお、対象者がリリィの場合には以下の死亡判定は全部スキップされるようになっています。

  • 100歳以上になった場合
  • 内部的に設定されている寿命を超えて、健康度が0の場合

バグによって健康度がNaNになる場合があり、その時には不老不死になってしまうバグがあったので対策として100歳以上になると強制死亡する処理があります。
一応健康度NaNバグは直したので発生はしないのですが、一応のセーフティーとして残っています。実際にはドーピングしまくれば100歳になるまで健康度を正の値を維持することはできますが、残念ながら強制死亡となります。

寿命の求め方については次の項目で説明しますのでここでは省略しますね。
ノーマル以下だと平常時の死亡判定はこれだけですが、ハード以上では過労死判定が追加で入ります。

過労死

/**
 * (日常最小安全係数 - 健康度) * 3 が死亡率
 * 0 - 100までのパーセンテージを求める
 */
v["tmp"]["dead_probability"] = (v["tmp"]["min_helth"] - v["calc_target_member"]["helth"]) * 3;
if(RAND(100) < v["tmp"]["dead_probability"]) {
  v["tmp"]["is_dead"] = true;
  /** 寿命を過ぎていたら寿命扱い、過ぎていなければ病死扱いにする */
  if(v["year"] >= v["calc_target_member"]["limit"]) {
    // 寿命
    v["calc_target_member"]["end_reason"] = "limit";
  }
  else if(v["calc_target_member"]["is_epidemic"]) {
    // 死亡したターンで流行り病に感染してたら病死扱いにする
    v["calc_target_member"]["end_reason"] = "disease";
  }
  else {
    // 過労死
    v["calc_target_member"]["end_reason"] = "karoshi";
  }
}

 \displaystyle
  過労死率= (日常安全係数 - 健康度) \times 3

日常最小安全係数はハードは10、異端は20に設定されています。
つまり健康度が5だとハードでは (10 - 5) * 3 = 15% の確率で、異端だと (20 - 5)* 3 = 45% の確率で死亡します。
こう見るとハードだと下限まで行っても死亡率30%に対して異端は健康度20を下回るとかなり死にやすくなるのがおわかりいただけるでしょう。
寿命条件を満たした場合で死亡すると「老衰」、健康度の低下によっての死亡だと流行り病に感染していたら「病死」それ以外では「過労死」として取り扱われます。
この死因は死亡時に手に入るオーブの質が変わってくるほか、死因が墓石にも刻まれます。

ダンジョンでの死亡判定

全滅

ここまでは日常での死亡判定ですが、ここからはダンジョン探索中の死亡判定について見ていきます。
まずはダンジョン内で全滅した場合条件についてです。

  • 難易度が ノーマル・ハード・異端 のいずれか
  • パーティ全員のHPが0になり全滅
  • リリィがパーティにいない

この条件を満たした場合には現在の年齢や健康度などを考慮せずに強制死亡となります。
逆に言えばリリィさえいれば全滅しても個別の死亡判定が移ります。

戦死

続いて個別で仲間のHPが0の状態で帰還した場合の死亡判定です。
個別の死亡判定は難易度「イージー」以上で発生します。

// 最小安全健康度の設定
if(v["game_level"] == "easy" || v["game_level"] == "normal") {
  v["tmp"]["min_helth"] = 10;
}
else if(v["game_level"] == "hard") {
  v["tmp"]["min_helth"] = 20;
}
else { // heresy
  v["tmp"]["min_helth"] = 30;
}

// 死亡確率 = (最小安全健康度 - 現在健康度) × 3
v["tmp"]["dead_probability"] = (v["tmp"]["min_helth"] - v["target_member"]["helth"]) * 3;

// 確率判定
if(RAND(100) < v["tmp"]["dead_probability"]) {
  v["target_member"]["dead"] = v["year"];
  v["target_member"]["is_alive"] = false;
  v["target_member"]["end_reason"] = "KIA"; // 戦死
}

 \displaystyle
  戦死率= (最小安全健康度 -  健康度) \times 3

  • 難易度別の死亡確率表
健康度 Easy/Normal Hard 異端
30 0% 0% 0%
20 0% 0% 30%
10 0% 30% 60%
5 15% 45% 75%
0 30% 60% 90%

確率だけ見ていてもピンとこないので、死亡に至る流れの具体例を紹介します。

  • ケース1: 全滅による強制死亡
1. ノーマル難易度でダンジョン挑戦
2. リリィを家に残し、残りの仲間でパーティ編成
3. ダンジョン内で全員HP0に
4. → ダンジョンに参加した全員が即座に死亡
  • ケース2: HP0帰還による確率死亡
1. ハード難易度、健康度15のメンバーが冒険参加
2. ダンジョン内でHP0になる
3. 健康度-30 → 健康度-15に
4. HP0で帰還を選択
5. 死亡判定: (20 - (-15)) × 3 = 105% → 確実に死亡
  • ケース3: 生存パターン
1. イージー難易度、健康度50のメンバー
2. ダンジョン内でHP0に
3. 健康度-30 → 健康度20に
4. HP0で帰還
5. 死亡判定: (10 - 20) × 3 = -30% → 死亡しない

とまあ、HP0の状態で帰還する場合があるということと、健康度がダンジョン最小安全係数以下だと死亡の可能性が出てくるってことですね。
ここでポイントなのはHP0で帰還したタイミングで健康度が-30されてから死亡判定が行われるという点で、仮にダンジョン内では健康度が35くらいであっても、死亡判定に使われる際には5として計算されるので、ノーマルであっても (20 - 5) * 3 で死亡確率が45%ほど出てきてしまう点で、ハードモードまではダンジョン内健康度が50を下回った場合にはローテーションするのが安全ですね。
この健康度50という指標は「ステータス」の項目でも紹介しますがステータスが健康度によって低下し始めるラインでもあるので、この意味でもある種の目安となっています。

ちなみに健康度由来で帰還時に死亡したら「戦死」として扱われ、全滅による死亡は「全滅」として扱われます。

死因によるオーブレートについて

仲間が死亡した時にオーブが貰えるのですが、老衰で死んだとしても「銀」止まりで「金」は中々見かけないんじゃないかと思います。
それとは別にしてもどのような仕組みで色が変わるのか、リリィが貰える経験値はどのように決まるのかを説明します。
ズバリ!仲間死亡時の獲得経験値は以下のように計算できます。

 \displaystyle
  獲得経験値 = 死亡キャラ生涯獲得経験値  \times オーブレート

ではオーブレートはどのように決まるかと言うと、基本的に死因に応じて以下のように決まります。

死因 オーブレート 備考
老衰 50% カルマ+2
病死 50% カルマ+0
過労死 30% 余命に応じてカルマ減少
戦死 20% 余命に応じたカルマ減少
全滅 0% カルマ-5

オーブレートの色はこのように決まります。

  • オーブレート100%:金色
  • オーブレート50% - 99%:銀色
  • オーブレート30% - 49%:青色
  • オーブレート29%以下:赤色

・・・死因のところを見てもらうとオーブレートは最高でも50%となります。
金色のオーブは一見存在できないように見えますが、唯一金色のオーブを獲得する手段があります。 それが性格が「ひよわ」キャラ死亡時のオーブボーナスです。

性格が「ひよわ」だと寿命も短く、性格による能力補正もデバフしかかからずに戦力としてはマイナスでしか無いのですが、 死亡時のオーブレートが2倍になる というメリットがあります。

つまり、性格「ひよわ」かつ病死あるいは寿命を迎えるまで育てるとオーブが金色となり、その仲間が生涯をかけて得られた経験値が丸々リリィに入ってくる仕組みになってきますね。
とはいえ「ひよわ」は打たれ弱い性格ではあるので将来でのリリィの成長を考えて弱いキャラを入れるか、普通の仲間を入れてオーブ倍率は最高50%とするかはその人のプレイスタイルによってくると思います。
とはいえ「ひよわ」でもステータス補正は0.8倍程度なので、全く使えないキャラというわけにはならないと思いますが・・・

「老化」の仕組みについて

雇った仲間は雇用した時点、正確には仲間候補が生成された段階であらかじめ寿命年が定められています。
性格が「ひよわ」「おちつき」「その他」それぞれで寿命が変わります。
「ひよわ」は50歳、「おちつき」は70歳、「その他」は60歳が平均寿命として、それぞれ±10歳程度の乱数をもたせてあります。 なので強い「ひよわ」は60歳まで生きるし、弱い「おちつき」は60歳で死ぬわけですね。

/** ひよわ */
if(v["tmp"]["persona"] == "fragile") {
  v["tmp"]["limit"] = 40 + RAND(20);  // 40~59年
  v["tmp"]["fee"] = v["tmp"]["fee"] * 0.7;  // 雇用費30%割引
}
/** おちつき */
else if(v["tmp"]["persona"] == "calm") {
  v["tmp"]["limit"] = 60 + RAND(20);  // 60~79年
}
/** その他 */
else {
  v["tmp"]["limit"] = 50 + RAND(20);  // 50~69年
}

注意点はここで定められた「寿命」を超えたとしても必ず死ぬというわけではないということ。「寿命」が近づくにつれてステータスが低下し、休息しても健康度が回復しにくくなります。そして寿命に達すると休息しても健康度が減っていくようになります。 そして寿命年を超えて健康度が0になると休息しても健康度が減るようになっていきます。こうなると薬で健康度を上げてもあまり意味がなく、遅かれ早かれ死ぬことになってしまいます。・・・あんまり無理に延命させすぎると100歳強制死亡になっちゃいますが。

最強の「おちつき」でも80歳が寿命となりますので、特にドーピングをしなければ大切にしても90歳が最大寿命になるでしょう。

寿命を伸ばせるアイテム

余談ですがダンジョン内では寿命を伸ばせるアイテムが入手できる場合があります。「時の実」は5年、「時の果実」は10年、「時の大果実」は20年寿命を伸ばすことが出来ます。
こちらのアイテムを利用すると寿命を伸ばすことが出来まして、使用時の処理は以下のようになります。

/** 寿命: リリィには効果がない */
if(v["tmp"]["member_id"] != 0 && v["tmp"]["update"]["life"] > 0) {
  /** 死ぬ日を伸ばす */
  // 寿命を90歳以上には伸ばせない
  v["tmp"]["dead_max_limit"] = v["calc_target_member"]["born"] + 90;
  // もう引き延ばせない時には効果なしとする
  if(v["tmp"]["dead_max_limit"] > v["calc_target_member"]["limit"]) {
    v["calc_target_member"]["limit"] += v["tmp"]["update"]["life"];
    v["tmp"]["msg"] += v["calc_target_member"]["name"] + "の寿命が伸びた!\n";
    // 寿命増加アイテムで限界突破してたら、限界値まで引き下げる
    if(v["tmp"]["dead_max_limit"] < v["calc_target_member"]["limit"]) {
      v["calc_target_member"]["limit"] = v["tmp"]["dead_max_limit"];
    }
  }
}

注意点として、どんなにドーピングしても寿命が90歳が限界です。
この状態で毎年休息させて延命を測っても100歳リミットに引っかかることになります。

ゲーム的な性能の話をすると寿命が近づいた仲間はステータスがほぼ0に近い状態になってしまいますし、死が近づいている状態で延命したとしても意味をなさないように設定してあります。
老体に鞭を打って労働させて稼ぐことは可能

「ステータス」の決定方法について

さてさて、お待ちかねのステータス決定アルゴリズムについて解説します。
まずは拠点にいる時のHP・AT・DFの最大値は以下の式で表されます。

 \displaystyle
  HPMAX = (基礎HP+ HPボーナス) \times 性格補正 \times R5

 \displaystyle
  ATMAX = (基礎AT+ ATボーナス) \times 性格補正  \times R5

 \displaystyle
  DFMAX =(基礎DF + DFボーナス) \times 性格補正 \times R5

これだけだと何のことか分からないと思うので解説していきます。
まず、レベル基礎値というのは以下のテーブルのようにレベルに応じて基礎値が定まっています。
この基礎値は固定のものであり、キャラクターの性別や年齢に依存せず固定です。

次にボーナス値について、これはトレーニングで鍛えた時の値が入ります。
続いて性格補正について、これは性格補正値でして各性格に応じて以下の補正値が入ります。

性格名 HP補正値 AT補正値 DF補正値
ゆうかん 1.0 1.2 0.8
がんこ 1.0 0.8 1.1
たくましい 1.5 0.9 0.9
ひよわ 0.8 0.8 0.8
その他 1.0 1.0 1.0

例えば「ゆうかん」であれば攻撃力が1.2倍となりますが、防御力は0.8になってしまいます。
「がんこ」でも防御力補正が1.1倍しか無いのは、全体的に攻撃力は防御力の2倍程度になるようなシステムを組んでいるため、防御力に関する価値を高くしているためになります。

R5パラメータについて

ここまでは単純なんですが、このR5パラメータというのが少々厄介です。
R5パラメータは主に余命によって計算される値となり、余命が40年以上あれば1で固定ですが、寿命を迎えると0.4まで低下し、最終的に寿命を迎えて12年後に0となります。
酒場で雇用する際に稀におじいさんが混ざってきまして、ステータスが1になっている場合があると思いますが、これは余命を12年超過しているためステータスが限界まで下がりきってしまっています。

で、肝心のR5を求めるまでが少し面倒な処理をしています。 R→R2→R3→R4→R5と求めていくわけですが、以下のような処理で求めていきます。
最終的にR4を10000で割った値がR5となります。

/** 行動によるステータス変化 */
v["tmp"]["limit"] = v["calc_target_member"]["limit"] - v["year"];
/** 余命による補正値R: 0: 余命40年以上 - 100: 寿命 */
v["tmp"]["r"] = 100 - (v["tmp"]["limit"] * 100 / 40);
if(v["tmp"]["r"] < 0) {
  v["tmp"]["r"] = 0;
}
/**
 * R2: 累乗にすることである程度の年齢を超えると急激に能力が落ちるようにする
 * 0: 余命40年以上 - 10000: 寿命
 **/
v["tmp"]["r2"] = v["tmp"]["r"] * v["tmp"]["r"];
/**
 * R3: 10000: 余命40年以上 - 0: 寿命
 **/
v["tmp"]["r3"] = 10000 - v["tmp"]["r2"];
/**
 * R4 = (R3*0.6) + 4000
 * 10000: 余命40年以上 - 4000: 寿命 - 2400: 寿命5年後
*/
v["tmp"]["r4"] = (v["tmp"]["r3"] * 0.6) + 4000;

これをまとめると余命を Y と置いて以下の式で求められます。

 \displaystyle
  R5= ((1 - (1 - \frac{Y}{40})^{2}) \times 0.6) + 0.4

この式だとよくわからないと思うので、グラフにすると余命40年を過ぎると少しずつ老化が始まっていって、寿命0年では40%となり、その後は急速にステータスが急落していくのが分かると思います。

おめでとうございます!これでステータスが求まりました!
・・・とはいきません、更に冒険中は健康度にあわせて攻撃力・防御力が低下していきます。

健康度による補正

ここまでは拠点にいる時のステータスとなりますが、冒険中は攻撃力・防御力が健康度に比例して低下していきます。
このデバフが発生するタイミングは健康度が50を下回った場合となります。

 \displaystyle
  現在AT= 最大AT \times \frac{健康度}{50}

この式が適用されるのは健康度が50以下の場合で、それ以上の場合には現在AT=最大ATとなります。
ちなみに防御に関しても同じ式なので省略します。

(余談)年次成長効率補正について

今までお話したのはあくまで戦闘中の健康度によるデバフ(即時戦闘デバフ)となります。
なら拠点にいる際には健康度なんてハードモード以上で死亡する可能性が出てくるだけだからいくら低くても良いのでは?と思われるかもしれませんが、もちろんそんな事はありません。

確かにステータスは拠点にいる際には健康度によって変わらないですが「労働系」「訓練系」のコマンドを選んだ際に、健康度が低いとそれに比例して効果も薄くなります。

長くなってしまうので具体的な計算式はもう省略してしまいますが、即時戦闘デバフに関しては線形による単純な式でステータスが減少していきますが、年間成長効率補正に関しては老化進行具合( R4ステータス )と同様に健康度50を下回ると放物線状の式を使って健康度が低くなると上昇ステータスや獲得賃金が減少していきます。
これによって休憩しないでずっと労働や訓練をしても効率が悪くなるように設定されてあります。

実際のゲーム画面と方程式を比べてみる

日常でのステータス

ここからは実際のゲーム画面と見比べながら、方程式通りのステータスになっているか見ていきましょう。
まずこちらに用意するのはイザドラさん29歳。デバッグモードがONなので隠しパラメータも全部見えている状態です。

まずは現在のステータスであるHP447、AT85、DF45を算出してみます。
HPに関してはLv20なので基礎379+ボーナス68でちょうど447になっています。
ATも基礎76+ボーナス9で85、DFも基礎38+ボーナス7で45になっています。
これは余命も48年あって健康度も90以上あるため、両方ともマイナス補正がかからないためシンプルに求められます。
ここで何もせずに30年経過させた時のステータスを見てみましょう。

特に何も成長せずイザドラさんは59歳になりました。先ほどと比べるとステータスが落ちていることがわかりますね。
彼女は長生きなのでまだまだ余命に余裕はありますが、余命は残り18年の状態です。
このときにR5は0.8185となりますので、先程のステータスにこの倍率をかけてみましょう。

 \displaystyle
  HP = 447 \times  0.8185 = 365

 \displaystyle
  AT = 85 \times  0.8185 = 69

 \displaystyle
  DF = 45 \times  0.8185 = 36

・・・とピッタリの値になっていることが分かると思います。
さてさて、更に時を進めてみましょう。

はい、イザドラさんの余命が0になりました。
この時R5は0.4となります。

 \displaystyle
  HP = 447 \times  0.4 = 178

 \displaystyle
  AT = 85 \times  0.4 = 34

 \displaystyle
  DF = 45 \times  0.4 = 18

とまあ計算通り基礎+ボーナスの4割までステータスが下がっていることが分かるかと思います。
せっかくなので死ぬギリギリまで行ってみましょうか

余命を11年もオーバーして88歳になりました。この段階ではR5は0.024625になります。

 \displaystyle
  HP = 447 \times  0.024625 = 11

 \displaystyle
  AT = 85 \times  0.024625 = 2

 \displaystyle
  DF = 45 \times  0.024625 = 1

ちょっと誤差はありますが、下限まで下がりました。
下限まで下がるとHP10・AT1・DF0の状態で固定となります。

冒険中のステータス

最後に冒険中のステータスについて見ていきましょう。
イザドラさんには若返ってもらって、余命が40年以上ある場合で健康度が15まで下がったときはこのようになります。

最大HPにはマイナス補正はありませんが、攻撃力と防御力が下がってしまっています。
この時には健康度が15なので 15/50 = 0.3 となります。
これを個別ステータスに当てはめると AT = 85 * 0.3 = 25.5 45 * 0.3 = 13.5 となり、値は切り捨てなので画面に表示したステータスとなります。

とまあ長かったですがステータス計算は以上になります。

「二つ名」の決まり方について

カルマ値や仲間の数、資金状況によってリリィは二つ名が決まります。
ここではどのような条件でリリィがどの二つ名を呼ばれるかを紹介します。

優先度1:特殊条件による二つ名

名称 条件
新鋭の魔女 ゲーム開始から10年以内
中堅の魔女 ゲーム開始から30年以内
真・孤高の魔女 ゲーム開始から30年以上かつ、一度も仲間を雇ってない

このあたりはゲーム開始直後だったり、孤高モードで呼ばれる名称になります。
他に条件を満たす場合でもこちらの条件が優先されますので、ゲーム開始から30年は実質二つ名は固定になります。
また、孤高モードを進めるときも名称は固定になります。

優先度2:一定条件を満たす場合の二つ名

まあこのへんはChatGPTに考えてもらったとおりです。

🌸【仲間の構成・カルマ条件】

条件 二つ名 備考
カルマ高、仲間が女性 高原の魔女 スライム倒して300年のアズサが元ネタ
カルマ中、仲間が女性 気まぐれの魔女 仲間が全員女性であるが、特に善悪に偏らず自然体に見えるため、「気まぐれ」という飄々とした印象を与える。
カルマ低、仲間が女性 妖美の魔女 -
カルマ高、仲間がリリィ以外男性(逆ハーレム) 紅一点の魔女 仲間に信頼される存在。軽妙な旅の女主人公感。
カルマ中、仲間がリリィ以外男性(逆ハーレム) 浮世の魔女 男性に囲まれつつも特に深入りせず、世俗を漂うイメージ。善でも悪でもなく、淡々と日々を生きるニュアンス。
カルマ低、仲間がリリィ以外男性(逆ハーレム) 傀儡の魔女 男たちを操る悪女的印象。冷酷かつ妖艶なイメージ。

🌑【孤独状態でのカルマ別】

条件 二つ名 備考
仲間なし、カルマ高 孤高の魔女 一人でも信念を貫く孤高の存在。求道者的な雰囲気。
仲間なし、カルマ中 彷徨いの魔女 自由だが不安定な状態。道半ばの漂流者。
仲間なし、カルマ低 孤絶の魔女 世界から完全に断絶された存在。人を拒絶し続けた結果。

👥【仲間MAX時のカルマ別】

条件 二つ名 備考
仲間MAX、カルマ高 慈母の魔女 全員から慕われる聖母のような存在。
仲間MAX、カルマ中 旅団の魔女 中立的立場で冒険を続ける魔女。小隊長のような立ち位置。
仲間MAX、カルマ低 偽りの魔女 外面は仲間に囲まれ華やかだが、内心は闇。操っている印象。

💰【資産×カルマの組み合わせ】

条件 二つ名 備考
所持金ほぼなし、カルマ高 清貧の魔女 物欲を捨てた徳のある存在。修道女のような印象。
所持金ほぼなし、カルマ中 風待ちの魔女 貧しくても特に絶望もせず、状況が良くなる「追い風」を待つ姿勢。中庸なカルマと合わせて未来を静かに見つめる存在。
所持金ほぼなし、カルマ低 破滅の魔女 何もかも失い、悪を抱えた存在。虚無と狂気の象徴。
大金持ち、カルマ高 寛大なる魔女 富を得ても他者に施しを忘れぬ存在。女神的な印象。
大金持ち、カルマ中 黄金の魔女 大金持ちだが、悪名高くもなく、英雄視もされず、ただ「黄金」を象徴する存在として知られる。
大金持ち、カルマ低 金獄の魔女 金で全てを支配する存在。金の奴隷でもある。

優先度3:条件を満たさない場合のカルマ値による二つ名

このあたりもChatGPTに考えてもらったとおりですね。

【カルマ善側(51〜100):「慈愛の魔女」など】

カルマ範囲 二つ名 意味・印象
91〜100 聖光の魔女 救世主のような存在、ほぼ聖人
81〜90 神恩の魔女 神に選ばれたかのような慈愛の象徴
71〜80 慈愛の魔女 人を助け、癒やす存在
61〜70 白羽の魔女 希望や安らぎの象徴
56〜60 風和の魔女 調和をもたらす穏やかな存在

【カルマ中立(41〜59):「黄昏の魔女」など】

カルマ範囲 二つ名 意味・印象
51〜55 灯火の魔女 混乱の中に小さな希望を灯す存在
46〜50 黄昏の魔女 善と悪の狭間、中立的立場
41〜45 霧間の魔女 判断つきにくい行動や立ち位置

【カルマ悪側(0〜49):「災禍の魔女」など】

カルマ範囲 二つ名 意味・印象
40〜45 冷眼の魔女 感情を感じさせない、距離を置かれる
30〜39 血月の魔女 恐れられる存在、時折暴走する
20〜29 災禍の魔女 不幸と破滅を呼ぶ存在
10〜19 終焉の魔女 世界に災いをもたらす存在
0〜9 炎の魔女 世界を焼き払う絶望の象徴

「失踪」の仕組みについて

難易度「ハード」以上では仲間にストレス値が設定されており、ストレスが溜まると失踪する可能性があります。
また、最上位モードである「異端」ではリリィですら失踪する可能性があり、この場合には強制ゲームオーバーとなります。

逃走が発生する確率は以下のとおりです。

 \displaystyle
  逃走確率 = \frac{ストレス - 逃走係数}{100}

逃走係数はハードでは90、異端では80となります。
ハードではストレスがMAXでも逃走確率は10%ですが、異端だと20%になります。
リリィの逃走率についても同じになります。

ストレスの上下は以下の行動で決まります。

行動 上下値
プレゼント(1000G) -5
リリィが自分にプレゼントを上げた時
他のメンバー
+1
評判 (60 - カルマ) / 20
休息(健康度40以下) -2
休息(健康度60以下) -1
休息(健康度80以下) +0
休息(健康度80以上) +1
休息(健康度80以上かつ
性格が「きんべん」「ゆうかん」)
+2
休息(性格が「ひよわ」の場合追加) -1
その他行動(健康度が20以下) +3
その他行動(健康度が40以下) +2
その他行動(健康度が80以下) +1
その他行動(健康度が80以上) +0
猛特訓・重労働時(追加) +3
冒険(性格が「ゆうかん」
追加)
-2
冒険(性格が「ひよわ」
追加)
+2
労働・重労働(性格が「きんべん」
追加)
-2
訓練・猛特訓(性格が「たくましい」
追加)
-2
指導(性格が「せわやき」
追加)
-2

例えばカルマが80の状態で健康度30でせいかくが「きんべん」のキャラが「重労働」をすると以下のようにストレスが上下します。

  • 評判: -1
  • その他行動(健康度40以下):+2
  • 重労働:+3
  • 「きんべん」で労働:-2

これを合わせるとこの1年だけでストレスが+2されることになりますね。
上の表を見てもらうと分かるのですが、休息すればストレスが減るというわけでもなく健康度がMAXに近い状態で休息させるとストレスが逆に溜まっていきます。

ステータスの項目でずーっと休息させ続けたイザドラさんのストレスがたまり続けているのはこんな理由になります。
ちなみに死ぬ直前はちょっとストレスが減ってるのは、老衰による健康度の低下によって低健康度での休息によるストレスの回復減少によります。
うーん・・・面倒くさいですね!でも安心してください。ストレスの概念があるのはハードモード以上からとなります。

「流行病」について

最上位難易度である「異端」では面倒くさいストレスの概念に加えて、流行病・生活資金という要素も追加されます!
最上位モードだから思いついた要素を片っ端から入れてみました。

「流行病」は毎年1/10の確率で発生します。
発生中には各仲間が33%の確率で感染し、0 - 120の間のランダム値の健康度が下がります。
・・・はい、最大120下がることになります。

ここで「異端」モードでの日常での死亡判定を見てみると、 (20 - 0) * 3 = 60 なので大きめの乱数を引いてしまって健康度が0になってしまうと60%の確率で死亡します。

前年まで健康度MAXだったキャラが、運が悪いと次の年に寿命でもなく、労働も訓練も出撃もしてないのに突然死ぬ・・・これが「異端」モードの世界になるわけですね。

「生活資金」について

そんなハードコアな「異端」モードですが更に「生活資金」の概念も登場します。
生活に必要な資金は毎年各キャラに依存して決定します。

 \displaystyle
  必要生活費 = 30 + (Lv \times 3)

つまり、Lv1では33Gで済みますが、Lv50では180G、Lv99では327G必要となります。
これら各キャラの必要資金を合算したうえで、カルマ値によって最終的に必要な生活費が求まります。

 \displaystyle
  最終合計必要生活費 = \frac{合計必要生活費 \times (150 - カルマ値)}{100}

これで最終的に必要な生活費が求まりましたが、もし足りない場合には毎年以下の健康度が減っていきます

 \displaystyle
  低下健康度 = 10 - \frac{10 \times 残資金}{合計必要資金}

なので必要生活費が1000Gで残資金が500Gの場合には、残資金が0Gになった上で、全仲間の健康度が毎年5下がっていくわけですね。
ちなみに全くお金がないと、毎年健康度が10下がっていきます。

生活費にいくらかかったかは毎年メッセージで表示されるのと、お金が足りない時には警告が出るようにしてあります。

これで1年終了時処理については説明が終わりです。お疲れ様でした。
続いては細々とした処理について説明していこうかなと思います。

「アイテム処理」について

いもスパにおけるアイテムの管理は、個人が持つアイテムと倉庫にまとめて格納されているアイテムに分けられます。
ちょっとユニークなのが個人保有アイテムは人に所属するのではなくて、ダンジョン探索の何番目のメンバーかによって紐付いています。
これは人に紐づくとメンバーの加入・脱退でのアイテムの処理が面倒になるためある程度制作側で楽をするためというのと、ダンジョン参加メンバーは毎回異なると思うので、毎回プレイヤー側でメンバーに応じてカスタマイズする手間があるのでお互いに面倒くさいかなと思ってこのシステムにしました。

先に行っておくとかなり面倒なシステムなので、いもスパのアイテム管理システムを利用したいと思う人はふーんで流し見したほうが良いとも思いますよ。
というか多分いもスパのアイテム管理システムを導入したいという人はもっと効率的なシステム自分で作れると思うので多分意味のない解説になります。

アイテム入手時の処理

手持ちアイテムはユーザー定義変数で管理しているのではなくて、手持ちアイテムエリアというのをマップの隠しエリアに持っていて、隠しエリアに配置したアイテムのIDによって管理しています。
一方の倉庫アイテムはユーザー定義変数によって管理しているという違いがあります。
まずはダンジョン探索中にアイテムを取得した時の処理ですが、以下のようになります。

これらを説明する前に、少々ややこしいのですがフィールドに配置された物体IDと実際にアイテム欄に格納される物体IDが違うという事情があります。
これはアイテムがいっぱいの時に諦める処理を入れたいためにデフォルトのアイテム取得のロジックでは対応できないためになります。
というのも、ダンジョンがランダムで作られる関係かつ、開発当初は敵がアイテムを落とす可能性もあったためアイテムがいっぱいだと通路が通れず詰んでしまう可能性を排除するためのこのような仕様にしています。

と、いうわけでまずは見た目上のアイテムを取得した時にはこちらの getItem() 関数が呼ばれます。

/** アイテム取得時に呼ばれる */
function getItem() {
  v["tmp"]["is_full"] = true; 
  v["tmp"]["target_item"] = null;
  for(i=0; i<LENGTH(v["ITEM_LIST"]); i++) {
    if(v["ITEM_LIST"][i]["field_id"] == ID) {
      v["tmp"]["target_item"] = v["ITEM_LIST"][i];
    }
  }
  if(v["tmp"]["target_item"] == null) {
    LOG("対象のアイテムが見つかりません");
    o[X][Y] = 0;
    return 0;
  }
  for(i=1; i<10; i++) {
    if(ITEM[i] == 0) {
      o[PX][PY] = v["tmp"]["target_item"]["item_id"];
      o[X][Y] = 0;
      // アイテム取得音
      SOUND(16);
      return 0;
    }
  }
  /** アイテムを削除するPOSを退避 */
  v["tmp"]["pos"] = {
    x: X,
    y: Y
  }
  /** アイテムを諦めるかの選択肢を表示する */
  o[PX][PY] = 215;
}

WWAのアイテム取得関数を手動で再度定義しているような内容なのですが、関数が呼ばれたパーツのIDがアイテムマスタ(後述)に登録されたアイテムかどうかを判定し、登録済みの場合にはアイテムボックスがいっぱいかどうかを判定、空きがある場合には実際の格納用アイテムを改めて召喚といった流れになります。

で、実際の格納用アイテムが呼ばれたときは次の CALL_GET_ITEM() が呼ばれます。

/** アイテム取得時に呼ばれる: カスタムイベント関数 */
function CALL_GET_ITEM() {
  /** ゲーム開始前に呼ばれる場合は何もしない */
  if(!v["tmp"]) {
    return 0;
  }
  /**
   * 以下の状態ではアイテムリスト更新をしない
   * 1. プレイヤーチェンジ時による切り替わり
   * 2. ダンジョン潜入時の所持アイテム初期化
   **/
  if(v["is_ignore_change_item_list"]) {
    return 0;
  }
  /** システムアイテムなら何もしない */
  for(i=0; i<LENGTH(v["SYSTEM_ITEM_ID"]); i++) {
    if(v["SYSTEM_ITEM_ID"][i] == ITEM_ID) {
      return 0;
    }
  }
  // 切り替え用: 所持アイテムリストを更新
  o[80 + ITEM_POS][122 + v["now_front_member_idx"]] = ITEM_ID;
}

CALL_GET_ITEM() 自体はゲーム開始前のシステムアイテムの取得、仲間の切り替えによるアイテムチェンジ、拠点パートからダンジョンに挑むにアイテムエリアに退避したアイテム群を手持ちに加えるときにも呼ばれるので、その時にはアイテムエリアには手を触れないようにしています。

アイテム使用時の処理

アイテム入手時があるなら使用した時の処理も説明しないと行けないですね。
というわけでアイテムボックスにあるアイテムをクリックした時には以下の CALL_USE_ITEM() が呼ばれます。

アイテムクリック時の処理

/** アイテム使用時に呼ばれる: カスタムイベント関数 */
function CALL_USE_ITEM() {
  /** ゲーム開始前に呼ばれる場合は何もしない */
  if(!v["tmp"]) {
    return 0;
  }
  /** プレイヤーチェンジ時による切り替わりでは何もしない */
  if(v["is_ignore_change_item_list"]) {
    return 0;
  }
  /** システムアイテムなら何もしない */
  for(i=0; i<LENGTH(v["SYSTEM_ITEM_ID"]); i++) {
    if(v["SYSTEM_ITEM_ID"][i] == ITEM_ID) {
      return 0;
    }
  }
  /** 使用したアイテムについて探査する */
  v["tmp"]["target_item"] = null;
  for(i=0; i<LENGTH(v["ITEM_LIST"]); i++) {
    if(v["ITEM_LIST"][i]["item_id"] == ITEM_ID) {
      v["tmp"]["target_item"] = v["ITEM_LIST"][i];
      v["tmp"]["item_idx"] = i;
    }
  }
  if(v["tmp"]["target_item"] == null) {
    LOG("対象のアイテムが見つかりません: " + ITEM_ID);
    return 0;
  }
  /** 消費アイテムは冒険中でしか使用できないようにする */
  if(v["game_mode"] == "adventure") {
    /** アイテムの位置を格納しておく */
    v["tmp"]["item_pos"] = ITEM_POS;
    /** 使用するかどうかを問うアイテムを格納する */
    o[PX][PY] = v["tmp"]["target_item"]["item_use_id"];
  }
  /** アイテムカスタムモードで押されたときには倉庫に入れる */
  else if(v["game_mode"] == "showStatus" && v["item_box_mode"] == "custom") {
    // アイテムボックスの数を1個増やす
    v["item_box"][v["tmp"]["item_idx"]] += 1;
    // アイテムボックスに保管されたアイテムを消す
    o[80 + ITEM_POS][122 + v["now_front_member_idx"]] = 0;
    // アイテムボックスを再描画する
    showItemBox();
  }
  else {
    o[PX][PY] = ITEM_ID;
    MSG("このアイテムは冒険中でしか使用できません。")
  }
}

こちらもゲーム開始前やシステムアイテムを利用した時には例外処理で弾いておき、次に使用したアイテムのIDが ITEM_ID で取れるのでこれアイテムマスタに登録されているかを探査して、マスタにに含まれるアイテムであれば続いて使用するかどうかの二者択一パーツをプレイヤーが居る座標に出現させて使用するかどうかの選択をします。

ちなみにアイテムマスタとは何かというと、スプレッドシートでこちらのようにまとまったリストでして、アイテムごとに以下の要素を持たせて、ゲーム中ではJSONで固めて持っています。

要素名 役割
no 管理上のID
field_id ダンジョン内に配置されるアイテムパーツ番号
item_id 実際に格納されるアイテムパーツ番号
item_use_id アイテムをクリックしたときに配置される、使用確認パーツ番号
sale_id 店で設置される販売パーツ番号(換金アイテムには存在しない)
HP アイテム使用時に回復するHP
AT アイテム使用時に上昇するボーナスAT
DF アイテム使用時に上昇するボーナスDF
GD 販売金額
HL アイテム使用時に上昇する健康度
LIFE アイテム使用時に回復する余命
WAP アイテムを持っているだけで上昇する攻撃力
WDF アイテムを持っているだけで上昇する防御力

こんな感じで内部的には1つのアイテムについて「フィールド配置用パーツ」「アイテム格納用パーツ」「使用二択表示パーツ」「販売用パーツ」の4つが存在していることが分かったかと思います。

実際にアイテムを利用する時の処理

ここでアイテムを使用しますか?(Y/N)の選択でYesと答えると useItem() が呼ばれます。
どのような処理かは見てもらうのが早いかなと思います。

/**
 * アイテムを使用するかどうかの選択肢でOKと答えた時に実際に使用する
 * 
 **/
function useItem() {
  v["tmp"]["update"] = {
    hp: v["tmp"]["target_item"]["hp"],
    at: v["tmp"]["target_item"]["at"],
    df: v["tmp"]["target_item"]["df"],
    helth: v["tmp"]["target_item"]["helth"],
    life: v["tmp"]["target_item"]["life"],
  }
  v["tmp"]["msg"] = "";
  /** 
   * 使用したアイテムの有効範囲が全体かを判定する
   * 効果範囲が全体のアイテムが増えたらここで処理する
   **/
  if(
    v["tmp"]["target_item"]["id"] >= 23 &&
    v["tmp"]["target_item"]["id"] <= 27
  ) {
    /** 有効範囲が全体のアイテムを処理する */
    allTargetItem();
  }
  else {
    /** 有効範囲が単体のアイテムを処理する */
    v["tmp"]["member_id"] = v["now_adv_member_list"][v["now_front_member_idx"]];
    oneTargetItem();
  }
  /** ステータスを更新する */
  createStatus();
  /** 探索中にHPが上下した時の処理 */
  updateFrontPlayerHPSync();
  if(v["tmp"]["msg"] == "") {
    MSG(v["tmp"]["target_item"]["name"] + "は使用しても効果がないので目の前に捨てた。")
    discardItem();
  }
  else {
    // 回復SE
    SOUND(15);
    MSG(v["tmp"]["msg"]);
  }
  // 切り替え用: 所持アイテムリストを更新
  o[80 + v["tmp"]["item_pos"]][122 + v["now_front_member_idx"]] = 0;
}

このアイテムを利用するとどのような効果が得られるか?というをObjectで持ってまして、まずはこれを初期化します。続いて使用しているかどうかのアイテムが単体か全体かの判定をしてます。これは本当は単体・全体判定をちゃんと要素を持ってそれで管理するべきなのですが、作中では全体効果があるアイテムがエリアポーション系に限られているのでITEM_IDが特定の範囲内かで判定しちゃってます。このシステムを参考に作るなら簡略化せずちゃんと要素を持たせて判定するようにしてくださいね。

範囲効果が全体なら allTargetItem() を呼び出します。これは冒険参加中のメンバーIDに対してforで回して個別で oneTargetItem(); を呼んでるだけなので省略します。
oneTargetItem() の前に、効果があるかどうかは表示メッセージ v["tmp"]["msg"] で管理してまして、効果があると「HPがXX回復した!」みたいにメッセージが積み重なっていくんですが、これが空っぽなら効果無しとして判定して、目の前に捨てる discardItem() 処理を呼び出します。

ちゃんと使えた時には累積された効果メッセージを表示して、アイテム管理エリアに置かれたアイテムも消し込みます。

で、使用する肝心の中身が oneTargetItem(); ですね。

/**
 * 有効範囲が単体のアイテムを処理する
 * v["tmp"]["member_id"]: 処理対象のIndex
 * v["tmp"]["target_item"]: 使用アイテム
 **/
function oneTargetItem() {
  /** 今表に出ているメンバーID */
  v["calc_target_member"] = v["MEMBER_LIST"][v["tmp"]["member_id"]];
  /** 対象がリリィか */
  v["tmp"]["is_lily"] = v["tmp"]["member_id"] == 0;
  /** 対象がリリィでも孤高モードでは効果があるようにする */
  if(!v["tmp"]["is_lily"] || v["is_alone_mode"]) {
    v["tmp"]["able_update_at_df"] = true;
  }
  else {
    v["tmp"]["able_update_at_df"] = false;
  }
  /** 計算対象が表に出ているか? */
  if(v["game_mode"] == "home") {
    v["tmp"]["is_front"] = false;
  }
  else {
    v["tmp"]["is_front"] = v["now_adv_member_list"][v["now_front_member_idx"]] == v["tmp"]["member_id"];
  }
  // 一旦後略
}

まずは前処理ですね。使用対象がどのメンバーかを判定したりしています。
あと一部のアイテムはリリィには効果がないので使用対象がリリィかどうかを判定しています。 立ち入りを禁じられる] 面倒なのが孤高モードでは一部アイテムがリリィでも効果があるので分岐させたりしてます。
控えのメンバーが回復した時には右側ステータス欄には反映させないので表に出ているメンバーが回復対象かの判定もあります。

/** 
 * HP系
 * HPが0の時には効果がない
 **/
if(v["tmp"]["update"]["hp"] != 0 && v["calc_target_member"]["hp"] > 0) {
  // 回復処理前のHP
  v["tmp"]["before_hp"] = v["calc_target_member"]["hp"];
  if(v["tmp"]["is_front"]) {
    HP += v["tmp"]["update"]["hp"];
  }
  v["calc_target_member"]["hp"] += v["tmp"]["update"]["hp"];
  // HP上限を超えていたら補正する
  if(v["calc_target_member"]["hp"] > v["calc_target_member"]["hpmax"]) {
    v["calc_target_member"]["hp"] = v["calc_target_member"]["hpmax"]
  }
  // トラップ系から呼ばれる場合もあるため
  else if(v["calc_target_member"]["hp"] < 0) {
    v["calc_target_member"]["hp"] = 0;
  }
  v["tmp"]["diff_hp"] =  v["calc_target_member"]["hp"] - v["tmp"]["before_hp"];
  if(v["tmp"]["diff_hp"] > 0) {
    v["tmp"]["msg"] += v["calc_target_member"]["name"] + "のHPが" + v["tmp"]["diff_hp"] + "回復した!\n";
  }
}

続いてHP回復系ですね。前提として倒れた仲間にポーションとか使えるとリザレクション系の意味がなくなるので、HPが0以上かどうかを判定させてます。 表に出ている仲間ならそのままHPを回復させて、共通処理として裏で持っている仲間のHPを回復させます。
裏で持っている方はWWAデフォルトのシステムで処理している上限切り捨て処理をやってくれないので自前で実装してます。
ユニークなのはHPダメージ系のダメージを踏んだときも、内部的にはHPがマイナス回復するアイテムを使用というフローで処理をしているので、HPが0になったときの処理も入れ込んでいます。
あとはどれくらい回復したかのDIFFを取って表に出してますね。

/** AT系: 孤高モードでないリリィには効果がない */
if(v["tmp"]["able_update_at_df"] && v["tmp"]["update"]["at"] > 0) {
  v["calc_target_member"]["effort_at"] += v["tmp"]["update"]["at"];
  v["tmp"]["msg"] += v["calc_target_member"]["name"] + "の基礎攻撃力が" + v["tmp"]["update"]["at"] + "上昇した!\n";
}
/** DF系: 孤高モードでないリリィには効果がない */
if(v["tmp"]["able_update_at_df"] && v["tmp"]["update"]["df"] > 0) {
  v["calc_target_member"]["effort_df"] += v["tmp"]["update"]["df"];
  v["tmp"]["msg"] += v["calc_target_member"]["name"] + "の基礎防御力が" + v["tmp"]["update"]["df"] + "上昇した!\n";
}
/** 健康度 */
v["tmp"]["before_helth"] = v["calc_target_member"]["helth"];
/** 健康度を回復 */
v["calc_target_member"]["helth"] += v["tmp"]["update"]["helth"];
// 健康度が100を超えていたら切り捨てる
if(v["calc_target_member"]["helth"] > 100) {
  v["calc_target_member"]["helth"] = 100;
}
else if(v["calc_target_member"]["helth"] < 0) {
  v["calc_target_member"]["helth"] = 0;
}
// 回復量
v["tmp"]["diff_helth"] =  v["calc_target_member"]["helth"] - v["tmp"]["before_helth"];
if(v["tmp"]["diff_helth"] > 0) {
  v["tmp"]["msg"] += v["calc_target_member"]["name"] + "の健康度が" + v["tmp"]["diff_helth"] + "回復した!\n";
}

AT/DF/HL回復系の処理です。
AT/DFについては書いてあるとおりで、健康度に関しては0 - 100の閾値に収まっているかのチェック入れてるくらいですね。 この後は寿命を伸ばすアイテムの処理がありますが「寿命を伸ばせるアイテム」の項目で解説済みなので割愛します。

/** 
 * スペシャルアイテムの効果をここに書く
 * スペシャルアイテムはダンジョン専用なので、家では使えないようにする
 * (家で下の階層に潜られるとヤバイし・・・)
 **/
if(v["tmp"]["target_item"] && v["game_mode"] != "home") {
  /** 透視の石 */
  if(v["tmp"]["target_item"]["id"] == 36) {
    if(v["dungeon_level"] % 10 != 0) {
      v["tmp"]["msg"] += "透視の石を使った!\n現在の階層がすべて見えるようになった!";
      /** ミニマップを全開放する */
      for (i = 0; i < v["DUNGEON_SIZE"]; i++) {
        v["is_hide_minimap_area"][i] = [];
        for (j = 0; j < v["DUNGEON_SIZE"]; j++) {
          v["is_hide_minimap_area"][i][j] = false;
        }
      }
      /** ミニマップを更新する */
      pictureDungeonMiniMap();
    }
    else {
      v["tmp"]["msg"] += "透視の石を使った!\n...ボスフロアでは効果が無いようだ...";
    }
  }
  /** 転移の羽根 */
  if(v["tmp"]["target_item"]["id"] == 37) {
    if(v["dungeon_level"] % 10 != 0 && v["dungeon_level"] < 100) {
      v["tmp"]["msg"] += "転移の羽根を使った!\n次の階層に転移します!";
      startCreateRandomDungeon();
    }
    else {
      v["tmp"]["msg"] += "転移の羽根を使った!\n次の階層に転移します!"
        + "<P>...ボスフロアでは効果が無いようだ...";
    }
  }
}

ラストにスペシャルアイテム系の処理です。透視の石と転移の羽ですね。
アイテム使用関数は拠点からも呼ばれるので、家からは呼ばれないようにしています。

後はボスフロアでは両方とも使えないので分岐して使えないようにしてあります。 次のフロアに移る処理は startCreateRandomDungeon(); で切り分けてまして、トラップからも呼ばれることがあるんですがアイテムとは関係ないのでこれも省略します。

アイテムを捨てる時の処理

アイテムを使用する選択肢でNoを選んだ時には捨てるかどうかの選択肢が出るので、ここでYESを選んだ場合や、使用したアイテムの効果がない場合には discardItem() 関数が呼ばれてアイテムを捨てる処理が走ります。

/** アイテムを捨てる時に呼ばれる */
function discardItem() {
  // 投げ捨てるアイテムの座標を決める
  if(PDIR == 2) {
    v["tmp"]["throw_item_pos"] = {
      x: PX,
      y: PY + 1
    }
  }
  else if(PDIR == 4) {
    v["tmp"]["throw_item_pos"] = {
      x: PX - 1,
      y: PY
    }
  }
  else if(PDIR == 6) {
    v["tmp"]["throw_item_pos"] = {
      x: PX + 1,
      y: PY
    }
  }
  else if(PDIR == 8) {
    v["tmp"]["throw_item_pos"] = {
      x: PX,
      y: PY - 1
    }
  }
  if(o[v["tmp"]["throw_item_pos"]["x"]][v["tmp"]["throw_item_pos"]["y"]] == 0) {
    MSG(v["tmp"]["target_item"]["name"] + "を捨てた。")
    o[v["tmp"]["throw_item_pos"]["x"]][v["tmp"]["throw_item_pos"]["y"]] = v["tmp"]["target_item"]["field_id"];
    // 切り替え用: 所持アイテムリストを更新
    o[80 + v["tmp"]["item_pos"]][122 + v["now_front_member_idx"]] = 0;
  }
  else {
    MSG("正面に何かあるのでアイテムを捨てられなかった。");
    o[PX][PY] = v["tmp"]["target_item"]["item_id"];
  }
}

これはプレイヤーが向いている方向に物体パーツがあるかどうかを判定して、ない場合には目の前に配置します。
なので壁にめり込ませて捨てることも出来るんですよね。
このあたりは本来はちゃんと壁パーツかどうかで判定する必要があるんですが、壁にめり込んでも実害がないですし、壁パーツは階層によって変わるので条件分岐が面倒なのでサボっています。

捨てた場合にはアイテム管理エリアのアイテムパーツもちゃんと消し込んでいきます。

余談ですが本作では内部的には消費アイテム・武器・防具の区分は存在せず同じアイテムとして処理しています。
なので武器や防具も内部的には同じ消費アイテム扱いでして、使用しても何の能力値も上がらないアイテムとして扱われいます。だから捨てる時に「ブロンズソードは使用しても効果がないので~」みたいなよく分からない文章になるんですね。

アイテムを倉庫に預ける時の処理

そろそろしんどくなってきましたか?私も面倒になってきてきました。
ざっと流して終わりにしましょうね。

個別アイテムの預け入れ

この時には「アイテムクリック時の処理」に貼ったソースコードに書いてあるとおり、 CALL_USE_ITEM() 内で拠点から呼ばれたかの条件分岐があって、 v["item_box"][v["tmp"]["item_idx"]] += 1; で内部的に持つ倉庫アイテムをインクリメントする代わりにアイテム保管エリアのアイテムパーツを消し込んで再描画してます。

個別アイテムの引き出し

/** 実際にアイテムを引き出す */
function pullItem() {
  // アイテムID
  v["tmp"]["item_idx"] = (PY - 101) + (v["item_page"] * 8);
  if(v["item_box"][v["tmp"]["item_idx"]] > 0) {
    /** アイテムを持てるかチェック */
    v["tmp"]["is_full"] = true;
    for(i=1; i<10; i++) {
      if(ITEM[i] == 0) {
        v["tmp"]["is_full"] = false;
      }
    }
    if(v["tmp"]["is_full"]) {
      MSG("これ以上アイテムを持てません!")
    }
    else {
      // アイテムボックスの数を1個減らす
      v["item_box"][v["tmp"]["item_idx"]] -= 1;
      // 手持ちのアイテムを増やす
      o[PX][PY] = v["ITEM_LIST"][v["tmp"]["item_idx"]]["item_id"];
      // アイテムボックスを再描画する
      showItemBox();
    }
  }
}

アイテムボックスページのページIDとプレイヤー座標から何番のアイテムを引き出したかを求め、該当アイテムがあってかつ1個以上あってかつアイテムが持てるなら預け入れとは逆の処理をしてますね。
まあ上のコード呼んでください

アイテムの一括預け入れ

テストプレイ時にこの辺はバグが多かったんですよね。
結局はWWA本体のバグによるものだったんですけど、詳しくはこの辺見てください。

github.com

やってる内容としては単純で、アイテム退避エリアのアイテムたちを1個ずつ参照して存在すればアイテムボックスの対応アイテムの個数をインクリメントするだけですね。 バグが多発してたのでLOGでアイテムボックスの状態を全部コンソール出力してます。 多分今ではバグは起きないと思うのですが、アイテムボックスがぶっ壊れてると預けた瞬間アイテムが虚無に消えるという悲しい事態になっていました。

// 手持ちのアイテムを全部倉庫に送る
function moveAllItemToBox() {
  /** アイテムボックスが存在してなければ生やす */
  if(!v["item_box"] || LENGTH(v["item_box"]) <= 0) {
    v["item_box"] = [];
  }
  for(i=0; i<4; i++) {
    for(j=0; j<9; j++) {
      v["tmp"]["x"] = 160 + 5 + (j % 5);
      v["tmp"]["y"] = 100 + 2 + (i * 2) + (j / 5); 
      v["tmp"]["item_id"] = o[81 + j][122 + i];
      if(v["tmp"]["item_id"] != 0) {
        /** 手持ちのアイテムは消す */
        o[81 + j][122 + i] = 0;
        v["tmp"]["is_match"] = false; 
        for(k=0; k<LENGTH(v["ITEM_LIST"]); k++) {
          if(v["ITEM_LIST"][k]["item_id"] == v["tmp"]["item_id"]) {
            /** アイテムボックスの数を増やす */
            v["item_box"][k] += 1;
            v["tmp"]["is_match"] = true;
            break;
          }
        }
        if(!v["tmp"]["is_match"]) {
          LOG("倉庫送り時に認識できないアイテムを削除しました: " + v["tmp"]["item_id"]);
        }
      }
    }
  }
  // この辺バグが多いので念の為ログを出しておく
  LOG(v["item_box"])
  /** ホーム画面:手持ちアイテム一覧を再描画 */
  showHomeITEMSelectMember();
  MSG("手持ちのアイテムを全部倉庫に移動しました!")
}

NPCのローテーション」について

ここから先は生成AIを使って各種生ソースコードを読ませて解析した文章を手直ししています。
その影響で変な文体になってたり [10] みたいな謎の脚注が出てくる場合がありますが許してください。

いもスパにおけるNPCは単なる背景ではなく、時間経過と共に世代交代していく仕組みが採用されています。これにより、プレイヤーの長い冒険の中で、世界が動的に変化しているという実感を生み出しています。

  • NPCのデータ構造

NPCの状態は v["npc_list"] という配列で管理されています。各NPCは以下の情報を持ち、彼らの「生涯」が記録されます。

要素名 役割
type NPCの種別(例: bar, tools, doctorなど)
name 現在の名前(ランダムで決定)
now_id 現在のNPC候補ID(候補リスト内でのインデックス)
change 最後に交代が発生したゲーム内年
next_change 次回交代予定のゲーム内年
change_reason 交代理由("dead": 死亡 または "retire": 引退)

NPCの種別リストには、酒場マスター (bar) や道具屋 (tools) のほか、医者 (doctor)、助手 (assistant)、領主 (lord)、神父 (priest) など多岐にわたります。

  • 交代のトリガーと周期

NPCの世代交代は、ゲーム内で1年が経過する際に行われる年次処理( endYear )の中で実行されます。 交代が実行される条件は以下のいずれかです

  1. 交代予定年超過: next_changeに設定された年を現在年(year)が過ぎた場合。
  2. 初期化状態: now_idが-1(初期値)の場合。

このシステムにより、NPCは最短20年、最長50年の周期で交代します。
具体的な計算式は以下の通り、ランダム性が組み込まれています

 \displaystyle
  次回交代年 = 現在年 +20+RAND(30)

世代交代の裏側について

NPCの交代処理を行うコア関数が rotationNPC() です。

  1. ID更新と候補の循環

交代判定が「YES」となった場合、まず現在のNPC候補ID(now_id)がインクリメントされます。 NPCには種別ごとに複数の候補(例:ID 0, 1, 2)が設定されており、IDが候補数を上回った場合、IDは0にリセットされ、ループバック(循環)します。これにより、同じ役職が固定されず、様々なNPCがローテーションすることになります。

  1. 名前と交代理由の決定

新しいNPCの候補情報(性別など)を取得した後、名前が決定されます。男性/女性の名前リストからランダムに選択され、新しいNPCに割り当てられます。 次に交代理由が決定されます。これは単純な50%の確率です。

  • 死亡 (dead): 50%
  • 引退 (retire): 50%

この交代理由は、後述するNPCとの会話時、プレイヤーに伝えられるメッセージ(前任者の死亡/引退に言及するセリフ)に影響します。

  1. マップ上での物体更新

交代が完了すると、マップ上に配置されているNPCの外観パーツと会話トリガーパーツが、新しいNPCのものに置き換わります。
ここで注目すべき裏話として、一部のNPCは外観の座標が0以下に設定され、会話トリガーパーツのみが配置されるように設計されています。
例えば、店主などは表示用のパーツとカウンター越しに話しかけるパーツが別のため、表示用と会話用2つのパーツが用意されています。一方で医者の助手や医者、メイドなどは会話パーツで表示するために表示用パーツは非表示とさせています。

NPC会話処理とカルマシステム

プレイヤーがNPCに話しかけた際のセリフは、単なる固定メッセージではなく、プレイヤーのカルマ値や交代のタイミングに強く依存する3層構造のシステムで決定されます。

階層1:交代年特別セリフ(最優先)

もしそのNPCが今年交代したばかりの場合、カルマ値に関係なく、最優先で交代挨拶の特別セリフが表示されます。

  • 前任者が「死亡」の場合 (Index 6): 前任者の死を悼むようなセリフ。
  • 前任者が「引退」の場合 (Index 7): 前任者が元気で引退したことを伝えるようなセリフ。

これにより、プレイヤーは「ああ、このNPCは最近代わったんだな」と、世界の時間経過を強く感じることができます。

階層2:カルマ依存セリフ(通常時)

交代年ではない場合、プレイヤーの現在のカルマ値(1〜100)に応じて、セリフのインデックス(0〜5)が決定されます。

カルマ範囲 Index 評価 意味・印象
81-100 0 最高 非常に友好的、尊敬の念を示す
61-80 1 友好的、好意的な反応
41-60 2 中立的な反応
21-40 3 やや冷たい反応
2-20 4 警戒や軽蔑の念を示す
1-2 5 最悪 最も厳しい、敵対的な反応

プレイヤーの行動(カルマ)がNPCからの評判として反映されるため、没入感が高まる仕組みです。

階層3:セリフテキストのフォールバック

セリフIndexが決定された後、そのNPC固有のセリフがデータベースにあればそれを表示し、なければ「共通セリフ」を使用するというフォールバック処理が組み込まれています。

特殊NPCに見る裏側の設定

NPCローテーションシステムには、特定のNPCにのみ適用される特殊な処理が組み込まれています。

  • 領主 (Lord) の処理

領主に話しかけた際、その年にまだ寄付を行っていない場合、寄付または強奪が出来るようにしています。

  • 神父 (Priest) の処理

最上位難易度でのゲームの緊張感を高めるため、神父を通じたセーブは厳しく制限されています。 神父に話しかけた際、難易度がハードまたは異端の場合に、セーブの可否が判定されます。

• ハードモード: 10年に1回セーブ可能 • 異端モード: 40年に1回セーブ可能

セーブが許可されると、セーブ物体と、周囲にセーブトリガーが配置されます。 この制限は、特に異端モードにおける緊張感を担保する重要なシステムとなっています。

このように、NPCローテーションシステムは、NPCの見た目や名前を変えるだけでなく、プレイヤーの評判を反映し、さらにはセーブ機能の制限といったゲームの核となる要素とも深く連携しているのです。
皆さんも、次に酒場に入ったときはマスターが何歳でカルマによってどんな態度が変わっているか、ちょっと意識してみてはいかがでしょうか?

「ゲームオーバー」について

いもスパでは、プレイヤーの選択や時間経過、管理の失敗によって、全6種類のバッドエンドが用意されています。これらは内部的には座標に紐付けられたカスタム関数として実装されています。

ゲームオーバー No. 1:反逆ゲームオーバー

【発生条件】 カルマが低い状態で領主を脅迫するなど、領民の怒りを買い、反旗を翻された場合。

【シナリオ概要】 リリィが長く領民を顧みなかったため、民衆の怒りが爆発。強大な不老不死の魔女といえど数の暴力には抗えず、捕らえられ、帝都の石造りの地下牢へと移送され、二度と陽の光を浴びることはなかった。

【開発ヒント】

ヒント: カルマが低い状態で 領主を脅迫しないようにしましょう。

ゲームオーバー No. 2:タイムオーバー(歴史の終焉)

【発生条件】 ゲーム内時間が経過し、帝政が崩壊する特定の年(1250年)を迎えてしまった場合。

【シナリオ概要】 1250年、帝国全土で反乱が起こり、帝政が崩壊し新たな時代が始まる。村は革命軍の占領下に置かれ、リリィの目的だった洞窟は文化財として立ち入りを禁じられる。リリィは目的を失い、村から姿を消す。

【開発ヒント】

ヒント: もう少しスムーズに洞窟を攻略するようにしましょう。

ゲームオーバー No. 3:墓地満員ゲームオーバー

【発生条件】 仲間の死亡数が上限(512人以上)に達し、墓地が埋め尽くされた場合。

【シナリオ概要】 あまりにも多くの命が失われ、村は沈黙に包まれる。帝国政府がこの事態を看過できなくなり、リリィを使ったダンジョン攻略計画は白紙に。リリィは帝都の地下牢に連行され、そこで永遠の時を過ごすこととなる。

【開発ヒント】

ヒント: 意図的にこれを見るくらいの根気がある貴方なら。通常クリアなんて簡単にできるでしょう。

ゲームオーバー No. 4:リリィ失踪ゲームオーバー

【発生条件】 難易度「異端」モードにおいて、リリィ自身のストレスが極度に高まり、失踪判定を引いた場合。

【シナリオ概要】 重圧と孤独がリリィの心を蝕み、戦う理由を見失う。仲間も村も置き去りにし、彼女は闇夜へ姿を消す。不死の魔女の物語はここで終わる。

【開発ヒント】

ヒント: 異端モードではリリィのストレスにも気を使う必要があります。

ゲームオーバー No. 5:最終決戦敗北ゲームオーバー(または単騎特攻)

【発生条件】 ラスボスに敗北した場合、またはリリィ単騎で最下層の石板に触れた場合。

【シナリオ概要】 (シナリオの根幹に関わる超ネタバレなので省略)

【開発ヒント】

ヒント: ここまでくればあと一歩!頑張ってください。

ゲームオーバー No. 6:セルフ追放ゲームオーバー

【発生条件】 プレイヤーが意図的にリリィを追放した場合。

【シナリオ概要】 リリィは自らを追放し、静寂が広がる。仲間は必死に呼びかけるが応答はなく、戸口には短い別れの言葉を残す。追放の決断は彼女を消し、残された者たちを打ち砕き、物語は途切れる。

【開発ヒント】

ヒント: 自分で自分を追放してはいけません。

「物語の裏話」について

ここからはシナリオの根幹に当たる要素のため、超ネタバレになります。未クリアの方はご注意ください。

基本的にストーリーの裏話とかはゼロディングで紹介しているのですが、ゼロディングの枠では語れないこともありますのでここで深堀りしていこうかなと思います。
他にもボツ要素なんかも紹介していきます。

未採用の二つ名

特定の行動(大量に追放・大量に雇用・大量に死に追いやる)をした場合に呼ばれる特別な二つ名がありました。

🔥【短期間の行動系】 (未採用)

条件 二つ名 備考
短期間で仲間を追放しまくった(5年継続) 見限りの魔女 信頼を切り捨てる決断の早さ。冷徹な管理者的存在。
短期間で仲間を雇用しまくった(5人以上) 呼び寄せの魔女 求心力とカリスマ、あるいは打算的なスカウト魔女。
短期間に仲間を戦闘で失った(5年継続) 死の行軍の魔女 死者を次々と出す過酷な旅。リーダーとしての闇が強調される。

物語の時系列

細かい年表については自分の世界観Wikiの年表ページも合わせてみてほしいのですが、ここでは本作に限った出来事を時系列順に紹介していきます。

hirarira.notion.site

年代 出来事
B.W.E.100頃 スタファイム魔法王国の守護者としてのオートマタとして、リリィが製造される
B.W.E.50頃 何かしらの理由により、リリィが封印される
B.W.E.5 エデン教とスタファイム魔法王国による終魔戦争が起こる。魔法時代が終わる。
W.E.108 レジエント帝国が建国される
W.E.490 リリィの育ての両親が遺跡にて封印が解けたリリィを発見し、我が子として育て始める
W.E.500 リリィが歳をとっても成長せず、異端審問を受ける。不死であることが明らかにされる。本作の始まり
W.E.1213 レジエント帝国の統治に反発し、レベット連合が結成される
W.E.1250 レベット連合の軍勢がリリィの住む街に押し寄せる。本作の強制ゲームオーバー地点
W.E.1324 レジエント帝国が完全に滅亡する
W.E.1500 不老不死であるリリィが舞台となった街に戻って来る(エンディング)

リリィの育ての両親について

オープニングにおいてはリリィが石板に触れたことによって不老不死になった・・・という表現がありますが、それは両親から吹き込まれたエピソードであって、育ての両親が遺跡で倒れていたリリィを拾い自分の子供として育てた・・・という経緯があります。
復活したばかりのリリィは記憶がかなり混濁しており、封印前の記憶も失われていたため両親からそう言われたことを信じてしまったんですね。

リリィの家の南に「ホワイトの家」があると思いますが、あの家こそがリリィの実家になります。
育ての両親について制作期間の都合でカットせざるを得ませんでしたが、ゲーム開始から10年後までは父親が、30年後までは母親が健在であり、両親ともに失って初めて誰も頼れる肉親がいなくなり、不老不死としてのリリィの第二の人生が始まる・・・という形に持って行く予定でした。

なので本来は「ホワイトの家」に両親を出す予定だったんですよね。
本作完成後はしばらくこれを実装しようと思っていたのですが、来年の作品の制作の方に取り掛かる必要がでてきてしまい、今後実装する見込みも薄くなってしまったのでネタばらししてしまいました。

リリィの正体についてのボツ案

主人公リリィの「正体」がどのように決まったのか。 その裏側にある3つの没案と、最終案に至るまでのChatGPTとの検討過程をまとめました。

主人公リリィの「正体」:3つのシナリオ案

【案1】旧文明のオートマタ(採用案)

  • 正体: リリィは人間に擬態した「人工物」であり、旧魔法文明によって何らかの目的(遺跡管理など)のために造られた。
  • 呪いの本質: 自己認識は人間だと思っていたが、実は人工の肉体であった。
  • 時間制限: 800年という活動寿命が設定されており、それを過ぎると機能を停止する。(これは当初案であり、最終的には肉体年齢の時間制限の設定は無くなりました。)

【案2】迷宮の生贄(没案)

  • 正体: もともとは人間だったが、魔法文明のトラップによって不老不死にされてしまった。(オープニングで語られていた建前そのまま)
  • 時間制限: 呪いの設計上、800年で不死化の呪いが消滅するようにできていた。(つまり時間経過で目的を達成、自動クリアとなる)
  • 結末: 呪いが解けると、急激な老化や損傷の反動で消滅する悲劇的な末路が想定された。(機械文明の起こりと共に滅びゆくリリィを演出予定だった。)

【案3】帝国の陰謀(没案)

  • 正体: 皇帝が持つ特殊な魔道具によって、無理やり不老不死にされていた「不死の尖兵」。
  • 目的: 帝国が遺跡調査のためにリリィを監視・利用していた。
  • 結末: 革命によって魔道具が破壊され、支配から解放されて人間に戻る。

幻の「ビターエンド」案

当初の構想では、物語はもっと救いのない、ほろ苦い結末を迎える予定でした。 最下層でリリィを倒して彼女を完全に破壊し、仲間たちが「元凶であった魔女を討った英雄」として村人から祝福を受けるという展開です。
しかし、共に旅をしてきた仲間たちは、果たしてこれが正しい結末だったのかという疑問を抱きながら物語が幕を閉じる……という、非常に後味の悪い「ビターエンド」が唯一の結末として考えられていました。

検討された「トゥルーエンド」と、そのボツ理由

このビターな結末を回避するために、「隠しダンジョン」や「隠しボス」を攻略して手に入る特定の「隠しアイテム」を持っている場合のみ到達できる、ハッピーな「トゥルーエンド」の構想もありました。
その内容は、「アイテムの力でリリィが本当の生身の人間として再構成され、帝国から逃亡して新天地で幸せに暮らす」というものです。
しかし、この案は以下の3つの理由からボツとなりました。

  • 世界観のリアリティと違和感: リリィの肉体は旧文明の魔法技術で作られたオートマタです。それが、たかだか薬一つやアイテム一つで生身の人間へと変質してしまうのは、物語としてあまりにも「都合が良すぎる」のではないかという違和感を最後まで拭い去ることができませんでした。

  • システム上の制約: 本作の根幹は「ランダム生成ダンジョン」です。この制約の中で、どのタイミングで、どのようにして「隠しエリア」への移動フラグを立てるのかというロジックをスマートに組み込むことが難しく、ランダム性と固定イベントの相性の悪さに直面しました。

  • 開発リソースの現実: そして、これが最も大きな理由ですが、複雑な分岐条件や裏ボス、専用のマップや演出を作り込むための「制作時間」が圧倒的に足りませんでした。

現在の結末へ

こうした葛藤の結果、現在の「オートマタとしての自己犠牲、あるいは絆による結末」へと集約されました。
結果として、リリィというキャラクターが背負う「不老不死の孤独」と「人になろうとする意志」がより鮮明に描き出される形となり、本作のテーマである「不死の螺旋」にふさわしい、プレイヤーの心に爪痕を残すエンディングに仕上がったと考えています。
制作時間の不足という現実的な問題はありましたが、結果的には「奇跡のハッピーエンド」に逃げず、あの世界観の中で最も納得感のある形を模索した結果が、現在の「いもスパ」の結末なのです。

リリィは喋る主人公 or 喋らない主人公

本作の主人公リリィの「台詞」をどう設定するか。これは制作過程において、彼女の正体と同じくらい頭を悩ませたポイントでした。
開発当初、リリィを「一言も喋らない完全寡黙型」にするか、それとも「最小限の言葉を発する寡黙型」にするかという大きな二択で葛藤がありました。その決断の裏側にあった、名作RPGからの影響と、本作ならではのゲームデザイン上の理由について明かします。

理想としての「完全寡黙型」と『LIVE A LIVE』の衝撃

当初、私が理想としていたのは 「完全寡黙型」 でした。 これには、名作RPGLIVE A LIVE』の中世編への強いリスペクトがあります。 中世編の主人公・オルステッドは、一貫してプレイヤーの分身として何も語らず、古典的な「勇者」として振る舞います。しかし、彼が世界に絶望して魔王へと堕ちた瞬間、プレイヤーの手を離れた一人の独立した存在として初めて言葉を発します。その時に受けた「自分の分身だと思っていた存在が、自分から切り離される」という凄まじい衝撃を、本作でも再現したいと考えたのです。

100Fの最深部に到達して初めてリリィが喋る。そうすることで、彼女がプレイヤーの分身(主人公)という座を降りて、オートマタとしての使命に目覚めた「ラスボス」へと君臨する演出を、これ以上なく鮮明にできると確信していました。

直面した課題:800年の歳月と「愛着」の欠如

しかし、システムを組み上げていく中で一つの懸念が浮上しました。 本作は、最長で800年という膨大な時間をかけて仲間を育成し、世代交代を繰り返すゲームです。
この長い年月の中で、リリィが一言も喋らない「ミステリアスな観察者」で居続けた場合、プレイヤーは彼女に対して「内面の反応がわからない無機質な存在」という距離感を感じてしまい、感情移入や愛着を持ちづらいのではないかという葛藤が生まれました。
また、本作にはカルマシステムや仲間との死別など、感情を揺さぶるイベントが豊富に用意されています。それらに対してリリィの反応が一切描けないことは、物語の厚みを削いでしまうリスクがありました。

採用された「寡黙型」:裏切りをより深くするために

検討を重ねた結果、最終的に採用したのは「要所でのみ発言し、任意会話も可能な寡黙型」でした。 決め手となったのは、「一言二言喋る程度の寡黙さであれば、オートマタだと明かされる衝撃は決して損なわれない」という結論に至ったことです。むしろ、普段から交流があるからこそ、ラスボスとして現れた際の衝撃は「ミステリアスな存在への驚き」から、「信じていた存在からの裏切り」という、より感情的な痛みへと変化します。

■ 「寡黙型」のメリットと演出案

  • 時の流れの演出: 長命であるゆえの空虚さや沈黙を表現しつつ、ユーモラスなリアクションも可能にする。
  • 感情の吐露: 仲間が死んだ際に「また、死んじゃった……」といった短い言葉を吐露させることで、リリィに人間味を与える。
  • ギャップの活用: 普段は短文でしか喋らない彼女が、ラスボス時に「自己の本質」を饒舌に語り始めることで、その異常性を際立たせる。

結果として、リリィは「無口だけど、どこか温かみを感じる主人公」として800年の旅路をプレイヤーと共に歩むキャラクターになりました。
もし彼女が『LIVE A LIVE』のオルステッドのように最初から最後まで沈黙を貫いていたら……それはそれで一つの名作になったかもしれませんが、共に過ごした仲間の最期に言葉を詰まらせる今のリリィの方が、本作の「不死の螺旋」というテーマをより残酷に、そして美しく描き出せたのではないかと考えています。

本作の反省点

本作はもう反省点しかありません。
コンセプトは自分でも良かったと思いますし、コンテストで3位を頂いたのも嬉しかったですが、限られた期間でこれだけのものが作れたというのは自分の中でも自信につなげることが出来ました。

・・・が、広げた風呂敷があまりにも広すぎた・・・
というのが本作の最大の後悔ポイントになりますね。

ゲームをプレイして頂いた中の感想に、最初と終わり以外にほとんどイベントが起こらない。同じパターンの繰り返しになってしまうという声がありましたが、この点はもうシンプルに途中イベントの作っている時間が全く無かったから・・・に尽きます。

ボツ要素で紹介した通り、リリィの両親イベントも追加したかったですし、エクストラダンジョンも作りたかったし、NPCももっと増やしたかったなあとやりたいことは色々ありました。
前作のDCEは本作よりミニマムなシステムということもあって色々と世界観を補強するために月間ニュースなんかも入れたり、施設で働く職員たちのセリフとかも凝ったものを作る時間があったんですが、今作ではそういったのを付け加える時間が足りませんでした・・・

というわけで公開後も断続的に更新する予定だったんですが、何だかんだで実装できたのはゼロディングが精一杯でした。それに来年の作品のアイディアも思いついたことからいもスパの改良よりも新作の着手のほうを優先したい状況ですね。

それからテストプレイについても不十分でした。理論値からこの階層に敵は平均何体配置されるから、平均獲得経験値はこれくらいで、普通に潜っていけばボスも倒せるはず・・・と設計したのですが、それが適切に機能するかのテストプレイをする時間が取れませんでした。
そういった意味で難易度を複数個に分割したのは、ゲームバランスが多少崩れていたとしても低難易度であればクリアできるように・・・という開発側の「逃げ」の要素があったのかもしれません。

おわりに

色々解説してきましたが私が言いたいことは大きく一つだけです。

RPGのシステムを一から全部実装すると死ぬほど面倒くさいからやめたほうが良い

・・・です。
良いところとしては自分で実装すると細かいこだわりポイントとかも柔軟に変えられるところなんですが、そのような奇特な方は自分と同じような修羅の道を歩むことを覚悟してください。

いもスパ作ってるときは平日は毎日仕事終わってアニメ視聴の前後で作ってて毎日寝不足だし、休日は全部いもスパ制作に注ぎ込んで平日のほうがゆっくり休めてるんじゃないかってくらい過酷な日々でした。

今回使用した画像は全部 Google Gemini nano banana pro (一部通常版)を作って作成しました。
いやあ・・・最近の画像生成AIは本当に凄いですね。
新作でも生成AIは積極的に利用していこうかなと考えています。

去年のDCE・今年のいもスパと同じ施設やダンジョンに閉じこもってひたすら同じことを繰り返し、暗めのテーマが続いたので、来年の作品は「冒険」と「鉄道」をテーマとしたもっと明るくて開放的な作品を予定しています。

本当にどうでもいい余談なんですが、今回の記事は作るのに結構苦労したので、よければはてなスター付けてくれると嬉しいです。

明日の記事はまつゆきさんの 「Cheerpj を用いた JavaWWA 起動方法および WWA Phoenix の公開終了について」 となります。 というわけで明日の記事もお楽しみに!

それでは長々とした文章に最後までお付き合いいただきましてありがとうございました。 良いお年をお過ごしください。