MOD制作 > クエスト




クエストの構造


我々が一般に「クエスト」と呼んでいるものは、NPC の抱える問題を表す Issue と、その解決手段から成り立っています。
解決手段は、
  • プレイヤーによる直接解決である Quest
  • コンパニオンによる代理解決である Alternative Solution
  • 自領で発生した Issue にのみ実行可能な Lord Solution
に分類できます。

クラス構造は以下のように、Issue と解決手段を Behavior でくるんでゲームに組み込む、という形になっています。
using TaleWorlds.CampaignSystem;
 
public class Behavior : CampaignBehaviorBase
{
    internal class Issue : IssueBase
    {
        // Issue 自体の定義
        // Alternative Solution 関連の定義
        // Lord Solution 関連の定義
    }
 
    internal class IssueQuest : QuestBase
    {
        // Quest の定義
    }
}
 

Alternative/Lord Solution は会話や数値のやり取りだけで完結するため、IssueQuest のようなクラスはありません。

以上に加え、データを保存するための SaveableCampaignBehaviorTypeDefiner というクラスも使用します。



サンプルクエスト


ExampleIssues

プロジェクト名は ExampleIssues としています。
プロジェクトのファイル構成は以下のようになります。
  • ExampleIssues
    • Main.cs
    • VillageNeedsFood.cs
    • (さらに自分で試作 Issue を増やしていくならファイルを追加)

Main.cs

  1. using TaleWorlds.CampaignSystem;
  2. using TaleWorlds.Core;
  3. using TaleWorlds.MountAndBlade;
  4.  
  5. namespace ExampleIssues
  6. {
  7. public class SubModule : MBSubModuleBase
  8. {
  9. protected override void OnGameStart(Game game, IGameStarter gameStarterObject)
  10. {
  11. base.OnGameStart(game, gameStarterObject);
  12.  
  13. if (game.GameType is Campaign)
  14. {
  15. CampaignGameStarter campaignGameStarter = (CampaignGameStarter)gameStarterObject;
  16.  
  17. campaignGameStarter.AddBehavior(new VillageNeedsFoodBehavior());
  18. // 他にも Issue を作ったなら
  19. // campaignGameStarter.AddBehavior(new Issue01Behavior());
  20. // campaignGameStarter.AddBehavior(new Issue02Behavior());
  21. // campaignGameStarter.AddBehavior(new Issue03Behavior());
  22. // のようにまとめて登録します。
  23. }
  24. }
  25. }
  26. }
  27.  


VillageNeedsFood.cs

+ クリックで展開
  1. using Helpers;
  2. using System;
  3. using TaleWorlds.CampaignSystem;
  4. using TaleWorlds.Core;
  5. using TaleWorlds.Localization;
  6.  
  7. namespace ExampleIssues
  8. {
  9. public class VillageNeedsFoodBehavior : CampaignBehaviorBase
  10. {
  11. private const float IssueDuration = 30f;
  12. private const float QuestTimeLimit = 10f;
  13. private const float IssuePreConditionMinPlayerRelation = -10f;
  14.  
  15. public override void RegisterEvents()
  16. {
  17. CampaignEvents.OnCheckForIssueEvent.AddNonSerializedListener(this, OnCheckForIssue);
  18. }
  19.  
  20. public override void SyncData(IDataStore dataStore)
  21. {
  22. }
  23.  
  24. // イベントリスナーです。
  25. private void OnCheckForIssue(Hero hero)
  26. {
  27. // 詳しいことは分かりませんが、条件に合う Hero が見つかると
  28. // 指定した頻度で Issue が生成されるという感じでしょうか。
  29. if (ConditionsHold(hero))
  30. {
  31. Campaign.Current.IssueManager.AddPotentialIssueData(
  32. hero,
  33. new PotentialIssueData(
  34. new PotentialIssueData.StartIssueDelegate(OnStartIssue),
  35. typeof(VillageNeedsFoodIssue),
  36. IssueBase.IssueFrequency.VeryCommon,
  37. null));
  38. }
  39. else
  40. {
  41. Campaign.Current.IssueManager.AddPotentialIssueData(
  42. hero,
  43. new PotentialIssueData(
  44. typeof(VillageNeedsFoodIssue),
  45. IssueBase.IssueFrequency.VeryCommon));
  46. }
  47. }
  48.  
  49. // Hero が Issue を持つのに必要な条件を設定しています。
  50. private bool ConditionsHold(Hero issueOwner)
  51. {
  52. return issueOwner.IsNotable;
  53. }
  54.  
  55. private IssueBase OnStartIssue(in PotentialIssueData pid, Hero issueOwner)
  56. {
  57. return new VillageNeedsFoodIssue(issueOwner, DefaultItems.Grain);
  58. }
  59.  
  60. // ----------------------------------------------------------------
  61. // Issue に対する Behavior の定義はここまで。
  62. //
  63. // ここからは、カスタム型を保存するためのクラスです。
  64. // ----------------------------------------------------------------
  65.  
  66. // カスタムビヘイビアーで使用する型を登録します。
  67. public class VillageNeedsFoodBehaviorTypeDefiner : SaveableCampaignBehaviorTypeDefiner
  68. {
  69. // saveBaseId は、自分のビヘイビアー同士はもちろん、同時使用している他の MOD とも被ってはいけないそうです。
  70. // また、番号を途中で変えるとセーブデータ読み込み時にクラッシュします。
  71. // 番号付け規則は、追々コミュニティー内で意思統一が図られることでしょう。
  72. public VillageNeedsFoodBehaviorTypeDefiner() : base(001_123_456)
  73. {
  74. }
  75.  
  76. // このクエストで使う独自のクラスを登録しています。
  77. //
  78. // 構造体型なら SaveableTypeDefiner.DefineStructTypes()
  79. // 列挙型なら SaveableTypeDefiner.DefineEnumTypes()
  80. // のようにメソッドが分かれています。
  81. protected override void DefineClassTypes()
  82. {
  83. // こちらの saveId は、各 SaveableCampaignBehaviorTypeDefiner の中で被りが無ければいいようです。
  84. AddClassDefinition(typeof(VillageNeedsFoodIssue), 1);
  85. AddClassDefinition(typeof(VillageNeedsFoodIssueQuest), 2);
  86. }
  87. }
  88.  
  89. // ----------------------------------------------------------------
  90. // カスタム型の宣言はここまで。
  91. //
  92. // ここからは Issue 本体の定義です。
  93. // ----------------------------------------------------------------
  94.  
  95. internal class VillageNeedsFoodIssue : IssueBase
  96. {
  97. [SaveableField(10)] private readonly ItemObject _requestedFood;
  98.  
  99. // IssueBase.IssueBase() に渡す日数は、Issue が自然消滅するまでの日数です。
  100. // クエストの達成期限と混同しないようにしてください。
  101. public VillageNeedsFoodIssue(Hero issueOwner, ItemObject requestedFood) : base(issueOwner, CampaignTime.DaysFromNow(IssueDuration))
  102. {
  103. _requestedFood = requestedFood;
  104. }
  105.  
  106. // Issue のタイトルです。
  107. // 町などで Issue を抱えている人物のポートレートに表示されます。
  108. // Quest のタイトルとは同じにも別にもできます。
  109. //
  110. // チートコードで IssueOwner を探せなくなるので、テストでは日本語名は避けた方がいいです。
  111. public override TextObject Title => new TextObject("Village Needs Food");
  112.  
  113. // Issue 内容の簡単な説明です。
  114. // IssueOwner のポートレートで Title にマウスカーソルを合わせるとホバー表示されます。
  115. public override TextObject Description
  116. {
  117. get
  118. {
  119. TextObject textObject = new TextObject("{ISSUE_OWNER.LINK} が食糧を届けるよう頼んでいる。");
  120. StringHelpers.SetCharacterProperties("ISSUE_OWNER", IssueOwner.CharacterObject, textObject);
  121. return textObject;
  122. }
  123. }
  124.  
  125. // 導入部のセリフのオーバーライドです。
  126. public override TextObject IssueBriefByIssueGiver
  127. {
  128. get
  129. {
  130. TextObject textObject = new TextObject(
  131. "{FOOD} が乏しくなっているのですが、 " +
  132. "近くの町でもあまり手に入らないのです。 " +
  133. "このご時世、あまり遠方へ買い求めに行くことも " +
  134. "できず、困っております。");
  135. textObject.SetTextVariable("FOOD", _requestedFood.Name);
  136. return textObject;
  137. }
  138. }
  139. public override TextObject IssueAcceptByPlayer => new TextObject(
  140. "何か私にできることはあるか?");
  141. public override TextObject IssueQuestSolutionExplanationByIssueGiver => new TextObject(
  142. "いずこかで買い求め、持ってきてはいただけませ " +
  143. "んでしょうか? 報酬はお支払いいたします。");
  144. public override TextObject IssueQuestSolutionAcceptByPlayer => new TextObject(
  145. "わかった、やってみよう。");
  146.  
  147. // コンパニオンに解決を任せられるか否かです。
  148. public override bool IsThereAlternativeSolution => false;
  149.  
  150. // 領主として影響力を消費して解決できるか否かです。
  151. public override bool IsThereLordSolution => false;
  152.  
  153. // 要求される食糧の量です。
  154. // クエスト難易度でスケーリングしています。
  155. private int RequestedFoodAmount => (int)(15f + (30f * IssueDifficultyMultiplier));
  156.  
  157. // Issue が自動生成される頻度です。
  158. // IssueFrequency.VeryCommon
  159. // IssueFrequency.Common
  160. // IssueFrequency.Rare
  161. public override IssueFrequency GetFrequency()
  162. {
  163. return IssueFrequency.VeryCommon;
  164. }
  165.  
  166. // Issue が消滅する条件を設定できます (StayAlive なので、正確には「消滅しないための条件」です)。
  167. //
  168. // 例えば、この Issue が「物資を依頼人とは別の人のもとに届ける」というような内容だった場合、
  169. // 届け先の人物が何らかの理由でゲームから退場してしまったら、その時点で Issue は破棄されなければなりません。
  170. // (もちろん、プレイヤーが既にクエストを請けていればクエストも中止されるべきですが、それは Quest 側の仕事です。)
  171. // それには、以下のような感じにします。フィールドはあくまで一例です。
  172. // bool IssueStayAliveConditions() => _targetNotable.IsActive;
  173. public override bool IssueStayAliveConditions()
  174. {
  175. return true;
  176. }
  177.  
  178. // プレイヤーが Issue 解決を請け負えるかどうかの条件を設定できます。
  179. protected override bool CanPlayerTakeQuestConditions(
  180. Hero issueGiver, out PreconditionFlags flag, out Hero relationHero, out SkillObject skill)
  181. {
  182. // 各条件に対応する PreconditionFlags でビットマスクをかけていきます。
  183. flag = PreconditionFlags.None;
  184.  
  185. relationHero = null;
  186. skill = null;
  187.  
  188. // 友好度が低いと受けられないようにする。
  189. if (issueGiver.GetRelationWithPlayer() < IssuePreConditionMinPlayerRelation)
  190. {
  191. // このフラグを立てたときには、誰と仲が悪くて受けられないのかを返す必要があるようです。
  192. flag |= PreconditionFlags.Relation;
  193. relationHero = issueGiver;
  194. }
  195.  
  196. // 所属国同士が戦争中だと受けられないようにする。
  197. if (issueGiver.MapFaction.IsAtWarWith(Hero.MainHero.MapFaction))
  198. {
  199. flag |= PreconditionFlags.AtWar;
  200. }
  201.  
  202. // いずれかのフラグが立っていると false が返るので請け負えなくなります。
  203. return flag == PreconditionFlags.None;
  204. }
  205.  
  206. // 詳細不明。公式の Issue でもほぼ使われていません。
  207. protected override void CompleteIssueWithTimedOutConsequences()
  208. {
  209. }
  210.  
  211. // Quest の発行処理です。
  212. // コンストラクターに渡す日数は Quest の達成期限です。
  213. protected override QuestBase GenerateIssueQuest(string questId)
  214. {
  215. return new VillageNeedsFoodIssueQuest(
  216. questId, IssueOwner, RequestedFoodAmount, _requestedFood, RewardGold, CampaignTime.DaysFromNow(QuestTimeLimit));
  217. }
  218.  
  219. protected override void OnGameLoad()
  220. {
  221. }
  222. }
  223.  
  224. // ----------------------------------------------------------------
  225. // Issue の定義はここまで。
  226. //
  227. // ここからは Quest の定義です。
  228. // ----------------------------------------------------------------
  229.  
  230. internal class VillageNeedsFoodIssueQuest : QuestBase
  231. {
  232. // これらのフィールドは、セーブ/ロードをまたいで保持されるべき数値なので、SaveableField 属性を与えています。
  233. // SaveableCampaignBehaviorTypeDefiner の宣言だけではダメなようです。
  234. [SaveableField(10)] private readonly ItemObject _foodToBeDelivered;
  235. [SaveableField(20)] private readonly int _numFoodToBeDelivered;
  236.  
  237. // Quest のタイトルです。
  238. // ジャーナル (クエストログ) に表示されます。
  239. // Issue のタイトルとは同じにも別にもできます。
  240. public override TextObject Title => new TextObject("村が食糧を求めている");
  241.  
  242. // ジャーナルで達成期限を非表示にするか否かです。
  243. public override bool IsRemainingTimeHidden => false;
  244.  
  245. private TextObject QuestAcceptLog
  246. {
  247. get
  248. {
  249. TextObject textObject = new TextObject("{SETTLEMENT.LINK} の {QUEST_GIVER.LINK} に、{REQUESTED_FOOD} を持ってくるよう頼まれた。 報酬として {PAYMENT}{GOLD_ICON} を払ってくれるそうだ。");
  250. StringHelpers.SetSettlementProperties("SETTLEMENT", QuestGiver.CurrentSettlement, textObject);
  251. StringHelpers.SetCharacterProperties("QUEST_GIVER", QuestGiver.CharacterObject, textObject);
  252. textObject.SetTextVariable("REQUESTED_FOOD", _foodToBeDelivered.Name)
  253. .SetTextVariable("PAYMENT", RewardGold);
  254. return textObject;
  255. }
  256. }
  257.  
  258. private TextObject QuestSuccessLog
  259. {
  260. get
  261. {
  262. TextObject textObject = new TextObject("{QUEST_GIVER.LINK} のもとに、約束していた食糧を届けた。");
  263. StringHelpers.SetCharacterProperties("QUEST_GIVER", QuestGiver.CharacterObject, textObject);
  264. return textObject;
  265. }
  266. }
  267.  
  268. public VillageNeedsFoodIssueQuest(
  269. string questId, Hero questGiver, int numFoodToBeDelivered, ItemObject foodToBeDelivered, int rewardGold, CampaignTime duration)
  270. : base(questId, questGiver, duration, rewardGold)
  271. {
  272. _numFoodToBeDelivered = numFoodToBeDelivered;
  273. _foodToBeDelivered = foodToBeDelivered;
  274.  
  275. SetDialogs();
  276. InitializeQuestOnCreation();
  277. }
  278.  
  279. // クエスト進行中にゲームをセーブ -> ロードした場合の初期化はここで行われます。
  280. protected override void InitializeQuestOnGameLoad()
  281. {
  282. SetDialogs();
  283. }
  284.  
  285. // 会話の流れと、それに伴う処理のデリゲートを設定します。
  286. protected override void SetDialogs()
  287. {
  288. OfferDialogFlow = DialogFlow.CreateDialogFlow(IssueManager.IssueClassicQuestStartToken)
  289. .NpcLine("お手数ですが、よろしくお願いいたします。")
  290. .Condition(() => Hero.OneToOneConversationHero == QuestGiver)
  291. .Consequence(new ConversationSentence.OnConsequenceDelegate(QuestAcceptedConsequences))
  292. .CloseDialog();
  293.  
  294. DiscussDialogFlow = DialogFlow.CreateDialogFlow(QuestManager.QuestDiscussToken)
  295. .NpcLine("お頼みした仕事の首尾はいかがですかな?")
  296. .Condition(() => Hero.OneToOneConversationHero == QuestGiver)
  297. .BeginPlayerOptions()
  298. .PlayerOption("うむ、ここに持ってまいった。")
  299. .Condition(new ConversationSentence.OnConditionDelegate(PlayerHasFood))
  300. .NpcLine("おお、ありがとうございます。 それでは、こちらをお納めくだされ。")
  301. .Consequence(new ConversationSentence.OnConsequenceDelegate(QuestFinishedConsequences))
  302. .PlayerOption("いま取り組んでいるところだ。 しばし待たれよ。")
  303. .NpcLine("さようですか。 何卒よろしくお願いいたします。")
  304. .EndPlayerOptions()
  305. .CloseDialog();
  306.  
  307. // QuestCharacterDialogFlow = DialogFlow.CreateDialogFlow(QuestManager.CharacterTalkToken);
  308. }
  309.  
  310. // 以下、会話の特定の段階で実行される処理です。
  311.  
  312. // クエストを請けたとき
  313. private void QuestAcceptedConsequences()
  314. {
  315. AddLog(QuestAcceptLog);
  316.  
  317. // これを呼ぶだけで、通知の表示や効果音再生など、クエスト開始に伴う処理が自動的に実行されます。
  318. StartQuest();
  319. }
  320.  
  321. // クエスト目標をクリアしたとき
  322. private void QuestFinishedConsequences()
  323. {
  324. TransferFoodFromPlayerInventory();
  325. AddLog(QuestSuccessLog);
  326.  
  327. // クエストがどういう結果に終わったのかを QuestBase に知らせる必要があります。
  328. // CompleteQuestWithSuccess() なら成功
  329. // CompleteQuestWithFail() なら失敗
  330. // CompleteQuestWithCancel() なら中止
  331. // CompleteQuestWithTimedOut() なら期限切れ
  332. // CompleteQuestWithBetrayal() なら依頼人を裏切る形での決着
  333. CompleteQuestWithSuccess();
  334. }
  335.  
  336. private bool PlayerHasFood()
  337. {
  338. return PartyBase.MainParty.ItemRoster.GetItemNumber(_foodToBeDelivered) >= _numFoodToBeDelivered;
  339. }
  340.  
  341. private void TransferFoodFromPlayerInventory()
  342. {
  343. GiveItemAction.ApplyForParties(
  344. PartyBase.MainParty, QuestGiver.CurrentSettlement.Party, _foodToBeDelivered, _numFoodToBeDelivered);
  345. }
  346. }
  347. }
  348. }
  349.  


テスト

フォルダー構成や SubModule.xml についてなどはこれまで同様です。
セリフ等に日本語テキストを使っていますが、日本語化 MOD を入れていなくても普通に表示されるはずです。

テストプレイの際はチートを使うとはかどります。使い方の詳細については「チート」ページを参照してください。
IssueOwner のもとに行くには、
campaign.print_specific_issues village needs food
で所在地が一覧表示されるので、
campaign.find_settlement 拠点名
でカメラ移動し、Ctrl + 左クリックで拠点のそばにワープしましょう。



サンプルクエストの肉付け

サンプルは、Issue の生成から Quest 完了までの骨組みだけが実装してあります。このままでも一応動作はしますが、中身はスカスカで何も実行されていない状態なので、これにいろいろ追加していきましょう。
コードはあくまで一例なので、大体のやり方をつかんだら好きにいじくってもらって構いません。


発生の条件

サンプルクエストは Grain を IssueOwner のもとに持っていくというものですが、IssueOwner のいる拠点に Grain が豊富にある場合、その拠点で買ってすぐにクリアなんてことができてしまいますし、何より「食糧が無くて困っている」という Issue のストーリー自体が破綻してしまっています。したがって、Grain が豊富な拠点の Hero はそもそも IssueOwner に選ばれないようにする措置が必要です。

+ サンプルコード
VillageNeedsFoodIssueBehavior:
private const int VillageGoodsThreshold = 10;
private const int TownGoodsThreshold = 50;
 
private bool ConditionsHold(Hero issueOwner)
{
    // IssueOwner になれるのは Headman (村長) のみ
    // IssueOwner は村に所属していなければならない
    // その村の食糧は一定値未満でなければならない
    Settlement currentSettlement = issueOwner.CurrentSettlement;
    if (!issueOwner.IsHeadman
        || currentSettlement is null
        || !currentSettlement.IsVillage
        || currentSettlement.ItemRoster.GetItemNumber(DefaultItems.Grain) >= VillageFoodThreshold)
    {
        return false;
    }
 
    // 町と取引していないか、町にも食糧が無い
    Settlement tradeBound = currentSettlement.Village.TradeBound;
    return tradeBound is null || tradeBound.ItemRoster.GetItemNumber(DefaultItems.Grain) < TownFoodThreshold;
}
 


条件消滅

Issue 生成時には存在していた問題が、プレイヤー以外の力によって解決してしまうこともあり得ます。サンプルで言えば、最初は Grain が不足していたとしても、村人が買い入れるなどして補充された、というような状況です。そんな時には Issue を消滅させる方が自然でしょう。
消滅条件は IssueBase.IssueStayAliveConditions() で設定します。

+ サンプルコード
VillageNeedsFoodIssue:
public override bool IssueStayAliveConditions()
{
    // 食糧がクエスト開始条件の3倍以上に増えたら false を返して消滅させます。
    return IssueOwner.CurrentSettlement.ItemRoster.GetItemNumber(_requestedFood) < VillageFoodThreshold * 3;
}
 


発生の影響

公式のクエストは、「Issue が存在している限り何らかのパラメーターが変化し続ける」という形で Issue の影響を表現しています。例えば、賊討伐クエストだったら賊がのさばっていることを表すために拠点の治安が減り続け、道具不足の村は寂れて世帯数が減り続ける、といった具合です。
これを実装するには IssueBase.GetIssueEffectsAndAmountInternal() をオーバーライドします。

+ サンプルコード
VillageNeedsFoodIssue:
// using System.Collections.Generic
protected override Dictionary<IssueEffect, float> GetIssueEffectsAndAmountInternal()
{
    // 変化量は1日あたりのものです。Issue の自然消滅日数を考慮して設定しないと、
    // トータルのペナルティが重くなりすぎてしまいます。
    return new Dictionary<IssueEffect, float>()
    {
        { DefaultIssueEffects.VillageHearth, -0.5f },
        { DefaultIssueEffects.IssueOwnerPower, -0.2f }
    };
}
 


解決手段

Issue の解決手段には、プレイヤーによる Quest クリア、Alternative Solution、Lord Solution の三通りがあります。サンプルには解決手段が Quest しか用意されていないので、Alternative Solution も追加してみましょう。

最初にも書いたように、Alternative/Lord Solution は Quest と違って会話と数値のやり取りしか発生しないので、全ての要素を Issue クラスの中で定義します。IssueBase に用意されたプロパティやメソッドをオーバーライドすることで、大体は動くようになっています。

public abstract bool IsThereAlternativeSolution
まずは、これを true にします。

protected virtual int AlternativeSolutionNeededBaseMenCountInternal
コンパニオンのお供として連れて行かせる兵数です。

public virtual bool DoTroopsSatisfyAlternativeSolution(TroopRoster troopRoster, out TextObject explanation)
Alternative Solution を開始するのに十分な兵がいるかをチェックする場所です。

public virtual bool CompanionOrFamilyMemberClickableCondition(Hero hero, out TextObject explanation)
派遣できる英雄の条件を設定する場所です。

public virtual bool IsTroopTypeNeededByAlternativeSolution(CharacterObject character)
お供として選べる兵の条件 (主にティア) を設定する場所です。

protected virtual int AlternativeSolutionBaseDurationInDaysInternal
解決までにかかる日数です。

protected virtual int CompanionSkillRewardXP
コンパニオンに入るスキル経験値です。

protected virtual TextObject AlternativeSolutionStartLog
protected virtual TextObject AlternativeSolutionEndLogDefault
開始・終了時にジャーナルに書き込まれるログテキストです。

public virtual TextObject IssueAlternativeSolutionExplanationByIssueGiver
Quest の説明を受けた後のプレイヤーのセリフ、“Any other way?”(他に手段は?) に続いて表示される IssueOwner のセリフです。

public virtual TextObject IssueAlternativeSolutionAcceptByPlayer
いずれかの解決手段を選ぶ場面で、Alternative Solution の選択肢に表示されるテキストです。

public virtual TextObject IssueAlternativeSolutionResponseByIssueGiver
Alternative Solution を選択した際の IssueOwner のセリフです。


進捗状況

クエストの進捗状況 (サンプルクエストの場合「物資を幾つ集めたか」) を、プログレスバーとしてジャーナルに表示できます。
protected JournalLog AddDiscreteLog(TextObject text, TextObject taskName, int currentProgress, int targetProgress, TextObject shortText = null, bool hideInformation = false)
text: ログテキスト
taskName: プログレスバーが何を表しているのかを説明するテキスト
currentProgress: プログレスバーの初期値
targetProgress: プログレスバーの最大値

ただし、このプログレスバーは自動的に更新されるわけではありません。サンプルクエストで言えば、インベントリーをチェックして物資量に変化があるたびに書き換える必要があります。

+ サンプルコード
VillageNeedsFoodIssueQuest:
// using TaleWorlds.CampaignSystem.SandBox
private JournalLog _progressLog;
 
private TextObject FoodGatheredInfo
{
    get
    {
        TextObject textObject = new TextObject("必要な {FOOD} が集まりました。 依頼人の所に戻りましょう。");
        textObject.SetTextVariable("FOOD", _foodToBeDelivered.Name);
        return textObject;
    }
}
private int CurrentFoodAmount => PartyBase.MainParty.ItemRoster.GetItemNumber(_foodToBeDelivered);
 
protected override void RegisterEvents()
{
    CampaignEvents.PlayerInventoryExchangeEvent.AddNonSerializedListener(this, OnPlayerInventoryExchange);
}
 
// クエストを請けたとき
private void QuestAcceptedConsequences()
{
    AddLog(QuestAcceptLog);
 
    // プログレスバーを追加しています。
    TextObject text = new TextObject("{FOOD}を{FOOD_NUMBER}集める。")
        .SetTextVariable("FOOD", _foodToBeDelivered.Name)
        .SetTextVariable("FOOD_NUMBER", _numFoodToBeDelivered);
    // text の部分を TextObject.Empty とすれば、純粋にプログレスバーだけの表示になります。
    _progressLog = AddDiscreteLog(text, new TextObject("集めた食糧"), 0, _numFoodToBeDelivered);
    UpdateProgress();
 
    // これを呼ぶだけで、通知の表示や効果音再生など、クエスト開始に伴う処理が自動的に実行されます。
    StartQuest();
}
 
private void UpdateProgress()
{
    _progressLog.UpdateCurrentProgress((int)MathF.Clamp(CurrentFoodAmount, 0f, _numFoodToBeDelivered));
}
 
private void OnPlayerInventoryExchange(
    List<ValueTuple<ItemRosterElement, int>> purchasedItems, List<ValueTuple<ItemRosterElement, int>> soldItems, bool isTrading)
{
    UpdateProgress();
    if (PlayerHasFood())
    {
        // クリア条件を満たしたことを通知でプレイヤーに知らせています。
        InformationManager.AddQuickInformation(FoodGatheredInfo);
    }
}
 


報酬

報酬をお金で渡すには、まず IssueBase.RewardGold プロパティをオーバーライドして報酬額を設定します。固定値にしてもいいですが、公式のクエストではクエスト難易度でスケーリングしていることが多いです。

