Add playable core UI: board painter, drag-and-drop, HUD, result overlay
CustomPainter board with gems/ghost/clear-flash, finger-lifted drag with snap preview, combo text effect, HUD chips, phase overlays with rescue stubs, demo stage. E2E widget test drives a real drag gesture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import '../../game/models/grid.dart';
|
||||
import '../../game/models/piece.dart';
|
||||
|
||||
/// Pure mapping between board-local pixels and grid cells.
|
||||
class BoardGeometry {
|
||||
const BoardGeometry({required this.boardSize});
|
||||
|
||||
final double boardSize;
|
||||
|
||||
double get cellSize => boardSize / GridState.size;
|
||||
|
||||
Rect cellRect(int x, int y) =>
|
||||
Rect.fromLTWH(x * cellSize, y * cellSize, cellSize, cellSize);
|
||||
|
||||
/// Cell under a local point, or null outside the board.
|
||||
(int, int)? cellAt(Offset local) {
|
||||
if (local.dx < 0 || local.dy < 0) return null;
|
||||
if (local.dx >= boardSize || local.dy >= boardSize) return null;
|
||||
return (local.dx ~/ cellSize, local.dy ~/ cellSize);
|
||||
}
|
||||
|
||||
/// Nearest anchor for a piece whose visual top-left sits at [pieceTopLeft],
|
||||
/// clamped so the piece's bounding box stays on the board.
|
||||
(int, int) snapAnchor(Piece piece, Offset pieceTopLeft) {
|
||||
var maxDx = 0;
|
||||
var maxDy = 0;
|
||||
for (final (dx, dy) in piece.offsets) {
|
||||
maxDx = math.max(maxDx, dx);
|
||||
maxDy = math.max(maxDy, dy);
|
||||
}
|
||||
final x = (pieceTopLeft.dx / cellSize).round();
|
||||
final y = (pieceTopLeft.dy / cellSize).round();
|
||||
return (
|
||||
x.clamp(0, GridState.size - 1 - maxDx),
|
||||
y.clamp(0, GridState.size - 1 - maxDy),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/models/cell.dart';
|
||||
import '../../game/models/grid.dart';
|
||||
import '../../game/models/piece.dart';
|
||||
import '../theme/palette.dart';
|
||||
import 'board_geometry.dart';
|
||||
import 'piece_painter.dart';
|
||||
|
||||
/// Drag ghost preview: a piece hovering at a snapped anchor.
|
||||
class GhostSpec {
|
||||
const GhostSpec({
|
||||
required this.piece,
|
||||
required this.anchorX,
|
||||
required this.anchorY,
|
||||
required this.legal,
|
||||
});
|
||||
|
||||
final Piece piece;
|
||||
final int anchorX;
|
||||
final int anchorY;
|
||||
final bool legal;
|
||||
}
|
||||
|
||||
class BoardPainter extends CustomPainter {
|
||||
const BoardPainter({
|
||||
required this.grid,
|
||||
required this.ghost,
|
||||
required this.flashProgress,
|
||||
required this.flashRows,
|
||||
required this.flashCols,
|
||||
});
|
||||
|
||||
final GridState grid;
|
||||
final GhostSpec? ghost;
|
||||
|
||||
/// 1.0 -> 0.0 while the clear flash fades.
|
||||
final double flashProgress;
|
||||
final List<int> flashRows;
|
||||
final List<int> flashCols;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final geo = BoardGeometry(boardSize: size.width);
|
||||
final radius = Radius.circular(geo.cellSize * 0.18);
|
||||
final inset = geo.cellSize * 0.05;
|
||||
|
||||
final bg = Paint()..color = GamePalette.boardBackground;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(12)),
|
||||
bg,
|
||||
);
|
||||
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
final rect = geo.cellRect(x, y).deflate(inset);
|
||||
final cell = grid.cellAt(x, y);
|
||||
final paint = Paint()
|
||||
..color = switch (cell.type) {
|
||||
CellType.empty => GamePalette.emptyCell,
|
||||
CellType.filled => GamePalette.tile(cell.colorId),
|
||||
CellType.gem => GamePalette.emptyCell,
|
||||
};
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
||||
|
||||
if (cell.type == CellType.gem) {
|
||||
_paintGem(canvas, rect);
|
||||
} else if (cell.type == CellType.filled) {
|
||||
final highlight = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.15);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(
|
||||
rect.left, rect.top, rect.width, rect.height * 0.32),
|
||||
radius,
|
||||
),
|
||||
highlight,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final g = ghost;
|
||||
if (g != null) {
|
||||
paintPiece(
|
||||
canvas,
|
||||
g.piece,
|
||||
cellSize: geo.cellSize,
|
||||
origin: Offset(g.anchorX * geo.cellSize, g.anchorY * geo.cellSize),
|
||||
overrideColor:
|
||||
g.legal ? GamePalette.ghostLegal : GamePalette.ghostIllegal,
|
||||
);
|
||||
}
|
||||
|
||||
if (flashProgress > 0) {
|
||||
final flash = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.75 * flashProgress);
|
||||
for (final y in flashRows) {
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, y * geo.cellSize, size.width, geo.cellSize),
|
||||
flash,
|
||||
);
|
||||
}
|
||||
for (final x in flashCols) {
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(x * geo.cellSize, 0, geo.cellSize, size.height),
|
||||
flash,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _paintGem(Canvas canvas, Rect rect) {
|
||||
final center = rect.center;
|
||||
final r = rect.width * 0.32;
|
||||
final path = Path()
|
||||
..moveTo(center.dx, center.dy - r)
|
||||
..lineTo(center.dx + r, center.dy)
|
||||
..lineTo(center.dx, center.dy + r)
|
||||
..lineTo(center.dx - r, center.dy)
|
||||
..close();
|
||||
canvas.drawPath(path, Paint()..color = GamePalette.gem);
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.6)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = rect.width * 0.05,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(BoardPainter old) =>
|
||||
old.grid != grid ||
|
||||
old.ghost != ghost ||
|
||||
old.flashProgress != flashProgress;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../state/game_session_notifier.dart';
|
||||
import 'board_painter.dart';
|
||||
|
||||
/// The board with clear-flash and combo-text effects. Pure display: drag
|
||||
/// orchestration lives in the game screen.
|
||||
class BoardWidget extends StatefulWidget {
|
||||
const BoardWidget({super.key, required this.view, required this.ghost});
|
||||
|
||||
final GameViewState view;
|
||||
final GhostSpec? ghost;
|
||||
|
||||
@override
|
||||
State<BoardWidget> createState() => _BoardWidgetState();
|
||||
}
|
||||
|
||||
class _BoardWidgetState extends State<BoardWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _flash = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
|
||||
List<int> _flashRows = const [];
|
||||
List<int> _flashCols = const [];
|
||||
int _comboStreak = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(BoardWidget old) {
|
||||
super.didUpdateWidget(old);
|
||||
final placement = widget.view.lastPlacement;
|
||||
if (widget.view.fxTick != old.view.fxTick &&
|
||||
placement != null &&
|
||||
placement.linesCleared > 0) {
|
||||
_flashRows = placement.clearedRows;
|
||||
_flashCols = placement.clearedCols;
|
||||
_comboStreak = placement.comboStreak;
|
||||
_flash.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_flash.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AnimatedBuilder(
|
||||
animation: _flash,
|
||||
builder: (context, _) {
|
||||
final fading = _flash.isAnimating ? 1 - _flash.value : 0.0;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: BoardPainter(
|
||||
grid: widget.view.grid,
|
||||
ghost: widget.ghost,
|
||||
flashProgress: fading,
|
||||
flashRows: _flashRows,
|
||||
flashCols: _flashCols,
|
||||
),
|
||||
),
|
||||
if (_flash.isAnimating && _comboStreak >= 2)
|
||||
Center(
|
||||
child: Transform.scale(
|
||||
scale: 0.8 + 0.6 * _flash.value,
|
||||
child: Opacity(
|
||||
opacity: 1 - _flash.value,
|
||||
child: Text(
|
||||
'COMBO x$_comboStreak',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.amber.shade300,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 12, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/models/objective.dart';
|
||||
import '../../state/game_session_notifier.dart';
|
||||
|
||||
class HudWidget extends StatelessWidget {
|
||||
const HudWidget({super.key, required this.view});
|
||||
|
||||
final GameViewState view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_movesChip(theme),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (child, anim) =>
|
||||
ScaleTransition(scale: anim, child: child),
|
||||
child: Text(
|
||||
'${view.score}',
|
||||
key: ValueKey(view.score),
|
||||
style: theme.textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
_comboChip(theme),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final obj in view.objectives) ...[
|
||||
_objectiveChip(theme, obj),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _movesChip(ThemeData theme) {
|
||||
return Chip(
|
||||
avatar: const Icon(Icons.swipe, size: 18),
|
||||
label: Text('${view.movesLeft}'),
|
||||
labelStyle: theme.textTheme.titleMedium,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _comboChip(ThemeData theme) {
|
||||
final visible = view.comboStreak >= 2;
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: visible ? 1 : 0,
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.local_fire_department,
|
||||
size: 18, color: Colors.deepOrange),
|
||||
label: Text('x${view.comboStreak}'),
|
||||
labelStyle: theme.textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _objectiveChip(ThemeData theme, Objective obj) {
|
||||
final (icon, color) = switch (obj) {
|
||||
ClearGemsObjective() => (Icons.diamond, const Color(0xFF7CF5FF)),
|
||||
ReachScoreObjective() => (Icons.star, Colors.amber),
|
||||
ClearLinesObjective() => (Icons.table_rows, Colors.lightGreen),
|
||||
};
|
||||
final done = obj.isComplete;
|
||||
return Chip(
|
||||
avatar: Icon(done ? Icons.check_circle : icon, size: 18, color: color),
|
||||
label: Text('${obj.current.clamp(0, obj.target)}/${obj.target}'),
|
||||
labelStyle: theme.textTheme.titleSmall,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/models/piece.dart';
|
||||
import '../theme/palette.dart';
|
||||
|
||||
/// Draws a piece as rounded tiles at a given cell size; reused by the tray,
|
||||
/// the drag overlay, and ghost previews.
|
||||
void paintPiece(
|
||||
Canvas canvas,
|
||||
Piece piece, {
|
||||
required double cellSize,
|
||||
Offset origin = Offset.zero,
|
||||
Color? overrideColor,
|
||||
}) {
|
||||
final paint = Paint()
|
||||
..color = overrideColor ?? GamePalette.tile(piece.colorId);
|
||||
final inset = cellSize * 0.05;
|
||||
final radius = Radius.circular(cellSize * 0.18);
|
||||
for (final (dx, dy) in piece.offsets) {
|
||||
final rect = Rect.fromLTWH(
|
||||
origin.dx + dx * cellSize + inset,
|
||||
origin.dy + dy * cellSize + inset,
|
||||
cellSize - inset * 2,
|
||||
cellSize - inset * 2,
|
||||
);
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
||||
if (overrideColor == null) {
|
||||
// Subtle top highlight for depth.
|
||||
final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32),
|
||||
radius,
|
||||
),
|
||||
highlight,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounding size of a piece in cells.
|
||||
(int, int) pieceCellBounds(Piece piece) {
|
||||
var w = 0;
|
||||
var h = 0;
|
||||
for (final (dx, dy) in piece.offsets) {
|
||||
if (dx + 1 > w) w = dx + 1;
|
||||
if (dy + 1 > h) h = dy + 1;
|
||||
}
|
||||
return (w, h);
|
||||
}
|
||||
|
||||
class PieceWidget extends StatelessWidget {
|
||||
const PieceWidget({super.key, required this.piece, required this.cellSize});
|
||||
|
||||
final Piece piece;
|
||||
final double cellSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (w, h) = pieceCellBounds(piece);
|
||||
return CustomPaint(
|
||||
size: Size(w * cellSize, h * cellSize),
|
||||
painter: _PiecePainter(piece, cellSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PiecePainter extends CustomPainter {
|
||||
const _PiecePainter(this.piece, this.cellSize);
|
||||
|
||||
final Piece piece;
|
||||
final double cellSize;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
paintPiece(canvas, piece, cellSize: cellSize);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PiecePainter old) =>
|
||||
old.piece != piece || old.cellSize != cellSize;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/models/piece.dart';
|
||||
import 'piece_painter.dart';
|
||||
|
||||
/// The 3-slot piece tray. Reports raw pan gestures up; the game screen owns
|
||||
/// drag state so the lifted piece can float over the board.
|
||||
class TrayWidget extends StatelessWidget {
|
||||
const TrayWidget({
|
||||
super.key,
|
||||
required this.tray,
|
||||
required this.draggingIndex,
|
||||
required this.onDragStart,
|
||||
required this.onDragUpdate,
|
||||
required this.onDragEnd,
|
||||
});
|
||||
|
||||
final List<Piece> tray;
|
||||
final int? draggingIndex;
|
||||
final void Function(int index, Offset globalPosition) onDragStart;
|
||||
final void Function(Offset globalPosition) onDragUpdate;
|
||||
final void Function() onDragEnd;
|
||||
|
||||
static const double slotCellSize = 16;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < 3; i++)
|
||||
Expanded(
|
||||
child: i < tray.length
|
||||
? _slot(context, i, tray[i])
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _slot(BuildContext context, int index, Piece piece) {
|
||||
final hidden = index == draggingIndex;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: (d) => onDragStart(index, d.globalPosition),
|
||||
onPanUpdate: (d) => onDragUpdate(d.globalPosition),
|
||||
onPanEnd: (_) => onDragEnd(),
|
||||
onPanCancel: onDragEnd,
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: hidden ? 0.0 : 1.0,
|
||||
child: PieceWidget(piece: piece, cellSize: slotCellSize),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user