559 lines
20 KiB
Dart
559 lines
20 KiB
Dart
import 'dart:math';
|
|
import 'dart:async';
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
|
|
import 'package:mymuseum_visitapp/app_context.dart';
|
|
import 'package:mymuseum_visitapp/constants.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:mymuseum_visitapp/Screens/Sections/Game/sliding_puzzle_piece.dart';
|
|
import 'puzzle_piece.dart';
|
|
|
|
const IMAGE_PATH = 'image_path';
|
|
|
|
class GamePage extends StatefulWidget {
|
|
final GameDTO section;
|
|
GamePage({required this.section});
|
|
|
|
@override
|
|
_GamePage createState() => _GamePage();
|
|
}
|
|
|
|
class _GamePage extends State<GamePage> {
|
|
GameDTO gameDTO = GameDTO();
|
|
|
|
int allInPlaceCount = 0;
|
|
bool isFinished = false;
|
|
DateTime? _gameStartTime;
|
|
GlobalKey _widgetKey = GlobalKey();
|
|
Size? realWidgetSize;
|
|
List<Widget> pieces = [];
|
|
|
|
bool isSplittingImage = true;
|
|
bool showHint = false;
|
|
List<int> slidingTileIndices = []; // Maps current slot index to original tile index. -1 for empty.
|
|
int emptySlotIndex = -1;
|
|
|
|
@override
|
|
void initState() {
|
|
//puzzleDTO = PuzzleDTO.fromJson(jsonDecode(widget.section!.data!))!;
|
|
gameDTO = widget.section;
|
|
gameDTO.rows = gameDTO.rows ?? 3;
|
|
gameDTO.cols = gameDTO.cols ?? 3;
|
|
_gameStartTime = DateTime.now();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
Size size = MediaQuery.of(context).size;
|
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
|
VisitAppContext visitAppContext = appContext.getContext();
|
|
|
|
print(gameDTO.messageDebut);
|
|
TranslationAndResourceDTO? messageDebut = gameDTO.messageDebut != null && gameDTO.messageDebut!.isNotEmpty ? gameDTO.messageDebut!.where((message) => message.language!.toUpperCase() == visitAppContext.language!.toUpperCase()).firstOrNull : null;
|
|
|
|
//await Future.delayed(const Duration(milliseconds: 50));
|
|
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
getRealWidgetSize();
|
|
|
|
if(gameDTO.puzzleImage != null && gameDTO.puzzleImage!.url != null) {
|
|
//splitImage(Image.network(puzzleDTO.image!.resourceUrl!));
|
|
splitImage(CachedNetworkImage(
|
|
imageUrl: gameDTO.puzzleImage!.url!,
|
|
fit: BoxFit.fill,
|
|
errorWidget: (context, url, error) => Icon(Icons.error),
|
|
));
|
|
} else {
|
|
setState(() {
|
|
isSplittingImage = false;
|
|
});
|
|
}
|
|
|
|
if(messageDebut != null) {
|
|
showMessage(messageDebut, appContext, context, size);
|
|
}
|
|
});
|
|
|
|
super.initState();
|
|
}
|
|
|
|
Future<void> getRealWidgetSize() async {
|
|
RenderBox renderBox = _widgetKey.currentContext?.findRenderObject() as RenderBox;
|
|
Size size = renderBox.size;
|
|
|
|
setState(() {
|
|
realWidgetSize = size;
|
|
});
|
|
print("Taille réelle du widget : $size");
|
|
}
|
|
|
|
// here we will split the image into small pieces
|
|
// using the rows and columns defined above; each piece will be added to a stack
|
|
void splitImage(CachedNetworkImage image) async {
|
|
final Completer<Size> completer = Completer<Size>();
|
|
final ImageProvider provider = CachedNetworkImageProvider(gameDTO.puzzleImage!.url!);
|
|
|
|
provider.resolve(const ImageConfiguration()).addListener(
|
|
ImageStreamListener((ImageInfo info, bool _) {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(Size(info.image.width.toDouble(), info.image.height.toDouble()));
|
|
}
|
|
}),
|
|
);
|
|
|
|
Size imageOriginalSize = await completer.future;
|
|
double imageAspectRatio = imageOriginalSize.width / imageOriginalSize.height;
|
|
|
|
// Calculate best fit for the puzzle inside the available area
|
|
double containerWidth = realWidgetSize!.width * 0.9; // 90% of available width
|
|
double containerHeight = realWidgetSize!.height * 0.8; // 80% of available height
|
|
double containerAspectRatio = containerWidth / containerHeight;
|
|
|
|
double puzzleWidth, puzzleHeight;
|
|
if (imageAspectRatio > containerAspectRatio) {
|
|
puzzleWidth = containerWidth;
|
|
puzzleHeight = containerWidth / imageAspectRatio;
|
|
} else {
|
|
puzzleHeight = containerHeight;
|
|
puzzleWidth = containerHeight * imageAspectRatio;
|
|
}
|
|
|
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
|
VisitAppContext visitAppContext = appContext.getContext();
|
|
setState(() {
|
|
visitAppContext.puzzleSize = Size(puzzleWidth, puzzleHeight);
|
|
appContext.setContext(visitAppContext);
|
|
});
|
|
|
|
final pieceWidth = puzzleWidth / gameDTO.cols!;
|
|
final pieceHeight = puzzleHeight / gameDTO.rows!;
|
|
|
|
if (gameDTO.gameType == GameTypes.SlidingPuzzle) {
|
|
// Initialize sliding puzzle slots: 0 to N-2 are tiles, last one is empty (-1)
|
|
int totalTiles = gameDTO.rows! * gameDTO.cols!;
|
|
slidingTileIndices = List.generate(totalTiles, (i) => i);
|
|
|
|
// Shuffle by making random valid moves to ensure solvability
|
|
emptySlotIndex = totalTiles - 1;
|
|
slidingTileIndices[emptySlotIndex] = -1; // -1 represents the empty slot
|
|
|
|
// Perform enough random moves to shuffle decently
|
|
int shuffleMoves = 100;
|
|
Random random = Random();
|
|
for (int i = 0; i < shuffleMoves; i++) {
|
|
List<int> adjacent = _getAdjacentIndices(emptySlotIndex, gameDTO.rows!, gameDTO.cols!);
|
|
int moveIndex = adjacent[random.nextInt(adjacent.length)];
|
|
// Swap empty slot with the adjacent tile
|
|
slidingTileIndices[emptySlotIndex] = slidingTileIndices[moveIndex];
|
|
slidingTileIndices[moveIndex] = -1;
|
|
emptySlotIndex = moveIndex;
|
|
}
|
|
} else {
|
|
for (int x = 0; x < gameDTO.rows!; x++) {
|
|
for (int y = 0; y < gameDTO.cols!; y++) {
|
|
// Target position in the puzzle grid
|
|
double targetLeft = y * pieceWidth;
|
|
double targetTop = x * pieceHeight;
|
|
|
|
// Scatter logic: Randomly place within the container.
|
|
double initialLeft = Random().nextDouble() * (containerWidth - pieceWidth);
|
|
double initialTop = Random().nextDouble() * (containerHeight - pieceHeight);
|
|
|
|
setState(() {
|
|
pieces.add(
|
|
PuzzlePiece(
|
|
key: GlobalKey(),
|
|
image: image,
|
|
imageSize: Size(puzzleWidth, puzzleHeight),
|
|
row: x,
|
|
col: y,
|
|
maxRow: gameDTO.rows!,
|
|
maxCol: gameDTO.cols!,
|
|
bringToTop: bringToTop,
|
|
sendToBack: sendToBack,
|
|
initialLeft: initialLeft - targetLeft,
|
|
initialTop: initialTop - targetTop,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
isSplittingImage = false;
|
|
});
|
|
}
|
|
|
|
List<int> _getAdjacentIndices(int index, int rows, int cols) {
|
|
List<int> adjacent = [];
|
|
int r = index ~/ cols;
|
|
int c = index % cols;
|
|
|
|
if (r > 0) adjacent.add(index - cols); // Top
|
|
if (r < rows - 1) adjacent.add(index + cols); // Bottom
|
|
if (c > 0) adjacent.add(index - 1); // Left
|
|
if (c < cols - 1) adjacent.add(index + 1); // Right
|
|
|
|
return adjacent;
|
|
}
|
|
|
|
void _onSlidingTileTapped(int currentSlot) {
|
|
if (isFinished) return;
|
|
|
|
// Check if empty slot is adjacent
|
|
List<int> adjacent = _getAdjacentIndices(currentSlot, gameDTO.rows!, gameDTO.cols!);
|
|
if (adjacent.contains(emptySlotIndex)) {
|
|
setState(() {
|
|
// Swap
|
|
slidingTileIndices[emptySlotIndex] = slidingTileIndices[currentSlot];
|
|
slidingTileIndices[currentSlot] = -1;
|
|
emptySlotIndex = currentSlot;
|
|
|
|
// Check win condition
|
|
bool won = true;
|
|
for (int i = 0; i < slidingTileIndices.length; i++) {
|
|
// In a solved puzzle, index i should contain tile i (or -1 at the very last slot)
|
|
if (i == slidingTileIndices.length - 1) {
|
|
if (slidingTileIndices[i] != -1) won = false;
|
|
} else {
|
|
if (slidingTileIndices[i] != i) won = false;
|
|
}
|
|
}
|
|
|
|
if (won) {
|
|
isFinished = true;
|
|
_onGameFinished('SlidingPuzzle');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// when the pan of a piece starts, we need to bring it to the front of the stack
|
|
void bringToTop(Widget widget) {
|
|
setState(() {
|
|
pieces.remove(widget);
|
|
pieces.add(widget);
|
|
});
|
|
}
|
|
|
|
// when a piece reaches its final position,
|
|
// it will be sent to the back of the stack to not get in the way of other, still movable, pieces
|
|
void sendToBack(Widget widget) {
|
|
setState(() {
|
|
allInPlaceCount++;
|
|
isFinished = allInPlaceCount == gameDTO.rows! * gameDTO.cols!;
|
|
pieces.remove(widget);
|
|
pieces.insert(0, widget);
|
|
|
|
if (isFinished) {
|
|
_onGameFinished('Puzzle');
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onGameFinished(String gameType) {
|
|
Size size = MediaQuery.of(context).size;
|
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
|
VisitAppContext visitAppContext = appContext.getContext();
|
|
final duration = _gameStartTime != null ? DateTime.now().difference(_gameStartTime!).inSeconds : 0;
|
|
|
|
visitAppContext.statisticsService?.track(
|
|
VisitEventType.gameComplete,
|
|
metadata: {'gameType': gameType, 'durationSeconds': duration},
|
|
);
|
|
|
|
TranslationAndResourceDTO? messageFin = gameDTO.messageFin != null && gameDTO.messageFin!.isNotEmpty ? gameDTO.messageFin!.where((message) => message.language!.toUpperCase() == visitAppContext.language!.toUpperCase()).firstOrNull : null;
|
|
|
|
if(messageFin != null) {
|
|
showMessage(messageFin, appContext, context, size);
|
|
}
|
|
}
|
|
|
|
Widget _buildContent(VisitAppContext visitAppContext) {
|
|
if (gameDTO.gameType == GameTypes.Escape) {
|
|
final paths = gameDTO.guidedPaths ?? [];
|
|
if (paths.isEmpty) {
|
|
return Center(
|
|
child: Text('Aucun parcours disponible', style: TextStyle(color: Colors.grey[500], fontSize: 15)),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: paths.length,
|
|
itemBuilder: (_, i) {
|
|
final path = paths[i];
|
|
final title = TranslationHelper.get(path.title, visitAppContext);
|
|
final desc = TranslationHelper.get(path.description, visitAppContext);
|
|
final stepCount = path.steps?.length ?? 0;
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.all(12),
|
|
leading: Container(
|
|
width: 42,
|
|
height: 42,
|
|
decoration: BoxDecoration(
|
|
color: kMainColor.withOpacity(0.12),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.explore, color: kMainColor, size: 22),
|
|
),
|
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
|
subtitle: Text(
|
|
desc.isNotEmpty ? desc : '$stepCount étape${stepCount > 1 ? 's' : ''}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
|
),
|
|
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
|
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => GuidedPathContentProgressionPage(
|
|
path: path,
|
|
visitAppContext: visitAppContext,
|
|
),
|
|
)),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
if (gameDTO.gameType == GameTypes.SlidingPuzzle) {
|
|
if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());
|
|
|
|
final puzzleSize = visitAppContext.puzzleSize ?? Size(realWidgetSize!.width * 0.8, realWidgetSize!.height * 0.6);
|
|
final tileWidth = puzzleSize.width / gameDTO.cols!;
|
|
final tileHeight = puzzleSize.height / gameDTO.rows!;
|
|
|
|
return Center(
|
|
child: Container(
|
|
width: puzzleSize.width,
|
|
height: puzzleSize.height,
|
|
child: Stack(
|
|
children: [
|
|
// Hint Background
|
|
if (showHint)
|
|
Opacity(
|
|
opacity: 0.25,
|
|
child: CachedNetworkImage(
|
|
imageUrl: gameDTO.puzzleImage!.url!,
|
|
fit: BoxFit.fill,
|
|
),
|
|
),
|
|
// Tiles
|
|
for (int i = 0; i < slidingTileIndices.length; i++)
|
|
if (slidingTileIndices[i] != -1) // Don't draw the empty slot
|
|
AnimatedPositioned(
|
|
key: ValueKey('tile_${slidingTileIndices[i]}'),
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeInOut,
|
|
left: (i % gameDTO.cols!) * tileWidth,
|
|
top: (i ~/ gameDTO.cols!) * tileHeight,
|
|
child: SlidingPuzzlePiece(
|
|
image: CachedNetworkImage(imageUrl: gameDTO.puzzleImage!.url!, fit: BoxFit.fill),
|
|
imageSize: puzzleSize,
|
|
originalRow: slidingTileIndices[i] ~/ gameDTO.cols!,
|
|
originalCol: slidingTileIndices[i] % gameDTO.cols!,
|
|
maxRows: gameDTO.rows!,
|
|
maxCols: gameDTO.cols!,
|
|
width: tileWidth,
|
|
height: tileHeight,
|
|
showNumberHint: showHint,
|
|
onTap: () => _onSlidingTileTapped(i),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Default: Puzzle
|
|
if (gameDTO.puzzleImage == null || gameDTO.puzzleImage!.url == null || realWidgetSize == null) {
|
|
return Center(child: Text("Aucune image à afficher", style: TextStyle(fontSize: kNoneInfoOrIncorrect)));
|
|
}
|
|
|
|
final puzzleSize = visitAppContext.puzzleSize ?? Size(realWidgetSize!.width * 0.8, realWidgetSize!.height * 0.6);
|
|
|
|
return Center(
|
|
child: Container(
|
|
width: puzzleSize.width,
|
|
height: puzzleSize.height,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Hint Background
|
|
if (showHint)
|
|
Opacity(
|
|
opacity: 0.2, // Unified opacity for both
|
|
child: CachedNetworkImage(
|
|
imageUrl: gameDTO.puzzleImage!.url!,
|
|
fit: BoxFit.fill,
|
|
),
|
|
),
|
|
...pieces,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final appContext = Provider.of<AppContext>(context);
|
|
VisitAppContext visitAppContext = appContext.getContext();
|
|
Size size = MediaQuery.of(context).size;
|
|
var title = TranslationHelper.get(widget.section.title, appContext.getContext());
|
|
String cleanedTitle = title.replaceAll('\n', ' ').replaceAll('<br>', ' ');
|
|
|
|
return Stack(
|
|
children: [
|
|
Container(
|
|
height: size.height * 0.28,
|
|
decoration: BoxDecoration(
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: kMainGrey,
|
|
spreadRadius: 0.5,
|
|
blurRadius: 5,
|
|
offset: Offset(0, 1), // changes position of shadow
|
|
),
|
|
],
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.centerRight,
|
|
end: Alignment.centerLeft,
|
|
colors: [
|
|
/*Color(0xFFDD79C2),
|
|
Color(0xFFB65FBE),
|
|
Color(0xFF9146BA),
|
|
Color(0xFF7633B8),
|
|
Color(0xFF6528B6),
|
|
Color(0xFF6025B6)*/
|
|
kMainColor0, //Color(0xFFf6b3c4)
|
|
kMainColor1,
|
|
kMainColor2,
|
|
|
|
],
|
|
),
|
|
image: widget.section.imageSource != null ? DecorationImage(
|
|
fit: BoxFit.cover,
|
|
opacity: 0.65,
|
|
image: NetworkImage(
|
|
widget.section.imageSource!,
|
|
),
|
|
): null,
|
|
),
|
|
),
|
|
Column(
|
|
children: <Widget>[
|
|
SizedBox(
|
|
height: size.height * 0.11,
|
|
width: size.width,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 22.0),
|
|
child: SizedBox(
|
|
width: size.width *0.7,
|
|
child: HtmlWidget(
|
|
cleanedTitle,
|
|
textStyle: const TextStyle(color: Colors.white, fontFamily: 'Roboto', fontSize: 20),
|
|
customStylesBuilder: (element)
|
|
{
|
|
return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"};
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 35,
|
|
left: 10,
|
|
child: SizedBox(
|
|
width: 50,
|
|
height: 50,
|
|
child: InkWell(
|
|
onTap: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
color: kMainColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
|
|
),
|
|
)
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
margin: const EdgeInsets.only(top: 0),
|
|
decoration: const BoxDecoration(
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: kMainGrey,
|
|
spreadRadius: 0.5,
|
|
blurRadius: 2,
|
|
offset: Offset(0, 1), // changes position of shadow
|
|
),
|
|
],
|
|
color: kBackgroundColor,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(30),
|
|
topRight: Radius.circular(30),
|
|
),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(30),
|
|
topRight: Radius.circular(30),
|
|
),
|
|
child: Center(
|
|
//color: Colors.green,
|
|
child: Container(
|
|
//color: Colors.green,
|
|
child: Padding(
|
|
key: _widgetKey,
|
|
padding: const EdgeInsets.all(0.0),
|
|
child: isSplittingImage ? Center(child: LoadingCommon()) : _buildContent(visitAppContext),
|
|
),
|
|
),
|
|
)
|
|
)
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (gameDTO.gameType == null || gameDTO.gameType == GameTypes.Puzzle || gameDTO.gameType == GameTypes.SlidingPuzzle)
|
|
Positioned(
|
|
bottom: 25,
|
|
right: 20,
|
|
child: FloatingActionButton(
|
|
heroTag: 'hint_button',
|
|
onPressed: () {
|
|
setState(() {
|
|
showHint = !showHint;
|
|
});
|
|
},
|
|
backgroundColor: showHint ? kMainColor : Colors.grey[400],
|
|
child: const Icon(Icons.help_outline, color: Colors.white, size: 28),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |