feat: Enhance CLI and GUI to support configurable game data directory persistence
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
/// Whether desktop window-management APIs are expected to work on this host.
|
||||
bool get supportsDesktopWindowing {
|
||||
@@ -15,3 +17,21 @@ bool get supportsDesktopWindowing {
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Ensures desktop windowing plugin state is initialized when supported.
|
||||
///
|
||||
/// This safely no-ops on non-desktop targets and in hosts where the plugin is
|
||||
/// not registered.
|
||||
Future<void> ensureDesktopWindowingInitialized({
|
||||
WindowManager? windowing,
|
||||
}) async {
|
||||
if (!supportsDesktopWindowing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await (windowing ?? windowManager).ensureInitialized();
|
||||
} on MissingPluginException {
|
||||
// No-op on hosts where the window manager plugin is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
library;
|
||||
|
||||
export 'game_data_directory_persistence_stub.dart'
|
||||
if (dart.library.io) 'game_data_directory_persistence_io.dart';
|
||||
@@ -0,0 +1,99 @@
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Persists the configured game-data directory path as JSON.
|
||||
class DefaultGameDataDirectoryPersistence {
|
||||
DefaultGameDataDirectoryPersistence({String? filePath})
|
||||
: _filePath = filePath;
|
||||
|
||||
final String? _filePath;
|
||||
String? _resolvedPath;
|
||||
|
||||
/// Absolute JSON file path used for persistence.
|
||||
String get filePath {
|
||||
if (_resolvedPath != null) {
|
||||
return _resolvedPath!;
|
||||
}
|
||||
|
||||
if (_filePath != null && _filePath.isNotEmpty) {
|
||||
_resolvedPath = _filePath;
|
||||
return _resolvedPath!;
|
||||
}
|
||||
|
||||
_resolvedPath = '$platformConfigDir/data_source.json';
|
||||
return _resolvedPath!;
|
||||
}
|
||||
|
||||
/// Loads a previously persisted data-directory path.
|
||||
Future<String?> loadDataDirectory() async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final raw = await file.readAsString();
|
||||
if (raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Object? value = decoded['dataDirectory'];
|
||||
if (value is! String) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalized = value.trim();
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [directoryPath] for future startups.
|
||||
Future<void> saveDataDirectory(String? directoryPath) async {
|
||||
try {
|
||||
final normalized = directoryPath?.trim();
|
||||
final file = File(filePath);
|
||||
|
||||
await file.parent.create(recursive: true);
|
||||
if (normalized == null || normalized.isEmpty) {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = jsonEncode(<String, Object>{
|
||||
'dataDirectory': normalized,
|
||||
});
|
||||
await file.writeAsString(payload, flush: true);
|
||||
} catch (_) {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
String get platformConfigDir {
|
||||
if (Platform.isLinux) {
|
||||
final String xdg = Platform.environment['XDG_CONFIG_HOME'] ?? '';
|
||||
final String home = Platform.environment['HOME'] ?? '.';
|
||||
return xdg.isNotEmpty ? '$xdg/wolf3d' : '$home/.config/wolf3d';
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
final String home = Platform.environment['HOME'] ?? '.';
|
||||
return '$home/Library/Application Support/wolf3d';
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
final String appData = Platform.environment['APPDATA'] ?? '.';
|
||||
return '$appData/wolf3d';
|
||||
}
|
||||
final String home = Platform.environment['HOME'] ?? '.';
|
||||
return '$home/.config/wolf3d';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
library;
|
||||
|
||||
/// No-op persistence used on platforms without `dart:io` support.
|
||||
class DefaultGameDataDirectoryPersistence {
|
||||
DefaultGameDataDirectoryPersistence({String? filePath});
|
||||
|
||||
/// Loads a previously persisted data-directory path.
|
||||
Future<String?> loadDataDirectory() async {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Persists [directoryPath] for future startups.
|
||||
Future<void> saveDataDirectory(String? directoryPath) async {}
|
||||
}
|
||||
@@ -1,15 +1,42 @@
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||
|
||||
/// Fallback screen shown when no Wolf3D game data files are discovered.
|
||||
class NoGameDataScreen extends StatelessWidget {
|
||||
const NoGameDataScreen({super.key});
|
||||
const NoGameDataScreen({
|
||||
super.key,
|
||||
this.configuredDataDirectory,
|
||||
});
|
||||
|
||||
/// Previously configured external game-data directory, if any.
|
||||
final String? configuredDataDirectory;
|
||||
|
||||
static Color _colorFromVgaIndex(int index) {
|
||||
final int packed = ColorPalette.vga32Bit[index]; // 0xAABBGGRR
|
||||
final int r = packed & 0xFF;
|
||||
final int g = (packed >> 8) & 0xFF;
|
||||
final int b = (packed >> 16) & 0xFF;
|
||||
return Color((0xFF << 24) | (r << 16) | (g << 8) | b);
|
||||
}
|
||||
|
||||
static final Color _backgroundColor = _colorFromVgaIndex(111);
|
||||
static final Color _panelColor = _colorFromVgaIndex(103);
|
||||
static final Color _borderColor = _colorFromVgaIndex(87);
|
||||
static final Color _titleColor = _colorFromVgaIndex(
|
||||
WolfMenuPalette.headerTextIndex,
|
||||
);
|
||||
static final Color _bodyColor = _colorFromVgaIndex(
|
||||
WolfMenuPalette.unselectedTextIndex,
|
||||
);
|
||||
static final Color _emphasisColor = _colorFromVgaIndex(10);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF140000),
|
||||
backgroundColor: _backgroundColor,
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -17,10 +44,10 @@ class NoGameDataScreen extends StatelessWidget {
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF590002),
|
||||
border: Border.all(color: const Color(0xFFB00000), width: 2),
|
||||
color: _panelColor,
|
||||
border: Border.all(color: _borderColor, width: 2),
|
||||
),
|
||||
child: const Padding(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -29,25 +56,44 @@ class NoGameDataScreen extends StatelessWidget {
|
||||
Text(
|
||||
'WOLF3D DATA NOT FOUND',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFFFF700),
|
||||
color: _titleColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No game files were discovered.\n\n'
|
||||
'Add Wolfenstein 3D data files to one of these locations:\n'
|
||||
'- packages/wolf_3d_assets/assets/retail\n'
|
||||
'- packages/wolf_3d_assets/assets/shareware\n'
|
||||
'- or a discoverable local game-data folder.\n\n'
|
||||
'Restart the app after adding the files.',
|
||||
'Select a game-data directory in setup.',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: _bodyColor,
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Once all required files are present, the game starts automatically.',
|
||||
style: TextStyle(
|
||||
color: _emphasisColor,
|
||||
fontSize: 14,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (configuredDataDirectory != null &&
|
||||
configuredDataDirectory!.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'Configured data directory: ${configuredDataDirectory!.trim()}',
|
||||
style: TextStyle(
|
||||
color: _bodyColor,
|
||||
fontSize: 13,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,17 +7,19 @@ import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||
/// host screens.
|
||||
class Wolf3dApp extends StatelessWidget {
|
||||
/// Shared initialized facade that owns game data, input, and audio services.
|
||||
final Wolf3dFlutterEngine wolf3d;
|
||||
final Wolf3dFlutterEngine engine;
|
||||
|
||||
const Wolf3dApp({
|
||||
super.key,
|
||||
required this.wolf3d,
|
||||
required this.engine,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return wolf3d.availableGames.isEmpty
|
||||
? const NoGameDataScreen()
|
||||
: GameScreen(wolf3d: wolf3d);
|
||||
return engine.availableGames.isEmpty
|
||||
? NoGameDataScreen(
|
||||
configuredDataDirectory: engine.configuredDataDirectory,
|
||||
)
|
||||
: GameScreen(wolf3d: engine);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import 'package:wolf_3d_dart/wolf_3d_data.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||
import 'package:wolf_3d_flutter/audio/wolf3d_platform_audio.dart';
|
||||
import 'package:wolf_3d_flutter/managers/desktop_windowing_support.dart'
|
||||
as desktop_windowing_support;
|
||||
import 'package:wolf_3d_flutter/managers/game_data_directory_persistence.dart';
|
||||
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
|
||||
|
||||
export 'package:wolf_3d_dart/wolf_3d_audio.dart' show DebugMusicPlayer;
|
||||
|
||||
export 'audio/wolf3d_platform_audio.dart' show Wolf3dPlatformAudio;
|
||||
export 'managers/desktop_windowing_support.dart' show supportsDesktopWindowing;
|
||||
export 'managers/game_app_lifecycle_manager.dart' show GameAppLifecycleManager;
|
||||
export 'managers/game_data_directory_persistence.dart'
|
||||
show DefaultGameDataDirectoryPersistence;
|
||||
export 'managers/game_display_manager.dart' show GameDisplayManager;
|
||||
export 'managers/game_persistence_manager.dart' show GamePersistenceManager;
|
||||
export 'managers/game_renderer_mode_manager.dart'
|
||||
@@ -44,12 +48,26 @@ export 'widgets/wolf_menu_shell.dart' show WolfMenuShell;
|
||||
class Wolf3dFlutterEngine extends Wolf3dEngine {
|
||||
/// Creates an empty facade that must be initialized with [init].
|
||||
Wolf3dFlutterEngine({
|
||||
bool debug = false,
|
||||
EngineAudio? audioBackend,
|
||||
Wolf3dFlutterInput? inputBackend,
|
||||
}) : super(
|
||||
DefaultGameDataDirectoryPersistence? dataDirectoryPersistence,
|
||||
}) : dataDirectoryPersistence =
|
||||
dataDirectoryPersistence ?? DefaultGameDataDirectoryPersistence(),
|
||||
super(
|
||||
audio: audioBackend ?? Wolf3dPlatformAudio(),
|
||||
input: inputBackend ?? Wolf3dFlutterInput(),
|
||||
);
|
||||
) {
|
||||
if (debug) {
|
||||
enableDebug();
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists and restores the preferred external game-data directory.
|
||||
final DefaultGameDataDirectoryPersistence dataDirectoryPersistence;
|
||||
|
||||
/// Last configured/loaded external game-data directory path.
|
||||
String? configuredDataDirectory;
|
||||
|
||||
/// Shared Flutter input adapter reused by gameplay screens.
|
||||
@override
|
||||
@@ -63,18 +81,24 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
||||
}
|
||||
|
||||
/// Initializes the engine by loading available game data.
|
||||
///
|
||||
/// Set [debug] to `true` to explicitly enable host-level debug affordances.
|
||||
Future<Wolf3dFlutterEngine> init({
|
||||
String? directory,
|
||||
bool debug = false,
|
||||
}) async {
|
||||
if (debug) {
|
||||
enableDebug();
|
||||
}
|
||||
await desktop_windowing_support.ensureDesktopWindowingInitialized();
|
||||
await audio.init();
|
||||
availableGames.clear();
|
||||
|
||||
final String? requestedDirectory = directory?.trim();
|
||||
final String? resolvedDirectory =
|
||||
requestedDirectory != null && requestedDirectory.isNotEmpty
|
||||
? requestedDirectory
|
||||
: await dataDirectoryPersistence.loadDataDirectory();
|
||||
configuredDataDirectory = resolvedDirectory;
|
||||
|
||||
if (requestedDirectory != null && requestedDirectory.isNotEmpty) {
|
||||
await dataDirectoryPersistence.saveDataDirectory(requestedDirectory);
|
||||
}
|
||||
|
||||
// Bundled assets let the GUI work out of the box on supported platforms.
|
||||
final versionsToTry = [
|
||||
(version: GameVersion.retail, path: 'retail'),
|
||||
@@ -106,10 +130,10 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
||||
|
||||
// On non-web platforms, also scan the local filesystem for user-supplied
|
||||
// data folders so the host can pick up extra versions automatically.
|
||||
if (!kIsWeb) {
|
||||
if (!kIsWeb && resolvedDirectory != null) {
|
||||
try {
|
||||
final externalGames = await WolfensteinLoader.discover(
|
||||
directoryPath: directory,
|
||||
directoryPath: resolvedDirectory,
|
||||
recursive: true,
|
||||
);
|
||||
for (var entry in externalGames.entries) {
|
||||
@@ -135,9 +159,3 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for the previous Flutter host facade name.
|
||||
typedef Wolf3dFlutter = Wolf3dFlutterEngine;
|
||||
|
||||
/// Backward-compatible alias for the legacy Flutter host facade name.
|
||||
typedef Wolf3d = Wolf3dFlutterEngine;
|
||||
|
||||
Reference in New Issue
Block a user