mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart
2026-07-17 15:23:12 +02:00

233 lines
7.2 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
/// Taquin classique (15-puzzle) : une case vide, seule une tuile adjacente à
/// la case vide peut glisser dedans. Équivalent de `SlidingPuzzle.tsx` (web).
class GuidedPathSlidingPuzzle extends StatefulWidget {
final String imageUrl;
final int rows;
final int cols;
final Color accentColor;
final bool isGame;
final VoidCallback onSolved;
const GuidedPathSlidingPuzzle({
Key? key,
required this.imageUrl,
required this.rows,
required this.cols,
required this.accentColor,
required this.isGame,
required this.onSolved,
}) : super(key: key);
@override
State<GuidedPathSlidingPuzzle> createState() => _GuidedPathSlidingPuzzleState();
}
class _GuidedPathSlidingPuzzleState extends State<GuidedPathSlidingPuzzle> {
// tiles[slot] = pièce d'origine occupant ce slot ; -1 = case vide.
late List<int> _tiles;
bool _won = false;
bool _showHint = false;
int get _total => widget.rows * widget.cols;
@override
void initState() {
super.initState();
_shuffle();
}
@override
void didUpdateWidget(GuidedPathSlidingPuzzle old) {
super.didUpdateWidget(old);
if (old.rows != widget.rows || old.cols != widget.cols) _shuffle();
}
List<int> _neighboursOf(int idx) {
final row = idx ~/ widget.cols;
final col = idx % widget.cols;
final list = <int>[];
if (row > 0) list.add(idx - widget.cols);
if (row < widget.rows - 1) list.add(idx + widget.cols);
if (col > 0) list.add(idx - 1);
if (col < widget.cols - 1) list.add(idx + 1);
return list;
}
void _shuffle() {
final arr = List.generate(_total - 1, (i) => i)..add(-1);
var emptyIdx = _total - 1;
final rand = Random();
for (int n = 0; n < 200; n++) {
final neighbours = _neighboursOf(emptyIdx);
final pick = neighbours[rand.nextInt(neighbours.length)];
final tmp = arr[emptyIdx];
arr[emptyIdx] = arr[pick];
arr[pick] = tmp;
emptyIdx = pick;
}
_tiles = arr;
_won = false;
}
void _onTapSlot(int slotIdx) {
if (_won) return;
final emptyIdx = _tiles.indexOf(-1);
if (!_neighboursOf(emptyIdx).contains(slotIdx)) return;
setState(() {
final tmp = _tiles[emptyIdx];
_tiles[emptyIdx] = _tiles[slotIdx];
_tiles[slotIdx] = tmp;
final win = _tiles.asMap().entries.every(
(e) => e.key == _tiles.length - 1 ? e.value == -1 : e.value == e.key);
if (win) {
_won = true;
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted) widget.onSolved();
});
}
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final board = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width - 64;
final tileW = board / widget.cols;
final tileH = board / widget.rows;
return SizedBox(
width: board,
height: board,
child: Stack(
children: [
if (_showHint && !_won)
Positioned.fill(
child: Opacity(
opacity: 0.25,
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
),
),
for (int slot = 0; slot < _total; slot++)
if (_tiles[slot] != -1)
AnimatedPositioned(
key: ValueKey('slide-${_tiles[slot]}'),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
left: (slot % widget.cols) * tileW,
top: (slot ~/ widget.cols) * tileH,
width: tileW,
height: tileH,
child: _SlidingTile(
piece: _tiles[slot],
rows: widget.rows,
cols: widget.cols,
boardSize: board,
imageUrl: widget.imageUrl,
onTap: () => _onTapSlot(slot),
),
),
if (_won)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: const Text(
'Puzzle résolu !',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
),
),
),
if (!_won)
Positioned(
right: 8,
bottom: 8,
child: GestureDetector(
onTap: () => setState(() => _showHint = !_showHint),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _showHint
? widget.accentColor
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
),
child: Icon(
Icons.remove_red_eye,
size: 18,
color: _showHint
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
),
),
),
),
],
),
);
},
);
}
}
class _SlidingTile extends StatelessWidget {
final int piece;
final int rows;
final int cols;
final double boardSize;
final String imageUrl;
final VoidCallback onTap;
const _SlidingTile({
required this.piece,
required this.rows,
required this.cols,
required this.boardSize,
required this.imageUrl,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final row = piece ~/ cols;
final col = piece % cols;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 6)],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: OverflowBox(
maxWidth: boardSize,
maxHeight: boardSize,
alignment: Alignment(
cols == 1 ? 0.0 : -1.0 + (col * 2 / (cols - 1)),
rows == 1 ? 0.0 : -1.0 + (row * 2 / (rows - 1)),
),
child: SizedBox(
width: boardSize,
height: boardSize,
child: Image.network(imageUrl, fit: BoxFit.cover),
),
),
),
),
);
}
}