feat: Implement Change View and Renderer Options menus

- Added functionality to display and navigate the Change View menu in SixelRenderer and SoftwareRenderer.
- Introduced methods to draw the Change View and Renderer Options menus, including handling cursor and selection states.
- Updated WolfClassicMenuArt to include a customize label for the new menu.
- Enhanced WolfMenuScreen to support new menu states.
- Created tests for Change View menu interactions, ensuring proper transitions and renderer settings toggling.
- Implemented persistence for renderer settings in Flutter, allowing settings to be saved and loaded from a local file.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 20:49:37 +01:00
parent 45e5302eac
commit 3270338f44
20 changed files with 2223 additions and 140 deletions
@@ -7,6 +7,8 @@ enum WolfMenuScreen {
gameSelect,
episodeSelect,
difficultySelect,
changeView,
rendererOptions,
}
enum WolfIntroSlide { retailWarning, pg13, title }
@@ -40,6 +42,36 @@ class WolfMenuMainEntry {
final bool isEnabled;
}
class WolfMenuRendererEntry {
const WolfMenuRendererEntry({
required this.mode,
required this.label,
required this.hasOptions,
this.isEnabled = true,
this.isChecked = false,
});
final WolfRendererMode mode;
final String label;
final bool hasOptions;
final bool isEnabled;
final bool isChecked;
}
class WolfMenuRendererOptionEntry {
const WolfMenuRendererOptionEntry({
required this.id,
required this.label,
this.isEnabled = true,
this.isChecked = false,
});
final WolfRendererOptionId id;
final String label;
final bool isEnabled;
final bool isChecked;
}
bool _isWiredMainMenuAction(WolfMenuMainAction action) {
switch (action) {
case WolfMenuMainAction.newGame:
@@ -48,11 +80,12 @@ bool _isWiredMainMenuAction(WolfMenuMainAction action) {
case WolfMenuMainAction.backToDemo:
case WolfMenuMainAction.quit:
return true;
case WolfMenuMainAction.changeView:
return true;
case WolfMenuMainAction.sound:
case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.changeView:
case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores:
return false;
@@ -85,6 +118,13 @@ class MenuManager {
int _selectedGameIndex = 0;
int _selectedEpisodeIndex = 0;
int _selectedDifficultyIndex = 0;
int _selectedChangeViewIndex = 0;
int _selectedRendererOptionIndex = 0;
String _rendererOptionsTitle = 'CUSTOMIZE';
List<WolfMenuRendererEntry> _changeViewEntries =
const <WolfMenuRendererEntry>[];
List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
const <WolfMenuRendererOptionEntry>[];
bool _showResumeOption = false;
int _gameCount = 1;
@@ -161,6 +201,18 @@ class MenuManager {
int get selectedEpisodeIndex => _selectedEpisodeIndex;
int get selectedChangeViewIndex => _selectedChangeViewIndex;
int get selectedRendererOptionIndex => _selectedRendererOptionIndex;
String get rendererOptionsTitle => _rendererOptionsTitle;
List<WolfMenuRendererEntry> get changeViewEntries =>
List<WolfMenuRendererEntry>.unmodifiable(_changeViewEntries);
List<WolfMenuRendererOptionEntry> get rendererOptionEntries =>
List<WolfMenuRendererOptionEntry>.unmodifiable(_rendererOptionEntries);
List<WolfMenuMainEntry> get mainMenuEntries {
return List<WolfMenuMainEntry>.unmodifiable(
<WolfMenuMainEntry>[
@@ -290,6 +342,133 @@ class MenuManager {
_resetEdgeState();
}
int get _changeViewItemCount =>
_changeViewEntries.length + _rendererOptionEntries.length;
void setChangeViewEntries(List<WolfMenuRendererEntry> entries) {
final WolfRendererMode? previouslySelectedMode =
(_selectedChangeViewIndex >= 0 &&
_selectedChangeViewIndex < _changeViewEntries.length)
? _changeViewEntries[_selectedChangeViewIndex].mode
: null;
_changeViewEntries = List<WolfMenuRendererEntry>.unmodifiable(entries);
final int itemCount = _changeViewItemCount;
if (itemCount == 0) {
_selectedChangeViewIndex = 0;
return;
}
if (previouslySelectedMode != null) {
final int modeIndex = _changeViewEntries.indexWhere(
(entry) => entry.mode == previouslySelectedMode,
);
if (modeIndex >= 0 && _isSelectableChangeViewIndex(modeIndex)) {
_selectedChangeViewIndex = modeIndex;
return;
}
}
_selectedChangeViewIndex = _findSelectableIndex(
_clampIndex(_selectedChangeViewIndex, itemCount),
itemCount,
_isSelectableChangeViewIndex,
);
}
void setRendererOptionEntries({
required String title,
required List<WolfMenuRendererOptionEntry> entries,
}) {
final bool wasSelectingOption =
_selectedChangeViewIndex >= _changeViewEntries.length;
final WolfRendererOptionId? previousOption =
(_selectedRendererOptionIndex >= 0 &&
_selectedRendererOptionIndex < _rendererOptionEntries.length)
? _rendererOptionEntries[_selectedRendererOptionIndex].id
: null;
_rendererOptionsTitle = title;
_rendererOptionEntries = List<WolfMenuRendererOptionEntry>.unmodifiable(
entries,
);
final int totalCount = _changeViewItemCount;
if (_rendererOptionEntries.isEmpty || totalCount == 0) {
_selectedRendererOptionIndex = 0;
if (_changeViewEntries.isNotEmpty) {
_selectedChangeViewIndex = _findSelectableIndex(
0,
_changeViewEntries.length,
_isSelectableChangeViewIndex,
);
}
return;
}
if (previousOption != null) {
final int previousIndex = _rendererOptionEntries.indexWhere(
(entry) => entry.id == previousOption,
);
if (previousIndex >= 0 &&
_isSelectableRendererOptionIndex(previousIndex)) {
_selectedRendererOptionIndex = previousIndex;
if (wasSelectingOption) {
_selectedChangeViewIndex = _changeViewEntries.length + previousIndex;
}
return;
}
}
_selectedRendererOptionIndex = _findSelectableIndex(
_clampIndex(_selectedRendererOptionIndex, _rendererOptionEntries.length),
_rendererOptionEntries.length,
_isSelectableRendererOptionIndex,
);
if (wasSelectingOption) {
_selectedChangeViewIndex =
_changeViewEntries.length + _selectedRendererOptionIndex;
} else {
_selectedChangeViewIndex = _findSelectableIndex(
_clampIndex(_selectedChangeViewIndex, totalCount),
totalCount,
_isSelectableChangeViewIndex,
);
}
}
void showChangeViewMenu() {
_activeMenu = WolfMenuScreen.changeView;
_selectedChangeViewIndex = _changeViewItemCount == 0
? 0
: _findSelectableIndex(
0,
_changeViewItemCount,
_isSelectableChangeViewIndex,
);
_transitionTarget = null;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_resetEdgeState();
}
void showRendererOptionsMenu() {
_activeMenu = WolfMenuScreen.rendererOptions;
_selectedRendererOptionIndex = _rendererOptionEntries.isEmpty
? 0
: _findSelectableIndex(
0,
_rendererOptionEntries.length,
_isSelectableRendererOptionIndex,
);
_transitionTarget = null;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_resetEdgeState();
}
/// Starts a menu transition. Input is locked until it completes.
///
/// Hosts can reuse this fade timing for future pre-menu splash/image
@@ -453,6 +632,88 @@ class MenuManager {
);
}
({
WolfRendererMode? selectedMode,
WolfRendererOptionId? selectedOption,
bool goBack,
})
updateChangeViewMenu(EngineInput input) {
if (isTransitioning) {
_consumeEdgeState(input);
return (
selectedMode: null,
selectedOption: null,
goBack: false,
);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedChangeViewIndex,
itemCount: _changeViewItemCount,
isSelectableIndex: _isSelectableChangeViewIndex,
);
_selectedChangeViewIndex = action.index;
if (!action.confirmed) {
return (
selectedMode: null,
selectedOption: null,
goBack: action.goBack,
);
}
if (_selectedChangeViewIndex < _changeViewEntries.length) {
final WolfMenuRendererEntry entry =
_changeViewEntries[_selectedChangeViewIndex];
return (
selectedMode: entry.mode,
selectedOption: null,
goBack: action.goBack,
);
}
final int optionIndex =
_selectedChangeViewIndex - _changeViewEntries.length;
if (optionIndex < 0 || optionIndex >= _rendererOptionEntries.length) {
return (
selectedMode: null,
selectedOption: null,
goBack: action.goBack,
);
}
_selectedRendererOptionIndex = optionIndex;
return (
selectedMode: null,
selectedOption: _rendererOptionEntries[optionIndex].id,
goBack: action.goBack,
);
}
({WolfRendererOptionId? selectedOption, bool goBack})
updateRendererOptionsMenu(EngineInput input) {
if (isTransitioning) {
_consumeEdgeState(input);
return (selectedOption: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedRendererOptionIndex,
itemCount: _rendererOptionEntries.length,
isSelectableIndex: _isSelectableRendererOptionIndex,
);
_selectedRendererOptionIndex = action.index;
return (
selectedOption: action.confirmed && _rendererOptionEntries.isNotEmpty
? _rendererOptionEntries[_selectedRendererOptionIndex].id
: null,
goBack: action.goBack,
);
}
/// Returns a menu action snapshot for this frame.
({Difficulty? selected, bool goBack}) updateDifficultySelection(
EngineInput input,
@@ -612,6 +873,27 @@ class MenuManager {
return mainMenuEntries[index].isEnabled;
}
bool _isSelectableChangeViewIndex(int index) {
if (index < 0) {
return false;
}
if (index < _changeViewEntries.length) {
return _changeViewEntries[index].isEnabled;
}
final int optionIndex = index - _changeViewEntries.length;
if (optionIndex >= 0 && optionIndex < _rendererOptionEntries.length) {
return _rendererOptionEntries[optionIndex].isEnabled;
}
return false;
}
bool _isSelectableRendererOptionIndex(int index) {
if (index < 0 || index >= _rendererOptionEntries.length) {
return false;
}
return _rendererOptionEntries[index].isEnabled;
}
WolfMenuMainEntry _mainMenuEntry({
required WolfMenuMainAction action,
required String label,