diff --git a/assets/cutscenes/main_menu.cts b/assets/cutscenes/main_menu.cts new file mode 100644 index 00000000..ef18b664 Binary files /dev/null and b/assets/cutscenes/main_menu.cts differ diff --git a/assets/cutscenes/main_menu_start_game.cts b/assets/cutscenes/main_menu_start_game.cts deleted file mode 100644 index 859d2bcb..00000000 Binary files a/assets/cutscenes/main_menu_start_game.cts and /dev/null differ diff --git a/assetsraw/cutscenes/main_menu_start_game.jsonc b/assetsraw/cutscenes/main_menu.jsonc similarity index 69% rename from assetsraw/cutscenes/main_menu_start_game.jsonc rename to assetsraw/cutscenes/main_menu.jsonc index d9c8d418..2c7d04a6 100644 --- a/assetsraw/cutscenes/main_menu_start_game.jsonc +++ b/assetsraw/cutscenes/main_menu.jsonc @@ -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" } ] }, diff --git a/src/dusk/asset/loader/cutscene/assetcutsceneloader.c b/src/dusk/asset/loader/cutscene/assetcutsceneloader.c index ab3bd803..9bef73ca 100644 --- a/src/dusk/asset/loader/cutscene/assetcutsceneloader.c +++ b/src/dusk/asset/loader/cutscene/assetcutsceneloader.c @@ -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: diff --git a/src/dusk/rpg/cutscene/cutscene.h b/src/dusk/rpg/cutscene/cutscene.h index e51b3437..2f2d2cbe 100644 --- a/src/dusk/rpg/cutscene/cutscene.h +++ b/src/dusk/rpg/cutscene/cutscene.h @@ -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 diff --git a/src/dusk/rpg/cutscene/item/control/CMakeLists.txt b/src/dusk/rpg/cutscene/item/control/CMakeLists.txt index 1c4e73d9..c422b90a 100644 --- a/src/dusk/rpg/cutscene/item/control/CMakeLists.txt +++ b/src/dusk/rpg/cutscene/item/control/CMakeLists.txt @@ -11,4 +11,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} cutscenemarker.c cutscenerestart.c cutscenescene.c + cutsceneidle.c ) diff --git a/src/dusk/rpg/cutscene/item/control/cutsceneidle.c b/src/dusk/rpg/cutscene/item/control/cutsceneidle.c new file mode 100644 index 00000000..fcadce3e --- /dev/null +++ b/src/dusk/rpg/cutscene/item/control/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; +} diff --git a/src/dusk/rpg/cutscene/item/control/cutsceneidle.h b/src/dusk/rpg/cutscene/item/control/cutsceneidle.h new file mode 100644 index 00000000..2249fce4 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/control/cutsceneidle.h @@ -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 +); diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.c b/src/dusk/rpg/cutscene/item/cutsceneitem.c index 45476234..8e1e428a 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.c +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.c @@ -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 } }; diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.h b/src/dusk/rpg/cutscene/item/cutsceneitem.h index 395dfd0d..f25e9cb2 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.h +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.h @@ -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; diff --git a/src/dusk/scene/mainmenu/scenemainmenu.c b/src/dusk/scene/mainmenu/scenemainmenu.c index 835cb961..7f8227e1 100644 --- a/src/dusk/scene/mainmenu/scenemainmenu.c +++ b/src/dusk/scene/mainmenu/scenemainmenu.c @@ -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(); } diff --git a/src/dusk/scene/mainmenu/scenemainmenu.h b/src/dusk/scene/mainmenu/scenemainmenu.h index fa29bfdb..23639b3d 100644 --- a/src/dusk/scene/mainmenu/scenemainmenu.h +++ b/src/dusk/scene/mainmenu/scenemainmenu.h @@ -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); diff --git a/tools/asset/cutscene/__main__.py b/tools/asset/cutscene/__main__.py index 53e7931e..62709d7d 100644 --- a/tools/asset/cutscene/__main__.py +++ b/tools/asset/cutscene/__main__.py @@ -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('