just a concept.
Some checks are pending
/ build-web (push) Waiting to run

This commit is contained in:
zeyus 2025-07-21 22:52:31 +02:00
parent aa823338be
commit bc128cef3d
Signed by: zeyus
GPG key ID: A836639BA719C614
24 changed files with 2921 additions and 159 deletions

View file

@ -0,0 +1,49 @@
{
"game": {
"title": "SHITMAN",
"subtitle": "Lorteprank Mesteren",
"description": "Snig rundt og efterlad brændende lorteposer\nudea at blive opdaget!"
},
"menu": {
"start_mission": "START MISSION",
"infinite_mode": "UENDELIG TILSTAND",
"settings": "INDSTILLINGER",
"main_menu": "HOVEDMENU",
"resume": "FORTSÆT",
"back_to_menu": "TILBAGE TIL MENU"
},
"gameplay": {
"hidden": "Skjult",
"visible": "Synlig",
"detected": "OPDAGET!",
"find_target": "Find målhus",
"place_poop": "Placer lortepose",
"ring_doorbell": "Ring på døren",
"escape": "FLYGT!",
"mission_complete": "Mission Fuldført!",
"mission_failed": "Mission Mislykkedes!"
},
"controls": {
"move": "WASD/Piletaster: Bevæg",
"place_bag": "MELLEMRUM: Placer lortepose",
"ring_bell": "E: Ring på døren",
"pause": "ESC: Pause"
},
"ui": {
"paused": "PAUSERET",
"debug_mode": "Debug Tilstand:",
"close": "Luk"
},
"messages": {
"placing_poop": "Placerer lortepose på position",
"attempting_doorbell": "Forsøger at ringe på døren",
"poop_lit": "Ding dong! Lorteposen brænder! LØB!",
"need_poop_first": "Skal placere lortepose først!",
"not_near_door": "Ikke tæt på målhusets dør",
"near_target": "Tæt på målhus med lortepose placeret!",
"player_detected": "Spiller opdaget! Mission mislykkedes!",
"new_target": "Nyt mål valgt",
"poop_burning": "Lorteposen brænder nu!",
"poop_extinguished": "Lorteposen er brændt ud"
}
}

View file

@ -0,0 +1,49 @@
{
"game": {
"title": "SHITMAN",
"subtitle": "Der Kackhaufen-Streich-Meister",
"description": "Schleich herum und hinterlasse brennende Kacktüten\nohne erwischt zu werden!"
},
"menu": {
"start_mission": "MISSION STARTEN",
"infinite_mode": "ENDLOS MODUS",
"settings": "EINSTELLUNGEN",
"main_menu": "HAUPTMENÜ",
"resume": "FORTSETZEN",
"back_to_menu": "ZURÜCK ZUM MENÜ"
},
"gameplay": {
"hidden": "Versteckt",
"visible": "Sichtbar",
"detected": "ENTDECKT!",
"find_target": "Zielhaus finden",
"place_poop": "Kacktüte platzieren",
"ring_doorbell": "Klingeln",
"escape": "FLUCHT!",
"mission_complete": "Mission Erfolgreich!",
"mission_failed": "Mission Gescheitert!"
},
"controls": {
"move": "WASD/Pfeiltasten: Bewegen",
"place_bag": "LEERTASTE: Kacktüte platzieren",
"ring_bell": "E: Klingeln",
"pause": "ESC: Pause"
},
"ui": {
"paused": "PAUSIERT",
"debug_mode": "Debug Modus:",
"close": "Schließen"
},
"messages": {
"placing_poop": "Kacktüte wird platziert an Position",
"attempting_doorbell": "Versuche zu klingeln",
"poop_lit": "Ding dong! Kacktüte brennt! LAUF!",
"need_poop_first": "Kacktüte muss zuerst platziert werden!",
"not_near_door": "Nicht in der Nähe der Zielhau­stür",
"near_target": "In der Nähe des Zielhauses mit platzierter Kacktüte!",
"player_detected": "Spieler entdeckt! Mission gescheitert!",
"new_target": "Neues Ziel ausgewählt",
"poop_burning": "Kacktüte brennt jetzt!",
"poop_extinguished": "Kacktüte ist ausgebrannt"
}
}

View file

@ -0,0 +1,49 @@
{
"game": {
"title": "SHITMAN",
"subtitle": "The Poop Prank Master",
"description": "Sneak around and leave flaming poop bags\nwithout getting caught!"
},
"menu": {
"start_mission": "START MISSION",
"infinite_mode": "INFINITE MODE",
"settings": "SETTINGS",
"main_menu": "MAIN MENU",
"resume": "RESUME",
"back_to_menu": "BACK TO MENU"
},
"gameplay": {
"hidden": "Hidden",
"visible": "Visible",
"detected": "DETECTED!",
"find_target": "Find target house",
"place_poop": "Place poop bag",
"ring_doorbell": "Ring doorbell",
"escape": "ESCAPE!",
"mission_complete": "Mission Complete!",
"mission_failed": "Mission Failed!"
},
"controls": {
"move": "WASD/Arrow Keys: Move",
"place_bag": "SPACE: Place poop bag",
"ring_bell": "E: Ring doorbell",
"pause": "ESC: Pause"
},
"ui": {
"paused": "PAUSED",
"debug_mode": "Debug Mode:",
"close": "Close"
},
"messages": {
"placing_poop": "Placing poop bag at position",
"attempting_doorbell": "Attempting to ring doorbell",
"poop_lit": "Ding dong! Poop bag is lit! RUN!",
"need_poop_first": "Need to place poop bag first!",
"not_near_door": "Not near target house door",
"near_target": "Near target house with poop bag placed!",
"player_detected": "Player detected! Mission failed!",
"new_target": "New target selected",
"poop_burning": "Poop bag is now on fire!",
"poop_extinguished": "Poop bag has burned out"
}
}

View file

@ -0,0 +1,19 @@
abstract interface class Serializable {
/// Converts the object to a JSON string representation.
/// This method should be implemented by all classes that mixin Serializable.
String toJson();
/// Creates an object from a JSON string representation.
/// This method should be implemented by all classes that mixin Serializable.
Serializable.fromJson(String json);
/// Converts the object to a map representation.
/// This method is useful for converting the object to a format that can be
/// easily serialized or stored.
Map<String, dynamic> toMap();
/// Creates an object from a map representation.
/// This method is useful for converting the object from a format that can be
/// easily serialized or stored.
Serializable.fromMap(Map<String, dynamic> map);
}

View file

@ -0,0 +1,236 @@
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'dart:math';
class Neighborhood extends Component {
static const double streetWidth = 60.0;
static const double houseSize = 80.0;
static const double yardSize = 40.0;
List<House> houses = [];
late List<Vector2> streetPaths;
@override
Future<void> onLoad() async {
await super.onLoad();
generateNeighborhood();
}
void generateNeighborhood() {
houses.clear();
removeAll(children);
// Create a simple 3x3 grid of houses
final random = Random();
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 3; col++) {
// Skip center for street intersection
if (row == 1 && col == 1) continue;
final housePosition = Vector2(
col * (houseSize + streetWidth) + streetWidth,
row * (houseSize + streetWidth) + streetWidth,
);
final house = House(
position: housePosition,
isTarget: false, // Target will be set separately
houseType: random.nextInt(3), // 3 different house types
);
houses.add(house);
add(house);
}
}
// Generate street paths
generateStreetPaths();
}
void generateStreetPaths() {
streetPaths = [];
// Horizontal streets
for (int i = 0; i < 4; i++) {
streetPaths.add(Vector2(0, i * (houseSize + streetWidth)));
streetPaths.add(Vector2(800, i * (houseSize + streetWidth)));
}
// Vertical streets
for (int i = 0; i < 4; i++) {
streetPaths.add(Vector2(i * (houseSize + streetWidth), 0));
streetPaths.add(Vector2(i * (houseSize + streetWidth), 600));
}
}
House? getRandomHouse() {
if (houses.isEmpty) return null;
final random = Random();
return houses[random.nextInt(houses.length)];
}
@override
void render(Canvas canvas) {
super.render(canvas);
// Draw streets
final streetPaint = Paint()
..color = const Color(0xFF333333);
// Horizontal streets
for (int row = 0; row <= 3; row++) {
canvas.drawRect(
Rect.fromLTWH(
0,
row * (houseSize + streetWidth) - streetWidth / 2,
800,
streetWidth
),
streetPaint,
);
}
// Vertical streets
for (int col = 0; col <= 3; col++) {
canvas.drawRect(
Rect.fromLTWH(
col * (houseSize + streetWidth) - streetWidth / 2,
0,
streetWidth,
600
),
streetPaint,
);
}
}
}
class House extends RectangleComponent {
bool isTarget;
int houseType;
bool hasLights = false;
bool hasSecurityCamera = false;
bool hasWatchDog = false;
Vector2? doorPosition;
Vector2? yardCenter;
House({
required Vector2 position,
required this.isTarget,
required this.houseType,
}) : super(
position: position,
size: Vector2.all(Neighborhood.houseSize),
);
@override
Future<void> onLoad() async {
await super.onLoad();
// Set house color based on type
paint = Paint()..color = _getHouseColor();
// Calculate door and yard positions
doorPosition = position + Vector2(size.x / 2, size.y);
yardCenter = position + size / 2;
// Randomly add security features
final random = Random();
hasLights = random.nextBool();
hasSecurityCamera = random.nextDouble() < 0.3;
hasWatchDog = random.nextDouble() < 0.2;
}
Color _getHouseColor() {
switch (houseType) {
case 0:
return isTarget ? const Color(0xFFFF6B6B) : const Color(0xFF8B4513); // Brown/Red if target
case 1:
return isTarget ? const Color(0xFFFF6B6B) : const Color(0xFF4682B4); // Blue/Red if target
case 2:
return isTarget ? const Color(0xFFFF6B6B) : const Color(0xFF228B22); // Green/Red if target
default:
return const Color(0xFF696969);
}
}
@override
void render(Canvas canvas) {
super.render(canvas);
// Draw door
final doorPaint = Paint()
..color = const Color(0xFF654321);
canvas.drawRect(
Rect.fromLTWH(size.x / 2 - 8, size.y - 4, 16, 4),
doorPaint,
);
// Draw windows
final windowPaint = Paint()
..color = hasLights ? const Color(0xFFFFFF00) : const Color(0xFF87CEEB);
// Left window
canvas.drawRect(
Rect.fromLTWH(size.x * 0.2, size.y * 0.3, 12, 12),
windowPaint,
);
// Right window
canvas.drawRect(
Rect.fromLTWH(size.x * 0.7, size.y * 0.3, 12, 12),
windowPaint,
);
// Draw security features
if (hasSecurityCamera) {
final cameraPaint = Paint()
..color = const Color(0xFF000000);
canvas.drawCircle(
Offset(size.x * 0.9, size.y * 0.1),
4,
cameraPaint,
);
}
if (hasWatchDog) {
// Draw dog house in yard
final dogHousePaint = Paint()
..color = const Color(0xFF8B4513);
canvas.drawRect(
Rect.fromLTWH(-20, size.y + 10, 15, 15),
dogHousePaint,
);
}
// Draw target indicator
if (isTarget) {
final targetPaint = Paint()
..color = const Color(0xFFFF0000)
..style = PaintingStyle.stroke
..strokeWidth = 3.0;
canvas.drawCircle(
Offset(size.x / 2, size.y / 2),
size.x / 2 + 10,
targetPaint,
);
}
}
double getDetectionRadius() {
double radius = 50.0;
if (hasLights) radius += 20.0;
if (hasSecurityCamera) radius += 40.0;
if (hasWatchDog) radius += 30.0;
return radius;
}
bool canDetectPlayer(Vector2 playerPosition, double playerStealthLevel) {
final distance = (playerPosition - yardCenter!).length;
final detectionRadius = getDetectionRadius() * (1.0 - playerStealthLevel);
return distance < detectionRadius;
}
}

