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,
],
),
),
),
),
),
);
}
}