Run one persistent cutscene for the whole main menu, not just start-game

Previously the main menu UI opened directly with no cutscene, and only
pressing "Start Game" spun up a brand new cutscene
(main_menu_start_game.jsonc) for the save-device check/load-slots flow.
Now a single cutscene (main_menu.jsonc) starts the moment the main menu
scene becomes active, sitting idle (new CUTSCENE_ITEM_TYPE_IDLE item -
no existing item type blocked forever with no side effect) while the
menu is shown, so it can own menu-wide state going forward.

Pressing "Start Game" now jumps the already-running cutscene to a
NEW_GAME marker via cutsceneGoTo instead of starting a second cutscene -
this already-existing mechanism needed no engine changes. Both retry
options (no device / load error) also now jump straight back to
NEW_GAME instead of RESTARTing the whole cutscene, since RESTART would
otherwise strand the player on the idle block. sceneMainMenuStartGame
also handles the case where the cutscene already ran to completion once
(e.g. backing out of the save picker and pressing Start Game again) by
restarting it landing straight on NEW_GAME, avoiding a crash from
jumping a marker with no cutscene running.

Built and verified on Linux (boots/idles without crashing, cutscene
tests pass), PSP, GameCube and Wii (Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-04 18:18:49 -05:00
co-authored by Claude Sonnet 5
parent 3c3dc35e1d
commit 707856fcf2
13 changed files with 144 additions and 50 deletions
Binary file not shown.
Binary file not shown.
@@ -1,12 +1,26 @@
{ {
"items": [ "items": [
// Runs immediately as soon as the main menu scene becomes active
// (see sceneMainMenuInit) - owns menu-wide ambience, then idles while
// the menu itself is shown and interacted with natively.
// {
// "type": "AUDIO_PLAY",
// "file": "audio/boa.mp3",
// "channel": "BGM_0",
// "volume": 1.0,
// "pan": 0.0,
// "looping": true
// },
{ {
"type": "AUDIO_PLAY", "type": "IDLE"
"file": "audio/boa.mp3", },
"channel": "BGM_0",
"volume": 1.0, // New Game pressed (sceneMainMenuStartGame jumps here via
"pan": 0.0, // cutsceneGoTo) - check for a save device before loading slots.
"looping": true {
"type": "MARKER",
"name": "NEW_GAME"
}, },
{ {
"type": "MODAL", "type": "MODAL",
@@ -24,6 +38,8 @@
}, },
// No save device found - offer to retry or continue without saving. // No save device found - offer to retry or continue without saving.
// Retry jumps straight back to NEW_GAME rather than restarting the
// whole cutscene, so the BGM above isn't retriggered.
{ {
"type": "MARKER", "type": "MARKER",
"name": "NO_DEVICE" "name": "NO_DEVICE"
@@ -38,7 +54,7 @@
"options": [ "options": [
{ {
"text": "main_menu.no_device.retry", "text": "main_menu.no_device.retry",
"marker": "RETRY" "marker": "NEW_GAME"
}, },
{ {
"text": "main_menu.no_device.continue", "text": "main_menu.no_device.continue",
@@ -47,18 +63,6 @@
] ]
}, },
// Retry
{
"type": "MARKER",
"name": "RETRY"
},
{
"type": "MODAL_CLOSE"
},
{
"type": "RESTART"
},
// Save device found - attempt to load all save slots. // Save device found - attempt to load all save slots.
{ {
"type": "MARKER", "type": "MARKER",
@@ -87,7 +91,7 @@
"options": [ "options": [
{ {
"text": "main_menu.save_load_error.retry", "text": "main_menu.save_load_error.retry",
"marker": "RETRY" "marker": "NEW_GAME"
} }
] ]
}, },
@@ -368,6 +368,7 @@ errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
case CUTSCENE_ITEM_TYPE_MODAL_CLOSE: case CUTSCENE_ITEM_TYPE_MODAL_CLOSE:
case CUTSCENE_ITEM_TYPE_RESTART: case CUTSCENE_ITEM_TYPE_RESTART:
case CUTSCENE_ITEM_TYPE_IDLE:
break; break;
case CUTSCENE_ITEM_TYPE_PRINT: case CUTSCENE_ITEM_TYPE_PRINT:
+6
View File
@@ -65,6 +65,12 @@ typedef struct cutscene_s {
#define CUTSCENE_RESTART() \ #define CUTSCENE_RESTART() \
{ .type = CUTSCENE_ITEM_TYPE_RESTART } { .type = CUTSCENE_ITEM_TYPE_RESTART }
// Blocks the cutscene here indefinitely - it never completes on its own,
// only cutsceneGoTo (called externally, e.g. from a UI callback) can move
// execution past it.
#define CUTSCENE_IDLE() \
{ .type = CUTSCENE_ITEM_TYPE_IDLE }
// Requests a switch to a different SCENE_TYPE via sceneSet, then // Requests a switch to a different SCENE_TYPE via sceneSet, then
// immediately continues on to whatever follows this item - the switch // immediately continues on to whatever follows this item - the switch
// itself doesn't happen until the next sceneUpdate() tick, so it does // itself doesn't happen until the next sceneUpdate() tick, so it does
@@ -11,4 +11,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenemarker.c cutscenemarker.c
cutscenerestart.c cutscenerestart.c
cutscenescene.c cutscenescene.c
cutsceneidle.c
) )
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
bool_t cutsceneIdleUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return false;
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t;
/**
* Updates an idle item. Never completes on its own - the cutscene blocks
* here indefinitely until something external (e.g. a UI callback) calls
* cutsceneGoTo to jump elsewhere.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns false always.
*/
bool_t cutsceneIdleUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -221,6 +221,10 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
[CUTSCENE_ITEM_TYPE_AUDIO_SET] = { [CUTSCENE_ITEM_TYPE_AUDIO_SET] = {
.init = cutsceneAudioSetStart, .init = cutsceneAudioSetStart,
.update = cutsceneAudioSetUpdate .update = cutsceneAudioSetUpdate
},
[CUTSCENE_ITEM_TYPE_IDLE] = {
.update = cutsceneIdleUpdate
} }
}; };
@@ -14,6 +14,7 @@
#include "control/cutscenemarker.h" #include "control/cutscenemarker.h"
#include "control/cutscenerestart.h" #include "control/cutscenerestart.h"
#include "control/cutscenescene.h" #include "control/cutscenescene.h"
#include "control/cutsceneidle.h"
#include "entity/cutsceneentityteleport.h" #include "entity/cutsceneentityteleport.h"
#include "entity/cutsceneentitywalkto.h" #include "entity/cutsceneentitywalkto.h"
#include "entity/cutsceneentityremove.h" #include "entity/cutsceneentityremove.h"
@@ -96,6 +97,7 @@ typedef enum {
CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN, CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN,
CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP, CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP,
CUTSCENE_ITEM_TYPE_AUDIO_SET, CUTSCENE_ITEM_TYPE_AUDIO_SET,
CUTSCENE_ITEM_TYPE_IDLE,
CUTSCENE_ITEM_TYPE_COUNT CUTSCENE_ITEM_TYPE_COUNT
} cutsceneitemtype_t; } cutsceneitemtype_t;
+43 -24
View File
@@ -13,11 +13,11 @@
#include "asset/asset.h" #include "asset/asset.h"
#include "assert/assert.h" #include "assert/assert.h"
// Loaded lazily on first Start Game click and kept resident for the rest // Loaded lazily on first activation and kept resident for the rest of the
// of the process - it's tiny and reused every time, so there's no benefit // process - it's tiny and reused every time, so there's no benefit to
// to unlocking/reloading it between attempts (same lifetime convention as // unlocking/reloading it between attempts (same lifetime convention as
// e.g. LOCALE.entry). // e.g. LOCALE.entry).
static assetentry_t *MAIN_MENU_START_GAME_CUTSCENE_ENTRY = NULL; static assetentry_t *MAIN_MENU_CUTSCENE_ENTRY = NULL;
void sceneMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) { void sceneMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) { if(slotIndex == UI_SELECT_SAVE_RESULT_NONE) {
@@ -35,36 +35,55 @@ void sceneMainMenuOpenSelectSave(void *userData) {
); );
} }
// Loads every save slot before opening the load-game picker (checking/ // Lazily locks/loads the main menu cutscene (runtime-loaded from
// retry-on-error shape mirroring the initial scene's device lookup, see // assets/cutscenes/main_menu.cts, authored at
// scene/initial/sceneinitial.c) - runtime-loaded from // assetsraw/cutscenes/main_menu.jsonc via `python3 -m tools.asset.cutscene`
// assets/cutscenes/main_menu_start_game.cts (authored at // rather than compiled in, since it's player-facing flow rather than core
// assetsraw/cutscenes/main_menu_start_game.json via // engine wiring) and asserts it's ready to run. Shared by both the initial
// `python3 -m tools.asset.cutscene`) rather than compiled in, since it's // scene-entry start and the New-Game fallback restart below.
// player-facing flow rather than core engine wiring. The file ends at the cutscene_t *sceneMainMenuLoadCutscene(void) {
// LOADED marker with no further action - sceneMainMenuOpenSelectSave is if(MAIN_MENU_CUTSCENE_ENTRY == NULL) {
// armed as the completion callback below instead of being baked into the MAIN_MENU_CUTSCENE_ENTRY = assetLock(
// cutscene itself, since a file can't store a native function pointer. "cutscenes/main_menu.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL
void sceneMainMenuStartGame(void) {
if(MAIN_MENU_START_GAME_CUTSCENE_ENTRY == NULL) {
MAIN_MENU_START_GAME_CUTSCENE_ENTRY = assetLock(
"cutscenes/main_menu_start_game.cts", ASSET_LOADER_TYPE_CUTSCENE, NULL
); );
} }
errorret_t result = assetRequireLoaded(MAIN_MENU_START_GAME_CUTSCENE_ENTRY); errorret_t result = assetRequireLoaded(MAIN_MENU_CUTSCENE_ENTRY);
if(errorIsNotOk(result)) { if(errorIsNotOk(result)) {
errorCatch(errorPrint(result)); errorCatch(errorPrint(result));
assertTrue(false, "Failed to load main menu start-game cutscene asset"); assertTrue(false, "Failed to load main menu cutscene asset");
} }
cutsceneSystemStartCutscene( return &MAIN_MENU_CUTSCENE_ENTRY->data.cutscene.cutscene;
&MAIN_MENU_START_GAME_CUTSCENE_ENTRY->data.cutscene.cutscene }
);
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave); // Jumps the main menu cutscene (already running and idling since
// sceneMainMenuInit) forward to its NEW_GAME marker, which checks for a
// save device, loads every slot (checking/retry-on-error shape mirroring
// the initial scene's device lookup, see scene/initial/sceneinitial.c),
// and ends at the LOADED marker with no further action -
// sceneMainMenuOpenSelectSave is armed as the completion callback since a
// file can't store a native function pointer.
void sceneMainMenuStartGame(void) {
if(CUTSCENE_SYSTEM.scene == NULL) {
// The menu cutscene already ran to completion once (e.g. the player
// backed out of the save picker back to this menu) - restart it
// landing straight on NEW_GAME rather than replaying the idle/BGM
// setup from the top.
cutsceneSystemStartCutsceneAndGoToMarker(
sceneMainMenuLoadCutscene(), "NEW_GAME"
);
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
} else {
cutsceneGoTo("NEW_GAME");
}
} }
errorret_t sceneMainMenuInit(scenedata_t *sceneData) { errorret_t sceneMainMenuInit(scenedata_t *sceneData) {
uiMainMenuOpen(); uiMainMenuOpen();
cutsceneSystemStartCutscene(sceneMainMenuLoadCutscene());
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
errorOk(); errorOk();
} }
+20 -5
View File
@@ -8,6 +8,8 @@
#pragma once #pragma once
#include "scene/scenebase.h" #include "scene/scenebase.h"
typedef struct cutscene_s cutscene_t;
// Empty - the menu itself lives in ui/screen/mainmenu/uimainmenu.c. A byte // Empty - the menu itself lives in ui/screen/mainmenu/uimainmenu.c. A byte
// placeholder keeps the struct non-empty for portability. // placeholder keeps the struct non-empty for portability.
typedef struct { typedef struct {
@@ -15,7 +17,9 @@ typedef struct {
} scenemainmenu_t; } scenemainmenu_t;
/** /**
* Initializes the main menu scene by opening the main menu panel. * Initializes the main menu scene: opens the main menu panel and starts
* the main menu cutscene, which plays the menu's BGM and idles until
* sceneMainMenuStartGame jumps it to its NEW_GAME marker.
* *
* @param sceneData The scene data used for this scene. * @param sceneData The scene data used for this scene.
* @return An error if the init failed, or errorOk() if it succeeded. * @return An error if the init failed, or errorOk() if it succeeded.
@@ -23,10 +27,21 @@ typedef struct {
errorret_t sceneMainMenuInit(scenedata_t *sceneData); errorret_t sceneMainMenuInit(scenedata_t *sceneData);
/** /**
* Starts the "Start Game" flow: runs a cutscene that loads every save * Lazily locks/loads the main menu cutscene asset (assets/cutscenes/
* slot (retrying on error, prompting if no save device is found) and * main_menu.cts) and returns it, asserting on failure. Kept resident for
* opens the load-game picker once it succeeds. Called by * the rest of the process once loaded.
* ui/screen/mainmenu/uimainmenu.c when the player selects Start Game. *
* @return The main menu cutscene.
*/
cutscene_t *sceneMainMenuLoadCutscene(void);
/**
* Starts the "Start Game" flow: jumps the already-running main menu
* cutscene to its NEW_GAME marker (or restarts it there if it already ran
* to completion) to load every save slot - retrying on error, prompting
* if no save device is found - and open the load-game picker once it
* succeeds. Called by ui/screen/mainmenu/uimainmenu.c when the player
* selects Start Game.
*/ */
void sceneMainMenuStartGame(void); void sceneMainMenuStartGame(void);
+2 -1
View File
@@ -102,6 +102,7 @@ ITEM_TYPE = {
'AUDIO_SET_PAN': 41, 'AUDIO_SET_PAN': 41,
'AUDIO_SET_LOOP': 42, 'AUDIO_SET_LOOP': 42,
'AUDIO_SET': 43, 'AUDIO_SET': 43,
'IDLE': 44,
} }
PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3} PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3}
@@ -327,7 +328,7 @@ def encode_item(item, pool):
marker_offset = pool.add_string(option['marker']) marker_offset = pool.add_string(option['marker'])
buf += struct.pack('<HH', text_offset, marker_offset) buf += struct.pack('<HH', text_offset, marker_offset)
elif item_type in ('MODAL_CLOSE', 'RESTART'): elif item_type in ('MODAL_CLOSE', 'RESTART', 'IDLE'):
pass pass
elif item_type == 'PRINT': elif item_type == 'PRINT':