View file

@ -0,0 +1,193 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:shitman/game/shitman_game.dart';
import 'package:shitman/game/components/poop_bag.dart';
import 'package:shitman/game/components/neighborhood.dart';
class Player extends RectangleComponent with HasGameReference<ShitmanGame> {
static const double speed = 100.0;
static const double playerSize = 32.0;
Vector2 velocity = Vector2.zero();
bool hasPoopBag = true;
bool isHidden = false;
double stealthLevel = 0.0; // 0.0 = fully visible, 1.0 = completely hidden
PoopBag? placedPoopBag;
@override
Future<void> onLoad() async {
await super.onLoad();
// Create a simple colored rectangle as player
size = Vector2.all(playerSize);
position = Vector2(400, 300); // Start in center
// Set player color
paint = Paint()..color = const Color(0xFF0000FF); // Blue player
}
void handleInput(Set<LogicalKeyboardKey> keysPressed) {
velocity = Vector2.zero();
// 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;
}
}
void handleAction(LogicalKeyboardKey key) {
// Action controls
if (key == LogicalKeyboardKey.space) {
placePoop();
}
if (key == LogicalKeyboardKey.keyE) {
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
if (velocity.length < 50) {
stealthLevel = (stealthLevel + dt * 0.5).clamp(0.0, 1.0);
} else {
stealthLevel = (stealthLevel - dt * 1.5).clamp(0.0, 1.0);
}
isHidden = stealthLevel > 0.7;
}
void placePoop() {
if (!hasPoopBag) return;
debugPrint('Placing poop bag at $position');
// Create and place the poop bag
placedPoopBag = PoopBag();
placedPoopBag!.position = position + Vector2(playerSize / 2, playerSize + 10);
game.world.add(placedPoopBag!);
hasPoopBag = false;
// Check if near target house
checkMissionProgress();
}
void ringDoorbell() {
debugPrint('Attempting to ring doorbell');
// Check if near target house door
if (game.targetHouse.isPlayerNearTarget(position)) {
if (placedPoopBag != null) {
// Light the poop bag on fire
placedPoopBag!.lightOnFire();
debugPrint('Ding dong! Poop bag is lit! RUN!');
// Start escape timer - player has limited time to escape
startEscapeSequence();
} else {
debugPrint('Need to place poop bag first!');
}
} else {
debugPrint('Not near target house door');
}
}
void startEscapeSequence() {
// TODO: Implement escape mechanics
// For now, automatically complete mission after a delay
Future.delayed(Duration(seconds: 3), () {
if (game.gameState == GameState.playing) {
game.completeCurrentMission();
}
});
}
void checkMissionProgress() {
// Check if near target house and has placed poop bag
final targetPos = game.targetHouse.getTargetPosition();
if (targetPos != null && placedPoopBag != null) {
final distance = (position - targetPos).length;
if (distance < 80) {
debugPrint('Near target house with poop bag placed!');
}
}
}
void getDetected() {
debugPrint('Player detected! Mission failed!');
game.failMission();
}
@override
void update(double dt) {
super.update(dt);
// Apply movement
if (velocity.length > 0) {
velocity = velocity.normalized() * speed;
position += velocity * dt;
// Keep player on screen (basic bounds checking)
position.x = position.x.clamp(0, 800 - size.x);
position.y = position.y.clamp(0, 600 - size.y);
}
// Update stealth level based on environment
updateStealthLevel(dt);
// Check for detection by houses
checkForDetection();
}
void checkForDetection() {
final neighborhood = game.world.children.whereType<Neighborhood>().firstOrNull;
if (neighborhood == null) return;
for (final house in neighborhood.houses) {
if (house.canDetectPlayer(position, stealthLevel)) {
getDetected();
break;
}
}
}
@override
void render(Canvas canvas) {
// Update paint color based on stealth level
paint = Paint()
..color = isHidden ?
const Color(0xFF00FF00).withOpacity(0.7) : // Green when hidden
const Color(0xFF0000FF).withOpacity(0.9); // Blue when visible
super.render(canvas);
// Draw stealth indicator in debug mode
if (game.debugMode) {
final stealthPaint = Paint()
..color = Color.lerp(const Color(0xFFFF0000), const Color(0xFF00FF00), stealthLevel)!;
canvas.drawRect(
Rect.fromLTWH(-5, -10, size.x + 10, 5),
stealthPaint,
);
}
}
}

View file

@ -0,0 +1,160 @@
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'dart:math';
enum PoopBagState { placed, lit, burning, extinguished }
class PoopBag extends CircleComponent {
PoopBagState state = PoopBagState.placed;
double burnTimer = 0.0;
static const double burnDuration = 3.0; // seconds to burn
static const double bagSize = 16.0;
late Vector2 smokeOffset;
List<SmokeParticle> smokeParticles = [];
@override
Future<void> onLoad() async {
await super.onLoad();
radius = bagSize / 2;
paint = Paint()..color = const Color(0xFF8B4513); // Brown color
smokeOffset = Vector2(0, -radius - 5);
}
void lightOnFire() {
if (state == PoopBagState.placed) {
state = PoopBagState.lit;
burnTimer = 0.0;
// Add flame effect
add(
ScaleEffect.to(
Vector2.all(1.2),
EffectController(duration: 0.5, infinite: true, reverseDuration: 0.5),
),
);
debugPrint('Poop bag is now on fire!');
}
}
@override
void update(double dt) {
super.update(dt);
if (state == PoopBagState.lit) {
burnTimer += dt;
// Generate smoke particles
if (burnTimer % 0.2 < dt) { // Every 0.2 seconds
generateSmokeParticle();
}
// Check if fully burned
if (burnTimer >= burnDuration) {
state = PoopBagState.burning;
extinguish();
}
}
// Update smoke particles
smokeParticles.removeWhere((particle) {
particle.update(dt);
return particle.shouldRemove;
});
}
void generateSmokeParticle() {
final random = Random();
final particle = SmokeParticle(
position: position + smokeOffset + Vector2(
random.nextDouble() * 10 - 5,
random.nextDouble() * 5,
),
);
smokeParticles.add(particle);
}
void extinguish() {
state = PoopBagState.extinguished;
removeAll(children.whereType<Effect>());
// Change to burnt color
paint = Paint()..color = const Color(0xFF2F2F2F);
debugPrint('Poop bag has burned out');
}
@override
void render(Canvas canvas) {
super.render(canvas);
// Draw flame effect when lit
if (state == PoopBagState.lit) {
final flamePaint = Paint()
..color = Color.lerp(
const Color(0xFFFF4500),
const Color(0xFFFFD700),
sin(burnTimer * 10) * 0.5 + 0.5,
)!;
// Draw flickering flame
canvas.drawCircle(
Offset(0, -radius - 5),
radius * 0.6 + sin(burnTimer * 15) * 2,
flamePaint,
);
}
// Render smoke particles
for (final particle in smokeParticles) {
particle.render(canvas);
}
}
bool isNearPosition(Vector2 targetPosition, {double threshold = 30.0}) {
return (position - targetPosition).length < threshold;
}
}
class SmokeParticle {
Vector2 position;
Vector2 velocity;
double life;
double maxLife;
bool shouldRemove = false;
SmokeParticle({required this.position})
: velocity = Vector2(
Random().nextDouble() * 20 - 10,
-Random().nextDouble() * 30 - 20,
),
life = 2.0,
maxLife = 2.0;
void update(double dt) {
position += velocity * dt;
velocity *= 0.98; // Slight air resistance
life -= dt;
if (life <= 0) {
shouldRemove = true;
}
}
void render(Canvas canvas) {
final alpha = (life / maxLife).clamp(0.0, 1.0);
final smokePaint = Paint()
..color = Color(0xFF666666).withOpacity(alpha * 0.3);
canvas.drawCircle(
Offset(position.x, position.y),
6.0 * (1.0 - life / maxLife),
smokePaint,
);
}
}

View file

@ -0,0 +1,52 @@
import 'package:flame/components.dart';
import 'package:flutter/foundation.dart';
import 'package:shitman/game/components/neighborhood.dart';
class TargetHouse extends Component {
House? currentTarget;
bool missionActive = false;
@override
Future<void> onLoad() async {
await super.onLoad();
selectNewTarget();
}
void selectNewTarget() {
// Find the neighborhood component
final neighborhood = parent?.children.whereType<Neighborhood>().firstOrNull;
if (neighborhood == null) return;
// Clear previous target
if (currentTarget != null) {
currentTarget!.isTarget = false;
}
// Select random house as new target
currentTarget = neighborhood.getRandomHouse();
if (currentTarget != null) {
currentTarget!.isTarget = true;
missionActive = true;
debugPrint('New target selected at ${currentTarget!.position}');
}
}
void completeMission() {
if (currentTarget != null) {
currentTarget!.isTarget = false;
currentTarget = null;
}
missionActive = false;
}
bool isPlayerNearTarget(Vector2 playerPosition, {double threshold = 50.0}) {
if (currentTarget?.doorPosition == null) return false;
final distance = (playerPosition - currentTarget!.doorPosition!).length;
return distance < threshold;
}
Vector2? getTargetPosition() {
return currentTarget?.doorPosition;
}
}

144
lib/game/shitman_game.dart Normal file
View file

@ -0,0 +1,144 @@
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:shitman/game/components/player.dart';
import 'package:shitman/game/components/neighborhood.dart';
import 'package:shitman/game/components/target_house.dart';
import 'package:shitman/settings/app_settings.dart';
/// Shitman Game
/// A 2D top-down "hitman" style game, but instead of assassinating people,
/// your objective is to place flaming bags of dog poop on the doorsteps of
/// your targets without getting caught.
enum GameState { mainMenu, playing, paused, gameOver, missionComplete }
class ShitmanGame extends FlameGame with HasKeyboardHandlerComponents, HasCollisionDetection, AppSettings {
late Player player;
late Neighborhood neighborhood;
late TargetHouse targetHouse;
late CameraComponent gameCamera;
GameState gameState = GameState.mainMenu;
@override
bool debugMode = false;
int missionScore = 0;
int totalMissions = 0;
bool infiniteMode = false;
@override
Future<void> onLoad() async {
await super.onLoad();
await initSettings();
// Setup camera
gameCamera = CameraComponent.withFixedResolution(
world: world,
width: 800,
height: 600,
);
addAll([gameCamera, world]);
// Initialize debug mode from settings
debugMode = appSettings.getBool('game.debug_mode');
}
void startGame() {
gameState = GameState.playing;
initializeLevel();
}
void startInfiniteMode() {
infiniteMode = true;
overlays.remove('MainMenu');
overlays.add('InGameUI');
startGame();
}
void stopGame() {
gameState = GameState.mainMenu;
world.removeAll(world.children);
missionScore = 0;
}
void pauseGame() {
gameState = GameState.paused;
}
void resumeGame() {
gameState = GameState.playing;
}
void initializeLevel() {
// Clear previous level
world.removeAll(world.children);
// Create neighborhood
neighborhood = Neighborhood();
world.add(neighborhood);
// Create target house
targetHouse = TargetHouse();
world.add(targetHouse);
// Create player
player = Player();
world.add(player);
// Setup camera to follow player
gameCamera.follow(player);
}
void completeCurrentMission() {
gameState = GameState.missionComplete;
missionScore += 100;
totalMissions++;
if (infiniteMode) {
// Generate new mission after delay
Future.delayed(Duration(seconds: 2), () {
initializeLevel();
gameState = GameState.playing;
});
}
}
void failMission() {
gameState = GameState.gameOver;
// TODO: Show game over screen
}
@override
KeyEventResult onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
if (gameState != GameState.playing) return KeyEventResult.ignored;
// Handle pause
if (keysPressed.contains(LogicalKeyboardKey.escape)) {
pauseGame();
overlays.add('PauseMenu');
return KeyEventResult.handled;
}
// Handle player input
player.handleInput(keysPressed);
// Handle action keys on key down
if (event is KeyDownEvent) {
player.handleAction(event.logicalKey);
}
return KeyEventResult.handled;
}
@override
void update(double dt) {
super.update(dt);
// Only update game logic when playing
if (gameState != GameState.playing) return;
// Game-specific update logic here
}
}

View file

@ -1,7 +1,23 @@
import 'dart:ui';
import 'package:flame/game.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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';
class AnyInputScrollBehavior extends MaterialScrollBehavior {
// Override behavior methods and getters like dragDevices
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
};
}
void main() {
LicenseRegistry.addLicense(() async* {
@ -9,105 +25,46 @@ void main() {
yield LicenseEntryWithLineBreaks(['google_fonts'], license);
});
runApp(const MyApp());
runApp(
EasyLocalization(
supportedLocales: [Locale('en'), Locale('da'), Locale('de')],
path: 'assets/translations',
fallbackLocale: Locale('en'),
startLocale: Locale('en'),
useOnlyLangCode: true,
useFallbackTranslations: true,
child: Shitman(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class Shitman extends StatelessWidget {
const Shitman({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
scrollBehavior: AnyInputScrollBehavior(),
locale: context.locale,
theme: flutterNesTheme(brightness: Brightness.dark),
themeMode: ThemeMode.dark,
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
home: GameWidget(
game: ShitmanGame(),
initialActiveOverlays: const ['MainMenu'],
overlayBuilderMap: {
InGameUI.overlayID: (context, game) =>
InGameUI(game as ShitmanGame),
MainMenuUI.overlayID: (context, game) =>
MainMenuUI(game as ShitmanGame),
SettingsUI.overlayID: (context, game) =>
SettingsUI(game as ShitmanGame),
PauseMenuUI.overlayID: (context, game) =>
PauseMenuUI(game as ShitmanGame),
},
),
);
}
}

View file

@ -0,0 +1,16 @@
import 'game.dart';
import 'colors.dart';
import 'settings_manager.dart';
mixin class AppSettings {
final Settings appSettings = Settings();
static bool _isInitialized = false;
Future<void> initSettings() async {
if (_isInitialized) return;
_isInitialized = true;
appSettings.register(gameSettings);
appSettings.register(colorSettings);
await appSettings.init();
}
}

15
lib/settings/colors.dart Normal file
View file

@ -0,0 +1,15 @@
import 'setting.dart';
import 'settings_group.dart';
/// Color settings, themes, etc.
final colorSettings = SettingsGroup(
key: 'colors',
items: [
/// Base color for game UI
IntSetting(
key: 'team_a_color',
defaultValue: 0xFF0000FF, // Blue
userConfigurable: true,
),
],
);

View file

@ -0,0 +1,104 @@
/// Exception thrown when a requested setting is not found.
///
/// This occurs when:
/// - Accessing a setting that doesn't exist in the group
/// - Using an invalid storage key format
/// - Referencing a setting before it's been registered
///
/// Example:
/// ```dart
/// try {
/// Settings.getBool('nonexistent.setting');
/// } catch (e) {
/// if (e is SettingNotFoundException) {
/// print('Setting not found: ${e.message}');
/// }
/// }
/// ```
class SettingNotFoundException implements Exception {
/// Descriptive error message explaining what setting was not found.
final String message;
/// Creates a new [SettingNotFoundException] with the given [message].
const SettingNotFoundException(this.message);
@override
String toString() => 'SettingNotFoundException: $message';
}
/// Exception thrown when attempting to modify a non-configurable setting.
///
/// Settings can be marked as non-configurable by setting `userConfigurable: false`.
/// This is useful for system settings or read-only configuration values.
///
/// Example:
/// ```dart
/// final systemSetting = BoolSetting(
/// key: 'systemFlag',
/// defaultValue: true,
/// userConfigurable: false, // This setting cannot be modified by users
/// );
/// ```
class SettingNotConfigurableException implements Exception {
/// Descriptive error message explaining which setting cannot be configured.
final String message;
/// Creates a new [SettingNotConfigurableException] with the given [message].
const SettingNotConfigurableException(this.message);
@override
String toString() => 'SettingNotConfigurableException: $message';
}
/// Exception thrown when a setting value fails validation.
///
/// This occurs when a validator function returns false for a given value.
/// Validators are useful for ensuring data integrity and business rules.
///
/// Example:
/// ```dart
/// final volumeSetting = DoubleSetting(
/// key: 'volume',
/// defaultValue: 0.5,
/// validator: (value) => value >= 0.0 && value <= 1.0,
/// );
///
/// // This will throw SettingValidationException
/// await Settings.setDouble('audio.volume', 1.5);
/// ```
class SettingValidationException implements Exception {
/// Descriptive error message explaining the validation failure.
final String message;
/// Creates a new [SettingValidationException] with the given [message].
const SettingValidationException(this.message);
@override
String toString() => 'SettingValidationException: $message';
}
/// Exception thrown when attempting to access settings before initialization.
///
/// The settings framework requires asynchronous initialization before use.
/// Always await `Settings.init()` or individual `readyFuture` properties
/// before accessing setting values.
///
/// Example:
/// ```dart
/// // Wrong - may throw SettingsNotReadyException
/// bool value = Settings.getBool('game.sound');
///
/// // Correct - wait for initialization
/// await Settings.init();
/// bool value = Settings.getBool('game.sound');
/// ```
class SettingsNotReadyException implements Exception {
/// Descriptive error message explaining the readiness issue.
final String message;
/// Creates a new [SettingsNotReadyException] with the given [message].
const SettingsNotReadyException(this.message);
@override
String toString() => 'SettingsNotReadyException: $message';
}

11
lib/settings/game.dart Normal file
View file

@ -0,0 +1,11 @@
import 'setting.dart';
import 'settings_group.dart';
/// Game settings group containing all game-related preferences.
final gameSettings = SettingsGroup(
key: 'game',
items: [
/// Debug mode, additional elements to help with development
BoolSetting(key: 'debug_mode', defaultValue: false),
],
);

343
lib/settings/setting.dart Normal file
View file

@ -0,0 +1,343 @@
import 'dart:convert';
import 'dart:async';
import 'package:shitman/attributes/serializable.dart';
/// Enum for supported setting value types.
///
/// This enum is used internally to track the type of each setting
/// and ensure proper type casting during storage and retrieval operations.
enum SettingType {
/// Boolean true/false values
bool,
/// Integer numeric values
int,
/// Double-precision floating point values
double,
/// String text values
string,
}
/// Abstract base class for all setting types.
///
/// This class defines the common interface and functionality for all settings,
/// including type safety, validation, change notifications, and metadata.
///
/// Type parameter [T] ensures compile-time type safety for setting values.
///
/// Example usage:
/// ```dart
/// // Create a validated volume setting
/// final volumeSetting = DoubleSetting(
/// key: 'volume',
/// defaultValue: 0.5,
/// validator: (value) => value >= 0.0 && value <= 1.0,
/// );
///
/// // Listen for changes
/// volumeSetting.stream.listen((newValue) {
/// print('Volume changed to: $newValue');
/// });
/// ```
///
/// Concrete implementations:
/// - [BoolSetting] for boolean values
/// - [IntSetting] for integer values
/// - [DoubleSetting] for floating-point values
/// - [StringSetting] for text values
abstract class Setting<T> implements Serializable {
/// Internal stream controller for broadcasting value changes.
/// Uses broadcast to allow multiple listeners.
final StreamController<T> _controller = StreamController<T>.broadcast();
/// Unique identifier for this setting within its group.
///
/// Keys should be descriptive and follow camelCase convention.
/// Examples: 'soundEnabled', 'maxRetries', 'serverUrl'
final String key;
/// The data type of this setting's value.
///
/// Used internally for type checking and storage operations.
/// Automatically set by concrete implementations.
final SettingType type;
/// The default value used when the setting hasn't been explicitly set.
///
/// This value is used during initialization and reset operations.
/// Must match the generic type parameter [T].
final T defaultValue;
/// Whether this setting can be modified by user code.
///
/// When false, attempts to modify the setting will throw
/// [SettingNotConfigurableException]. Useful for system settings
/// or read-only configuration values.
///
/// Defaults to true.
final bool userConfigurable;
/// Optional function to validate setting values before storage.
///
/// The validator receives the new value and should return:
/// - `true` if the value is valid
/// - `false` if the value should be rejected
///
/// When validation fails, [SettingValidationException] is thrown.
///
/// Example:
/// ```dart
/// validator: (value) => value >= 0 && value <= 100
/// ```
final bool Function(T)? validator;
/// Stream that emits new values when the setting changes.
///
/// This stream uses broadcast semantics, allowing multiple listeners.
/// The stream emits the new value immediately after it's stored.
///
/// Example:
/// ```dart
/// setting.stream.listen((newValue) {
/// print('Setting changed to: $newValue');
/// updateUI(newValue);
/// });
/// ```
Stream<T> get stream => _controller.stream;
/// Creates a new setting with the specified configuration.
///
/// Parameters:
/// - [key]: Unique identifier within the settings group
/// - [type]: Data type of the setting value
/// - [defaultValue]: Initial/reset value for the setting
/// - [userConfigurable]: Whether the setting can be modified (default: true)
/// - [validator]: Optional validation function for new values
Setting({
required this.key,
required this.type,
required this.defaultValue,
this.userConfigurable = true,
this.validator,
});
/// Internal method to notify all stream listeners of a value change.
///
/// This method is called automatically by the settings framework
/// after a value has been successfully stored. Application code
/// should not call this method directly.
///
/// Parameters:
/// - [value]: The new value that was stored
void notifyChange(T value) {
_controller.add(value);
}
/// Internal method to validate a value using the validator function.
///
/// Returns true if no validator is provided or if the validator
/// function returns true. Returns false if validation fails.
///
/// Parameters:
/// - [value]: The value to validate
///
/// Returns: true if valid, false if invalid
bool validate(T value) {
return validator?.call(value) ?? true;
}
/// Dispose of the stream controller and release resources.
///
/// This method should be called when the setting is no longer needed
/// to prevent memory leaks. It's automatically called by the settings
/// framework when disposing of setting groups.
///
/// After calling dispose, the [stream] will no longer emit events.
void dispose() {
_controller.close();
}
/// Converts the setting to a map.
@override
Map<String, dynamic> toMap() {
return {
'key': key,
'type': type.name,
'defaultValue': defaultValue,
'userConfigurable': userConfigurable,
// Todo: convert validator to use validation classes (e.g. RangeValidator)
'validator': null,
};
}
/// Creates a setting from a map representation.
Setting.fromMap(Map<String, dynamic> map)
: key = map['key'] as String,
type = SettingType.values.firstWhere(
(e) => e.name == map['type'],
orElse:
() => throw ArgumentError('Invalid setting type: ${map['type']}'),
),
defaultValue = map['defaultValue'] as T,
userConfigurable = map['userConfigurable'] as bool? ?? true,
validator = null;
/// Converts the setting to a JSON string representation.
@override
String toJson() {
return jsonEncode(toMap());
}
/// Creates a setting from a JSON string representation.
Setting.fromJson(String json)
: this.fromMap(jsonDecode(json) as Map<String, dynamic>);
}
/// A setting that stores boolean (true/false) values.
///
/// This is a concrete implementation of [Setting] specialized for boolean values.
/// Commonly used for feature flags, toggles, and binary preferences.
///
/// Example:
/// ```dart
/// final soundEnabled = BoolSetting(
/// key: 'soundEnabled',
/// defaultValue: true,
/// );
///
/// final debugMode = BoolSetting(
/// key: 'debugMode',
/// defaultValue: false,
/// userConfigurable: false, // System setting
/// );
/// ```
class BoolSetting extends Setting<bool> {
/// Creates a new boolean setting.
///
/// Parameters:
/// - [key]: Unique identifier for this setting
/// - [defaultValue]: Initial boolean value (true or false)
/// - [userConfigurable]: Whether users can modify this setting (default: true)
/// - [validator]: Optional validation function for boolean values
BoolSetting({
required super.key,
required super.defaultValue,
super.userConfigurable,
super.validator,
}) : super(type: SettingType.bool);
/// Converts the boolean value to a JSON string representation.
@override
String toJson() {
return defaultValue.toString();
}
/// Creates a boolean setting from a JSON string representation.
}
/// A setting that stores integer numeric values.
///
/// This is a concrete implementation of [Setting] specialized for integer values.
/// Useful for counts, limits, indices, and whole number preferences.
///
/// Example:
/// ```dart
/// final maxRetries = IntSetting(
/// key: 'maxRetries',
/// defaultValue: 3,
/// validator: (value) => value >= 0 && value <= 10,
/// );
///
/// final fontSize = IntSetting(
/// key: 'fontSize',
/// defaultValue: 14,
/// validator: (value) => value >= 8 && value <= 72,
/// );
/// ```
class IntSetting extends Setting<int> {
/// Creates a new integer setting.
///
/// Parameters:
/// - [key]: Unique identifier for this setting
/// - [defaultValue]: Initial integer value
/// - [userConfigurable]: Whether users can modify this setting (default: true)
/// - [validator]: Optional validation function (e.g., range checking)
IntSetting({
required super.key,
required super.defaultValue,
super.userConfigurable,
super.validator,
}) : super(type: SettingType.int);
}
/// A setting that stores double-precision floating-point values.
///
/// This is a concrete implementation of [Setting] specialized for decimal values.
/// Perfect for percentages, ratios, measurements, and precise numeric settings.
///
/// Example:
/// ```dart
/// final volume = DoubleSetting(
/// key: 'volume',
/// defaultValue: 0.8,
/// validator: (value) => value >= 0.0 && value <= 1.0,
/// );
///
/// final animationSpeed = DoubleSetting(
/// key: 'animationSpeed',
/// defaultValue: 1.0,
/// validator: (value) => value > 0.0 && value <= 5.0,
/// );
/// ```
class DoubleSetting extends Setting<double> {
/// Creates a new double setting.
///
/// Parameters:
/// - [key]: Unique identifier for this setting
/// - [defaultValue]: Initial floating-point value
/// - [userConfigurable]: Whether users can modify this setting (default: true)
/// - [validator]: Optional validation function (e.g., range checking)
DoubleSetting({
required super.key,
required super.defaultValue,
super.userConfigurable,
super.validator,
}) : super(type: SettingType.double);
}
/// A setting that stores string text values.
///
/// This is a concrete implementation of [Setting] specialized for text values.
/// Ideal for names, URLs, file paths, themes, and textual preferences.
///
/// Example:
/// ```dart
/// final theme = StringSetting(
/// key: 'theme',
/// defaultValue: 'light',
/// validator: (value) => ['light', 'dark', 'auto'].contains(value),
/// );
///
/// final serverUrl = StringSetting(
/// key: 'serverUrl',
/// defaultValue: 'https://api.example.com',
/// validator: (value) => Uri.tryParse(value) != null,
/// );
/// ```
class StringSetting extends Setting<String> {
/// Creates a new string setting.
///
/// Parameters:
/// - [key]: Unique identifier for this setting
/// - [defaultValue]: Initial text value
/// - [userConfigurable]: Whether users can modify this setting (default: true)
/// - [validator]: Optional validation function (e.g., format checking)
StringSetting({
required super.key,
required super.defaultValue,
super.userConfigurable,
super.validator,
}) : super(type: SettingType.string);
}

View file

@ -0,0 +1,45 @@
/// =============================================================================
/// MODULAR SETTINGS FRAMEWORK
/// =============================================================================
///
/// A comprehensive, type-safe settings management framework for Flutter/Dart
/// applications.
///
/// Usage Example:
/// ```dart
/// // Define your settings
/// final gameSettings = SettingsBase(
/// key: 'game',
/// items: SettingsGroup(items: [
/// BoolSetting(key: 'soundEnabled', defaultValue: true),
/// DoubleSetting(
/// key: 'volume',
/// defaultValue: 0.8,
/// validator: (v) => v >= 0.0 && v <= 1.0,
/// ),
/// ]),
/// );
///
/// // Register and initialize
/// Settings.register(gameSettings);
/// await Settings.init();
///
/// // Use settings
/// bool sound = Settings.getBool('game.soundEnabled');
/// await Settings.setBool('game.soundEnabled', false);
///
/// // Listen for changes
/// gameSettings.items['soundEnabled']!.stream.listen((value) {
/// print('Sound enabled changed to: $value');
/// });
/// ```
///
/// =============================================================================
library;
export 'settings_manager.dart';
export 'exceptions.dart';
export 'setting.dart';
export 'settings_group.dart';
export 'settings_store.dart';
export 'game.dart';

View file

@ -0,0 +1,468 @@
import 'dart:async';
import 'dart:collection';
import 'exceptions.dart';
import 'settings_store.dart';
import 'setting.dart';
/// A comprehensive settings group that manages related settings with persistence,
/// initialization, and type-safe access.
///
/// [SettingsGroup] extends [UnmodifiableMapBase] to provide convenient
/// map-like access to settings while managing their persistence and validation.
/// Each group has a unique key namespace and handles its own initialization.
///
/// Usage pattern:
/// ```dart
/// // 1. Define your settings group
/// final gameSettings = SettingsGroup(
/// key: 'game',
/// items: [
/// BoolSetting(key: 'soundEnabled', defaultValue: true),
/// DoubleSetting(key: 'volume', defaultValue: 0.8),
/// ],
/// );
///
/// // 2. Register with the global settings manager
/// Settings.register(gameSettings);
///
/// // 3. Wait for initialization
/// await gameSettings.readyFuture;
///
/// // 4. Use the settings
/// bool soundEnabled = gameSettings.get<bool>('soundEnabled');
/// await gameSettings.setValue('volume', 0.5);
/// ```
class SettingsGroup extends UnmodifiableMapBase<String, Setting> {
/// Reference to the singleton settings store for persistence.
///
/// This store handles the actual reading and writing of values
/// to SharedPreferences with caching for performance.
late final SettingsStore _store;
/// Unique identifier for this settings group.
///
/// This key is used as a namespace prefix for all settings in this group.
/// For example, if key is 'game' and a setting key is 'volume',
/// the stored key becomes 'game.volume'.
///
/// Should be descriptive and unique across your application.
final String key;
/// Immutable set of all settings contained in this group.
///
/// This set is created during construction and cannot be modified afterward.
/// It contains all the setting objects that belong to this group.
late final Set<Setting<dynamic>> items;
/// Internal cache of setting keys for efficient lookups.
///
/// This set contains the string keys of all settings in the group,
/// providing O(1) key existence checks and fast iteration.
late final Set<String> _keys;
/// Internal flag tracking initialization status.
bool _ready = false;
/// Public property indicating whether this settings group is ready for use.
///
/// When false, accessing setting values will throw [SettingsNotReadyException].
/// When true, all settings have been loaded and are available synchronously.
bool get ready => _ready;
/// Internal completer that completes when initialization finishes.
late Completer<bool> _readyCompleter;
/// Future that completes when all settings in this group are initialized.
///
/// Await this future before accessing setting values to ensure they've
/// been loaded from storage. The future completes with true on success
/// or throws an exception if initialization fails.
///
/// Example:
/// ```dart
/// await gameSettings.readyFuture;
/// // Now safe to access settings synchronously
/// bool soundEnabled = gameSettings.get<bool>('soundEnabled');
/// ```
Future<bool> get readyFuture => _readyCompleter.future;
/// Creates a new settings group with the given key and settings.
///
/// The provided [items] are converted to an immutable set, and their
/// keys are extracted for efficient access. Duplicate keys within
/// the same group are not allowed and will cause undefined behavior.
///
/// Parameters:
/// - [key]: Unique identifier for this settings group
/// - [items]: Collection of settings to include in this group
/// - [forceRegularSharedPreferences]: Whether to force regular SharedPreferences (for testing)
///
/// Example:
/// ```dart
/// final group = SettingsGroup(
/// key: 'game',
/// items: [
/// BoolSetting(key: 'notifications', defaultValue: true),
/// IntSetting(key: 'timeout', defaultValue: 30),
/// ],
/// );
/// ```
SettingsGroup({
required this.key,
required Iterable<Setting> items,
bool forceRegularSharedPreferences = false,
}) {
this.items = Set<Setting>.from(items);
_keys = items.map((item) => item.key).toSet();
_store = SettingsStore(
forceRegularSharedPreferences: forceRegularSharedPreferences,
);
_readyCompleter = Completer<bool>();
// Initialize the settings in the storage if they haven't been set yet.
_init();
}
/// Creates a new settings group optimized for testing.
/// This constructor forces the use of regular SharedPreferences instead
/// of SharedPreferencesWithCache to avoid test compatibility issues.
SettingsGroup.forTesting({
required this.key,
required Iterable<Setting> items,
}) {
this.items = Set<Setting>.from(items);
_keys = items.map((item) => item.key).toSet();
_store = SettingsStore(forceRegularSharedPreferences: true);
_readyCompleter = Completer<bool>();
// Initialize the settings in the storage if they haven't been set yet.
_init();
}
/// Retrieves a setting by its key.
///
/// This operator provides map-like access to settings within the group.
/// The return type is [Setting<dynamic>] to accommodate different setting types.
///
/// Parameters:
/// - [key]: The string key of the setting to retrieve
///
/// Returns: The setting object with the specified key or null if not found.
///
/// Example:
/// ```dart
/// Setting volumeSetting = audioGroup['volume'];
/// BoolSetting enabledSetting = audioGroup['enabled'] as BoolSetting;
/// ```
@override
Setting<dynamic>? operator [](Object? key) {
try {
return items.firstWhere((item) => item.key == key);
} catch (_) {
return null;
}
}
/// Returns an iterable of all setting keys in this group.
///
/// This property provides the keys needed for map-like iteration
/// and key existence checking.
///
/// Returns: Iterable containing all setting keys as strings
@override
Iterable<String> get keys => _keys;
/// Returns the number of settings in this group.
///
/// This count includes all settings regardless of their type
/// or configurability status.
///
/// Returns: Integer count of settings in the group
@override
int get length => _keys.length;
/// Initializes the settings by checking if they are set in the storage.
/// If not, it sets them with their default values.
/// This is called in the constructor to ensure settings are ready to use.
/// It waits for the store to be ready before proceeding, but there is no
/// guarantee that the settings are initialized before the first access.
/// If you need to ensure settings are initialized before use, you should
/// await the [readyFuture] before accessing any settings.
Future<void> _init() async {
try {
if (!_store.ready) {
await _store.readyFuture;
}
for (final Setting setting in items) {
final storageKey = _storageKey(setting.key);
if (!_store.prefs.containsKey(storageKey)) {
// If the setting is not set, initialize it with the default value.
await _set(storageKey, setting, null, force: true);
} else {
// Validate existing value and reset to default if invalid
try {
final currentValue = _get(setting);
if (setting.validator != null && !setting.validate(currentValue)) {
await _set(storageKey, setting, null, force: true);
}
} catch (e) {
// If there's an error reading the current value, reset to default
await _set(storageKey, setting, null, force: true);
}
}
}
_ready = true;
_readyCompleter.complete(true);
} catch (error) {
_ready = false;
_readyCompleter.completeError(error);
rethrow;
}
}
/// Sets the value of a setting by its key.
Future<void> setValue<T>(String key, T value) async {
await _waitUntilReady();
final setting = this[key];
if (setting == null) {
throw SettingNotFoundException(
'No setting in ${this.key} found for key: $key',
);
}
final storageKey = _storageKey(setting.key);
if (!setting.userConfigurable) {
throw SettingNotConfigurableException(
'Setting $storageKey is not user configurable',
);
}
// Validate the value if a validator is provided
if (setting is Setting<T> && !setting.validate(value)) {
throw SettingValidationException(
'Invalid value for setting $storageKey: $value',
);
}
await _set(storageKey, setting, value);
// Notify change listeners
if (setting is Setting<T>) {
setting.notifyChange(value);
}
}
/// Convenience method to get a typed value of a setting by its key.
/// Throws an error if the setting is not found or if the type does not match.
T get<T>(String key) {
_readySync();
if (T == dynamic) {
return getValue(key);
}
final setting = this[key];
if (setting == null) {
throw SettingNotFoundException(
'No setting in ${this.key} found for key: $key',
);
}
if (setting is! Setting<T>) {
throw ArgumentError(
'Setting $key is not of type ${T.runtimeType}, but ${setting.type}',
);
}
return _get<T>(setting);
}
/// Gets the value of a setting by its key.
dynamic getValue(String key) {
_readySync();
final setting = this[key];
if (setting == null) {
throw SettingNotFoundException(
'No setting in ${this.key} found for key: $key',
);
}
return _get(setting);
}
/// Ensures that the settings are ready before accessing them.
/// Throws a [SettingsNotReadyException] if the settings are not ready.
void _readySync() {
if (!_ready) {
throw SettingsNotReadyException(
'Settings are not ready. Please await readyFuture.',
);
}
}
/// Waits until the settings are ready.
/// This is useful for asynchronous operations that need to ensure
/// settings are initialized.
Future<void> _waitUntilReady() async {
if (!_ready) {
await _readyCompleter.future;
}
}
/// Constructs a storage key for the given key in this settings group.
/// This is used to namespace the settings keys to avoid conflicts.
/// For example, if the group key is "game" and the setting key is
/// "fullscreen", the storage key will be "game.fullscreen".
String _storageKey(String key) {
return "${this.key}.$key";
}
T _validateOrDefault<T>(Setting<T> setting, T? value) {
if (value == null) return setting.defaultValue;
if (setting.validator != null && !setting.validate(value)) {
// return default value if validation fails
return setting.defaultValue;
}
return value;
}
/// Gets the value of a setting by its key and type.
/// Throws an error if the setting is not found or if the type does not match.
/// This method is used internally to retrieve the value of a setting.
T _get<T>(Setting<T> setting) {
final storageKey = _storageKey(setting.key);
if (!_store.prefs.containsKey(storageKey)) {
// If not found in storage, return default value
return setting.defaultValue;
}
try {
switch (T) {
case const (bool):
final value = _store.prefs.getBool(storageKey);
// validate the value if a validator is provided
return _validateOrDefault(setting, value as T);
case const (int):
final value = _store.prefs.getInt(storageKey);
return _validateOrDefault(setting, value as T);
case const (double):
final value = _store.prefs.getDouble(storageKey);
return _validateOrDefault(setting, value as T);
case const (String):
final value = _store.prefs.getString(storageKey);
return _validateOrDefault(setting, value as T);
default:
throw ArgumentError('Unsupported setting type: ${T.runtimeType}');
}
} catch (e) {
// If there's a type mismatch or other error, return default value
return setting.defaultValue;
}
}
/// Sets the value of a setting by its key and type.
/// Throws an error if the setting is not found or if the type does not match.
/// This method is used internally to set the value of a setting.
/// If [force] is true, it will set the value even if the setting is
/// not user configurable.
/// If [value] is null, it will use the default value of the setting.
/// If the setting is not user configurable and [force] is false,
/// it will throw an error.
Future<void> _set<T>(
String storageKey,
Setting<T> setting,
T? value, {
bool force = false,
}) async {
if (!force && !setting.userConfigurable) {
throw SettingNotConfigurableException(
'Setting $storageKey is not user configurable',
);
}
if (!force && !_store.prefs.containsKey(storageKey)) {
throw SettingNotFoundException('No setting found for: $storageKey');
}
switch (T) {
case const (bool):
value ??= setting.defaultValue;
return _setBool(storageKey, value as bool);
case const (int):
value ??= setting.defaultValue;
return _setInt(storageKey, value as int);
case const (double):
value ??= setting.defaultValue;
return _setDouble(storageKey, value as double);
case const (String):
value ??= setting.defaultValue;
return _setString(storageKey, value as String);
case const (dynamic):
// If the type is dynamic, we can return any value.
// This is a fallback for when the type is not known at compile time.
// it is less efficient, but let's face it, you probably should not be
// updating settings 1000s of times per second.
value ??= setting.defaultValue;
switch (setting.type) {
case SettingType.bool:
return _setBool(storageKey, value as bool);
case SettingType.int:
return _setInt(storageKey, value as int);
case SettingType.double:
return _setDouble(storageKey, value as double);
case SettingType.string:
return _setString(storageKey, value as String);
}
default:
throw ArgumentError('Unsupported setting type: ${T.runtimeType}');
}
}
/// Sets a boolean value for the given storage key.
Future<void> _setBool(String storageKey, bool value) async {
await _store.prefs.setBool(storageKey, value);
}
/// Sets an integer value for the given storage key.
Future<void> _setInt(String storageKey, int value) async {
await _store.prefs.setInt(storageKey, value);
}
/// Sets a double value for the given storage key.
Future<void> _setDouble(String storageKey, double value) async {
await _store.prefs.setDouble(storageKey, value);
}
/// Sets a string value for the given storage key.
Future<void> _setString(String storageKey, String value) async {
await _store.prefs.setString(storageKey, value);
}
/// Reset a setting to its default value.
Future<void> reset(String key) async {
await _waitUntilReady();
final setting = this[key];
if (setting == null) {
throw SettingNotFoundException(
'No setting in ${this.key} found for key: $key',
);
}
final storageKey = _storageKey(setting.key);
await _set(storageKey, setting, null, force: true);
// Notify change listeners
setting.notifyChange(setting.defaultValue);
}
/// Reset all settings in this group to their default values.
Future<void> resetAll() async {
await _waitUntilReady();
for (final setting in items) {
final storageKey = _storageKey(setting.key);
await _set(storageKey, setting, null, force: true);
setting.notifyChange(setting.defaultValue);
}
}
/// Dispose all stream controllers for settings in this group.
void dispose() {
for (final setting in items) {
setting.dispose();
}
}
}

View file

@ -0,0 +1,350 @@
import 'dart:async';
import 'settings_group.dart';
import 'exceptions.dart';
/// Global settings manager providing centralized access to all setting groups.
///
/// The [Settings] class serves as the main entry point for the settings framework,
/// offering static methods for registration, initialization, and access to settings
/// across your entire application. It manages multiple [SettingsGroup] instances and
/// provides both individual and batch operations.
///
/// ## Overview
///
/// The settings framework follows a hierarchical structure:
/// ```
/// Settings (Global Manager)
///
/// SettingsGroup (Group: "game")
/// BoolSetting ("soundEnabled")
/// DoubleSetting ("volume")
///
/// SettingsGroup (Group: "ui")
/// StringSetting ("theme")
/// IntSetting ("fontSize")
/// ```
///
/// ## Usage Pattern
///
/// ```dart
/// // 1. Define your setting groups
/// final gameSettings = SettingsGroup(
/// key: 'game',
/// items: [
/// BoolSetting(key: 'soundEnabled', defaultValue: true),
/// DoubleSetting(key: 'volume', defaultValue: 0.8),
/// ],
/// );
///
/// final uiSettings = SettingsGroup(
/// key: 'ui',
/// items: [
/// StringSetting(key: 'theme', defaultValue: 'light'),
/// IntSetting(key: 'fontSize', defaultValue: 14),
/// ],
/// );
///
/// // 2. Register all groups
/// Settings.register(gameSettings);
/// Settings.register(uiSettings);
///
/// // 3. Initialize the entire settings system
/// await Settings.init();
///
/// // 4. Access settings using dot notation
/// bool soundEnabled = Settings.getBool('game.soundEnabled');
/// String theme = Settings.getString('ui.theme');
///
/// // 5. Modify settings with automatic validation
/// await Settings.setBool('game.soundEnabled', false);
/// await Settings.setString('ui.theme', 'dark');
///
/// // 6. Batch operations for efficiency
/// await Settings.setMultiple({
/// 'game.volume': 0.5,
/// 'ui.fontSize': 16,
/// });
///
/// // 7. Reset operations
/// await Settings.resetSetting('game.volume'); // Reset single setting
/// await Settings.resetGroup('ui'); // Reset entire group
/// await Settings.resetAll(); // Reset everything
/// ```
///
/// ## Storage Key Format
///
/// Settings are stored using a namespaced key format: `groupKey.settingKey`
/// - `game.soundEnabled` boolean setting in the game group
/// - `ui.theme` string setting in the ui group
/// - `network.timeout` integer setting in the network group
///
/// This prevents key conflicts between different setting groups and provides
/// logical organization of related settings.
class Settings {
/// Internal registry of all settings groups keyed by their group names.
///
/// This map stores all registered [SettingsGroup] instances, providing
/// fast lookup by group key. Groups must be registered before use.
static final Map<String, SettingsGroup> _settings = {};
/// Initializes all registered settings groups concurrently.
///
/// This method waits for all registered settings groups to complete their
/// asynchronous initialization. It's essential to call this method before
/// accessing any setting values to ensure they've been loaded from storage.
///
/// The initialization process:
/// 1. Waits for the underlying SharedPreferences to be ready
/// 2. Loads existing values from storage for each setting
/// 3. Creates default values for settings that don't exist yet
/// 4. Marks all groups as ready for synchronous access
///
/// Returns: Future that completes when all settings are initialized
///
/// Throws: Exception if any settings group fails to initialize
///
/// Example:
/// ```dart
/// // Register your settings groups first
/// Settings.register(gameSettings);
/// Settings.register(uiSettings);
///
/// // Then initialize everything
/// await Settings.init();
///
/// // Now safe to use settings synchronously
/// bool soundEnabled = Settings.getBool('game.soundEnabled');
/// ```
Future<void> init() async {
final futures = _settings.values.map((settings) => settings.readyFuture);
await Future.wait(futures);
}
/// Returns a map of all registered settings groups.
Map<String, SettingsGroup> get groups => _settings;
/// Returns a list of all registered settings groups keys.
List<String> get groupKeys => _settings.keys.toList();
/// Allow access to settings by key using dynamic getters.
/// This allows you to access settings like:
/// Settings.game.fullscreen, Settings.game.soundVolume, etc.
@override
SettingsGroup noSuchMethod(Invocation invocation) {
if (invocation.isGetter) {
final key = invocation.memberName.toString();
if (_settings.containsKey(key)) {
return _settings[key]!;
}
}
throw NoSuchMethodError.withInvocation(this, invocation);
}
/// Validate and get the parts of a storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid.
static ({String group, String setting}) _parseStorageKey(String storageKey) {
final parts = storageKey.split('.');
if (parts.length < 2) {
throw ArgumentError('Invalid storage key: $storageKey');
}
return (group: parts.first, setting: parts.sublist(1).join('.'));
}
// ===== Getters =====
/// Override the accessor to allow dynamic access to settings
/// using the `[]` operator.
dynamic operator [](String key) {
return get<dynamic>(key);
}
/// Registers a settings group with the global settings manager.
///
/// Each settings group must be registered before the system can be initialized.
/// Groups are identified by their unique key, and duplicate keys are not allowed.
///
/// This method should be called during application startup, before calling [init].
///
/// Parameters:
/// - [settings]: The SettingsGroup instance to register
///
/// Throws: [ArgumentError] if a group with the same key already exists
///
/// Example:
/// ```dart
/// final gameSettings = SettingsGroup(key: 'game', items: [...]);
/// final uiSettings = SettingsGroup(key: 'ui', items: [...]);
///
/// Settings.register(gameSettings);
/// Settings.register(uiSettings);
///
/// await Settings.init(); // Initialize after all groups are registered
/// ```
void register(SettingsGroup settings) {
if (_settings.containsKey(settings.key)) {
throw ArgumentError('Settings with key ${settings.key} already exists');
}
_settings[settings.key] = settings;
}
/// Gets a settings group by its key.
SettingsGroup getGroup(String key) {
if (!_settings.containsKey(key)) {
throw SettingNotFoundException('No settings group found for key: $key');
}
return _settings[key]!;
}
/// Get a setting by its storage key and type.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found.
T get<T>(String storageKey) {
// Split the storage key to get the group key and setting key.
final id = _parseStorageKey(storageKey);
final group = getGroup(id.group);
return group.get<T>(id.setting);
}
// Helpers for typed access to settings.
// These methods are for convenience to access settings without
// ending up with a dynamic value.
/// Gets a boolean setting by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not of type bool.
bool getBool(String storageKey) {
return get<bool>(storageKey);
}
/// Gets a double setting by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not of type int.
int getInt(String storageKey) {
return get<int>(storageKey);
}
/// Gets a double setting by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not of type double.
double getDouble(String storageKey) {
return get<double>(storageKey);
}
/// Gets a string setting by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not of type string.
String getString(String storageKey) {
return get<String>(storageKey);
}
// ===== Setters =====
/// Sets a setting value by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not user configurable.
Future<void> setValue(String storageKey, dynamic value) async {
final id = _parseStorageKey(storageKey);
final group = getGroup(id.group);
await group.setValue(id.setting, value);
}
/// Sets a setting value by its storage key and type.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not user configurable.
Future<void> set<T>(String storageKey, T value) async {
final id = _parseStorageKey(storageKey);
final group = getGroup(id.group);
await group.setValue<T>(id.setting, value);
}
/// Sets a boolean setting value by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not user configurable.
Future<void> setBool(String storageKey, bool value) async {
await set<bool>(storageKey, value);
}
/// Sets an integer setting value by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not user configurable.
Future<void> setInt(String storageKey, int value) async {
await set<int>(storageKey, value);
}
/// Sets a double setting value by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not user configurable.
Future<void> setDouble(String storageKey, double value) async {
await set<double>(storageKey, value);
}
/// Sets a string setting value by its storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
/// Throws an [ArgumentError] if the storage key is invalid or
/// if the setting is not found or is not user configurable.
Future<void> setString(String storageKey, String value) async {
await set<String>(storageKey, value);
}
/// Sets multiple settings values in a batch operation.
/// The [settings] map should contain storage keys as keys and values as values.
/// This is more efficient than setting values individually.
Future<void> setMultiple(Map<String, dynamic> settings) async {
final futures = <Future<void>>[];
for (final entry in settings.entries) {
futures.add(setValue(entry.key, entry.value));
}
await Future.wait(futures);
}
/// Reset a setting to its default value by storage key.
/// The [storageKey] should be in the format "groupKey.settingKey".
Future<void> resetSetting(String storageKey) async {
final id = _parseStorageKey(storageKey);
final group = getGroup(id.group);
await group.reset(id.setting);
}
/// Reset all settings in a group to their default values.
/// The [groupKey] should be the key of the settings group.
Future<void> resetGroup(String groupKey) async {
final group = getGroup(groupKey);
await group.resetAll();
}
/// Reset all settings across all groups to their default values.
Future<void> resetAll() async {
final futures = _settings.values.map((group) => group.resetAll());
await Future.wait(futures);
}
/// Dispose all settings groups and their stream controllers.
void dispose() {
for (final group in _settings.values) {
group.dispose();
}
_settings.clear();
}
/// Clear all registered settings groups (for testing purposes).
void clearAll() {
dispose();
}
@override
String toString() {
return 'Settings{groups: ${_settings.keys.join(', ')}}';
}
}

View file

@ -0,0 +1,143 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'exceptions.dart';
/// A singleton store that manages the underlying SharedPreferences with caching.
///
/// This class provides a centralized, cached interface to SharedPreferences,
/// eliminating the need for repeated async calls during normal operation.
/// The store initializes asynchronously but provides synchronous access
/// once ready, improving performance for frequent setting access.
///
/// The store is used internally by the settings framework and typically
/// doesn't need to be accessed directly by application code.
///
/// In test environments, it automatically falls back to regular SharedPreferences
/// to ensure compatibility with test mocking frameworks.
///
/// Example internal usage:
/// ```dart
/// final store = SettingsStore();
/// await store.readyFuture; // Wait for initialization
/// bool value = store.prefs.getBool('some.key') ?? false;
/// ```
class SettingsStore {
/// The singleton instance of the settings store.
static SettingsStore? _instance;
/// Internal flag tracking whether the store is ready for use.
bool _ready = false;
/// Public getter indicating if the store has been initialized and is ready.
/// When true, the [prefs] getter can be used synchronously.
bool get ready => _ready;
/// Future that completes when the store is fully initialized.
/// Await this future before accessing settings to ensure proper initialization.
late final Future<bool> readyFuture;
/// Factory constructor that returns the singleton instance.
/// Multiple calls to this constructor return the same instance.
factory SettingsStore({bool forceRegularSharedPreferences = false}) {
_instance ??= SettingsStore._internal(forceRegularSharedPreferences);
return _instance!;
}
/// The SharedPreferences instance (cached or regular depending on environment).
/// Only accessible after initialization is complete.
late final dynamic _prefs;
/// Whether we're using the cached version or regular SharedPreferences.
bool _isUsingCache = true;
/// Private constructor that initializes SharedPreferences.
///
/// In debug mode or when [forceRegularSharedPreferences] is true, uses regular
/// SharedPreferences for better test compatibility. In release mode, uses
/// SharedPreferencesWithCache for better performance.
///
/// This constructor:
/// 1. Creates a completer for the ready future
/// 2. Chooses appropriate SharedPreferences implementation based on environment
/// 3. Sets up success and error handling
/// 4. Marks the store as ready when initialization completes
SettingsStore._internal(bool forceRegularSharedPreferences) {
final completer = Completer<bool>();
readyFuture = completer.future;
// In debug mode or when forced, use regular SharedPreferences for better test compatibility
// In release mode, use SharedPreferencesWithCache for better performance
final useRegularSharedPreferences =
forceRegularSharedPreferences || kDebugMode;
if (useRegularSharedPreferences) {
_isUsingCache = false;
SharedPreferences.getInstance()
.then((prefs) {
_prefs = prefs;
_ready = true;
completer.complete(true);
})
.catchError((error) {
_ready = false;
completer.completeError(error);
throw Exception('Failed to initialize SharedPreferences: $error');
});
} else {
_isUsingCache = true;
SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(),
)
.then((prefs) {
_prefs = prefs;
_ready = true;
completer.complete(true);
})
.catchError((error) {
// If SharedPreferencesWithCache fails, fall back to regular SharedPreferences
_isUsingCache = false;
SharedPreferences.getInstance()
.then((fallbackPrefs) {
_prefs = fallbackPrefs;
_ready = true;
completer.complete(true);
})
.catchError((fallbackError) {
_ready = false;
completer.completeError(fallbackError);
throw Exception(
'Failed to initialize any SharedPreferences: $fallbackError',
);
});
});
}
}
/// Reset the singleton instance (useful for testing).
static void reset() {
_instance = null;
}
/// Provides access to the SharedPreferences instance.
///
/// This getter should only be called after the store is ready.
/// Use [ready] to check readiness or await [readyFuture] to ensure
/// the store is initialized before accessing this property.
///
/// Returns either SharedPreferencesWithCache (production) or
/// SharedPreferences (test environment) depending on initialization.
///
/// Throws: SettingsNotReadyException if accessed before initialization completes.
dynamic get prefs {
if (!_ready) {
throw SettingsNotReadyException(
'SettingsStore is not ready. Please await readyFuture first.',
);
}
return _prefs;
}
/// Returns true if using SharedPreferencesWithCache, false if using regular SharedPreferences.
bool get isUsingCache => _isUsingCache;
}

314
lib/ui/in_game_ui.dart Normal file
View file

@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:nes_ui/nes_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:shitman/game/shitman_game.dart';
import 'package:shitman/settings/app_settings.dart';
class InGameUI extends StatelessWidget with AppSettings {
static const String overlayID = 'InGameUI';
final ShitmanGame game;
InGameUI(this.game, {super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
// Top HUD
Positioned(
top: 20,
left: 20,
right: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Stealth indicator
NesContainer(
backgroundColor: Colors.black87,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Icon(Icons.visibility_off, size: 16),
SizedBox(width: 8),
Text('gameplay.hidden'.tr(), style: TextStyle(color: Colors.green)),
],
),
),
),
// Mission objective
NesContainer(
backgroundColor: Colors.black87,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text('gameplay.find_target'.tr(), style: TextStyle(color: Colors.white)),
),
),
// Pause button
IconButton(
icon: Icon(Icons.pause, color: Colors.white),
onPressed: () => game.overlays.add(PauseMenuUI.overlayID),
),
],
),
),
// Bottom controls hint
Positioned(
bottom: 20,
left: 20,
right: 20,
child: NesContainer(
backgroundColor: Colors.black54,
child: Padding(
padding: const EdgeInsets.all(12.0),
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)),
],
),
),
),
),
],
),
);
}
}
class MainMenuUI extends StatelessWidget with AppSettings {
static const String overlayID = 'MainMenu';
final ShitmanGame game;
MainMenuUI(this.game, {super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Game title
Text(
'game.title'.tr(),
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: Colors.orange,
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
'game.subtitle'.tr(),
style: TextStyle(color: Colors.white70, fontSize: 16),
),
SizedBox(height: 40),
// Menu buttons
Column(
children: [
NesButton(
type: NesButtonType.primary,
onPressed: () {
game.overlays.remove(MainMenuUI.overlayID);
game.overlays.add(InGameUI.overlayID);
game.startGame();
},
child: Text('menu.start_mission'.tr()),
),
SizedBox(height: 16),
NesButton(
type: NesButtonType.normal,
onPressed: () {
game.overlays.remove(MainMenuUI.overlayID);
game.overlays.add(SettingsUI.overlayID);
},
child: Text('menu.settings'.tr()),
),
SizedBox(height: 16),
NesButton(
type: NesButtonType.normal,
onPressed: () => game.startInfiniteMode(),
child: Text('menu.infinite_mode'.tr()),
),
],
),
SizedBox(height: 40),
Text(
'game.description'.tr(),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
);
}
}
class SettingsUI extends StatelessWidget with AppSettings {
static const String overlayID = 'Settings';
final ShitmanGame game;
SettingsUI(this.game, {super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black87,
child: Center(
child: NesContainer(
backgroundColor: Colors.black,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'menu.settings'.tr(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
),
),
IconButton(
icon: Icon(Icons.close, color: Colors.white),
onPressed: () {
game.overlays.remove(SettingsUI.overlayID);
game.overlays.add(MainMenuUI.overlayID);
},
),
],
),
SizedBox(height: 24),
// Language selector
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Language / Sprog / Sprache:', style: TextStyle(color: Colors.white)),
DropdownButton<String>(
value: context.locale.languageCode,
dropdownColor: Colors.black,
style: TextStyle(color: Colors.white),
items: [
DropdownMenuItem(value: 'en', child: Text('English', style: TextStyle(color: Colors.white))),
DropdownMenuItem(value: 'da', child: Text('Dansk', style: TextStyle(color: Colors.white))),
DropdownMenuItem(value: 'de', child: Text('Deutsch', style: TextStyle(color: Colors.white))),
],
onChanged: (String? newValue) {
if (newValue != null) {
context.setLocale(Locale(newValue));
}
},
),
],
),
SizedBox(height: 16),
// Debug mode toggle
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('ui.debug_mode'.tr(), style: TextStyle(color: Colors.white)),
NesCheckBox(
value: false, // TODO: Connect to settings
onChange: (value) {
// TODO: Update settings
},
),
],
),
SizedBox(height: 40),
Center(
child: NesButton(
type: NesButtonType.primary,
onPressed: () {
game.overlays.remove(SettingsUI.overlayID);
game.overlays.add(MainMenuUI.overlayID);
},
child: Text('menu.back_to_menu'.tr()),
),
),
],
),
),
),
),
);
}
}
class PauseMenuUI extends StatelessWidget {
static const String overlayID = 'PauseMenu';
final ShitmanGame game;
const PauseMenuUI(this.game, {super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black54,
child: Center(
child: NesContainer(
backgroundColor: Colors.black,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'ui.paused'.tr(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
),
),
SizedBox(height: 24),
NesButton(
type: NesButtonType.primary,
onPressed: () => game.overlays.remove(PauseMenuUI.overlayID),
child: Text('menu.resume'.tr()),
),
SizedBox(height: 12),
NesButton(
type: NesButtonType.normal,
onPressed: () {
game.overlays.remove(PauseMenuUI.overlayID);
game.overlays.remove(InGameUI.overlayID);
game.overlays.add(SettingsUI.overlayID);
},
child: Text('menu.settings'.tr()),
),
SizedBox(height: 12),
NesButton(
type: NesButtonType.warning,
onPressed: () {
game.overlays.remove(PauseMenuUI.overlayID);
game.overlays.remove(InGameUI.overlayID);
game.overlays.add(MainMenuUI.overlayID);
game.stopGame();
},
child: Text('menu.main_menu'.tr()),
),
],
),
),
),
),
);
}
}

