Skip to content

Commit 59490e0

Browse files
committed
merge: Integrate custom event docs and scaffolding updates from dev
2 parents 1460951 + 72d7d79 commit 59490e0

15 files changed

Lines changed: 1512 additions & 6 deletions

Const.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ public static class Const
44
{
55
public const string Name = "RitsuLib";
66
public const string ModId = "com.ritsukage.sts2-RitsuLib";
7-
public const string Version = "0.0.12";
7+
public const string Version = "0.0.13";
88
public const string SettingsKey = "settings";
99
public const string SettingsFileName = "settings.json";
1010
}

Docs/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
|---|---|
2020
| [Content Authoring Toolkit](en/ContentAuthoringToolkit.md) | Authoring overview: identity, localization coupling, asset override basics |
2121
| [Character & Unlock Scaffolding](en/CharacterAndUnlockScaffolding.md) | Practical character-mod assembly guide |
22+
| [Custom Events](en/CustomEvents.md) | Shared events, act events, ancients, and custom event scene workflow |
2223
| [Timeline & Unlocks](en/TimelineAndUnlocks.md) | Story / epoch registration, progression rules, and compatibility bridges |
2324
| [Asset Profiles & Fallbacks](en/AssetProfilesAndFallbacks.md) | Character placeholder fallback, content profiles, and path diagnostics |
2425
| [Localization & Keywords](en/LocalizationAndKeywords.md) | `I18N`, keyword registry, and ancient dialogue localization |
2526
| [Godot Scene Authoring](en/GodotSceneAuthoring.md) | Scene-script wrappers, editor caveats, and runtime script registration |
2627
| [Card Dynamic Variables](en/CardDynamicVarToolkit.md) | Custom card vars with tooltip support |
28+
| [LocString Placeholder Resolution](en/LocStringPlaceholderResolution.md) | Placeholder syntax, custom formatters, and extension guide |
2729

2830
### Runtime & Infrastructure
2931

@@ -50,11 +52,13 @@
5052
|---|---|
5153
| [内容注册规则](zh/ContentAuthoringToolkit.md) | 内容编写总览:身份规则、本地化关系、资源覆写基础 |
5254
| [角色与解锁脚手架](zh/CharacterAndUnlockScaffolding.md) | 角色 Mod 的实践搭建指南 |
55+
| [自定义事件](zh/CustomEvents.md) | 共享事件、Act 事件、Ancient 与自定义事件场景工作流 |
5356
| [时间线与解锁](zh/TimelineAndUnlocks.md) | Story / Epoch 注册、进度规则与兼容桥接 |
5457
| [资源配置与回退规则](zh/AssetProfilesAndFallbacks.md) | 角色 placeholder、内容 profile 与路径诊断 |
5558
| [本地化与关键词](zh/LocalizationAndKeywords.md) | `I18N`、关键词注册与 Ancient 对话本地化 |
5659
| [Godot 场景编写说明](zh/GodotSceneAuthoring.md) | 场景脚本包装、编辑器问题与运行时脚本注册 |
5760
| [卡牌动态变量](zh/CardDynamicVarToolkit.md) | 带 Tooltip 支持的自定义卡牌变量 |
61+
| [LocString 占位符解析](zh/LocStringPlaceholderResolution.md) | 占位符语法、自定义格式化器与扩展指南 |
5862

5963
### 运行时与基础设施
6064

Docs/en/ContentAuthoringToolkit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ The fixed-entry rule applies only to model types explicitly registered through t
146146
- [Getting Started](GettingStarted.md)
147147
- [Content Packs & Registries](ContentPacksAndRegistries.md)
148148
- [Character & Unlock Scaffolding](CharacterAndUnlockScaffolding.md)
149+
- [Custom Events](CustomEvents.md)
149150
- [Card Dynamic Variables](CardDynamicVarToolkit.md)
150151
- [Localization & Keywords](LocalizationAndKeywords.md)
151152
- [Framework Design](FrameworkDesign.md)

