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:
@@ -20,12 +20,22 @@ class PlacementResult {
|
|||||||
required this.pointsGained,
|
required this.pointsGained,
|
||||||
required this.linesCleared,
|
required this.linesCleared,
|
||||||
required this.gemsCleared,
|
required this.gemsCleared,
|
||||||
|
required this.clearedRows,
|
||||||
|
required this.clearedCols,
|
||||||
|
required this.comboStreak,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<GameEvent> events;
|
final List<GameEvent> events;
|
||||||
final int pointsGained;
|
final int pointsGained;
|
||||||
final int linesCleared;
|
final int linesCleared;
|
||||||
final int gemsCleared;
|
final int gemsCleared;
|
||||||
|
|
||||||
|
/// Which lines vanished, for the UI clear animation.
|
||||||
|
final List<int> clearedRows;
|
||||||
|
final List<int> clearedCols;
|
||||||
|
|
||||||
|
/// Streak level after this placement, for combo effects.
|
||||||
|
final int comboStreak;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single stateful pure-Dart session object for one stage attempt.
|
/// The single stateful pure-Dart session object for one stage attempt.
|
||||||
@@ -130,6 +140,9 @@ class GameEngine {
|
|||||||
pointsGained: delta.points,
|
pointsGained: delta.points,
|
||||||
linesCleared: clear.linesCleared,
|
linesCleared: clear.linesCleared,
|
||||||
gemsCleared: clear.gemsCleared,
|
gemsCleared: clear.gemsCleared,
|
||||||
|
clearedRows: clear.clearedRows,
|
||||||
|
clearedCols: clear.clearedCols,
|
||||||
|
comboStreak: _combo.streak,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -3,5 +3,13 @@
|
|||||||
"appTitle": "Block Seasons",
|
"appTitle": "Block Seasons",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"comingSoon": "Coming soon"
|
"comingSoon": "Coming soon",
|
||||||
|
"stageClear": "Stage Clear!",
|
||||||
|
"stageFailed": "Stage Failed",
|
||||||
|
"outOfMoves": "Out of moves",
|
||||||
|
"boardFull": "No space left",
|
||||||
|
"watchAdContinue": "Continue (ad)",
|
||||||
|
"plusFiveMoves": "+5 moves (ad)",
|
||||||
|
"giveUp": "Give up",
|
||||||
|
"playAgain": "Play again"
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -3,5 +3,13 @@
|
|||||||
"appTitle": "블록 시즌즈",
|
"appTitle": "블록 시즌즈",
|
||||||
"play": "플레이",
|
"play": "플레이",
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"comingSoon": "준비 중"
|
"comingSoon": "준비 중",
|
||||||
|
"stageClear": "스테이지 클리어!",
|
||||||
|
"stageFailed": "스테이지 실패",
|
||||||
|
"outOfMoves": "이동 횟수 소진",
|
||||||
|
"boardFull": "둘 공간이 없어요",
|
||||||
|
"watchAdContinue": "광고 보고 이어하기",
|
||||||
|
"plusFiveMoves": "+5 이동 (광고)",
|
||||||
|
"giveUp": "포기하기",
|
||||||
|
"playAgain": "다시 하기"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../game/engine/game_engine.dart';
|
||||||
|
import '../game/engine/piece_generator.dart';
|
||||||
|
import '../game/models/grid.dart';
|
||||||
|
import '../game/models/objective.dart';
|
||||||
|
import '../game/models/piece.dart';
|
||||||
|
import '../game/models/stage.dart';
|
||||||
|
|
||||||
|
/// Immutable snapshot of one engine moment; the only game state the UI sees.
|
||||||
|
class GameViewState {
|
||||||
|
const GameViewState({
|
||||||
|
required this.grid,
|
||||||
|
required this.tray,
|
||||||
|
required this.score,
|
||||||
|
required this.comboStreak,
|
||||||
|
required this.movesLeft,
|
||||||
|
required this.moveLimit,
|
||||||
|
required this.phase,
|
||||||
|
required this.stuckReason,
|
||||||
|
required this.objectives,
|
||||||
|
required this.starsEarned,
|
||||||
|
required this.objectiveProgress,
|
||||||
|
required this.lastPlacement,
|
||||||
|
required this.fxTick,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GridState grid;
|
||||||
|
final List<Piece> tray;
|
||||||
|
final int score;
|
||||||
|
final int comboStreak;
|
||||||
|
final int movesLeft;
|
||||||
|
final int moveLimit;
|
||||||
|
final GamePhase phase;
|
||||||
|
final StuckReason? stuckReason;
|
||||||
|
final List<Objective> objectives;
|
||||||
|
final int starsEarned;
|
||||||
|
final double objectiveProgress;
|
||||||
|
final PlacementResult? lastPlacement;
|
||||||
|
|
||||||
|
/// Increments on every accepted placement so animations can retrigger.
|
||||||
|
final int fxTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
|
||||||
|
/// never leaks past this class.
|
||||||
|
class GameSessionNotifier extends Notifier<GameViewState?> {
|
||||||
|
GameEngine? _engine;
|
||||||
|
StageConfig? _stage;
|
||||||
|
PieceGenerator? _generatorOverride;
|
||||||
|
int _attempt = 0;
|
||||||
|
int _fxTick = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
GameViewState? build() => null;
|
||||||
|
|
||||||
|
void startStage(StageConfig stage,
|
||||||
|
{int attempt = 0, PieceGenerator? generator}) {
|
||||||
|
_stage = stage;
|
||||||
|
_attempt = attempt;
|
||||||
|
_generatorOverride = generator;
|
||||||
|
_engine = GameEngine(stage, attempt: attempt, generator: generator);
|
||||||
|
_fxTick = 0;
|
||||||
|
_publish(lastPlacement: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restarts the current stage as a new attempt (fresh piece sequence).
|
||||||
|
void restart() {
|
||||||
|
final stage = _stage;
|
||||||
|
if (stage == null) throw StateError('no stage to restart');
|
||||||
|
startStage(stage, attempt: _attempt + 1, generator: _generatorOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tryPlace(int trayIndex, int x, int y) {
|
||||||
|
final engine = _engine;
|
||||||
|
if (engine == null) return false;
|
||||||
|
final result = engine.tryPlace(trayIndex, x, y);
|
||||||
|
if (result == null) return false;
|
||||||
|
_fxTick++;
|
||||||
|
_publish(lastPlacement: result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canPlaceAt(int trayIndex, int x, int y) =>
|
||||||
|
_engine?.tryPlaceWouldSucceed(trayIndex, x, y) ?? false;
|
||||||
|
|
||||||
|
void useContinue() {
|
||||||
|
_engine?.useContinue();
|
||||||
|
_publish(lastPlacement: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addExtraMoves() {
|
||||||
|
_engine?.addExtraMoves();
|
||||||
|
_publish(lastPlacement: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void declineAndLose() {
|
||||||
|
_engine?.declineAndLose();
|
||||||
|
_publish(lastPlacement: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _publish({required PlacementResult? lastPlacement}) {
|
||||||
|
final engine = _engine!;
|
||||||
|
state = GameViewState(
|
||||||
|
grid: engine.grid,
|
||||||
|
tray: engine.tray,
|
||||||
|
score: engine.score,
|
||||||
|
comboStreak: engine.combo.streak,
|
||||||
|
movesLeft: engine.movesLeft,
|
||||||
|
moveLimit: engine.movesLeft + engine.movesUsed,
|
||||||
|
phase: engine.phase,
|
||||||
|
stuckReason: engine.stuckReason,
|
||||||
|
objectives: engine.objectives,
|
||||||
|
starsEarned: engine.starsEarned,
|
||||||
|
objectiveProgress: engine.objectiveProgress,
|
||||||
|
lastPlacement: lastPlacement,
|
||||||
|
fxTick: _fxTick,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'game_session_notifier.dart';
|
||||||
|
|
||||||
|
final gameSessionProvider =
|
||||||
|
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
||||||
|
GameSessionNotifier.new,
|
||||||
|
);
|
||||||
@@ -1,17 +1,288 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '../../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.
|
/// Demo stage until the season map lands in Phase 3.
|
||||||
class GameScreen extends StatelessWidget {
|
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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.appTitle)),
|
body: SafeArea(
|
||||||
body: Center(child: Text(l10n.comingSoon)),
|
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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,8 @@ void main() {
|
|||||||
final monoIndex = engine.tray.indexWhere((p) => p.id == 'mono');
|
final monoIndex = engine.tray.indexWhere((p) => p.id == 'mono');
|
||||||
final result = engine.tryPlace(monoIndex, 0, 3)!;
|
final result = engine.tryPlace(monoIndex, 0, 3)!;
|
||||||
expect(result.linesCleared, 1);
|
expect(result.linesCleared, 1);
|
||||||
|
expect(result.clearedRows, [3]);
|
||||||
|
expect(result.clearedCols, isEmpty);
|
||||||
// 1 cell + round(100 * 1.5) = 151
|
// 1 cell + round(100 * 1.5) = 151
|
||||||
expect(engine.score, 151);
|
expect(engine.score, 151);
|
||||||
expect(engine.grid.occupiedCount, 0);
|
expect(engine.grid.occupiedCount, 0);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:block_seasons/core/rng.dart';
|
||||||
|
import 'package:block_seasons/game/engine/game_engine.dart';
|
||||||
|
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||||
|
import 'package:block_seasons/game/models/cell.dart';
|
||||||
|
import 'package:block_seasons/game/models/grid.dart';
|
||||||
|
import 'package:block_seasons/game/models/piece_library.dart';
|
||||||
|
import 'package:block_seasons/game/models/stage.dart';
|
||||||
|
import 'package:block_seasons/state/providers.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
StageConfig _stage({List<Map<String, dynamic>>? objectives}) =>
|
||||||
|
StageConfig.fromJson({
|
||||||
|
'id': 'ui_stage',
|
||||||
|
'seed': 99,
|
||||||
|
'moveLimit': 20,
|
||||||
|
'preset': [
|
||||||
|
for (var x = 1; x < GridState.size; x++)
|
||||||
|
{'x': x, 'y': 3, 't': 'filled', 'c': 0},
|
||||||
|
],
|
||||||
|
'objectives': objectives ??
|
||||||
|
[
|
||||||
|
{'type': 'reachScore', 'target': 999999},
|
||||||
|
],
|
||||||
|
'stars': {
|
||||||
|
'two': {'movesLeft': 5},
|
||||||
|
'three': {'movesLeft': 10},
|
||||||
|
},
|
||||||
|
'generatorProfile': 'mid',
|
||||||
|
});
|
||||||
|
|
||||||
|
PieceGenerator _smallPool() => PieceGenerator(
|
||||||
|
SeededRng(1),
|
||||||
|
pool: [
|
||||||
|
PieceLibrary.byId('mono'),
|
||||||
|
PieceLibrary.byId('domino_h'),
|
||||||
|
PieceLibrary.byId('domino_v'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('starts idle with no session', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
expect(container.read(gameSessionProvider), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startStage exposes a fresh view state', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(gameSessionProvider.notifier);
|
||||||
|
notifier.startStage(_stage(), generator: _smallPool());
|
||||||
|
|
||||||
|
final view = container.read(gameSessionProvider)!;
|
||||||
|
expect(view.phase, GamePhase.playing);
|
||||||
|
expect(view.tray, hasLength(3));
|
||||||
|
expect(view.score, 0);
|
||||||
|
expect(view.movesLeft, 20);
|
||||||
|
expect(view.grid.cellAt(1, 3).type, CellType.filled);
|
||||||
|
expect(view.fxTick, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryPlace mutates the view and bumps fxTick', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(gameSessionProvider.notifier);
|
||||||
|
notifier.startStage(_stage(), generator: _smallPool());
|
||||||
|
|
||||||
|
final placed = notifier.tryPlace(0, 0, 0);
|
||||||
|
expect(placed, isTrue);
|
||||||
|
|
||||||
|
final view = container.read(gameSessionProvider)!;
|
||||||
|
expect(view.tray, hasLength(2));
|
||||||
|
expect(view.score, greaterThan(0));
|
||||||
|
expect(view.fxTick, 1);
|
||||||
|
expect(view.lastPlacement, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('illegal placement leaves state untouched', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(gameSessionProvider.notifier);
|
||||||
|
notifier.startStage(_stage(), generator: _smallPool());
|
||||||
|
|
||||||
|
final placed = notifier.tryPlace(0, 1, 3); // occupied preset cell
|
||||||
|
expect(placed, isFalse);
|
||||||
|
final view = container.read(gameSessionProvider)!;
|
||||||
|
expect(view.tray, hasLength(3));
|
||||||
|
expect(view.fxTick, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('winning surfaces stars and cleared lines fx', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(gameSessionProvider.notifier);
|
||||||
|
notifier.startStage(
|
||||||
|
_stage(objectives: [
|
||||||
|
{'type': 'clearLines', 'count': 1},
|
||||||
|
]),
|
||||||
|
generator: _smallPool(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final view0 = container.read(gameSessionProvider)!;
|
||||||
|
final monoIndex = view0.tray.indexWhere((p) => p.id == 'mono');
|
||||||
|
notifier.tryPlace(monoIndex, 0, 3);
|
||||||
|
|
||||||
|
final view = container.read(gameSessionProvider)!;
|
||||||
|
expect(view.phase, GamePhase.won);
|
||||||
|
expect(view.starsEarned, 3);
|
||||||
|
expect(view.lastPlacement!.clearedRows, [3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restart deals a fresh attempt with a different tray sequence', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final notifier = container.read(gameSessionProvider.notifier);
|
||||||
|
notifier.startStage(_stage());
|
||||||
|
notifier.tryPlace(0, 0, 0);
|
||||||
|
notifier.restart();
|
||||||
|
|
||||||
|
final view = container.read(gameSessionProvider)!;
|
||||||
|
expect(view.phase, GamePhase.playing);
|
||||||
|
expect(view.score, 0);
|
||||||
|
expect(view.movesLeft, 20);
|
||||||
|
expect(view.tray, hasLength(3));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:block_seasons/game/models/piece_library.dart';
|
||||||
|
import 'package:block_seasons/ui/widgets/board_geometry.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const geo = BoardGeometry(boardSize: 320); // cell = 40
|
||||||
|
|
||||||
|
group('cellAt', () {
|
||||||
|
test('maps points inside cells', () {
|
||||||
|
expect(geo.cellAt(const Offset(5, 5)), (0, 0));
|
||||||
|
expect(geo.cellAt(const Offset(60, 100)), (1, 2));
|
||||||
|
expect(geo.cellAt(const Offset(319, 319)), (7, 7));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null outside the board', () {
|
||||||
|
expect(geo.cellAt(const Offset(-1, 10)), isNull);
|
||||||
|
expect(geo.cellAt(const Offset(10, 321)), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('cellRect', () {
|
||||||
|
test('returns the drawn rect for a cell', () {
|
||||||
|
final rect = geo.cellRect(2, 3);
|
||||||
|
expect(rect.left, 80);
|
||||||
|
expect(rect.top, 120);
|
||||||
|
expect(rect.width, 40);
|
||||||
|
expect(rect.height, 40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('snapAnchor', () {
|
||||||
|
final mono = PieceLibrary.byId('mono');
|
||||||
|
final line5h = PieceLibrary.byId('line5_h');
|
||||||
|
|
||||||
|
test('rounds to the nearest cell', () {
|
||||||
|
expect(geo.snapAnchor(mono, const Offset(78, 122)), (2, 3));
|
||||||
|
expect(geo.snapAnchor(mono, const Offset(99, 99)), (2, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps so the piece bounding box stays on the board', () {
|
||||||
|
// line5_h is 5 wide: anchor x can be at most 3.
|
||||||
|
expect(geo.snapAnchor(line5h, const Offset(310, 0)), (3, 0));
|
||||||
|
expect(geo.snapAnchor(line5h, const Offset(-50, -50)), (0, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:block_seasons/core/rng.dart';
|
||||||
|
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||||
|
import 'package:block_seasons/game/models/piece_library.dart';
|
||||||
|
import 'package:block_seasons/game/models/stage.dart';
|
||||||
|
import 'package:block_seasons/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:block_seasons/state/providers.dart';
|
||||||
|
import 'package:block_seasons/ui/screens/game_screen.dart';
|
||||||
|
import 'package:block_seasons/ui/widgets/board_widget.dart';
|
||||||
|
import 'package:block_seasons/ui/widgets/piece_painter.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
final _stage = StageConfig.fromJson({
|
||||||
|
'id': 'drag_stage',
|
||||||
|
'seed': 11,
|
||||||
|
'moveLimit': 20,
|
||||||
|
'preset': const <Map<String, dynamic>>[],
|
||||||
|
'objectives': [
|
||||||
|
{'type': 'reachScore', 'target': 999999},
|
||||||
|
],
|
||||||
|
'stars': {
|
||||||
|
'two': {'movesLeft': 5},
|
||||||
|
'three': {'movesLeft': 10},
|
||||||
|
},
|
||||||
|
'generatorProfile': 'mid',
|
||||||
|
});
|
||||||
|
|
||||||
|
PieceGenerator _smallPool() => PieceGenerator(
|
||||||
|
SeededRng(3),
|
||||||
|
pool: [
|
||||||
|
PieceLibrary.byId('mono'),
|
||||||
|
PieceLibrary.byId('domino_h'),
|
||||||
|
PieceLibrary.byId('domino_v'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('dragging a tray piece onto the board places it',
|
||||||
|
(tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
container
|
||||||
|
.read(gameSessionProvider.notifier)
|
||||||
|
.startStage(_stage, generator: _smallPool());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: const MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: GameScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final view0 = container.read(gameSessionProvider)!;
|
||||||
|
final monoIndex = view0.tray.indexWhere((p) => p.id == 'mono');
|
||||||
|
expect(monoIndex, greaterThanOrEqualTo(0));
|
||||||
|
|
||||||
|
final boardRect = tester.getRect(find.byType(BoardWidget));
|
||||||
|
final cell = boardRect.width / 8;
|
||||||
|
|
||||||
|
// The dragged piece floats 70px above the finger, so aim the finger
|
||||||
|
// below the intended landing cell (0, 0).
|
||||||
|
final targetCenter = boardRect.topLeft + Offset(cell * 0.5, cell * 0.5);
|
||||||
|
final fingerEnd = targetCenter + Offset(0, 70 + cell / 2);
|
||||||
|
|
||||||
|
final start = tester.getCenter(find.byType(PieceWidget).at(monoIndex));
|
||||||
|
final gesture = await tester.startGesture(start);
|
||||||
|
await gesture.moveBy(const Offset(0, -30));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveTo(fingerEnd);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final view = container.read(gameSessionProvider)!;
|
||||||
|
expect(view.grid.isOccupied(0, 0), isTrue,
|
||||||
|
reason: 'mono should land on (0,0)');
|
||||||
|
expect(view.tray, hasLength(2));
|
||||||
|
expect(view.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('result overlay appears when out of moves', (tester) async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final oneMove = StageConfig.fromJson({
|
||||||
|
..._stage.toJson(),
|
||||||
|
'moveLimit': 1,
|
||||||
|
});
|
||||||
|
container
|
||||||
|
.read(gameSessionProvider.notifier)
|
||||||
|
.startStage(oneMove, generator: _smallPool());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: const MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: GameScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
container.read(gameSessionProvider.notifier).tryPlace(0, 0, 0);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Out of moves'), findsOneWidget);
|
||||||
|
expect(find.text('+5 moves (ad)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user