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.
This commit is contained in:
2026-06-11 22:58:14 +09:00
parent 3d1f3b30c7
commit 963d0d5dd6
4 changed files with 214 additions and 2 deletions
+61
View File
@@ -5,6 +5,7 @@ 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';
@@ -18,6 +19,7 @@ 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.
@@ -38,6 +40,8 @@ class _GameScreenState extends ConsumerState<GameScreen>
duration: const Duration(milliseconds: 350),
);
bool _tutorialStartChecked = false;
int? _dragIndex;
Offset? _dragGlobal;
@@ -126,6 +130,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
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?;
@@ -175,6 +183,18 @@ class _GameScreenState extends ConsumerState<GameScreen>
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;
@@ -235,6 +255,17 @@ class _GameScreenState extends ConsumerState<GameScreen>
_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,
@@ -252,6 +283,36 @@ class _GameScreenState extends ConsumerState<GameScreen>
);
}
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 =