Repository navigation
Cutscenes and Dialogue
Task: #190
PR: #231
Builds on: Interactable NPCs (#191, PR #216)
Reusable dialogue boxes and video cutscenes for story sequences. Content lives in JSON files and is started by sending an event on the player, so adding a new conversation or cutscene needs no new code.
While a dialogue or cutscene plays the world is frozen (no enemy, projectile or trap updates, no physics, no run timer, sprite animations stop) and the player's controls are locked. Both are always restored when it ends, including when it is skipped or a video cannot be played.
- Player (
PlayerActionsfor the control lock; events are sent on the player's event handler) -
CutsceneEvents(event names and helpers) -
NarrativeFactory, which builds the narrative entity registered inMainGameScreen-
NarrativeManagerComponent(events, freeze, lock, once-only, timed events) -
DialogueDisplay(dialogue UI) driven byDialogueRunner(typewriter and line flow) -
CutscenePlayer(full-screen video) driven byMp4VideoPlayer -
NarrativeInputComponent(input capture)
-
-
DialogueScriptandCutsceneScript(data loaded from JSON) -
EntityService(setFrozen)
The narrative entity is separate from the player because it has to keep updating while the world is frozen, and the player must not.
Open the Debug Terminal with F1:
| Command | Plays |
|---|---|
dialogue demo |
assets/configs/dialogues/demo.json |
cutscene demovideo |
assets/configs/cutscenes/demovideo.json, which plays assets/videos/demo.mp4
|
Give the file name with or without .json. The terminal stays open if the file is not found.
Controls while something is playing: SPACE / ENTER / left click advance a dialogue line (the first press finishes the typewriter, the second goes to the next line) or skip a skippable cutscene. ESC leaves the dialogue entirely (the remaining lines are not shown; talk to the NPC again to restart it), or skips the cutscene if no dialogue is open. F1 still opens the terminal, and while the terminal is open it gets all the input, so typing a command (including spaces) never advances a dialogue.
The Interactable NPCs in the hub (Travelling Merchant, Wandering Spirit) also play these: press E next to one and its dialogue and cutscene run through this system.
Everything is an event on the player. The helpers in CutsceneEvents wrap it:
CutsceneEvents.playDialogue(player, "old_sage"); // configs/dialogues/old_sage.json
CutsceneEvents.playCutscene(player, "boss_defeated"); // configs/cutscenes/boss_defeated.json
// or build one in code, no file needed
CutsceneEvents.playDialogue(player,
new DialogueScript("tip")
.speaker("sage", "Old Sage", "images/ghost.atlas", "float")
.line("sage", "Watch out for the traps!"));
CutsceneEvents.playCutscene(player,
CutsceneScript.ofVideo("gate_opens", "videos/gate_opens.mp4").at(2f, "openGate", null));
// react when one ends
player.getEvents().addListener(CutsceneEvents.CUTSCENE_FINISHED, (String id) -> openGate());| Request (send) | Arguments |
|---|---|
START_DIALOGUE |
(String dialogueId, Entity npcOrNull) |
START_CUTSCENE |
(String cutsceneId, Entity npcOrNull) |
START_DIALOGUE_SCRIPT |
(DialogueScript) |
START_CUTSCENE_SCRIPT |
(CutsceneScript) |
| Notification (listen) | Arguments |
|---|---|
DIALOGUE_STARTED, DIALOGUE_FINISHED
|
(String dialogueId) |
DIALOGUE_SKIPPED |
(String dialogueId), sent when the player presses ESC; followed by DIALOGUE_FINISHED
|
DIALOGUE_LINE_SHOWN |
(String dialogueId, Integer lineIndex) |
DIALOGUE_CUTSCENE_CUE |
(String dialogueId), sent when a line marked "cue": true appears |
CUTSCENE_STARTED, CUTSCENE_FINISHED
|
(String cutsceneId) |
CUTSCENE_SKIPPED |
(String cutsceneId), followed by CUTSCENE_FINISHED
|
START_DIALOGUE, START_CUTSCENE, DIALOGUE_FINISHED, CUTSCENE_FINISHED and DIALOGUE_CUTSCENE_CUE are the same events the Interactable NPC system uses (NpcInteractionEvents), so an NPC's dialogueId and cutsceneId in friendlyNpcs.json are just script file names.
EventHandler casts every listener of an event name to one argument count, so keep to the signatures above.
- If a script file is missing, nothing is shown, a warning is logged and the matching
*_FINISHEDevent is still sent so nothing waits forever. - A second dialogue (or cutscene) requested while one of that kind is open is ignored. A dialogue and a cutscene can overlap, e.g. a cutscene started from a dialogue cue.
- A
oncescript plays a single time per session.
configs/dialogues/<id>.json:
{
"once": false,
"speakers": [
{ "id": "sage", "name": "Old Sage", "atlas": "images/ghost.atlas", "animation": "float" },
{ "id": "me", "name": "You", "texture": "images/box_boy.png" }
],
"lines": [
{ "speaker": "sage", "text": "Welcome, traveller." },
{ "speaker": "me", "text": "Who are you?", "cue": true }
]
}The text appears on the left and is typed out. The speaker's framed portrait and name are on the right. A portrait is either an animation (atlas plus animation, with optional frameSeconds) or a still texture. Speaker art is loaded when the dialogue opens.
configs/cutscenes/<id>.json. A cutscene is an mp4 played full screen:
{
"video": "videos/boss_intro.mp4",
"skippable": true,
"once": false,
"events": [ { "at": 2.5, "event": "openGate", "arg": "optional string" } ]
}-
skippable(default true) lets the player skip. -
eventsare fired on the player's event handler at those times (seconds from the start of the video). - Decoding is pure Java (JCodec), so it behaves the same on every OS. It works best at 720p or lower, H.264 baseline or main profile.
- There is no audio.
- If a video cannot be decoded the cutscene ends immediately (the error is logged) and the game is released.
- The three NPC cutscenes (
merchant_opens_shop,spirit_reveals_dungeons,guardian_awakens) currently play the demo video as a placeholder.
EntityService.setFrozen(owner, boolean) freezes every entity that has not called Entity.setUpdatesWhilePaused(true) (the HUD and the narrative entity do). Each owner releases its own freeze, so overlapping dialogue and cutscene cannot unfreeze each other. This is separate from the inventory's toggleUpdate() pause.
MainGameScreen also skips physics, room logic and the run timer, and sets the game time scale to 0, while frozen.
flowchart TD
A[Code, NPC or terminal sends START_DIALOGUE / START_CUTSCENE] --> B[NarrativeManagerComponent]
B --> C{Script found and playable?}
C -->|no| D[Send FINISHED straight away]
C -->|yes| E[Freeze world and lock controls]
E --> F[DialogueDisplay / CutscenePlayer shows it]
F --> G{Finished or skipped?}
G -->|no| F
G -->|yes| H[Unfreeze and unlock]
H --> I[Send FINISHED]
sequenceDiagram
participant Src as Code / NPC / Terminal
participant P as Player events
participant M as NarrativeManagerComponent
participant E as EntityService
participant V as DialogueDisplay
participant In as NarrativeInputComponent
Src->>P: START_DIALOGUE(id, npc)
P->>M: playDialogue(id)
M->>E: setFrozen(owner, true)
M->>P: DIALOGUE_STARTED(id)
M->>V: show(script)
loop each line
V->>P: DIALOGUE_LINE_SHOWN(id, index)
In->>V: SPACE advances
end
V->>M: onFinished
M->>E: setFrozen(owner, false)
M->>P: DIALOGUE_FINISHED(id)
Full plan, coverage and manual QA steps: Cutscenes and Dialogue Test Plan.
DialogueRunnerTestNarrativeManagerComponentTestNarrativeInputComponentTestScriptLoadingTestMp4VideoPlayerTestEntityServiceFreezeTestNarrativeCommandsTest
Entities and Components
Input Handling
- Service Locator
- Loading Resources
- Logging
- Unit Testing
- Debug Terminal
- Animations
- Audio
- AI
- Physics
- Terrain
- Concurrency & Threading
- Settings
Components
Achievements
Player
Items and Inventory
Enemies
Bosses
- Mini Boss 1: Cerberus (Egyptian)
- Mini Boss 2: Snake (Greek)
- Mini Boss 2: Snake Remake
- Mini Boss 3: Chinese Dragon
- Mini Boss 4: Sleipnir (Norse)
- Final Boss: Grandpa (Stage 2 Remake and Stage 3 Improvements)