diff --git a/assets/translations/da.json b/assets/translations/da.json index 85392c3..677a649 100644 --- a/assets/translations/da.json +++ b/assets/translations/da.json @@ -1,8 +1,8 @@ { "game": { "title": "SHITMAN", - "subtitle": "Lorteprank Mesteren", - "description": "Snig rundt og efterlad brændende lorteposer\nudea at blive opdaget!" + "subtitle": "Hitman, but shit", + "description": "Snig rundt og efterlad brændende lorteposer\ndiskret og stinkende" }, "menu": { "start_mission": "START MISSION", @@ -40,7 +40,15 @@ "show_vision_cones": "Vis Synsfelt", "show_detection_radius": "Vis Detektionsområder", "vision_cones_help": "Viser synsfelt for fjender og spiller", - "detection_help": "Viser detektionsområder omkring sikkerhedsfunktioner" + "detection_help": "Viser detektionsområder omkring sikkerhedsfunktioner", + "touch_controls": "Berøringskontroller", + "touch_controls_help": "Aktivér virtuel joystick og knapper på skærmen", + "language_title": "Sprog", + "language": { + "english": "Engelsk", + "danish": "Dansk", + "german": "Tysk" + } }, "messages": { "placing_poop": "Placerer lortepose på position", diff --git a/assets/translations/de.json b/assets/translations/de.json index 1c8def9..77b1ce2 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -1,8 +1,8 @@ { "game": { "title": "SHITMAN", - "subtitle": "Der Kackhaufen-Streich-Meister", - "description": "Schleich herum und hinterlasse brennende Kacktüten\nohne erwischt zu werden!" + "subtitle": "Hitman, but shit", + "description": "Schleich herum und hinterlasse brennende Kacktüten\nheimlich und stinkend" }, "menu": { "start_mission": "MISSION STARTEN", @@ -40,7 +40,15 @@ "show_vision_cones": "Sichtfelder Anzeigen", "show_detection_radius": "Erkennungsbereiche Anzeigen", "vision_cones_help": "Zeigt Sichtfelder für Feinde und Spieler", - "detection_help": "Zeigt Erkennungsbereiche um Sicherheitsmerkmale" + "detection_help": "Zeigt Erkennungsbereiche um Sicherheitsmerkmale", + "touch_controls": "Touch-Steuerung", + "touch_controls_help": "Aktiviere virtuellen Joystick und Tasten auf dem Bildschirm", + "language_title": "Sprache", + "language": { + "english": "Englisch", + "danish": "Dänisch", + "german": "Deutsch" + } }, "messages": { "placing_poop": "Kacktüte wird platziert an Position", @@ -54,4 +62,4 @@ "poop_burning": "Kacktüte brennt jetzt!", "poop_extinguished": "Kacktüte ist ausgebrannt" } -} \ No newline at end of file +} diff --git a/assets/translations/en.json b/assets/translations/en.json index b4a233a..17539af 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1,8 +1,8 @@ { "game": { "title": "SHITMAN", - "subtitle": "The Poop Prank Master", - "description": "Sneak around and leave flaming poop bags\nwithout getting caught!" + "subtitle": "Hitman, but shit", + "description": "Sneak around and leave flaming poop bags\nstealthy and stinky." }, "menu": { "start_mission": "START MISSION", @@ -40,7 +40,15 @@ "show_vision_cones": "Show Vision Cones", "show_detection_radius": "Show Detection Areas", "vision_cones_help": "Shows field of view for enemies and player", - "detection_help": "Shows detection areas around security features" + "detection_help": "Shows detection areas around security features", + "touch_controls": "Touch Controls", + "touch_controls_help": "Enable on-screen virtual joystick and buttons", + "language_title": "Language", + "language": { + "english": "English", + "danish": "Dansk", + "german": "Deutsch" + } }, "messages": { "placing_poop": "Placing poop bag at position", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6c8d521..9907a1a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -22,6 +22,12 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + CFBundleLocalizations + + en + da + de + LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/lib/game/components/player.dart b/lib/game/components/player.dart index c7bc2de..3f5f176 100644 --- a/lib/game/components/player.dart +++ b/lib/game/components/player.dart @@ -1,12 +1,12 @@ import 'package:flame/components.dart'; import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; -import 'package:shitman/game/shitman_game.dart'; import 'package:shitman/game/components/poop_bag.dart'; import 'package:shitman/game/components/house_components.dart'; import 'package:shitman/game/components/vision_cone.dart'; import 'package:shitman/game/components/base.dart'; import 'package:shitman/game/levels/operation_shitstorm.dart'; +import 'package:shitman/game/input_manager.dart'; import 'package:shitman/settings/app_settings.dart'; import 'dart:math'; @@ -22,12 +22,15 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { PoopBag? placedPoopBag; VisionCone? playerVisionCone; double lastMovementDirection = 0.0; - + // Mission state bool isEscaping = false; double escapeTimeLimit = 30.0; // 30 seconds to escape double escapeTimeRemaining = 0.0; + // Input manager for handling both keyboard and touch input + final InputManager inputManager = InputManager(); + @override Future onLoad() async { await super.onLoad(); @@ -55,34 +58,22 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { // Don't handle input if caught if (isCaught) { velocity = Vector2.zero(); + inputManager.reset(); return; } - velocity = Vector2.zero(); + // Update input manager with keyboard input + inputManager.setKeyboardMovement(keysPressed); - // Movement controls - if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || - keysPressed.contains(LogicalKeyboardKey.keyW)) { - velocity.y -= speed; - } - if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || - keysPressed.contains(LogicalKeyboardKey.keyS)) { - velocity.y += speed; - } - if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || - keysPressed.contains(LogicalKeyboardKey.keyA)) { - velocity.x -= speed; - } - if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || - keysPressed.contains(LogicalKeyboardKey.keyD)) { - velocity.x += speed; - } + // Apply movement from input manager + final movement = inputManager.movementInput; + velocity = movement * speed; } void handleAction(LogicalKeyboardKey key) { // Don't handle actions if caught if (isCaught) return; - + // Action controls if (key == LogicalKeyboardKey.space) { placePoop(); @@ -92,6 +83,27 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { } } + /// Handle touch input from virtual controls + void handleTouchMovement(Vector2 input) { + if (isCaught) return; + inputManager.setTouchMovement(input); + + // Apply movement from input manager + final movement = inputManager.movementInput; + velocity = movement * speed; + } + + /// Handle touch actions + void handleTouchPoopBag() { + if (isCaught) return; + placePoop(); + } + + void handleTouchDoorbell() { + if (isCaught) return; + ringDoorbell(); + } + void updateStealthLevel(double dt) { // Simple stealth calculation - can be enhanced later // For now, player is more hidden when moving slowly or not at all @@ -125,7 +137,8 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { appLog.fine('Attempting to ring doorbell'); // Check if near any target house door - final currentLevel = game.world.children.whereType().firstOrNull; + final currentLevel = + game.world.children.whereType().firstOrNull; if (currentLevel != null && currentLevel.isPlayerNearTarget(position)) { if (placedPoopBag != null) { // Light the poop bag on fire @@ -146,25 +159,27 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { appLog.info('Escape sequence started! Get to the edge of the map!'); isEscaping = true; escapeTimeRemaining = escapeTimeLimit; - + // TODO: Show escape timer UI // TODO: Highlight escape routes on the map } - + /// Check if player is at an escape point (edge of the map) bool isAtEscapePoint() { const double escapeZoneThreshold = 50.0; - + // Check if near any edge of the level - return position.x < escapeZoneThreshold || - position.y < escapeZoneThreshold || - position.x > (5 * 100) - escapeZoneThreshold || // 5x5 grid * 100 cell size - position.y > (5 * 100) - escapeZoneThreshold; + return position.x < escapeZoneThreshold || + position.y < escapeZoneThreshold || + position.x > + (5 * 100) - escapeZoneThreshold || // 5x5 grid * 100 cell size + position.y > (5 * 100) - escapeZoneThreshold; } void checkMissionProgress() { // Check if near target house and has placed poop bag - final currentLevel = game.world.children.whereType().firstOrNull; + final currentLevel = + game.world.children.whereType().firstOrNull; final targetPos = currentLevel?.getTargetPosition(); if (targetPos != null && placedPoopBag != null) { final distance = (position - targetPos).length; @@ -204,14 +219,14 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { // Handle escape sequence if (isEscaping) { escapeTimeRemaining -= dt; - + // Check if time ran out if (escapeTimeRemaining <= 0) { appLog.warning('Time ran out! Mission failed!'); getDetected(); return; } - + // Check if player reached escape point (edge of map) if (isAtEscapePoint()) { appLog.info('Successfully escaped! Mission complete!'); @@ -242,10 +257,11 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { void checkForDetection() { // Check for detection from houses in any active level - final houses = game.world.children - .expand((component) => component.children) - .whereType(); - + final houses = + game.world.children + .expand((component) => component.children) + .whereType(); + for (final house in houses) { if (house.detectsPlayer(position, stealthLevel)) { getDetected(); @@ -257,10 +273,16 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { @override void render(Canvas canvas) { // Draw player as rectangle with color based on stealth level - final playerPaint = Paint() - ..color = isHidden - ? const Color(0xFF00FF00).withValues(alpha: 0.7) // Green when hidden - : const Color(0xFF0000FF).withValues(alpha: 0.9); // Blue when visible + final playerPaint = + Paint() + ..color = + isHidden + ? const Color(0xFF00FF00).withValues( + alpha: 0.7, + ) // Green when hidden + : const Color( + 0xFF0000FF, + ).withValues(alpha: 0.9); // Blue when visible canvas.drawRect(size.toRect(), playerPaint); @@ -268,12 +290,14 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { try { final debugMode = appSettings.getBool('game.debug_mode'); if (debugMode) { - final stealthPaint = Paint() - ..color = Color.lerp( - const Color(0xFFFF0000), - const Color(0xFF00FF00), - stealthLevel, - )!; + final stealthPaint = + Paint() + ..color = + Color.lerp( + const Color(0xFFFF0000), + const Color(0xFF00FF00), + stealthLevel, + )!; canvas.drawRect(Rect.fromLTWH(-5, -10, size.x + 10, 5), stealthPaint); } } catch (e) { @@ -293,13 +317,13 @@ class Player extends InteractiveShit with Ambulatory, AppSettings { lastMovementDirection = 0.0; placedPoopBag = null; position = Vector2(200, 200); // Reset to starting position - + // Reset vision cone if (playerVisionCone != null) { await playerVisionCone!.reset(); playerVisionCone!.updatePosition(size / 2, lastMovementDirection); } - + appLog.fine('Player reset to initial state'); } } diff --git a/lib/game/input_manager.dart b/lib/game/input_manager.dart new file mode 100644 index 0000000..7c2ed28 --- /dev/null +++ b/lib/game/input_manager.dart @@ -0,0 +1,87 @@ +import 'package:flame/components.dart'; +import 'package:flutter/services.dart'; + +/// Manages input from both keyboard and touch controls +class InputManager { + // Movement state + Vector2 _movementInput = Vector2.zero(); + + // Action states + bool _poopBagPressed = false; + bool _doorbellPressed = false; + + /// Set movement input from touch controls (-1 to 1 range) + void setTouchMovement(Vector2 input) { + _movementInput = input; + } + + /// Set movement input from keyboard + void setKeyboardMovement(Set keysPressed) { + Vector2 keyboardInput = Vector2.zero(); + + if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || + keysPressed.contains(LogicalKeyboardKey.keyW)) { + keyboardInput.y -= 1; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || + keysPressed.contains(LogicalKeyboardKey.keyS)) { + keyboardInput.y += 1; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || + keysPressed.contains(LogicalKeyboardKey.keyA)) { + keyboardInput.x -= 1; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || + keysPressed.contains(LogicalKeyboardKey.keyD)) { + keyboardInput.x += 1; + } + + _movementInput = keyboardInput; + } + + /// Trigger poop bag action from touch + void setTouchPoopBag(bool pressed) { + if (pressed && !_poopBagPressed) { + _poopBagPressed = true; + } else if (!pressed) { + _poopBagPressed = false; + } + } + + /// Trigger doorbell action from touch + void setTouchDoorbell(bool pressed) { + if (pressed && !_doorbellPressed) { + _doorbellPressed = true; + } else if (!pressed) { + _doorbellPressed = false; + } + } + + /// Check if poop bag action was triggered this frame + bool consumePoopBagAction() { + if (_poopBagPressed) { + _poopBagPressed = false; + return true; + } + return false; + } + + /// Check if doorbell action was triggered this frame + bool consumeDoorbellAction() { + if (_doorbellPressed) { + _doorbellPressed = false; + return true; + } + return false; + } + + /// Get current movement input + Vector2 get movementInput => _movementInput; + + /// Reset all input states + void reset() { + _movementInput = Vector2.zero(); + _poopBagPressed = false; + _doorbellPressed = false; + } +} \ No newline at end of file diff --git a/lib/game/shitman_game.dart b/lib/game/shitman_game.dart index c3d743e..c0c226f 100644 --- a/lib/game/shitman_game.dart +++ b/lib/game/shitman_game.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; import 'package:shitman/game/components/player.dart'; import 'package:shitman/game/levels/operation_shitstorm.dart'; import 'package:shitman/game/shitman_world.dart'; +import 'package:shitman/ui/touch_controls.dart'; import 'package:shitman/settings/app_settings.dart'; /// Shitman Game @@ -49,9 +50,33 @@ class ShitmanGame extends FlameGame } } + void _initializeTouchControls() { + try { + final touchControlsEnabled = appSettings.getBool('ui.touch_enabled'); + if (touchControlsEnabled) { + overlays.add('TouchControls'); + } + } catch (e) { + // If setting doesn't exist, auto-detect based on platform + if (TouchDetection.isTouchDevice) { + // Enable touch controls by default on touch devices + try { + appSettings.setBool('ui.touch_enabled', true); + overlays.add('TouchControls'); + } catch (e) { + // Settings not ready, just add overlay + overlays.add('TouchControls'); + } + } + } + } + Future startGame() async { gameState = GameState.playing; await initializeLevel(); + + // Initialize touch controls when game starts + _initializeTouchControls(); } Future startInfiniteMode() async { @@ -65,14 +90,19 @@ class ShitmanGame extends FlameGame gameState = GameState.mainMenu; world.removeAll(world.children); missionScore = 0; + + // Remove touch controls when stopping game + overlays.remove('TouchControls'); } void pauseGame() { gameState = GameState.paused; + // Keep touch controls visible during pause for consistency } void resumeGame() { gameState = GameState.playing; + // Touch controls remain visible } Future initializeLevel() async { @@ -81,7 +111,7 @@ class ShitmanGame extends FlameGame // Create the Operation Shitstorm level currentLevel = OperationShitstorm(); - + // Add and wait for the level to load await world.add(currentLevel); @@ -120,8 +150,8 @@ class ShitmanGame extends FlameGame Set keysPressed, ) { super.onKeyEvent(event, keysPressed); - if (gameState != GameState.playing && - gameState != GameState.missionComplete && + if (gameState != GameState.playing && + gameState != GameState.missionComplete && gameState != GameState.gameOver) { return KeyEventResult.ignored; } @@ -147,6 +177,42 @@ class ShitmanGame extends FlameGame return KeyEventResult.handled; } + /// Handle touch movement from virtual joystick + void handleTouchMovement(Vector2 input) { + if (gameState == GameState.playing) { + player.handleTouchMovement(input); + } + } + + /// Handle poop bag action from touch + void handleTouchPoopBag() { + if (gameState == GameState.playing) { + player.handleTouchPoopBag(); + } + } + + /// Handle doorbell action from touch + void handleTouchDoorbell() { + if (gameState == GameState.playing) { + player.handleTouchDoorbell(); + } + } + + /// Toggle touch controls on/off + void toggleTouchControls(bool enabled) { + try { + appSettings.setBool('ui.touch_enabled', enabled); + if (enabled && (gameState == GameState.playing || gameState == GameState.paused || gameState == GameState.missionComplete || gameState == GameState.gameOver)) { + // Only show touch controls during actual gameplay states + overlays.add('TouchControls'); + } else { + overlays.remove('TouchControls'); + } + } catch (e) { + // Handle settings error + } + } + @override void update(double dt) { super.update(dt); diff --git a/lib/main.dart b/lib/main.dart index 9573d2f..7428e45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ import 'package:nes_ui/nes_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:shitman/game/shitman_game.dart'; import 'package:shitman/ui/in_game_ui.dart'; +import 'package:shitman/ui/touch_controls_overlay.dart'; +import 'package:shitman/settings/app_settings.dart'; class AnyInputScrollBehavior extends MaterialScrollBehavior { // Override behavior methods and getters like dragDevices @@ -19,18 +21,30 @@ class AnyInputScrollBehavior extends MaterialScrollBehavior { }; } -void main() { +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + LicenseRegistry.addLicense(() async* { final license = await rootBundle.loadString('google_fonts/OFL.txt'); yield LicenseEntryWithLineBreaks(['google_fonts'], license); }); + // Load saved language preference + final appSettings = AppSettings(); + await appSettings.initSettings(); + String savedLanguage = 'en'; + try { + savedLanguage = appSettings.appSettings.getString('ui.language'); + } catch (e) { + // Use default if not found + } + runApp( EasyLocalization( supportedLocales: [Locale('en'), Locale('da'), Locale('de')], path: 'assets/translations', fallbackLocale: Locale('en'), - startLocale: Locale('en'), + startLocale: Locale(savedLanguage), useOnlyLangCode: true, useFallbackTranslations: true, child: Shitman(), @@ -38,10 +52,29 @@ void main() { ); } -class Shitman extends StatelessWidget { +class Shitman extends StatefulWidget { const Shitman({super.key}); - // This widget is the root of your application. + @override + State createState() => _ShitmanState(); +} + +class _ShitmanState extends State { + late ThemeData _currentTheme; + + @override + void initState() { + super.initState(); + _currentTheme = flutterNesTheme(brightness: Brightness.dark); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Recreate theme when locale changes to avoid lerp issues + _currentTheme = flutterNesTheme(brightness: Brightness.dark); + } + @override Widget build(BuildContext context) { return MaterialApp( @@ -49,8 +82,9 @@ class Shitman extends StatelessWidget { supportedLocales: context.supportedLocales, scrollBehavior: AnyInputScrollBehavior(), locale: context.locale, - theme: flutterNesTheme(brightness: Brightness.dark), + theme: _currentTheme, themeMode: ThemeMode.dark, + themeAnimationDuration: Duration.zero, // Disable theme animation home: GameWidget( game: ShitmanGame(), initialActiveOverlays: const ['MainMenu'], @@ -63,6 +97,8 @@ class Shitman extends StatelessWidget { SettingsUI(game as ShitmanGame), PauseMenuUI.overlayID: (context, game) => PauseMenuUI(game as ShitmanGame), + TouchControlsOverlayWidget.overlayID: (context, game) => + TouchControlsOverlayWidget(game: game as ShitmanGame), }, ), ); diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index 53428ff..f56036b 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -1,6 +1,7 @@ import 'game.dart'; import 'colors.dart'; import 'settings_manager.dart'; +import 'ui.dart'; mixin class AppSettings { final Settings appSettings = Settings(); @@ -11,6 +12,7 @@ mixin class AppSettings { _isInitialized = true; appSettings.register(gameSettings); appSettings.register(colorSettings); + appSettings.register(uiSettings); await appSettings.init(); } } diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 043096a..c24eb39 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -42,4 +42,3 @@ export 'exceptions.dart'; export 'setting.dart'; export 'settings_group.dart'; export 'settings_store.dart'; -export 'game.dart'; diff --git a/lib/settings/ui.dart b/lib/settings/ui.dart new file mode 100644 index 0000000..65210dc --- /dev/null +++ b/lib/settings/ui.dart @@ -0,0 +1,21 @@ +import 'setting.dart'; +import 'settings_group.dart'; + +/// Color settings, themes, etc. +final uiSettings = SettingsGroup( + key: 'ui', + items: [ + /// Base color for game UI + BoolSetting( + key: 'touch_enabled', + defaultValue: false, + userConfigurable: true, + ), + /// Selected language code + StringSetting( + key: 'language', + defaultValue: 'en', + userConfigurable: true, + ), + ], +); diff --git a/lib/ui/in_game_ui.dart b/lib/ui/in_game_ui.dart index 818b75e..e831997 100644 --- a/lib/ui/in_game_ui.dart +++ b/lib/ui/in_game_ui.dart @@ -33,7 +33,10 @@ class InGameUI extends StatelessWidget with AppSettings { children: [ Icon(Icons.visibility_off, size: 16), SizedBox(width: 8), - Text('gameplay.hidden'.tr(), style: TextStyle(color: Colors.green)), + Text( + 'gameplay.hidden'.tr(), + style: TextStyle(color: Colors.green), + ), ], ), ), @@ -43,7 +46,10 @@ class InGameUI extends StatelessWidget with AppSettings { backgroundColor: Colors.black87, child: Padding( padding: const EdgeInsets.all(8.0), - child: Text('gameplay.find_target'.tr(), style: TextStyle(color: Colors.white)), + child: Text( + 'gameplay.find_target'.tr(), + style: TextStyle(color: Colors.white), + ), ), ), // Pause button @@ -54,7 +60,7 @@ class InGameUI extends StatelessWidget with AppSettings { ], ), ), - + // Bottom controls hint Positioned( bottom: 20, @@ -67,9 +73,18 @@ class InGameUI extends StatelessWidget with AppSettings { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('controls.move'.tr(), style: TextStyle(color: Colors.white)), - Text('controls.place_bag'.tr(), style: TextStyle(color: Colors.white)), - Text('controls.ring_bell'.tr(), style: TextStyle(color: Colors.white)), + Text( + 'controls.move'.tr(), + style: TextStyle(color: Colors.white), + ), + Text( + 'controls.place_bag'.tr(), + style: TextStyle(color: Colors.white), + ), + Text( + 'controls.ring_bell'.tr(), + style: TextStyle(color: Colors.white), + ), ], ), ), @@ -110,7 +125,7 @@ class MainMenuUI extends StatelessWidget with AppSettings { style: TextStyle(color: Colors.white70, fontSize: 16), ), SizedBox(height: 40), - + // Menu buttons Column( children: [ @@ -140,7 +155,7 @@ class MainMenuUI extends StatelessWidget with AppSettings { ), ], ), - + SizedBox(height: 40), Text( 'game.description'.tr(), @@ -168,6 +183,7 @@ class _SettingsUIState extends State with AppSettings { bool showVisionCones = false; bool showDetectionRadius = false; bool debugMode = false; + bool touchControlsEnabled = false; @override void initState() { @@ -177,14 +193,22 @@ class _SettingsUIState extends State with AppSettings { void _loadSettings() { try { - showVisionCones = widget.game.appSettings.getBool('game.show_vision_cones'); - showDetectionRadius = widget.game.appSettings.getBool('game.show_detection_radius'); + showVisionCones = widget.game.appSettings.getBool( + 'game.show_vision_cones', + ); + showDetectionRadius = widget.game.appSettings.getBool( + 'game.show_detection_radius', + ); debugMode = widget.game.appSettings.getBool('game.debug_mode'); + touchControlsEnabled = widget.game.appSettings.getBool( + 'ui.touch_enabled', + ); } catch (e) { // Settings not ready, use defaults showVisionCones = false; showDetectionRadius = false; debugMode = false; + touchControlsEnabled = false; } } @@ -206,9 +230,9 @@ class _SettingsUIState extends State with AppSettings { children: [ Text( 'menu.settings'.tr(), - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - ), + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(color: Colors.white), ), IconButton( icon: Icon(Icons.close, color: Colors.white), @@ -220,43 +244,58 @@ class _SettingsUIState extends State with AppSettings { ], ), SizedBox(height: 24), - + // Language selector - Text('Language / Sprog / Sprache:', style: TextStyle(color: Colors.white)), + Row( + children: [ + Text( + 'settings.language_title'.tr(), + style: TextStyle(color: Colors.white), + ), + SizedBox(width: 8), + Icon(Icons.language, color: Colors.white), + ], + ), SizedBox(height: 8), NesDropdownMenu( initialValue: context.locale.languageCode, - entries: const [ + entries: [ NesDropdownMenuEntry( value: 'en', - label: 'English', + label: 'settings.language.english'.tr(), ), NesDropdownMenuEntry( value: 'da', - label: 'Dansk', + label: 'settings.language.danish'.tr(), ), NesDropdownMenuEntry( value: 'de', - label: 'Deutsch', + label: 'settings.language.german'.tr(), ), ], - onChanged: (String? value) { + onChanged: (String? value) async { if (value != null) { context.setLocale(Locale(value)); - setState(() {}); + // Save language preference + await widget.game.appSettings.setString('ui.language', value); + setState(() {}); // Simple setState to rebuild UI } }, ), - + SizedBox(height: 16), - + // Gameplay Settings Section Text( 'settings.gameplay'.tr(), - style: TextStyle(color: Colors.orange, fontSize: 16, fontWeight: FontWeight.bold), + style: TextStyle( + color: Colors.orange, + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), SizedBox(height: 12), - + // Vision Cones toggle Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -265,8 +304,17 @@ class _SettingsUIState extends State with AppSettings { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('settings.show_vision_cones'.tr(), style: TextStyle(color: Colors.white)), - Text('settings.vision_cones_help'.tr(), style: TextStyle(color: Colors.white60, fontSize: 11)), + Text( + 'settings.show_vision_cones'.tr(), + style: TextStyle(color: Colors.white), + ), + Text( + 'settings.vision_cones_help'.tr(), + style: TextStyle( + color: Colors.white60, + fontSize: 11, + ), + ), ], ), ), @@ -276,14 +324,17 @@ class _SettingsUIState extends State with AppSettings { setState(() { showVisionCones = value; }); - await widget.game.appSettings.setBool('game.show_vision_cones', value); + await widget.game.appSettings.setBool( + 'game.show_vision_cones', + value, + ); }, ), ], ), SizedBox(height: 8), - - // Detection Radius toggle + + // Detection Radius toggle Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -291,8 +342,17 @@ class _SettingsUIState extends State with AppSettings { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('settings.show_detection_radius'.tr(), style: TextStyle(color: Colors.white)), - Text('settings.detection_help'.tr(), style: TextStyle(color: Colors.white60, fontSize: 11)), + Text( + 'settings.show_detection_radius'.tr(), + style: TextStyle(color: Colors.white), + ), + Text( + 'settings.detection_help'.tr(), + style: TextStyle( + color: Colors.white60, + fontSize: 11, + ), + ), ], ), ), @@ -302,39 +362,92 @@ class _SettingsUIState extends State with AppSettings { setState(() { showDetectionRadius = value; }); - await widget.game.appSettings.setBool('game.show_detection_radius', value); + await widget.game.appSettings.setBool( + 'game.show_detection_radius', + value, + ); }, ), ], ), - + SizedBox(height: 20), - - // Accessibility Section + + // Accessibility Section Text( 'settings.accessibility'.tr(), - style: TextStyle(color: Colors.orange, fontSize: 16, fontWeight: FontWeight.bold), + style: TextStyle( + color: Colors.orange, + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), SizedBox(height: 12), - + // Debug mode toggle Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('ui.debug_mode'.tr(), style: TextStyle(color: Colors.white)), + Text( + 'ui.debug_mode'.tr(), + style: TextStyle(color: Colors.white), + ), NesCheckBox( value: debugMode, onChange: (value) async { setState(() { debugMode = value; }); - await widget.game.appSettings.setBool('game.debug_mode', value); + await widget.game.appSettings.setBool( + 'game.debug_mode', + value, + ); widget.game.debugMode = value; }, ), ], ), - + + SizedBox(height: 8), + + // Touch controls toggle + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'settings.touch_controls'.tr(), + style: TextStyle(color: Colors.white), + ), + Text( + 'settings.touch_controls_help'.tr(), + style: TextStyle( + color: Colors.white60, + fontSize: 11, + ), + ), + ], + ), + ), + NesCheckBox( + value: touchControlsEnabled, + onChange: (value) async { + setState(() { + touchControlsEnabled = value; + }); + await widget.game.appSettings.setBool( + 'ui.touch_enabled', + value, + ); + widget.game.toggleTouchControls(value); + }, + ), + ], + ), + SizedBox(height: 40), Center( child: NesButton( @@ -375,12 +488,12 @@ class PauseMenuUI extends StatelessWidget { children: [ Text( 'ui.paused'.tr(), - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - ), + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(color: Colors.white), ), SizedBox(height: 24), - + NesButton( type: NesButtonType.primary, onPressed: () => game.overlays.remove(PauseMenuUI.overlayID), diff --git a/lib/ui/touch_controls.dart b/lib/ui/touch_controls.dart new file mode 100644 index 0000000..9287622 --- /dev/null +++ b/lib/ui/touch_controls.dart @@ -0,0 +1,265 @@ +import 'dart:math'; +import 'dart:io' show Platform; +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; + +/// A joystick thumb that shows movement direction +class JoystickThumb extends StatefulWidget { + JoystickThumb({ + super.key, + this.size = 20, + Vector2? offset, + this.color = Colors.white, + }) : offset = offset ?? Vector2.zero(); + + final double size; + final Vector2 offset; + final Color color; + + @override + State createState() => _JoystickThumbState(); +} + +class _JoystickThumbState extends State { + @override + Widget build(BuildContext context) { + return Positioned( + left: widget.offset.x, + top: widget.offset.y, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + border: Border.all(color: Colors.black26, width: 1), + ), + ), + ); + } + + void setOffset(Vector2 offset) { + setState(() { + widget.offset.setFrom(offset); + }); + } +} + +/// Virtual joystick for movement +class VirtualJoystick extends StatelessWidget { + VirtualJoystick({ + super.key, + required this.onMove, + this.size = 80, + }); + + final Function(Vector2) onMove; + final double size; + final GlobalKey<_JoystickThumbState> _key = GlobalKey(); + + Vector2 get _centerPosition => Vector2(size / 2 - (size / 6), size / 2 - (size / 6)); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onPanUpdate: (details) { + final offset = details.localPosition; + final center = Offset(size / 2, size / 2); + final distance = offset - center; + final angle = distance.direction; + final magnitude = distance.distance; + + if (magnitude <= size / 2) { + final position = Vector2( + magnitude * cos(angle), + magnitude * sin(angle), // Fixed Y-axis + ); + // Calculate thumb position relative to center + final thumbPos = Vector2( + size / 2 + distance.dx - (size / 6), // Center thumb in joystick + size / 2 + distance.dy - (size / 6), + ); + onMove(position * 2 / size); // Normalize to -1 to 1 + _key.currentState?.setOffset(thumbPos); + } else { + final position = Vector2( + (size / 2) * cos(angle), + (size / 2) * sin(angle), // Fixed Y-axis + ); + // Calculate thumb position at edge + final thumbPos = Vector2( + size / 2 + (size / 2) * cos(angle) - (size / 6), + size / 2 + (size / 2) * sin(angle) - (size / 6), + ); + onMove(position * 2 / size); // Normalize to -1 to 1 + _key.currentState?.setOffset(thumbPos); + } + }, + onPanCancel: () => { + onMove(Vector2.zero()), + _key.currentState?.setOffset(_centerPosition), + }, + onPanEnd: (_) => { + onMove(Vector2.zero()), + _key.currentState?.setOffset(_centerPosition), + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.3), + border: Border.all(color: Colors.white.withValues(alpha: 0.5), width: 2), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + JoystickThumb( + key: _key, + size: size / 3, + offset: _centerPosition, // Start at center + color: Colors.white.withValues(alpha: 0.8), + ), + ], + ), + ), + ); + } +} + +/// Touch button for actions +class TouchButton extends StatefulWidget { + const TouchButton({ + super.key, + required this.onPressed, + this.onReleased, + required this.icon, + this.size = 60, + this.color = Colors.white, + }); + + final VoidCallback onPressed; + final VoidCallback? onReleased; + final IconData icon; + final double size; + final Color color; + + @override + State createState() => _TouchButtonState(); +} + +class _TouchButtonState extends State { + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) { + setState(() => _isPressed = true); + widget.onPressed(); + HapticFeedback.lightImpact(); + }, + onTapUp: (_) { + setState(() => _isPressed = false); + widget.onReleased?.call(); + }, + onTapCancel: () { + setState(() => _isPressed = false); + widget.onReleased?.call(); + }, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _isPressed + ? Colors.white.withValues(alpha: 0.8) + : Colors.black.withValues(alpha: 0.3), + border: Border.all( + color: Colors.white.withValues(alpha: 0.5), + width: 2, + ), + ), + child: Icon( + widget.icon, + size: widget.size * 0.4, + color: _isPressed ? Colors.black : Colors.white, + ), + ), + ); + } +} + +/// Main touch controls overlay +class TouchControlsOverlay extends StatelessWidget { + const TouchControlsOverlay({ + super.key, + required this.onMove, + required this.onPoopBag, + required this.onDoorbell, + }); + + final Function(Vector2) onMove; + final VoidCallback onPoopBag; + final VoidCallback onDoorbell; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Movement joystick (bottom left) + Positioned( + left: 20, + bottom: 20, + child: VirtualJoystick( + onMove: onMove, + size: 100, + ), + ), + + // Action buttons (bottom right) + Positioned( + right: 20, + bottom: 80, + child: TouchButton( + onPressed: onPoopBag, + icon: Icons.eco, // Poop bag icon + size: 70, + ), + ), + + Positioned( + right: 20, + bottom: 20, + child: TouchButton( + onPressed: onDoorbell, + icon: Icons.notifications, + size: 70, + ), + ), + + // Optional: escape timer display when escaping + // TODO: Add escape timer UI here + ], + ); + } +} + +/// Utility to detect if device supports touch +class TouchDetection { + static bool get isTouchDevice { + if (kIsWeb) { + // On web, assume no touch controls by default + return false; + } + + try { + return Platform.isAndroid || Platform.isIOS; + } catch (e) { + // Fallback: assume no touch if platform detection fails + return false; + } + } +} \ No newline at end of file diff --git a/lib/ui/touch_controls_overlay.dart b/lib/ui/touch_controls_overlay.dart new file mode 100644 index 0000000..5e8739b --- /dev/null +++ b/lib/ui/touch_controls_overlay.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:shitman/game/shitman_game.dart'; +import 'package:shitman/ui/touch_controls.dart'; + +/// Touch controls overlay widget +class TouchControlsOverlayWidget extends StatelessWidget { + static const String overlayID = 'TouchControls'; + + const TouchControlsOverlayWidget({ + super.key, + required this.game, + }); + + final ShitmanGame game; + + @override + Widget build(BuildContext context) { + return TouchControlsOverlay( + onMove: (input) => game.handleTouchMovement(input), + onPoopBag: () => game.handleTouchPoopBag(), + onDoorbell: () => game.handleTouchDoorbell(), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 5273740..638083d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + easy_shared_preferences: + dependency: "direct main" + description: + name: easy_shared_preferences + sha256: "47baab4cc8f48b85fa1a5c3613932b47c52449288ba16acdf52f01350aeb6555" + url: "https://pub.dev" + source: hosted + version: "1.0.1" equatable: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 712bd3d..ec80d43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: shared_preferences: ^2.2.2 meta: ^1.16.0 logging: ^1.3.0 + easy_shared_preferences: ^1.0.1 dev_dependencies: flutter_test: