Files
BlockSeasons/lib/ui/screens/game_screen.dart
T
airkjw 963d0d5dd6 feat: first-play interactive tutorial overlay
Add TutorialOverlay widget (dim veil, message bubble, animated hand on
dragPiece step, skip button) and wire it into game_screen: start on
flow.index==0 when tutorialDone is false, forward onPlaced/onLineCleared
events unconditionally from fxTick handler, and compute hand-path
coordinates from board/tray RenderBox geometry.
2026-06-11 22:58:14 +09:00

462 lines
16 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 '../../game/models/grid.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';
import '../widgets/tutorial_overlay.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),
);
bool _tutorialStartChecked = false;
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();
}
ref.read(tutorialProvider.notifier).onPlaced();
if (placement.linesCleared > 0) {
ref.read(tutorialProvider.notifier).onLineCleared();
}
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 tutorialStep = ref.watch(tutorialProvider);
if (!_tutorialStartChecked) {
_tutorialStartChecked = true;
final flow = ref.read(seasonFlowProvider);
if (flow != null &&
flow.index == 0 &&
!ref.read(saveRepositoryProvider).tutorialDone) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.read(tutorialProvider.notifier).start());
}
}
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 (tutorialStep != null)
Positioned.fill(
child: TutorialOverlay(
step: tutorialStep,
handFrom: _tutorialHandFrom(),
handTo: _tutorialHandTo(view),
onSkip: () => ref.read(tutorialProvider.notifier).skip(),
onDismissHud: () =>
ref.read(tutorialProvider.notifier).dismissHud(),
),
),
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(),
),
),
],
),
),
],
),
);
}
Offset _tutorialHandFrom() {
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox == null) return Offset.zero;
// Tray sits at the bottom; aim at the left slot.
final size = stackBox.size;
return Offset(size.width * 0.18, size.height - 80);
}
Offset _tutorialHandTo(GameViewState view) {
final boardBox = _boardBox;
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (boardBox == null || stackBox == null || view.tray.isEmpty) {
return Offset.zero;
}
final geo = BoardGeometry(boardSize: boardBox.size.width);
final notifier = ref.read(gameSessionProvider.notifier);
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (notifier.canPlaceAt(0, x, y)) {
final local =
Offset((x + 0.5) * geo.cellSize, (y + 0.5) * geo.cellSize);
return stackBox.globalToLocal(boardBox.localToGlobal(local));
}
}
}
return Offset.zero;
}
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>(
key: ValueKey(i),
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,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall,
),
),
],
),
),
],
const SizedBox(height: 20),
...actions,
],
),
),
),
),
),
);
}
}