Added touch controls.
All checks were successful
/ build-web (push) Successful in 3m24s

This commit is contained in:
zeyus 2025-08-04 11:50:47 +02:00
parent 67aaa9589f
commit f7a08a5099
Signed by: zeyus
GPG key ID: A836639BA719C614
16 changed files with 788 additions and 112 deletions

View file

@ -1,8 +1,8 @@
{ {
"game": { "game": {
"title": "SHITMAN", "title": "SHITMAN",
"subtitle": "Lorteprank Mesteren", "subtitle": "Hitman, but shit",
"description": "Snig rundt og efterlad brændende lorteposer\nudea at blive opdaget!" "description": "Snig rundt og efterlad brændende lorteposer\ndiskret og stinkende"
}, },
"menu": { "menu": {
"start_mission": "START MISSION", "start_mission": "START MISSION",
@ -40,7 +40,15 @@
"show_vision_cones": "Vis Synsfelt", "show_vision_cones": "Vis Synsfelt",
"show_detection_radius": "Vis Detektionsområder", "show_detection_radius": "Vis Detektionsområder",
"vision_cones_help": "Viser synsfelt for fjender og spiller", "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": { "messages": {
"placing_poop": "Placerer lortepose på position", "placing_poop": "Placerer lortepose på position",

View file

@ -1,8 +1,8 @@
{ {
"game": { "game": {
"title": "SHITMAN", "title": "SHITMAN",
"subtitle": "Der Kackhaufen-Streich-Meister", "subtitle": "Hitman, but shit",
"description": "Schleich herum und hinterlasse brennende Kacktüten\nohne erwischt zu werden!" "description": "Schleich herum und hinterlasse brennende Kacktüten\nheimlich und stinkend"
}, },
"menu": { "menu": {
"start_mission": "MISSION STARTEN", "start_mission": "MISSION STARTEN",
@ -40,7 +40,15 @@
"show_vision_cones": "Sichtfelder Anzeigen", "show_vision_cones": "Sichtfelder Anzeigen",
"show_detection_radius": "Erkennungsbereiche Anzeigen", "show_detection_radius": "Erkennungsbereiche Anzeigen",
"vision_cones_help": "Zeigt Sichtfelder für Feinde und Spieler", "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": { "messages": {
"placing_poop": "Kacktüte wird platziert an Position", "placing_poop": "Kacktüte wird platziert an Position",

View file

@ -1,8 +1,8 @@
{ {
"game": { "game": {
"title": "SHITMAN", "title": "SHITMAN",
"subtitle": "The Poop Prank Master", "subtitle": "Hitman, but shit",
"description": "Sneak around and leave flaming poop bags\nwithout getting caught!" "description": "Sneak around and leave flaming poop bags\nstealthy and stinky."
}, },
"menu": { "menu": {
"start_mission": "START MISSION", "start_mission": "START MISSION",
@ -40,7 +40,15 @@
"show_vision_cones": "Show Vision Cones", "show_vision_cones": "Show Vision Cones",
"show_detection_radius": "Show Detection Areas", "show_detection_radius": "Show Detection Areas",
"vision_cones_help": "Shows field of view for enemies and player", "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": { "messages": {
"placing_poop": "Placing poop bag at position", "placing_poop": "Placing poop bag at position",

View file

@ -22,6 +22,12 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>da</string>
<string>de</string>
</array>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View file

@ -1,12 +1,12 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/material.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/poop_bag.dart';
import 'package:shitman/game/components/house_components.dart'; import 'package:shitman/game/components/house_components.dart';
import 'package:shitman/game/components/vision_cone.dart'; import 'package:shitman/game/components/vision_cone.dart';
import 'package:shitman/game/components/base.dart'; import 'package:shitman/game/components/base.dart';
import 'package:shitman/game/levels/operation_shitstorm.dart'; import 'package:shitman/game/levels/operation_shitstorm.dart';
import 'package:shitman/game/input_manager.dart';
import 'package:shitman/settings/app_settings.dart'; import 'package:shitman/settings/app_settings.dart';
import 'dart:math'; import 'dart:math';
@ -28,6 +28,9 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
double escapeTimeLimit = 30.0; // 30 seconds to escape double escapeTimeLimit = 30.0; // 30 seconds to escape
double escapeTimeRemaining = 0.0; double escapeTimeRemaining = 0.0;
// Input manager for handling both keyboard and touch input
final InputManager inputManager = InputManager();
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
@ -55,28 +58,16 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
// Don't handle input if caught // Don't handle input if caught
if (isCaught) { if (isCaught) {
velocity = Vector2.zero(); velocity = Vector2.zero();
inputManager.reset();
return; return;
} }
velocity = Vector2.zero(); // Update input manager with keyboard input
inputManager.setKeyboardMovement(keysPressed);
// Movement controls // Apply movement from input manager
if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || final movement = inputManager.movementInput;
keysPressed.contains(LogicalKeyboardKey.keyW)) { velocity = movement * speed;
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;
}
} }
void handleAction(LogicalKeyboardKey key) { void handleAction(LogicalKeyboardKey key) {
@ -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) { void updateStealthLevel(double dt) {
// Simple stealth calculation - can be enhanced later // Simple stealth calculation - can be enhanced later
// For now, player is more hidden when moving slowly or not at all // 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'); appLog.fine('Attempting to ring doorbell');
// Check if near any target house door // Check if near any target house door
final currentLevel = game.world.children.whereType<OperationShitstorm>().firstOrNull; final currentLevel =
game.world.children.whereType<OperationShitstorm>().firstOrNull;
if (currentLevel != null && currentLevel.isPlayerNearTarget(position)) { if (currentLevel != null && currentLevel.isPlayerNearTarget(position)) {
if (placedPoopBag != null) { if (placedPoopBag != null) {
// Light the poop bag on fire // Light the poop bag on fire
@ -158,13 +171,15 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
// Check if near any edge of the level // Check if near any edge of the level
return position.x < escapeZoneThreshold || return position.x < escapeZoneThreshold ||
position.y < escapeZoneThreshold || position.y < escapeZoneThreshold ||
position.x > (5 * 100) - escapeZoneThreshold || // 5x5 grid * 100 cell size position.x >
(5 * 100) - escapeZoneThreshold || // 5x5 grid * 100 cell size
position.y > (5 * 100) - escapeZoneThreshold; position.y > (5 * 100) - escapeZoneThreshold;
} }
void checkMissionProgress() { void checkMissionProgress() {
// Check if near target house and has placed poop bag // Check if near target house and has placed poop bag
final currentLevel = game.world.children.whereType<OperationShitstorm>().firstOrNull; final currentLevel =
game.world.children.whereType<OperationShitstorm>().firstOrNull;
final targetPos = currentLevel?.getTargetPosition(); final targetPos = currentLevel?.getTargetPosition();
if (targetPos != null && placedPoopBag != null) { if (targetPos != null && placedPoopBag != null) {
final distance = (position - targetPos).length; final distance = (position - targetPos).length;
@ -242,7 +257,8 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
void checkForDetection() { void checkForDetection() {
// Check for detection from houses in any active level // Check for detection from houses in any active level
final houses = game.world.children final houses =
game.world.children
.expand((component) => component.children) .expand((component) => component.children)
.whereType<HouseComponent>(); .whereType<HouseComponent>();
@ -257,10 +273,16 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
@override @override
void render(Canvas canvas) { void render(Canvas canvas) {
// Draw player as rectangle with color based on stealth level // Draw player as rectangle with color based on stealth level
final playerPaint = Paint() final playerPaint =
..color = isHidden Paint()
? const Color(0xFF00FF00).withValues(alpha: 0.7) // Green when hidden ..color =
: const Color(0xFF0000FF).withValues(alpha: 0.9); // Blue when visible 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); canvas.drawRect(size.toRect(), playerPaint);
@ -268,8 +290,10 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
try { try {
final debugMode = appSettings.getBool('game.debug_mode'); final debugMode = appSettings.getBool('game.debug_mode');
if (debugMode) { if (debugMode) {
final stealthPaint = Paint() final stealthPaint =
..color = Color.lerp( Paint()
..color =
Color.lerp(
const Color(0xFFFF0000), const Color(0xFFFF0000),
const Color(0xFF00FF00), const Color(0xFF00FF00),
stealthLevel, stealthLevel,

View file

@ -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<LogicalKeyboardKey> 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;
}
}

View file

@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
import 'package:shitman/game/components/player.dart'; import 'package:shitman/game/components/player.dart';
import 'package:shitman/game/levels/operation_shitstorm.dart'; import 'package:shitman/game/levels/operation_shitstorm.dart';
import 'package:shitman/game/shitman_world.dart'; import 'package:shitman/game/shitman_world.dart';
import 'package:shitman/ui/touch_controls.dart';
import 'package:shitman/settings/app_settings.dart'; import 'package:shitman/settings/app_settings.dart';
/// Shitman Game /// 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<void> startGame() async { Future<void> startGame() async {
gameState = GameState.playing; gameState = GameState.playing;
await initializeLevel(); await initializeLevel();
// Initialize touch controls when game starts
_initializeTouchControls();
} }
Future<void> startInfiniteMode() async { Future<void> startInfiniteMode() async {
@ -65,14 +90,19 @@ class ShitmanGame extends FlameGame
gameState = GameState.mainMenu; gameState = GameState.mainMenu;
world.removeAll(world.children); world.removeAll(world.children);
missionScore = 0; missionScore = 0;
// Remove touch controls when stopping game
overlays.remove('TouchControls');
} }
void pauseGame() { void pauseGame() {
gameState = GameState.paused; gameState = GameState.paused;
// Keep touch controls visible during pause for consistency
} }
void resumeGame() { void resumeGame() {
gameState = GameState.playing; gameState = GameState.playing;
// Touch controls remain visible
} }
Future<void> initializeLevel() async { Future<void> initializeLevel() async {
@ -147,6 +177,42 @@ class ShitmanGame extends FlameGame
return KeyEventResult.handled; 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 @override
void update(double dt) { void update(double dt) {
super.update(dt); super.update(dt);

View file

@ -8,6 +8,8 @@ import 'package:nes_ui/nes_ui.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:shitman/game/shitman_game.dart'; import 'package:shitman/game/shitman_game.dart';
import 'package:shitman/ui/in_game_ui.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 { class AnyInputScrollBehavior extends MaterialScrollBehavior {
// Override behavior methods and getters like dragDevices // 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* { LicenseRegistry.addLicense(() async* {
final license = await rootBundle.loadString('google_fonts/OFL.txt'); final license = await rootBundle.loadString('google_fonts/OFL.txt');
yield LicenseEntryWithLineBreaks(['google_fonts'], license); 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( runApp(
EasyLocalization( EasyLocalization(
supportedLocales: [Locale('en'), Locale('da'), Locale('de')], supportedLocales: [Locale('en'), Locale('da'), Locale('de')],
path: 'assets/translations', path: 'assets/translations',
fallbackLocale: Locale('en'), fallbackLocale: Locale('en'),
startLocale: Locale('en'), startLocale: Locale(savedLanguage),
useOnlyLangCode: true, useOnlyLangCode: true,
useFallbackTranslations: true, useFallbackTranslations: true,
child: Shitman(), child: Shitman(),
@ -38,10 +52,29 @@ void main() {
); );
} }
class Shitman extends StatelessWidget { class Shitman extends StatefulWidget {
const Shitman({super.key}); const Shitman({super.key});
// This widget is the root of your application. @override
State<Shitman> createState() => _ShitmanState();
}
class _ShitmanState extends State<Shitman> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
@ -49,8 +82,9 @@ class Shitman extends StatelessWidget {
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
scrollBehavior: AnyInputScrollBehavior(), scrollBehavior: AnyInputScrollBehavior(),
locale: context.locale, locale: context.locale,
theme: flutterNesTheme(brightness: Brightness.dark), theme: _currentTheme,
themeMode: ThemeMode.dark, themeMode: ThemeMode.dark,
themeAnimationDuration: Duration.zero, // Disable theme animation
home: GameWidget( home: GameWidget(
game: ShitmanGame(), game: ShitmanGame(),
initialActiveOverlays: const ['MainMenu'], initialActiveOverlays: const ['MainMenu'],
@ -63,6 +97,8 @@ class Shitman extends StatelessWidget {
SettingsUI(game as ShitmanGame), SettingsUI(game as ShitmanGame),
PauseMenuUI.overlayID: (context, game) => PauseMenuUI.overlayID: (context, game) =>
PauseMenuUI(game as ShitmanGame), PauseMenuUI(game as ShitmanGame),
TouchControlsOverlayWidget.overlayID: (context, game) =>
TouchControlsOverlayWidget(game: game as ShitmanGame),
}, },
), ),
); );

View file

@ -1,6 +1,7 @@
import 'game.dart'; import 'game.dart';
import 'colors.dart'; import 'colors.dart';
import 'settings_manager.dart'; import 'settings_manager.dart';
import 'ui.dart';
mixin class AppSettings { mixin class AppSettings {
final Settings appSettings = Settings(); final Settings appSettings = Settings();
@ -11,6 +12,7 @@ mixin class AppSettings {
_isInitialized = true; _isInitialized = true;
appSettings.register(gameSettings); appSettings.register(gameSettings);
appSettings.register(colorSettings); appSettings.register(colorSettings);
appSettings.register(uiSettings);
await appSettings.init(); await appSettings.init();
} }
} }

View file

@ -42,4 +42,3 @@ export 'exceptions.dart';
export 'setting.dart'; export 'setting.dart';
export 'settings_group.dart'; export 'settings_group.dart';
export 'settings_store.dart'; export 'settings_store.dart';
export 'game.dart';

21
lib/settings/ui.dart Normal file
View file

@ -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,
),
],
);

View file

@ -33,7 +33,10 @@ class InGameUI extends StatelessWidget with AppSettings {
children: [ children: [
Icon(Icons.visibility_off, size: 16), Icon(Icons.visibility_off, size: 16),
SizedBox(width: 8), 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, backgroundColor: Colors.black87,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), 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 // Pause button
@ -67,9 +73,18 @@ class InGameUI extends StatelessWidget with AppSettings {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('controls.move'.tr(), style: TextStyle(color: Colors.white)), Text(
Text('controls.place_bag'.tr(), style: TextStyle(color: Colors.white)), 'controls.move'.tr(),
Text('controls.ring_bell'.tr(), style: TextStyle(color: Colors.white)), 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),
),
], ],
), ),
), ),
@ -168,6 +183,7 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
bool showVisionCones = false; bool showVisionCones = false;
bool showDetectionRadius = false; bool showDetectionRadius = false;
bool debugMode = false; bool debugMode = false;
bool touchControlsEnabled = false;
@override @override
void initState() { void initState() {
@ -177,14 +193,22 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
void _loadSettings() { void _loadSettings() {
try { try {
showVisionCones = widget.game.appSettings.getBool('game.show_vision_cones'); showVisionCones = widget.game.appSettings.getBool(
showDetectionRadius = widget.game.appSettings.getBool('game.show_detection_radius'); 'game.show_vision_cones',
);
showDetectionRadius = widget.game.appSettings.getBool(
'game.show_detection_radius',
);
debugMode = widget.game.appSettings.getBool('game.debug_mode'); debugMode = widget.game.appSettings.getBool('game.debug_mode');
touchControlsEnabled = widget.game.appSettings.getBool(
'ui.touch_enabled',
);
} catch (e) { } catch (e) {
// Settings not ready, use defaults // Settings not ready, use defaults
showVisionCones = false; showVisionCones = false;
showDetectionRadius = false; showDetectionRadius = false;
debugMode = false; debugMode = false;
touchControlsEnabled = false;
} }
} }
@ -206,9 +230,9 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
children: [ children: [
Text( Text(
'menu.settings'.tr(), 'menu.settings'.tr(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(
color: Colors.white, context,
), ).textTheme.headlineMedium?.copyWith(color: Colors.white),
), ),
IconButton( IconButton(
icon: Icon(Icons.close, color: Colors.white), icon: Icon(Icons.close, color: Colors.white),
@ -222,28 +246,39 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
SizedBox(height: 24), SizedBox(height: 24),
// Language selector // 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), SizedBox(height: 8),
NesDropdownMenu<String>( NesDropdownMenu<String>(
initialValue: context.locale.languageCode, initialValue: context.locale.languageCode,
entries: const [ entries: [
NesDropdownMenuEntry( NesDropdownMenuEntry(
value: 'en', value: 'en',
label: 'English', label: 'settings.language.english'.tr(),
), ),
NesDropdownMenuEntry( NesDropdownMenuEntry(
value: 'da', value: 'da',
label: 'Dansk', label: 'settings.language.danish'.tr(),
), ),
NesDropdownMenuEntry( NesDropdownMenuEntry(
value: 'de', value: 'de',
label: 'Deutsch', label: 'settings.language.german'.tr(),
), ),
], ],
onChanged: (String? value) { onChanged: (String? value) async {
if (value != null) { if (value != null) {
context.setLocale(Locale(value)); context.setLocale(Locale(value));
setState(() {}); // Save language preference
await widget.game.appSettings.setString('ui.language', value);
setState(() {}); // Simple setState to rebuild UI
} }
}, },
), ),
@ -253,7 +288,11 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
// Gameplay Settings Section // Gameplay Settings Section
Text( Text(
'settings.gameplay'.tr(), '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), SizedBox(height: 12),
@ -265,8 +304,17 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('settings.show_vision_cones'.tr(), style: TextStyle(color: Colors.white)), Text(
Text('settings.vision_cones_help'.tr(), style: TextStyle(color: Colors.white60, fontSize: 11)), 'settings.show_vision_cones'.tr(),
style: TextStyle(color: Colors.white),
),
Text(
'settings.vision_cones_help'.tr(),
style: TextStyle(
color: Colors.white60,
fontSize: 11,
),
),
], ],
), ),
), ),
@ -276,7 +324,10 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
setState(() { setState(() {
showVisionCones = value; showVisionCones = value;
}); });
await widget.game.appSettings.setBool('game.show_vision_cones', value); await widget.game.appSettings.setBool(
'game.show_vision_cones',
value,
);
}, },
), ),
], ],
@ -291,8 +342,17 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('settings.show_detection_radius'.tr(), style: TextStyle(color: Colors.white)), Text(
Text('settings.detection_help'.tr(), style: TextStyle(color: Colors.white60, fontSize: 11)), 'settings.show_detection_radius'.tr(),
style: TextStyle(color: Colors.white),
),
Text(
'settings.detection_help'.tr(),
style: TextStyle(
color: Colors.white60,
fontSize: 11,
),
),
], ],
), ),
), ),
@ -302,7 +362,10 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
setState(() { setState(() {
showDetectionRadius = value; showDetectionRadius = value;
}); });
await widget.game.appSettings.setBool('game.show_detection_radius', value); await widget.game.appSettings.setBool(
'game.show_detection_radius',
value,
);
}, },
), ),
], ],
@ -313,7 +376,11 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
// Accessibility Section // Accessibility Section
Text( Text(
'settings.accessibility'.tr(), '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), SizedBox(height: 12),
@ -321,20 +388,66 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('ui.debug_mode'.tr(), style: TextStyle(color: Colors.white)), Text(
'ui.debug_mode'.tr(),
style: TextStyle(color: Colors.white),
),
NesCheckBox( NesCheckBox(
value: debugMode, value: debugMode,
onChange: (value) async { onChange: (value) async {
setState(() { setState(() {
debugMode = value; debugMode = value;
}); });
await widget.game.appSettings.setBool('game.debug_mode', value); await widget.game.appSettings.setBool(
'game.debug_mode',
value,
);
widget.game.debugMode = 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), SizedBox(height: 40),
Center( Center(
child: NesButton( child: NesButton(
@ -375,9 +488,9 @@ class PauseMenuUI extends StatelessWidget {
children: [ children: [
Text( Text(
'ui.paused'.tr(), 'ui.paused'.tr(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(
color: Colors.white, context,
), ).textTheme.headlineMedium?.copyWith(color: Colors.white),
), ),
SizedBox(height: 24), SizedBox(height: 24),

265
lib/ui/touch_controls.dart Normal file
View file

@ -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<JoystickThumb> createState() => _JoystickThumbState();
}
class _JoystickThumbState extends State<JoystickThumb> {
@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<TouchButton> createState() => _TouchButtonState();
}
class _TouchButtonState extends State<TouchButton> {
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;
}
}
}

View file

@ -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(),
);
}
}

View file

@ -105,6 +105,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.2" 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: equatable:
dependency: transitive dependency: transitive
description: description:

View file

@ -18,6 +18,7 @@ dependencies:
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
meta: ^1.16.0 meta: ^1.16.0
logging: ^1.3.0 logging: ^1.3.0
easy_shared_preferences: ^1.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: