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:
2026-06-11 13:19:34 +09:00
parent 62cbb4b16a
commit 3138fc4b08
17 changed files with 1249 additions and 7 deletions
+276 -5
View File
@@ -1,17 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/engine/game_engine.dart';
import '../../game/models/stage.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/game_session_notifier.dart';
import '../../state/providers.dart';
import '../theme/palette.dart';
import '../widgets/board_geometry.dart';
import '../widgets/board_painter.dart';
import '../widgets/board_widget.dart';
import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart';
import '../widgets/tray_widget.dart';
/// Placeholder until the board UI lands in Phase 2.
class GameScreen extends StatelessWidget {
/// Demo stage until the season map lands in Phase 3.
final _demoStage = StageConfig.fromJson({
'id': 'demo_001',
'seed': 20260611,
'moveLimit': 25,
'preset': [
{'x': 2, 'y': 2, 't': 'gem'},
{'x': 5, 'y': 2, 't': 'gem'},
{'x': 2, 'y': 5, 't': 'gem'},
{'x': 5, 'y': 5, 't': 'gem'},
{'x': 3, 'y': 7, 't': 'filled', 'c': 1},
{'x': 4, 'y': 7, 't': 'filled', 'c': 1},
],
'objectives': [
{'type': 'clearGems', 'count': 4},
],
'stars': {
'two': {'movesLeft': 6},
'three': {'movesLeft': 12},
},
'generatorProfile': 'mid',
});
class GameScreen extends ConsumerStatefulWidget {
const GameScreen({super.key});
@override
ConsumerState<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends ConsumerState<GameScreen> {
final _boardKey = GlobalKey();
final _stackKey = GlobalKey();
int? _dragIndex;
Offset? _dragGlobal;
/// How far the dragged piece floats above the finger so it stays visible.
static const double _lift = 70;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (ref.read(gameSessionProvider) == null) {
ref.read(gameSessionProvider.notifier).startStage(_demoStage);
}
});
}
RenderBox? get _boardBox =>
_boardKey.currentContext?.findRenderObject() as RenderBox?;
/// Global top-left of the dragged piece, rendered at board cell size.
Offset? _draggedPieceTopLeft(GameViewState view) {
final i = _dragIndex;
final g = _dragGlobal;
final box = _boardBox;
if (i == null || g == null || box == null || i >= view.tray.length) {
return null;
}
final geo = BoardGeometry(boardSize: box.size.width);
final piece = view.tray[i];
final (w, h) = pieceCellBounds(piece);
final pw = w * geo.cellSize;
final ph = h * geo.cellSize;
return g + Offset(-pw / 2, -_lift - ph);
}
GhostSpec? _ghost(GameViewState view) {
final i = _dragIndex;
final box = _boardBox;
final topLeftGlobal = _draggedPieceTopLeft(view);
if (i == null || box == null || topLeftGlobal == null) return null;
final geo = BoardGeometry(boardSize: box.size.width);
final piece = view.tray[i];
final (w, h) = pieceCellBounds(piece);
final centerLocal = box.globalToLocal(
topLeftGlobal + Offset(w * geo.cellSize / 2, h * geo.cellSize / 2),
);
// Only preview while the piece is actually over the board.
if (centerLocal.dx < -geo.cellSize ||
centerLocal.dy < -geo.cellSize ||
centerLocal.dx > box.size.width + geo.cellSize ||
centerLocal.dy > box.size.height + geo.cellSize) {
return null;
}
final topLeftLocal = box.globalToLocal(topLeftGlobal);
final (ax, ay) = geo.snapAnchor(piece, topLeftLocal);
final legal =
ref.read(gameSessionProvider.notifier).canPlaceAt(i, ax, ay);
return GhostSpec(piece: piece, anchorX: ax, anchorY: ay, legal: legal);
}
void _onDragEnd(GameViewState view) {
final i = _dragIndex;
final ghost = _ghost(view);
setState(() {
_dragIndex = null;
_dragGlobal = null;
});
if (i != null && ghost != null && ghost.legal) {
ref
.read(gameSessionProvider.notifier)
.tryPlace(i, ghost.anchorX, ghost.anchorY);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final view = ref.watch(gameSessionProvider);
if (view == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final ghost = _ghost(view);
final draggedTopLeft = _draggedPieceTopLeft(view);
final boardBox = _boardBox;
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: Center(child: Text(l10n.comingSoon)),
body: SafeArea(
child: Stack(
key: _stackKey,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
HudWidget(view: view),
Expanded(
child: Center(
child: BoardWidget(
key: _boardKey,
view: view,
ghost: ghost,
),
),
),
TrayWidget(
tray: view.tray,
draggingIndex: _dragIndex,
onDragStart: (index, global) => setState(() {
_dragIndex = index;
_dragGlobal = global;
}),
onDragUpdate: (global) =>
setState(() => _dragGlobal = global),
onDragEnd: () => _onDragEnd(view),
),
],
),
),
if (_dragIndex != null &&
draggedTopLeft != null &&
boardBox != null &&
_dragIndex! < view.tray.length)
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
if (view.phase != GamePhase.playing) _resultOverlay(view),
],
),
),
);
}
Widget _draggedPieceOverlay(
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
final stackBox =
_stackKey.currentContext!.findRenderObject()! as RenderBox;
final local = stackBox.globalToLocal(topLeftGlobal);
final cellSize = boardBox.size.width / 8;
return Positioned(
left: local.dx,
top: local.dy,
child: IgnorePointer(
child: PieceWidget(piece: view.tray[_dragIndex!], cellSize: cellSize),
),
);
}
Widget _resultOverlay(GameViewState view) {
final l10n = AppLocalizations.of(context)!;
final notifier = ref.read(gameSessionProvider.notifier);
final theme = Theme.of(context);
final (title, actions) = switch ((view.phase, view.stuckReason)) {
(GamePhase.won, _) => (
l10n.stageClear,
[
FilledButton(
onPressed: notifier.restart,
child: Text(l10n.playAgain),
),
],
),
(GamePhase.stuck, StuckReason.outOfMoves) => (
l10n.outOfMoves,
[
FilledButton(
onPressed: notifier.addExtraMoves,
child: Text(l10n.plusFiveMoves),
),
TextButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
),
],
),
(GamePhase.stuck, _) => (
l10n.boardFull,
[
FilledButton(
onPressed: notifier.useContinue,
child: Text(l10n.watchAdContinue),
),
TextButton(
onPressed: notifier.declineAndLose,
child: Text(l10n.giveUp),
),
],
),
(_, _) => (
l10n.stageFailed,
[
FilledButton(
onPressed: notifier.restart,
child: Text(l10n.playAgain),
),
],
),
};
return Positioned.fill(
child: ColoredBox(
color: Colors.black54,
child: Center(
child: Card(
color: GamePalette.boardBackground,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: theme.textTheme.headlineSmall),
if (view.phase == GamePhase.won) ...[
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < 3; i++)
Icon(
Icons.star,
size: 40,
color: i < view.starsEarned
? Colors.amber
: Colors.white24,
),
],
),
],
const SizedBox(height: 20),
...actions,
],
),
),
),
),
),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
/// Season-themeable color set. Season 1 default: vivid candy tones on a
/// deep navy board.
class GamePalette {
static const boardBackground = Color(0xFF151A2E);
static const emptyCell = Color(0xFF222A45);
static const boardBorder = Color(0xFF2E3858);
static const tileColors = <Color>[
Color(0xFF5B7FFF), // blue
Color(0xFFFF6B6B), // red
Color(0xFFFFC94D), // yellow
Color(0xFF4DD599), // green
Color(0xFFB980FF), // purple
Color(0xFFFF8FAB), // pink
Color(0xFF4DC9E6), // cyan
Color(0xFFFF9A5B), // orange
];
static Color tile(int colorId) => tileColors[colorId % tileColors.length];
static const gem = Color(0xFF7CF5FF);
static const ghostLegal = Color(0x66FFFFFF);
static const ghostIllegal = Color(0x55FF5252);
}
+41
View File
@@ -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),
);
}
}
+137
View File
@@ -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;
}
+95
View File
@@ -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),
],
),
),
),
),
),
],
);
},
),
);
}
}
+83
View File
@@ -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,
);
}
}
+82
View File
@@ -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;
}
+59
View File
@@ -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),
),
),
);
}
}