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: