233 lines
7.2 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|