protected override int RewardGold => (int)(500f + 1000f * IssueDifficultyMultiplier);
こうすると、基本値 500 と、難易度係数 (0.1 ~ 1.0) で変動する値が 1000 なので、600 ~ 1500 が報酬額となるわけです。

IssueBase.RewardGold は QuestBase のコンストラクターに渡されるので、QuestBase.RewardGold フィールドにも自動的に値が設定されます。

値を設定したら、次はそれを授与する処理です。「報酬」とは成功したことを報いるものですから、仮想メソッド QuestBase.OnCompleteWithSuccess() をオーバーライドして、そこに記述するのがいいでしょう。

+ サンプルコード
VillageNeedsFoodIssueQuest:
protected override void OnCompleteWithSuccess()
{
    // プレイヤーキャラクターに対する報酬
    // IssueBase.RewardGold が設定してあれば、QuestBase.RewardGold ↓ にも反映されています。
    GiveGoldAction.ApplyBetweenCharacters(null, Hero.MainHero, RewardGold);
    Hero.MainHero.AddSkillXp(DefaultSkills.Athletics, 100f);
}
 

報酬を物品で渡したいなら、ここをアイテム授与の記述にします。
VillageNeedsFoodIssueQuest:
protected override void OnCompleteWithSuccess()
{
    GiveItemAction.ApplyForParties(null, PartyBase.MainParty, DefaultItems.Meat, 10);
}
 

これで、プレイヤーが Issue を解決する行為、すなわち Quest への報酬が設定されました。Quest 以外の解決手段である、Alternative/Lord Solution に対する金銭報酬は、IssueBase.RewardGold を設定しておくだけで支払いまで勝手にやってくれます。物品の場合は、IssueBase.AlternativeSolutionEndWithSuccessConsequence() や IssueBase.LordSolutionConsequence() あたりをオーバーライドして記述することになるでしょう。


決着の影響

Issue が、成功にせよ失敗にせよ、一応の決着を見たことによって生じる影響はいろいろ考えられますが、代表的なのは、プレイヤーと IssueOwner との Relation (関係性、友好度) 変化でしょうか。IssueBase.RelationshipChangeWithIssueOwner と、QuestBase.RelationshipChangeWithQuestGiver という専用のプロパティが用意されており、そこに変化量を指定するだけで、一部パークの影響なども含めて勝手に処理してくれます。前者は Alternative/Lord Solution 用、後者は Quest 用です。

他にも影響するであろう物事は、
  • Power: 英雄の権力や地位を数値化したもの。問題を解決したことで、IssueOwner の所属コミュニティー内での地位が向上する、というイメージです。
  • Trait: 英雄の特性、性質、性格といったもの。期限を守らなかったり IssueOwner を裏切ったりしたら、プレイヤーの Honor を下げる、みたいなことです。
  • Skill: 例えば武力行使を伴うようなクエストだったら、武器スキル経験値が入ったりするのもいいかもしれません。

+ サンプルコード
VillageNeedsFoodIssueQuest:
protected override void OnCompleteWithSuccess()
{
    // プレイヤーキャラクターに対する報酬
    // IssueBase.RewardGold が設定してあれば、QuestBase.RewardGold ↓ にも反映されています。
    GiveGoldAction.ApplyBetweenCharacters(null, Hero.MainHero, RewardGold);
    Hero.MainHero.AddSkillXp(DefaultSkills.Athletics, 100f);
 
    // クリアしたことによる影響
    // Relation 値の幅は -100 ~ +100 なので、それを考慮した変化量にしましょう。
    RelationshipChangeWithQuestGiver = 10;
    // QuestGiver 以外との Relation を変えたいときは、ChangeRelationAction クラスを使います。
    // using TaleWorlds.CampaignSystem.Actions
    // 
    // 例えば、QuestGiver と同じ拠点にいる全ての Notable (有力者) との Relation も上げたいのであれば
    // 以下のようにします。
    foreach (Hero hero in QuestGiver.CurrentSettlement.Notables)
    {
        // QuestGiver はすでに上げてあるので除外。
        if (hero != QuestGiver)
        {
            ChangeRelationAction.ApplyPlayerRelation(hero, 2);
        }
    }
    // Hero の Power 変更は簡単です。
    // 公式のクエストを見るに、変化量は最大で±10程度にしておくのがよさそうです。
    QuestGiver.AddPower(10f);
    // 各 Trait (Mercy, Valor, Honor, Generosity, Calculating) の値の幅は -4000 ~ +4000 です。
    // using TaleWorlds.CampaignSystem.CharacterDevelopment.Managers
    TraitLevelingHelper.OnIssueSolvedThroughQuest(
        QuestGiver,
        new Tuple<TraitObject, int>[]
        {
            new Tuple<TraitObject, int>(DefaultTraits.Honor, 50),
            new Tuple<TraitObject, int>(DefaultTraits.Mercy, 20)
        });
}
 
public override void OnFailed()
{
    RelationshipChangeWithQuestGiver = -10;
    QuestGiver.AddPower(-5f);
    TraitLevelingHelper.OnIssueFailed(
        QuestGiver,
        new Tuple<TraitObject, int>[]
        {
            new Tuple<TraitObject, int>(DefaultTraits.Honor, -50)
        });
}
 
protected override void OnTimedOut()
{
    RelationshipChangeWithQuestGiver = -5;
    QuestGiver.AddPower(-5f);
    TraitLevelingHelper.OnIssueFailed(
        QuestGiver,
        new Tuple<TraitObject, int>[]
        {
            new Tuple<TraitObject, int>(DefaultTraits.Honor, -25)
        });
}
 


Quest の中断

Issue の関係者が死亡したとか、拠点が略奪を受けたために期限内に報告できないとかいうような状況になってしまうこともあり得ます。そうした不可抗力が発生してしまった場合には Quest を中断させましょう。公式のクエストは、拠点が略奪を受けた時と、プレイヤーと IssueOwner の所属勢力同士が戦争を始めた時に中断していることが多いです。

+ サンプルコード
VillageNeedsFoodIssueQuest:
private TextObject VillageRaidedLog
{
    get
    {
        TextObject textObject = new TextObject("{VILLAGE.LINK} が略奪を受けたようだ。 もはや任務どころではない。");
        StringHelpers.SetSettlementProperties("VILLAGE", QuestGiver.CurrentSettlement, textObject);
        return textObject;
    }
}
 
private TextObject WarDeclaredLog
{
    get
    {
        TextObject textObject = new TextObject("{QUEST_GIVER.LINK} の国と戦争が始まった。 {?QUEST_GIVER.GENDER}彼女{?}彼{\\?}との約束もこれまでだろう。");
        StringHelpers.SetCharacterProperties("QUEST_GIVER", QuestGiver.CharacterObject, textObject);
        return textObject;
    }
}
 
protected override void RegisterEvents()
{
    CampaignEvents.RaidCompletedEvent.AddNonSerializedListener(this, OnRaidCompleted);
    CampaignEvents.WarDeclared.AddNonSerializedListener(this, OnWarDeclared);
}
 
private void OnRaidCompleted(BattleSideEnum winnerSide, MapEvent mapEvent)
{
    if (mapEvent.MapEventSettlement == QuestGiver.CurrentSettlement)
    {
        CompleteQuestWithCancel(VillageRaidedLog);
    }
}
 
private void OnWarDeclared(IFaction faction1, IFaction faction2)
{
    if ((faction1.MapFaction == Clan.PlayerClan.MapFaction && faction2.MapFaction == QuestGiver.MapFaction)
        || (faction2.MapFaction == Clan.PlayerClan.MapFaction && faction1.MapFaction == QuestGiver.MapFaction))
    {
        CompleteQuestWithCancel(WarDeclaredLog);
    }
}
 


会話の作成

導入部

Issue に関する NPC とのやり取りの導入部は会話の流れが決まっており、“I heard you may need some help with a problem?”(何か困りごとがあると聞いたが?) より後の部分を、IssueBase クラスに用意された会話の部品となるプロパティをオーバーライドすることで成立させます。


IssueBase のプロパティ

public abstract TextObject IssueBriefByIssueGiver
Issue 内容を説明する IssueOwner のセリフです。

public abstract TextObject IssueAcceptByPlayer
Issue 解決方法に関して説明を求めるプレイヤーのセリフです。

protected virtual TextObject IssuePlayerResponseAfterLordExplanation
protected virtual TextObject IssuePlayerResponseAfterAlternativeExplanation
詳細不明です。

public abstract TextObject IssueQuestSolutionExplanationByIssueGiver
クエストによる解決に必要な事柄を説明する IssueOwner のセリフです。

public virtual TextObject IssueAlternativeSolutionExplanationByIssueGiver
コンパニオンによる解決に必要な事柄を説明する IssueOwner のセリフです。

protected virtual TextObject IssueLordSolutionExplanationByIssueGiver
領主としての解決に必要な事柄を説明する IssueOwner のセリフです。

protected virtual TextObject IssueRewardExplanationByIssueGiver
現状は使われていません。

public abstract TextObject IssueQuestSolutionAcceptByPlayer
プレイヤー自身が解決する選択肢を選んだ際のプレイヤーのセリフです。

public virtual TextObject IssueAlternativeSolutionAcceptByPlayer
public virtual TextObject IssueAlternativeSolutionResponseByIssueGiver
コンパニオンに解決させる選択肢を選んだ際のプレイヤーと IssueOwner のセリフです。

