677a09f8cb
Stars on win now appear sequentially with elastic-bounce via TweenAnimationBuilder (400/650/900 ms). Lost overlay shows a CircularProgressIndicator ring with "87% complete!" (l10n: almostThere) when objectiveProgress > 0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
398 lines
14 KiB
Dart
398 lines
14 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../game/engine/game_engine.dart';
|
|
import '../../l10n/gen/app_localizations.dart';
|
|
import '../../services/audio_service.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/effects_overlay.dart';
|
|
import '../widgets/hud_widget.dart';
|
|
import '../widgets/piece_painter.dart';
|
|
import '../widgets/season_background.dart';
|
|
import '../widgets/tray_widget.dart';
|
|
|
|
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
|
/// stage (via SeasonFlowNotifier) before navigating here.
|
|
class GameScreen extends ConsumerStatefulWidget {
|
|
const GameScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<GameScreen> createState() => _GameScreenState();
|
|
}
|
|
|
|
class _GameScreenState extends ConsumerState<GameScreen>
|
|
with TickerProviderStateMixin {
|
|
final _boardKey = GlobalKey();
|
|
final _stackKey = GlobalKey();
|
|
final _effectsKey = GlobalKey<EffectsOverlayState>();
|
|
late final AnimationController _shake = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 350),
|
|
);
|
|
|
|
int? _dragIndex;
|
|
Offset? _dragGlobal;
|
|
|
|
/// How far the dragged piece floats above the finger so it stays visible.
|
|
static const double _lift = 70;
|
|
|
|
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
|
|
void dispose() {
|
|
_shake.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onSessionChange(GameViewState? prev, GameViewState? next) {
|
|
if (next == null) return;
|
|
final audio = ref.read(audioServiceProvider);
|
|
if (prev?.fxTick != next.fxTick && next.lastPlacement != null) {
|
|
final placement = next.lastPlacement!;
|
|
if (placement.linesCleared > 0) {
|
|
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
|
|
HapticFeedback.mediumImpact();
|
|
if (placement.comboStreak >= 4) {
|
|
HapticFeedback.heavyImpact();
|
|
_shake.forward(from: 0);
|
|
}
|
|
} else {
|
|
audio.play(Sfx.place);
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
final boardBox = _boardBox;
|
|
final stackBox =
|
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (boardBox != null && stackBox != null) {
|
|
final topLeft =
|
|
stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero));
|
|
_effectsKey.currentState?.onPlacement(
|
|
placement,
|
|
boardRect: topLeft & boardBox.size,
|
|
);
|
|
}
|
|
}
|
|
if (prev?.phase != next.phase) {
|
|
if (next.phase == GamePhase.won) {
|
|
audio.play(Sfx.win);
|
|
// recordResult keeps the best run, so re-entry is harmless.
|
|
ref
|
|
.read(seasonFlowProvider.notifier)
|
|
.recordWin(stars: next.starsEarned, score: next.score);
|
|
final stackBox =
|
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (stackBox != null) {
|
|
_effectsKey.currentState?.onWin(stackBox.size);
|
|
}
|
|
}
|
|
if (next.phase == GamePhase.lost) audio.play(Sfx.lose);
|
|
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
|
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.listen<GameViewState?>(gameSessionProvider, _onSessionChange);
|
|
ref.listen(streakProvider, (prev, next) {
|
|
final milestone = next.hitMilestone;
|
|
if (milestone != null && prev?.hitMilestone != milestone) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.streakMilestone(milestone))),
|
|
);
|
|
}
|
|
});
|
|
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;
|
|
|
|
final theme = ref.watch(activeThemeProvider);
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
SeasonBackground(theme: theme),
|
|
SafeArea(
|
|
child: Stack(
|
|
key: _stackKey,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
HudWidget(view: view),
|
|
Expanded(
|
|
child: Center(
|
|
child: AnimatedBuilder(
|
|
animation: _shake,
|
|
builder: (context, child) {
|
|
final t = _shake.value;
|
|
final dx =
|
|
math.sin(t * math.pi * 10) * 6 * (1 - t);
|
|
return Transform.translate(
|
|
offset: Offset(dx, 0), child: child);
|
|
},
|
|
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),
|
|
Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
|
|
if (view.phase != GamePhase.playing) _resultOverlay(view),
|
|
if (Navigator.of(context).canPop())
|
|
Positioned(
|
|
top: 4,
|
|
left: 4,
|
|
child: IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.white54),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 flow = ref.read(seasonFlowProvider);
|
|
final (title, actions) = switch ((view.phase, view.stuckReason)) {
|
|
(GamePhase.won, _) => (
|
|
l10n.stageClear,
|
|
[
|
|
if (flow != null && flow.hasNext)
|
|
FilledButton(
|
|
onPressed:
|
|
ref.read(seasonFlowProvider.notifier).nextStage,
|
|
child: Text(l10n.nextStage),
|
|
),
|
|
TextButton(
|
|
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++)
|
|
TweenAnimationBuilder<double>(
|
|
tween: Tween(begin: 0, end: 1),
|
|
duration: Duration(milliseconds: 400 + i * 250),
|
|
curve: Interval(i * 0.22, 1, curve: Curves.elasticOut),
|
|
builder: (context, v, child) =>
|
|
Transform.scale(scale: v, child: child),
|
|
child: Icon(
|
|
Icons.star,
|
|
size: 44,
|
|
color: i < view.starsEarned ? Colors.amber : Colors.white24,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
if (view.phase == GamePhase.lost && view.objectiveProgress > 0) ...[
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: 88,
|
|
height: 88,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: view.objectiveProgress,
|
|
strokeWidth: 7,
|
|
backgroundColor: Colors.white12,
|
|
color: Colors.amber,
|
|
),
|
|
Center(
|
|
child: Text(
|
|
l10n.almostThere((view.objectiveProgress * 100).round()),
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.labelSmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 20),
|
|
...actions,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|