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:
Binary file not shown.
Binary file not shown.
+24
-20
@@ -1,12 +1,26 @@
|
||||
{
|
||||
"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",
|
||||
"file": "audio/boa.mp3",
|
||||
"channel": "BGM_0",
|
||||
"volume": 1.0,
|
||||
"pan": 0.0,
|
||||
"looping": true
|
||||
"type": "IDLE"
|
||||
},
|
||||
|
||||
// New Game pressed (sceneMainMenuStartGame jumps here via
|
||||
// cutsceneGoTo) - check for a save device before loading slots.
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "NEW_GAME"
|
||||
},
|
||||
{
|
||||
"type": "MODAL",
|
||||
@@ -24,6 +38,8 @@
|
||||
},
|
||||
|
||||
// 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",
|
||||
"name": "NO_DEVICE"
|
||||
@@ -38,7 +54,7 @@
|
||||
"options": [
|
||||
{
|
||||
"text": "main_menu.no_device.retry",
|
||||
"marker": "RETRY"
|
||||
"marker": "NEW_GAME"
|
||||
},
|
||||
{
|
||||
"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.
|
||||
{
|
||||
"type": "MARKER",
|
||||
@@ -87,7 +91,7 @@
|
||||
"options": [
|
||||
{
|
||||
"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_RESTART:
|
||||
case CUTSCENE_ITEM_TYPE_IDLE:
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_PRINT:
|
||||
|
||||
@@ -65,6 +65,12 @@ typedef struct cutscene_s {
|
||||
#define CUTSCENE_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
|
||||
// immediately continues on to whatever follows this item - the switch
|
||||
// itself doesn't happen until the next sceneUpdate() tick, so it does
|
||||
|
||||
@@ -11,4 +11,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
cutscenemarker.c
|
||||
cutscenerestart.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] = {
|
||||
.init = cutsceneAudioSetStart,
|
||||
.update = cutsceneAudioSetUpdate
|
||||
},
|
||||
|
||||
[CUTSCENE_ITEM_TYPE_IDLE] = {
|
||||
.update = cutsceneIdleUpdate
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "control/cutscenemarker.h"
|
||||
#include "control/cutscenerestart.h"
|
||||
#include "control/cutscenescene.h"
|
||||
#include "control/cutsceneidle.h"
|
||||
#include "entity/cutsceneentityteleport.h"
|
||||
#include "entity/cutsceneentitywalkto.h"
|
||||
#include "entity/cutsceneentityremove.h"
|
||||
@@ -96,6 +97,7 @@ typedef enum {
|
||||
CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN,
|
||||
CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP,
|
||||
CUTSCENE_ITEM_TYPE_AUDIO_SET,
|
||||
CUTSCENE_ITEM_TYPE_IDLE,
|
||||
|
||||
CUTSCENE_ITEM_TYPE_COUNT
|
||||
} cutsceneitemtype_t;
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
#include "asset/asset.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
// Loaded lazily on first Start Game click and kept resident for the rest
|
||||
// of the process - it's tiny and reused every time, so there's no benefit
|
||||
// to unlocking/reloading it between attempts (same lifetime convention as
|
||||
// Loaded lazily on first activation and kept resident for the rest of the
|
||||
// process - it's tiny and reused every time, so there's no benefit to
|
||||
// unlocking/reloading it between attempts (same lifetime convention as
|
||||
// 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) {
|
||||
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/
|
||||
// retry-on-error shape mirroring the initial scene's device lookup, see
|
||||
// scene/initial/sceneinitial.c) - runtime-loaded from
|
||||
// assets/cutscenes/main_menu_start_game.cts (authored at
|
||||
// assetsraw/cutscenes/main_menu_start_game.json via
|
||||
// `python3 -m tools.asset.cutscene`) rather than compiled in, since it's
|
||||
// player-facing flow rather than core engine wiring. The file ends at the
|
||||
// LOADED marker with no further action - sceneMainMenuOpenSelectSave is
|
||||
// armed as the completion callback below instead of being baked into the
|
||||
// cutscene itself, since a file can't store a native function pointer.
|
||||
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
|
||||
// Lazily locks/loads the main menu cutscene (runtime-loaded from
|
||||
// assets/cutscenes/main_menu.cts, authored at
|
||||
// assetsraw/cutscenes/main_menu.jsonc via `python3 -m tools.asset.cutscene`
|
||||
// rather than compiled in, since it's player-facing flow rather than core
|
||||
// engine wiring) and asserts it's ready to run. Shared by both the initial
|
||||
// scene-entry start and the New-Game fallback restart below.
|
||||
cutscene_t *sceneMainMenuLoadCutscene(void) {
|
||||
if(MAIN_MENU_CUTSCENE_ENTRY == NULL) {
|
||||
MAIN_MENU_CUTSCENE_ENTRY = assetLock(
|
||||
"cutscenes/main_menu.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)) {
|
||||
errorCatch(errorPrint(result));
|
||||
assertTrue(false, "Failed to load main menu start-game cutscene asset");
|
||||
assertTrue(false, "Failed to load main menu cutscene asset");
|
||||
}
|
||||
|
||||
cutsceneSystemStartCutscene(
|
||||
&MAIN_MENU_START_GAME_CUTSCENE_ENTRY->data.cutscene.cutscene
|
||||
);
|
||||
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
|
||||
return &MAIN_MENU_CUTSCENE_ENTRY->data.cutscene.cutscene;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
uiMainMenuOpen();
|
||||
|
||||
cutsceneSystemStartCutscene(sceneMainMenuLoadCutscene());
|
||||
cutsceneSystemSetOnComplete(sceneMainMenuOpenSelectSave);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#pragma once
|
||||
#include "scene/scenebase.h"
|
||||
|
||||
typedef struct cutscene_s cutscene_t;
|
||||
|
||||
// Empty - the menu itself lives in ui/screen/mainmenu/uimainmenu.c. A byte
|
||||
// placeholder keeps the struct non-empty for portability.
|
||||
typedef struct {
|
||||
@@ -15,7 +17,9 @@ typedef struct {
|
||||
} 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.
|
||||
* @return An error if the init failed, or errorOk() if it succeeded.
|
||||
@@ -23,10 +27,21 @@ typedef struct {
|
||||
errorret_t sceneMainMenuInit(scenedata_t *sceneData);
|
||||
|
||||
/**
|
||||
* Starts the "Start Game" flow: runs a cutscene that loads every save
|
||||
* slot (retrying on error, prompting if no save device is found) and
|
||||
* opens the load-game picker once it succeeds. Called by
|
||||
* ui/screen/mainmenu/uimainmenu.c when the player selects Start Game.
|
||||
* Lazily locks/loads the main menu cutscene asset (assets/cutscenes/
|
||||
* main_menu.cts) and returns it, asserting on failure. Kept resident for
|
||||
* the rest of the process once loaded.
|
||||
*
|
||||
* @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);
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ ITEM_TYPE = {
|
||||
'AUDIO_SET_PAN': 41,
|
||||
'AUDIO_SET_LOOP': 42,
|
||||
'AUDIO_SET': 43,
|
||||
'IDLE': 44,
|
||||
}
|
||||
|
||||
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'])
|
||||
buf += struct.pack('<HH', text_offset, marker_offset)
|
||||
|
||||
elif item_type in ('MODAL_CLOSE', 'RESTART'):
|
||||
elif item_type in ('MODAL_CLOSE', 'RESTART', 'IDLE'):
|
||||
pass
|
||||
|
||||
elif item_type == 'PRINT':
|
||||
|
||||
Reference in New Issue
Block a user