protected virtual TextObject IssueLordSolutionAcceptByPlayer
protected virtual TextObject IssueLordSolutionResponseByIssueGiver
プレイヤーが領主として解決する選択肢を選んだ際のプレイヤーと IssueOwner のセリフです。

protected virtual TextObject IssueLordSolutionCounterOfferBriefByOtherNpc
protected virtual TextObject IssueLordSolutionCounterOfferExplanationByOtherNpc
protected virtual TextObject IssueLordSolutionCounterOfferAcceptByPlayer
protected virtual TextObject IssueLordSolutionCounterOfferDeclineByPlayer
protected virtual TextObject IssueLordSolutionCounterOfferAcceptResponseByOtherNpc
protected virtual TextObject IssueLordSolutionCounterOfferDeclineResponseByOtherNpc
IssueOwner と利害が対立する NPC が、逆オファーを持ちかけてくるような展開のクエストで使用します。


DialogFlow

導入部以外の会話は TaleWorlds.CampaignSystem.DialogFlow クラスを使って一から組み立てなければなりません。

[[NPC]]「こんにちは」
プレイヤー「やあどうも」
[[NPC]]「今日はどういったご用件で?」
プレイヤー「これこれこういう用で…」
[[NPC]]「分かりました。他に何か?」
・ある
 プレイヤー「こういったことが…」 → 「他に何か?」に戻る
・ない
 [[NPC]]「では、これにて」
のような一連のやり取りをひとまとめにするものだと考えてください。

DialogFlow のメソッドは DialogFlow 自身を返すので、会話の流れに沿ってメソッドを連結していけます。
上の例は、おおむね次のようになります。
var dialogFlow = DialogFlow.CreateDialogFlow("conv_start") // 渡している文字列 (トークン) は適当です
    .NpcLine("こんにちは")
    .PlayerLine("やあどうも")
    .NpcLine("今日はどういったご用件で?")
    .PlayerLine("これこれこういう用で…")
    .GetOutputToken(out string token)
    .NpcLine("分かりました。他に何か?")
    .BeginPlayerOptions()
        .PlayerOption("ある")
            .PlayerLine("こういったことが…")
            .GotoDialogState(token)
        .PlayerOption("ない")
            .NpcLine("では、これにて")
    .EndPlayerOptions()
    .CloseDialog();
 


DialogFlow のメソッド

public static DialogFlow CreateDialogFlow(string inputToken = null, int priority = 100)
DialogFlow 自体を生成します。

public DialogFlow NpcLine(string npcText, ConversationSentence.OnMultipleConversationConsequenceDelegate speakerDelegate = null, ConversationSentence.OnMultipleConversationConsequenceDelegate listenerDelegate = null)
NPC 側のセリフです。

public DialogFlow Condition(ConversationSentence.OnConditionDelegate conditionDelegate)
直前のセリフを条件付きで表示するときに用います。

public DialogFlow Consequence(ConversationSentence.OnConsequenceDelegate consequenceDelegate)
直前のセリフが表示された後に実行される処理を指定します。

public DialogFlow BeginPlayerOptions()
public DialogFlow EndPlayerOptions()
public DialogFlow PlayerOption(string text, ConversationSentence.OnMultipleConversationConsequenceDelegate listenerDelegate = null)
選択肢を作るときに用います。PlayerOption() が各選択肢で、それを BeginPlayerOptions() と EndPlayerOptions() で挟むようにします。

public DialogFlow GetOutputToken(out string oState)
public DialogFlow GotoDialogState(string input)
会話の特定位置にジャンプするときに用います。

public DialogFlow CloseDialog()
会話を終了します。


TextObject の装飾

TaleWorlds.Localization.TextObject の文字列には、特殊な記述によってキャラクター等が持つ動的なパラメーターを反映させることができます。

TextObject textObject = new TextObject("私の名前は {PLAYER.LINK}。 年齢{PLAYER.AGE}歳だ。");
StringHelpers.SetCharacterProperties("PLAYER", Hero.MainHero.CharacterObject, textObject);
 

"PLAYER" となっている部分は任意の文字列リテラルです。ドット以下の部分は使用したメソッドによって使えるものが異なります。どういう値に置換されるのかは Helpers.StringHelpers クラスを参照してください。
上の例は、名前が百科事典のリンク付きになり、現在の年齢が表示されます。


サンプルクエスト最終形

これまでの肉付けを踏まえたコードの最終形です。

VillageNeedsFood.cs