View file

@ -6,7 +6,9 @@ import FlutterMacOS
import Foundation
import path_provider_foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View file

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -57,6 +65,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
easy_localization:
dependency: "direct main"
description:
name: easy_localization
sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12"
url: "https://pub.dev"
source: hosted
version: "3.0.7+1"
easy_logger:
dependency: transitive
description:
name: easy_logger
sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7
url: "https://pub.dev"
source: hosted
version: "0.0.2"
equatable:
dependency: transitive
description:
@ -81,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flame:
dependency: "direct main"
description:
@ -102,6 +134,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_mini_sprite:
dependency: transitive
description:
@ -115,6 +152,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
@ -139,6 +181,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
leak_tracker:
dependency: transitive
description:
@ -299,6 +349,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
url: "https://pub.dev"
source: hosted
version: "2.4.10"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter

View file

@ -1,92 +1,29 @@
name: shitman
description: "Hitman, but with shit."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: 'none'
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.7.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flame: ^1.30.1
nes_ui: ^0.25.0
google_fonts: ^6.2.1
easy_localization: ^3.0.7+1
shared_preferences: ^2.2.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: false
# To add assets to your application, add an assets section, like this:
uses-material-design: true
assets:
- assets/google_fonts/
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
- assets/translations/

View file

@ -13,7 +13,7 @@ import 'package:shitman/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(const Shitman());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);