Docs/en/CustomEvents.md

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
# Custom Events
2+
3+
This guide explains custom event authoring by relating the event runtime flow in `sts-2-source` to the registration APIs provided by RitsuLib.
4+
5+
It covers three cases:
6+
7+
- normal shared events via `SharedEvent<TEvent>()`
8+
- act-specific events via `ActEvent<TAct, TEvent>()`
9+
- ancients via `SharedAncient<TAncient>()` / `ActAncient<TAct, TAncient>()`
10+
11+
---
12+
13+
## How the game loads events
14+
15+
In `sts-2-source`, events enter the game through several key access points:
16+
17+
- `ActModel.GenerateRooms(...)` merges `AllEvents` with `ModelDb.AllSharedEvents`
18+
- `RoomSet.EnsureNextEventIsValid(...)` skips events that fail `IsAllowed(runState)` or were already visited
19+
- `EventRoom.Enter(...)` preloads assets, creates a mutable event instance, and builds the event UI
20+
- `EventModel.GetAssetPaths(...)` decides which room assets must be preloaded
21+
22+
RitsuLib does not replace that pipeline. Instead, it appends registered content to those existing access points:
23+
24+
- shared events are appended to `ModelDb.AllSharedEvents` and `ModelDb.AllEvents`
25+
- act events are appended to each act's `AllEvents` via dynamic patches
26+
- ancients are appended similarly to shared and act ancient lists
27+
28+
In practice, the essential steps are:
29+
30+
1. write a valid `EventModel` / `AncientEventModel` subclass
31+
2. register it before content registration freezes
32+
33+
---
34+
35+
## Minimal normal event
36+
37+
Prefer inheriting from `ModEventTemplate` rather than directly from `EventModel`.
38+
39+
```csharp
40+
using MegaCrit.Sts2.Core.Events;
41+
using STS2RitsuLib.Scaffolding.Content;
42+
43+
public sealed class MyFirstEvent : ModEventTemplate
44+
{
45+
protected override IReadOnlyList<EventOption> GenerateInitialOptions()
46+
{
47+
return
48+
[
49+
new EventOption(this, Accept, InitialOptionKey("ACCEPT")),
50+
new EventOption(this, Leave, InitialOptionKey("LEAVE")),
51+
];
52+
}
53+
54+
private Task Accept()
55+
{
56+
SetEventFinished(L10NLookup($"{Id.Entry}.pages.ACCEPT.description"));
57+
return Task.CompletedTask;
58+
}
59+
60+
private Task Leave()
61+
{
62+
SetEventFinished(L10NLookup($"{Id.Entry}.pages.LEAVE.description"));
63+
return Task.CompletedTask;
64+
}
65+
}
66+
```
67+
68+
A minimal event model should satisfy the following requirements:
69+
70+
- implement `GenerateInitialOptions()`
71+
- advance the event state or call `SetEventFinished(...)` from option callbacks
72+
- keep localization keys aligned with the final `ModelId.Entry`
73+
74+
---
75+
76+
## Registration
77+
78+
### Shared event
79+
80+
```csharp
81+
RitsuLibFramework.CreateContentPack("MyMod")
82+
.SharedEvent<MyFirstEvent>()
83+
.Apply();
84+
```
85+
86+
This appends the event to the shared pool used across acts.
87+
88+
### Act event
89+
90+
```csharp
91+
RitsuLibFramework.CreateContentPack("MyMod")
92+
.ActEvent<MyAct, MyFirstEvent>()
93+
.Apply();
94+
```
95+
96+
This appends the event only to the chosen act.
97+
98+
### Ancient
99+
100+
```csharp
101+
RitsuLibFramework.CreateContentPack("MyMod")
102+
.SharedAncient<MyAncient>()
103+
.Apply();
104+
```
105+
106+
Or:
107+
108+
```csharp
109+
RitsuLibFramework.CreateContentPack("MyMod")
110+
.ActAncient<MyAct, MyAncient>()
111+
.Apply();
112+
```
113+
114+
---
115+
116+
## Localization keys
117+
118+
After RitsuLib registration, an event gets a fixed public entry in the form:
119+
120+
```text
121+
<MODID>_EVENT_<TYPENAME>
122+
```
123+
124+
For `MyMod` + `MyFirstEvent`, that becomes:
125+
126+
```text
127+
MY_MOD_EVENT_MY_FIRST_EVENT
128+
```
129+
130+
A minimal normal-event localization block usually looks like this:
131+
132+
```json
133+
{
134+
"MY_MOD_EVENT_MY_FIRST_EVENT.title": "A Strange Spring",
135+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.INITIAL.description": "A glowing spring waits by the roadside.",
136+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.INITIAL.options.ACCEPT.title": "Drink",
137+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.INITIAL.options.ACCEPT.description": "This might go well.",
138+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.INITIAL.options.LEAVE.title": "Leave",
139+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.INITIAL.options.LEAVE.description": "Do not risk it.",
140+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.ACCEPT.description": "You feel renewed.",
141+
"MY_MOD_EVENT_MY_FIRST_EVENT.pages.LEAVE.description": "You walk away."
142+
}
143+
```
144+
145+
Two details are especially important here:
146+
147+
- event title and body text are looked up by `Id.Entry`
148+
- `ModEventTemplate.InitialOptionKey(...)` also generates option keys from `Id.Entry`
149+
150+
---
151+
152+
## Why `ModEventTemplate` exists
153+
154+
There is an implementation mismatch in the base-game helper methods:
155+
156+
- vanilla `EventModel.InitialOptionKey(...)` / internal `OptionKey(...)` use `GetType().Name`
157+
- event title lookup, page descriptions, and `GameInfoOptions` use `Id.Entry`
158+
- for vanilla content, those values usually match
159+
- for RitsuLib-registered mod events, they usually do not
160+
161+
As a result, a raw `EventModel` subclass can easily generate option keys under something like `MY_FIRST_EVENT...` while the event body and title live under `MY_MOD_EVENT_MY_FIRST_EVENT...`, producing inconsistent localization lookups.
162+
163+
To resolve that mismatch, RitsuLib now provides:
164+
165+
- `ModEventTemplate`
166+
- `ModAncientEventTemplate`
167+
168+
Their `InitialOptionKey(...)` / `ModOptionKey(...)` helpers stay aligned with the final public `Id.Entry`.
169+
170+
---
171+
172+
## `IsAllowed`
173+
174+
If an event should only appear in some runs, override `IsAllowed(RunState runState)`:
175+
176+
```csharp
177+
public override bool IsAllowed(RunState runState)
178+
{
179+
return !runState.VisitedEventIds.Contains(Id);
180+
}
181+
```
182+
183+
The game rotates the pool in `RoomSet.EnsureNextEventIsValid(...)` until it finds an event that:
184+
185+
- returns `true` from `IsAllowed(...)`
186+
- has not already been visited in the current run
187+
188+
Accordingly, `IsAllowed` should describe run-time availability rather than registration-time setup.
189+
190+
---
191+
192+
## Custom event scenes
193+
194+
If the default event layout is not appropriate, return a custom layout type:
195+
196+
```csharp
197+
public override EventLayoutType LayoutType => EventLayoutType.Custom;
198+
```
199+
200+
The game then loads:
201+
202+
```text
203+
res://scenes/events/custom/<event-id-lower>.tscn
204+
```
205+
206+
For example:
207+
208+
```text
209+
res://scenes/events/custom/my_mod_event_my_first_event.tscn
210+
```
211+
212+
The scene root must implement `ICustomEventNode` and provide at least:
213+
214+
- `Initialize(EventModel eventModel)`
215+
- `CurrentScreenContext`
216+
217+
`EventModel.SetNode(...)` hard-casts custom layouts to `ICustomEventNode`, so implementing that interface is mandatory.
218+
219+
---
220+
221+
## Asset preloading
222+
223+
Normal events preload, by default:
224+
225+
- the layout scene
226+
- `res://images/events/<event-id-lower>.png`
227+
- optional `res://scenes/vfx/events/<event-id-lower>_vfx.tscn`
228+
229+
Ancients preload, by default:
230+
231+
- the layout scene
232+
- `res://scenes/events/background_scenes/<event-id-lower>.tscn`
233+
234+
If the event requires additional assets, override `GetAssetPaths(IRunState runState)` and append those paths.
235+
236+
---
237+
238+
## Minimal ancient example
239+
240+
```csharp
241+
using MegaCrit.Sts2.Core.Entities.Ancients;
242+
using MegaCrit.Sts2.Core.Events;
243+
using STS2RitsuLib.Scaffolding.Content;
244+
245+
public sealed class MyAncient : ModAncientEventTemplate
246+
{
247+
protected override AncientDialogueSet DefineDialogues()
248+
{
249+
return new AncientDialogueSet();
250+
}
251+
252+
public override IEnumerable<EventOption> AllPossibleOptions =>
253+
[
254+
new EventOption(this, Accept, InitialOptionKey("ACCEPT")),
255+
];
256+
257+
protected override IReadOnlyList<EventOption> GenerateInitialOptions()
258+
{
259+
return AllPossibleOptions.ToArray();
260+
}
261+
262+
private Task Accept()
263+
{
264+
Done();
265+
return Task.CompletedTask;
266+
}
267+
}
268+
```
269+
270+
Compared with a normal event, ancients add a few requirements:
271+
272+
- `LocTable` is `ancients`
273+
- you must implement `DefineDialogues()`
274+
- finishing should usually go through `Done()` so ancient history is recorded correctly
275+
276+
If you only want to add dialogue for a custom character to an existing ancient, use `AncientDialogueLocalization` instead of creating a new ancient model. See `LocalizationAndKeywords.md`.
277+
278+
---
279+
280+
## Using unlock rules with events
281+
282+
Events can also be gated behind epochs:
283+
284+
```csharp
285+
RitsuLibFramework.CreateContentPack("MyMod")
286+
.SharedEvent<MyFirstEvent>()
287+
.RequireEpoch<MyFirstEvent, MyEpoch>()
288+
.Apply();
289+
```
290+
291+
RitsuLib filters generated act event pools after room generation.
292+
293+
This area also included a framework-level stability gap:
294+
295+
- previously, if unlock filtering removed every generated event for an act, later room selection could fail at run time
296+
- RitsuLib now preserves the original generated pool and logs a warning instead of leaving the act with no available events
297+
298+
Even with that safeguard, the preferred content design is still:
299+
300+
- do not lock every possible event in an act
301+
- keep at least one event available at all times
302+
303+
---
304+
305+
## Recommended Practices
306+
307+
- inherit normal events from `ModEventTemplate`
308+
- inherit ancients from `ModAncientEventTemplate`
309+
- generate option keys through `InitialOptionKey(...)` / `ModOptionKey(...)`
310+
- for custom layouts, make the scene root implement `ICustomEventNode`
311+
- if you add epoch gating, leave at least one event available in each pool
312+
313+
---
314+
315+
## Related docs
316+
317+
- [Content Packs & Registries](ContentPacksAndRegistries.md)
318+
- [Timeline & Unlocks](TimelineAndUnlocks.md)
319+
- [Localization & Keywords](LocalizationAndKeywords.md)
320+
- [Godot Scene Authoring](GodotSceneAuthoring.md)

0 commit comments

Comments
 (0)