+ クリックで展開
  1. using Helpers;
  2. using System;
  3. using System.Collections.Generic;
  4. using TaleWorlds.CampaignSystem;
  5. using TaleWorlds.CampaignSystem.Actions;
  6. using TaleWorlds.CampaignSystem.CharacterDevelopment.Managers;
  7. using TaleWorlds.CampaignSystem.SandBox;
  8. using TaleWorlds.Core;
  9. using TaleWorlds.Library;
  10. using TaleWorlds.Localization;
  11. using TaleWorlds.SaveSystem;
  12.  
  13. namespace ExampleIssues
  14. {
  15. public class VillageNeedsFoodBehavior : CampaignBehaviorBase
  16. {
  17. private const int VillageFoodThreshold = 10;
  18. private const int TownFoodThreshold = 50;
  19. private const int MinTroopTier = 1;
  20. private const float IssueDuration = 30f;
  21. private const float QuestTimeLimit = 10f;
  22. private const float IssuePreConditionMinPlayerRelation = -10f;
  23.  
  24. public override void RegisterEvents()
  25. {
  26. CampaignEvents.OnCheckForIssueEvent.AddNonSerializedListener(this, OnCheckForIssue);
  27. }
  28.  
  29. public override void SyncData(IDataStore dataStore)
  30. {
  31. }
  32.  
  33. // イベントリスナーです。
  34. private void OnCheckForIssue(Hero hero)
  35. {
  36. // 詳しいことは分かりませんが、条件に合う Hero が見つかると
  37. // 指定した頻度で Issue が生成されるという感じでしょうか。
  38. if (ConditionsHold(hero))
  39. {
  40. Campaign.Current.IssueManager.AddPotentialIssueData(
  41. hero,
  42. new PotentialIssueData(
  43. new PotentialIssueData.StartIssueDelegate(OnStartIssue),
  44. typeof(VillageNeedsFoodIssue),
  45. IssueBase.IssueFrequency.VeryCommon,
  46. null));
  47. }
  48. else
  49. {
  50. Campaign.Current.IssueManager.AddPotentialIssueData(
  51. hero,
  52. new PotentialIssueData(
  53. typeof(VillageNeedsFoodIssue),
  54. IssueBase.IssueFrequency.VeryCommon));
  55. }
  56. }
  57.  
  58. // Hero が Issue を持つのに必要な条件を設定しています。
  59. //
  60. // 開始直後から困窮している場所など無いと思われるので、
  61. // テストプレイ用に条件は甘くしてあります。
  62. private bool ConditionsHold(Hero issueOwner)
  63. {
  64. // IssueOwner になれるのは Headman (村長) のみ
  65. // IssueOwner は村に所属していなければならない
  66. // その村の食糧は一定値未満でなければならない
  67. Settlement currentSettlement = issueOwner.CurrentSettlement;
  68. if (!issueOwner.IsHeadman
  69. || currentSettlement is null
  70. || !currentSettlement.IsVillage
  71. || currentSettlement.ItemRoster.GetItemNumber(DefaultItems.Grain) >= VillageFoodThreshold)
  72. {
  73. return false;
  74. }
  75.  
  76. // 町と取引していないか、町にも食糧が無い
  77. Settlement tradeBound = currentSettlement.Village.TradeBound;
  78. return tradeBound is null || tradeBound.ItemRoster.GetItemNumber(DefaultItems.Grain) < TownFoodThreshold;
  79. }
  80.  
  81. private IssueBase OnStartIssue(in PotentialIssueData pid, Hero issueOwner)
  82. {
  83. return new VillageNeedsFoodIssue(issueOwner, DefaultItems.Grain);
  84. }
  85.  
  86. // ----------------------------------------------------------------
  87. // Issue に対する Behavior の定義はここまで。
  88. //
  89. // ここからは、カスタム型を保存するためのクラスです。
  90. // ----------------------------------------------------------------
  91.  
  92. // カスタムビヘイビアーで使用する型を登録します。
  93. public class VillageNeedsFoodBehaviorTypeDefiner : SaveableCampaignBehaviorTypeDefiner
  94. {
  95. // saveBaseId は、自分のビヘイビアー同士はもちろん、同時使用している他の MOD とも被ってはいけないそうです。
  96. // また、番号を途中で変えるとセーブデータ読み込み時にクラッシュします。
  97. // 番号付け規則は、追々コミュニティー内で意思統一が図られることでしょう。
  98. public VillageNeedsFoodBehaviorTypeDefiner() : base(001_123_456)
  99. {
  100. }
  101.  
  102. // このクエストで使う独自のクラスを登録しています。
  103. //
  104. // 構造体型なら SaveableTypeDefiner.DefineStructTypes()
  105. // 列挙型なら SaveableTypeDefiner.DefineEnumTypes()
  106. // のようにメソッドが分かれています。
  107. protected override void DefineClassTypes()
  108. {
  109. // こちらの saveId は、各 SaveableCampaignBehaviorTypeDefiner の中で被りが無ければいいようです。
  110. AddClassDefinition(typeof(VillageNeedsFoodIssue), 1);
  111. AddClassDefinition(typeof(VillageNeedsFoodIssueQuest), 2);
  112. }
  113. }
  114.  
  115. // ----------------------------------------------------------------
  116. // カスタム型の宣言はここまで。
  117. //
  118. // ここからは Issue 本体の定義です。
  119. // ----------------------------------------------------------------
  120.  
  121. internal class VillageNeedsFoodIssue : IssueBase
  122. {
  123. [SaveableField(10)] private readonly ItemObject _requestedFood;
  124.  
  125. // IssueBase.IssueBase() に渡す日数は、Issue が自然消滅するまでの日数です。
  126. // クエストの達成期限と混同しないようにしてください。
  127. public VillageNeedsFoodIssue(Hero issueOwner, ItemObject requestedFood) : base(issueOwner, CampaignTime.DaysFromNow(IssueDuration))
  128. {
  129. _requestedFood = requestedFood;
  130. }
  131.  
  132. // Issue のタイトルです。
  133. // 町などで Issue を抱えている人物のポートレートに表示されます。
  134. // Quest のタイトルとは同じにも別にもできます。
  135. //
  136. // チートコードで IssueOwner を探せなくなるので、テストでは日本語名は避けた方がいいです。
  137. public override TextObject Title => new TextObject("Village Needs Food");
  138.  
  139. // Issue 内容の簡単な説明です。
  140. // IssueOwner のポートレートで Title にマウスカーソルを合わせるとホバー表示されます。
  141. public override TextObject Description
  142. {
  143. get
  144. {
  145. TextObject textObject = new TextObject("{ISSUE_OWNER.LINK} が食糧を届けるよう頼んでいる。");
  146. StringHelpers.SetCharacterProperties("ISSUE_OWNER", IssueOwner.CharacterObject, textObject);
  147. return textObject;
  148. }
  149. }
  150.  
  151. // 導入部のセリフのオーバーライドです。
  152. public override TextObject IssueBriefByIssueGiver
  153. {
  154. get
  155. {
  156. TextObject textObject = new TextObject(
  157. "{FOOD} が乏しくなっているのですが、 " +
  158. "近くの町でもあまり手に入らないのです。 " +
  159. "このご時世、あまり遠方へ買い求めに行くことも " +
  160. "できず、困っております。");
  161. textObject.SetTextVariable("FOOD", _requestedFood.Name);
  162. return textObject;
  163. }
  164. }
  165. public override TextObject IssueAcceptByPlayer => new TextObject(
  166. "何か私にできることはあるか?");
  167. public override TextObject IssueQuestSolutionExplanationByIssueGiver => new TextObject(
  168. "いずこかで買い求め、持ってきてはいただけませ " +
  169. "んでしょうか? 報酬はお支払いいたします。");
  170. public override TextObject IssueQuestSolutionAcceptByPlayer => new TextObject(
  171. "わかった、やってみよう。");
  172.  
  173. // コンパニオンに解決を任せられるか否かです。
  174. public override bool IsThereAlternativeSolution => true;
  175.  
  176. // Alternative Solution 用のセリフです。
  177. public override TextObject IssueAlternativeSolutionExplanationByIssueGiver => new TextObject(
  178. "あなた様のご都合が悪いようであれば、お側付き " +
  179. "の方に届けていただくのでも構いません。");
  180. public override TextObject IssueAlternativeSolutionAcceptByPlayer => new TextObject(
  181. "ならば部下を遣わすとしよう。");
  182. public override TextObject IssueAlternativeSolutionResponseByIssueGiver => new TextObject(
  183. "それでは、よろしくお願いいたします。");
  184.  
  185. // Alternative Solution 用のログテキストです。
  186. protected override TextObject AlternativeSolutionStartLog
  187. {
  188. get
  189. {
  190. TextObject textObject = new TextObject(
  191. "{SETTLEMENT.LINK} の {ISSUE_OWNER.LINK} に、 {REQUESTED_FOOD} を持ってくるよう頼まれた。 " +
  192. "あなたは部下の {COMPANION.LINK} に{TROOP_COUNT}人の兵を預け、それら届けさせることにした。 " +
  193. "{RETURN_DAYS}日後には任務を終え、戻ってくるだろう。 " +
  194. "その際には、報酬として {PAYMENT}{GOLD_ICON} を手にしてくるはずだ。");
  195. StringHelpers.SetSettlementProperties("SETTLEMENT", IssueOwner.CurrentSettlement, textObject);
  196. StringHelpers.SetCharacterProperties("ISSUE_OWNER", IssueOwner.CharacterObject, textObject);
  197. StringHelpers.SetCharacterProperties("COMPANION", AlternativeSolutionHero.CharacterObject, textObject);
  198. textObject.SetTextVariable("REQUESTED_FOOD", _requestedFood.Name)
  199. .SetTextVariable("TROOP_COUNT", AlternativeSolutionSentTroops.TotalManCount)
  200. .SetTextVariable("RETURN_DAYS", GetTotalAlternativeSolutionDurationInDays())
  201. .SetTextVariable("PAYMENT", RewardGold);
  202. return textObject;
  203. }
  204. }
  205. protected override TextObject AlternativeSolutionEndLogDefault
  206. {
  207. get
  208. {
  209. TextObject textObject = new TextObject(
  210. "{COMPANION.LINK} たちは {REQUESTED_FOOD} を無事に届けた。 " +
  211. "{ISSUE_OWNER.LINK} は大いに喜び、約束通りの報酬を差し出した。");
  212. StringHelpers.SetCharacterProperties("COMPANION", AlternativeSolutionHero.CharacterObject, textObject);
  213. StringHelpers.SetCharacterProperties("ISSUE_OWNER", IssueOwner.CharacterObject, textObject);
  214. textObject.SetTextVariable("REQUESTED_FOOD", _requestedFood.Name)
  215. .SetTextVariable("PAYMENT", RewardGold);
  216. return textObject;
  217. }
  218. }
  219.  
  220. // 領主として影響力を消費して解決できるか否かです。
  221. public override bool IsThereLordSolution => false;
  222.  
  223. protected override int AlternativeSolutionNeededBaseMenCountInternal => (int)(9f + (14f * IssueDifficultyMultiplier));
  224. protected override int AlternativeSolutionBaseDurationInDaysInternal => (int)(10f + (8f * IssueDifficultyMultiplier));
  225.  
  226. protected override int RewardGold => (int)(500f + (1000f * IssueDifficultyMultiplier));
  227.  
  228. protected override int CompanionSkillRewardXP => (int)(600f + (800f * IssueDifficultyMultiplier));
  229.  
  230. // コンパニオンに求められるスキル値です。
  231. // しきい値を下回っているコンパニオンに任せると失敗する可能性が発生します。
  232. public override List<SkillObject> GetAlternativeSolutionRequiredCompanionSkill(out int requiredSkillLevel)
  233. {
  234. requiredSkillLevel = CompanionSkillThreshold;
  235. return new List<SkillObject>
  236. {
  237. DefaultSkills.Roguery,
  238. DefaultSkills.Trade,
  239. };
  240. }
  241.  
  242. // 要求される食糧の量です。
  243. // クエスト難易度でスケーリングしています。
  244. private int RequestedFoodAmount => (int)(15f + (30f * IssueDifficultyMultiplier));
  245.  
  246. private int CompanionSkillThreshold => 75;
  247.  
  248. // Issue が自動生成される頻度です。
  249. // IssueFrequency.VeryCommon
  250. // IssueFrequency.Common
  251. // IssueFrequency.Rare
  252. public override IssueFrequency GetFrequency()
  253. {
  254. return IssueFrequency.VeryCommon;
  255. }
  256.  
  257. // Issue が消滅する条件を設定できます (StayAlive なので、正確には「消滅しないための条件」です)。
  258. //
  259. // 例えば、この Issue が「物資を依頼人とは別の人のもとに届ける」というような内容だった場合、
  260. // 届け先の人物が何らかの理由でゲームから退場してしまったら、その時点で Issue は破棄されなければなりません。
  261. // (もちろん、プレイヤーが既にクエストを請けていればクエストも中止されるべきですが、それは Quest 側の仕事です。)
  262. // それには、以下のような感じにします。フィールドはあくまで一例です。
  263. // bool IssueStayAliveConditions() => _targetNotable.IsActive;
  264. public override bool IssueStayAliveConditions()
  265. {
  266. // 食糧がクエスト開始条件の3倍以上に増えたら false を返して消滅させます。
  267. return IssueOwner.CurrentSettlement.ItemRoster.GetItemNumber(_requestedFood) < VillageFoodThreshold * 3;
  268. }
  269.  
  270. // プレイヤーが Issue 解決を請け負えるかどうかの条件を設定できます。
  271. protected override bool CanPlayerTakeQuestConditions(
  272. Hero issueGiver, out PreconditionFlags flag, out Hero relationHero, out SkillObject skill)
  273. {
  274. // 各条件に対応する PreconditionFlags でビットマスクをかけていきます。
  275. flag = PreconditionFlags.None;
  276.  
  277. relationHero = null;
  278. skill = null;
  279.  
  280. // 友好度が低いと受けられないようにする。
  281. if (issueGiver.GetRelationWithPlayer() < IssuePreConditionMinPlayerRelation)
  282. {
  283. // このフラグを立てたときには、誰と仲が悪くて受けられないのかを返す必要があるようです。
  284. flag |= PreconditionFlags.Relation;
  285. relationHero = issueGiver;
  286. }
  287.  
  288. // 所属国同士が戦争中だと受けられないようにする。
  289. if (issueGiver.MapFaction.IsAtWarWith(Hero.MainHero.MapFaction))
  290. {
  291. flag |= PreconditionFlags.AtWar;
  292. }
  293.  
  294. // いずれかのフラグが立っていると false が返るので請け負えなくなります。
  295. return flag == PreconditionFlags.None;
  296. }
  297.  
  298. // 詳細不明。公式の Issue でもほぼ使われていません。
  299. protected override void CompleteIssueWithTimedOutConsequences()
  300. {
  301. }
  302.  
  303. // Quest の発行処理です。
  304. // コンストラクターに渡す日数は Quest の達成期限です。
  305. protected override QuestBase GenerateIssueQuest(string questId)
  306. {
  307. return new VillageNeedsFoodIssueQuest(
  308. questId, IssueOwner, RequestedFoodAmount, _requestedFood, RewardGold, CampaignTime.DaysFromNow(QuestTimeLimit));
  309. }
  310.  
  311. protected override void OnGameLoad()
  312. {
  313. }
  314.  
  315. // Issue 発生の影響です。
  316. protected override Dictionary<IssueEffect, float> GetIssueEffectsAndAmountInternal()
  317. {
  318. // 変化量は1日あたりのものです。Issue の自然消滅日数を考慮して設定しないと、
  319. // トータルのペナルティが重くなりすぎてしまいます。
  320. return new Dictionary<IssueEffect, float>()
  321. {
  322. { DefaultIssueEffects.VillageHearth, -0.5f },
  323. { DefaultIssueEffects.IssueOwnerPower, -0.2f }
  324. };
  325. }
  326.  
  327. public override bool DoTroopsSatisfyAlternativeSolution(TroopRoster troopRoster, out TextObject explanation)
  328. {
  329. explanation = TextObject.Empty;
  330. return QuestHelper.CheckRosterForAlternativeSolution(troopRoster, GetTotalAlternativeSolutionNeededMenCount(), ref explanation, MinTroopTier);
  331. }
  332.  
  333. // 任務に派遣可能な英雄の条件です。
  334. // e1.6.2 にて条件による制限は撤廃され、代わりに担当者のスキルに応じて失敗確率が発生するようになりました。
  335. /*
  336.   public override bool CompanionOrFamilyMemberClickableCondition(Hero companion, out TextObject explanation)
  337.   {
  338.   GetAlternativeSolutionRequiredCompanionSkills(out Dictionary<SkillObject, int> shouldHaveAll, out Dictionary<SkillObject, int> shouldHaveOneOfThem);
  339.   explanation = TextObject.Empty;
  340.   return QuestHelper.CheckCompanionForAlternativeSolution(companion.CharacterObject, ref explanation, shouldHaveAll, shouldHaveOneOfThem);
  341.   }
  342.   */
  343.  
  344. public override bool IsTroopTypeNeededByAlternativeSolution(CharacterObject character)
  345. {
  346. return character.Tier >= MinTroopTier;
  347. }
  348.  
  349. public override void AlternativeSolutionStartConsequence()
  350. {
  351. // 食糧の購入資金を差し引いています。
  352. Hero.MainHero.ChangeHeroGold(-_requestedFood.Value * RequestedFoodAmount);
  353. }
  354.  
  355. protected override void AlternativeSolutionEndWithSuccessConsequence()
  356. {
  357. // 担当キャラクターに対する報酬は、プロパティで設定してあるので勝手に適用されます。
  358.  
  359. // クリアしたことによる影響
  360. RelationshipChangeWithIssueOwner = 10;
  361. foreach (Hero hero in IssueOwner.CurrentSettlement.Notables)
  362. {
  363. // QuestGiver はすでに上げてあるので除外。
  364. if (hero != IssueOwner)
  365. {
  366. ChangeRelationAction.ApplyPlayerRelation(hero, 2);
  367. }
  368. }
  369. IssueOwner.AddPower(10f);
  370. TraitLevelingHelper.OnIssueSolvedThroughQuest(
  371. IssueOwner,
  372. new Tuple<TraitObject, int>[]
  373. {
  374. new Tuple<TraitObject, int>(DefaultTraits.Honor, 50),
  375. new Tuple<TraitObject, int>(DefaultTraits.Mercy, 20)
  376. });
  377. }
  378.  
  379. /*
  380.   private void GetAlternativeSolutionRequiredCompanionSkills(out Dictionary<SkillObject, int> shouldHaveAll, out Dictionary<SkillObject, int> shouldHaveOneOfThem)
  381.   {
  382.   // 必須スキル
  383.   // こちらの Dictionary に登録したスキルは、全てが指定レベル以上でなければなりません。
  384.   shouldHaveAll = new Dictionary<SkillObject, int>
  385.   {
  386.   { DefaultSkills.Trade, CompanionSkillThreshold }
  387.   };
  388.  
  389.   // 選択スキル
  390.   // こちらの Dictionary に登録したスキルは、いずれかが指定レベル以上でなければなりません。
  391.   shouldHaveOneOfThem = null;
  392.   // 例えば「近接武器スキルいずれか」という条件を追加したいのであれば、以下のように記述します。
  393.   // もちろん、しきい値は同一でなくても構いません。
  394.   //
  395.   // shouldHaveOneOfThem = new Dictionary<SkillObject, int>
  396.   // {
  397.   // { DefaultSkills.OneHanded, CompanionSkillThreshold },
  398.   // { DefaultSkills.TwoHanded, CompanionSkillThreshold },
  399.   // { DefaultSkills.Polearm, CompanionSkillThreshold }
  400.   // };
  401.   }
  402.   */
  403. }
  404.  
  405. // ----------------------------------------------------------------
  406. // Issue の定義はここまで。
  407. //
  408. // ここからは Quest の定義です。
  409. // ----------------------------------------------------------------
  410.  
  411. internal class VillageNeedsFoodIssueQuest : QuestBase
  412. {
  413. // これらのフィールドは、セーブ/ロードをまたいで保持されるべき数値なので、SaveableField 属性を与えています。
  414. // SaveableCampaignBehaviorTypeDefiner の宣言だけではダメなようです。
  415. [SaveableField(10)] private readonly ItemObject _foodToBeDelivered;
  416. [SaveableField(20)] private readonly int _numFoodToBeDelivered;
  417. [SaveableField(30)] private JournalLog _progressLog;
  418.  
  419. // Quest のタイトルです。
  420. // ジャーナル (クエストログ) に表示されます。
  421. // Issue のタイトルとは同じにも別にもできます。
  422. public override TextObject Title => new TextObject("村が食糧を求めている");
  423.  
  424. // ジャーナルで達成期限を非表示にするか否かです。
  425. public override bool IsRemainingTimeHidden => false;
  426.  
  427. private TextObject QuestAcceptLog
  428. {
  429. get
  430. {
  431. TextObject textObject = new TextObject("{SETTLEMENT.LINK} の {QUEST_GIVER.LINK} に、{REQUESTED_FOOD} を持ってくるよう頼まれた。 報酬として {PAYMENT}{GOLD_ICON} を払ってくれるそうだ。");
  432. StringHelpers.SetSettlementProperties("SETTLEMENT", QuestGiver.CurrentSettlement, textObject);
  433. StringHelpers.SetCharacterProperties("QUEST_GIVER", QuestGiver.CharacterObject, textObject);
  434. textObject.SetTextVariable("REQUESTED_FOOD", _foodToBeDelivered.Name)
  435. .SetTextVariable("PAYMENT", RewardGold);
  436. return textObject;
  437. }
  438. }
  439.  
  440. private TextObject QuestSuccessLog
  441. {
  442. get
  443. {
  444. TextObject textObject = new TextObject("{QUEST_GIVER.LINK} のもとに、約束していた食糧を届けた。");
  445. StringHelpers.SetCharacterProperties("QUEST_GIVER", QuestGiver.CharacterObject, textObject);
  446. return textObject;
  447. }
  448. }
  449.  
  450. private TextObject FoodGatheredInfo
  451. {
  452. get
  453. {
  454. TextObject textObject = new TextObject("必要な {FOOD} が集まりました。 依頼人の所に戻りましょう。");
  455. textObject.SetTextVariable("FOOD", _foodToBeDelivered.Name);
  456. return textObject;
  457. }
  458. }
  459.  
  460. private TextObject VillageRaidedLog
  461. {
  462. get
  463. {
  464. TextObject textObject = new TextObject("{VILLAGE.LINK} が略奪を受けたようだ。 もはや任務どころではない。");
  465. StringHelpers.SetSettlementProperties("VILLAGE", QuestGiver.CurrentSettlement, textObject);
  466. return textObject;
  467. }
  468. }
  469.  
  470. private TextObject WarDeclaredLog
  471. {
  472. get
  473. {
  474. TextObject textObject = new TextObject("{QUEST_GIVER.LINK} の国と戦争が始まった。 {?QUEST_GIVER.GENDER}彼女{?}彼{\\?}との約束もこれまでだろう。");
  475. StringHelpers.SetCharacterProperties("QUEST_GIVER", QuestGiver.CharacterObject, textObject);
  476. return textObject;
  477. }
  478. }
  479.  
  480. public VillageNeedsFoodIssueQuest(
  481. string questId, Hero questGiver, int numFoodToBeDelivered, ItemObject foodToBeDelivered, int rewardGold, CampaignTime duration)
  482. : base(questId, questGiver, duration, rewardGold)
  483. {
  484. _numFoodToBeDelivered = numFoodToBeDelivered;
  485. _foodToBeDelivered = foodToBeDelivered;
  486.  
  487. SetDialogs();
  488. InitializeQuestOnCreation();
  489. }
  490.  
  491. private int CurrentFoodAmount => PartyBase.MainParty.ItemRoster.GetItemNumber(_foodToBeDelivered);
  492.  
  493. // クエスト進行中にゲームをセーブ -> ロードした場合の初期化はここで行われます。
  494. protected override void InitializeQuestOnGameLoad()
  495. {
  496. SetDialogs();
  497. }
  498.  
  499. // 会話の流れと、それに伴う処理のデリゲートを設定します。
  500. protected override void SetDialogs()
  501. {
  502. OfferDialogFlow = DialogFlow.CreateDialogFlow(IssueManager.IssueClassicQuestStartToken)
  503. .NpcLine("お手数ですが、よろしくお願いいたします。")
  504. .Condition(() => Hero.OneToOneConversationHero == QuestGiver)
  505. .Consequence(new ConversationSentence.OnConsequenceDelegate(QuestAcceptedConsequences))
  506. .CloseDialog();
  507.  
  508. DiscussDialogFlow = DialogFlow.CreateDialogFlow(QuestManager.QuestDiscussToken)
  509. .NpcLine("お頼みした仕事の首尾はいかがですかな?")
  510. .Condition(() => Hero.OneToOneConversationHero == QuestGiver)
  511. .BeginPlayerOptions()
  512. .PlayerOption("うむ、ここに持ってまいった。")
  513. .Condition(new ConversationSentence.OnConditionDelegate(PlayerHasFood))
  514. .NpcLine("おお、ありがとうございます。 それでは、こちらをお納めくだされ。")
  515. .Consequence(new ConversationSentence.OnConsequenceDelegate(QuestFinishedConsequences))
  516. .PlayerOption("いま取り組んでいるところだ。 しばし待たれよ。")
  517. .NpcLine("さようですか。 何卒よろしくお願いいたします。")
  518. .EndPlayerOptions()
  519. .CloseDialog();
  520.  
  521. // QuestCharacterDialogFlow = DialogFlow.CreateDialogFlow(QuestManager.CharacterTalkToken);
  522. }
  523.  
  524. protected override void RegisterEvents()
  525. {
  526. CampaignEvents.PlayerInventoryExchangeEvent.AddNonSerializedListener(this, OnPlayerInventoryExchange);
  527. CampaignEvents.RaidCompletedEvent.AddNonSerializedListener(this, OnRaidCompleted);
  528. CampaignEvents.WarDeclared.AddNonSerializedListener(this, OnWarDeclared);
  529. }
  530.  
  531. protected override void OnCompleteWithSuccess()
  532. {
  533. // プレイヤーキャラクターに対する報酬
  534. // IssueBase.RewardGold が設定してあれば、QuestBase.RewardGold ↓ にも反映されています。
  535. GiveGoldAction.ApplyBetweenCharacters(null, Hero.MainHero, RewardGold);
  536. Hero.MainHero.AddSkillXp(DefaultSkills.Athletics, 100f);
  537.  
  538. // クリアしたことによる影響
  539. RelationshipChangeWithQuestGiver = 10;
  540. foreach (Hero hero in QuestGiver.CurrentSettlement.Notables)
  541. {
  542. // QuestGiver はすでに上げてあるので除外。
  543. if (hero != QuestGiver)
  544. {
  545. ChangeRelationAction.ApplyPlayerRelation(hero, 2);
  546. }
  547. }
  548. QuestGiver.AddPower(10f);
  549. TraitLevelingHelper.OnIssueSolvedThroughQuest(
  550. QuestGiver,
  551. new Tuple<TraitObject, int>[]
  552. {
  553. new Tuple<TraitObject, int>(DefaultTraits.Honor, 50),
  554. new Tuple<TraitObject, int>(DefaultTraits.Mercy, 20)
  555. });
  556. }
  557.  
  558. public override void OnFailed()
  559. {
  560. RelationshipChangeWithQuestGiver = -10;
  561. QuestGiver.AddPower(-5f);
  562. TraitLevelingHelper.OnIssueFailed(
  563. QuestGiver,
  564. new Tuple<TraitObject, int>[]
  565. {
  566. new Tuple<TraitObject, int>(DefaultTraits.Honor, -50)
  567. });
  568. }
  569.  
  570. protected override void OnTimedOut()
  571. {
  572. RelationshipChangeWithQuestGiver = -5;
  573. QuestGiver.AddPower(-5f);
  574. TraitLevelingHelper.OnIssueFailed(
  575. QuestGiver,
  576. new Tuple<TraitObject, int>[]
  577. {
  578. new Tuple<TraitObject, int>(DefaultTraits.Honor, -25)
  579. });
  580. }
  581.  
  582. // 以下、会話の特定の段階で実行される処理です。
  583.  
  584. // クエストを請けたとき
  585. private void QuestAcceptedConsequences()
  586. {
  587. AddLog(QuestAcceptLog);
  588.  
  589. // プログレスバーを追加しています。
  590. TextObject text = new TextObject("{FOOD}を{FOOD_NUMBER}集める。")
  591. .SetTextVariable("FOOD", _foodToBeDelivered.Name)
  592. .SetTextVariable("FOOD_NUMBER", _numFoodToBeDelivered);
  593. // text の部分を TextObject.Empty とすれば、純粋にプログレスバーだけの表示になります。
  594. _progressLog = AddDiscreteLog(text, new TextObject("集めた食糧"), 0, _numFoodToBeDelivered);
  595. UpdateProgress();
  596.  
  597. // これを呼ぶだけで、通知の表示や効果音再生など、クエスト開始に伴う処理が自動的に実行されます。
  598. StartQuest();
  599. }
  600.  
  601. // クエスト目標をクリアしたとき
  602. private void QuestFinishedConsequences()
  603. {
  604. TransferFoodFromPlayerInventory();
  605. AddLog(QuestSuccessLog);
  606.  
  607. // クエストがどういう結果に終わったのかを QuestBase に知らせる必要があります。
  608. // CompleteQuestWithSuccess() なら成功
  609. // CompleteQuestWithFail() なら失敗
  610. // CompleteQuestWithCancel() なら中止
  611. // CompleteQuestWithTimedOut() なら期限切れ
  612. // CompleteQuestWithBetrayal() なら依頼人を裏切る形での決着
  613. CompleteQuestWithSuccess();
  614. }
  615.  
  616. private bool PlayerHasFood()
  617. {
  618. return CurrentFoodAmount >= _numFoodToBeDelivered;
  619. }
  620.  
  621. private void TransferFoodFromPlayerInventory()
  622. {
  623. GiveItemAction.ApplyForParties(
  624. PartyBase.MainParty, QuestGiver.CurrentSettlement.Party, _foodToBeDelivered, _numFoodToBeDelivered);
  625. }
  626.  
  627. private void UpdateProgress()
  628. {
  629. _progressLog.UpdateCurrentProgress((int)MathF.Clamp(CurrentFoodAmount, 0f, _numFoodToBeDelivered));
  630. }
  631.  
  632. // 以下、クエストの推移を判断するためのイベントリスナーです。
  633.  
  634. private void OnPlayerInventoryExchange(
  635. List<ValueTuple<ItemRosterElement, int>> purchasedItems, List<ValueTuple<ItemRosterElement, int>> soldItems, bool isTrading)
  636. {
  637. UpdateProgress();
  638. foreach ((ItemRosterElement purchasedItem, int _) in purchasedItems)
  639. {
  640. // インベントリーに Grain が入れられた場合
  641. if (purchasedItem.EquipmentElement.Item == _foodToBeDelivered && PlayerHasFood())
  642. {
  643. // クリア条件を満たしたことを通知でプレイヤーに知らせています。
  644. InformationManager.AddQuickInformation(FoodGatheredInfo);
  645. break;
  646. }
  647. }
  648. }
  649.  
  650. private void OnRaidCompleted(BattleSideEnum winnerSide, MapEvent mapEvent)
  651. {
  652. if (mapEvent.MapEventSettlement == QuestGiver.CurrentSettlement)
  653. {
  654. CompleteQuestWithCancel(VillageRaidedLog);
  655. }
  656. }
  657.  
  658. private void OnWarDeclared(IFaction faction1, IFaction faction2)
  659. {
  660. if ((faction1.MapFaction == Clan.PlayerClan.MapFaction && faction2.MapFaction == QuestGiver.MapFaction)
  661. || (faction2.MapFaction == Clan.PlayerClan.MapFaction && faction1.MapFaction == QuestGiver.MapFaction))
  662. {
  663. CompleteQuestWithCancel(WarDeclaredLog);
  664. }
  665. }
  666. }
  667. }
  668. }
  669.  



タグ:

+ タグ編集
  • タグ:
最終更新:2021年10月29日 20:04