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
+6 -1
View File
@@ -37,5 +37,10 @@
"type": "int" "type": "int"
} }
} }
} },
"skip": "Skip",
"gotIt": "Got it!",
"tutorialDrag": "Drag a block onto the board!",
"tutorialClear": "Fill a row or column to clear it!",
"tutorialHud": "Hit the goal before you run out of moves. Your turn!"
} }
+6 -1
View File
@@ -16,5 +16,10 @@
"streakMilestone": "{days}일 연속 플레이! 대단해요!", "streakMilestone": "{days}일 연속 플레이! 대단해요!",
"almostThere": "{percent}% 달성!", "almostThere": "{percent}% 달성!",
"seasonLabel": "SEASON", "seasonLabel": "SEASON",
"seasonStages": "{count}개 스테이지" "seasonStages": "{count}개 스테이지",
"skip": "건너뛰기",
"gotIt": "알겠어요!",
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!"
} }
+61
View File
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/engine/game_engine.dart'; import '../../game/engine/game_engine.dart';
import '../../game/models/grid.dart';
import '../../l10n/gen/app_localizations.dart'; import '../../l10n/gen/app_localizations.dart';
import '../../services/audio_service.dart'; import '../../services/audio_service.dart';
import '../../state/game_session_notifier.dart'; import '../../state/game_session_notifier.dart';
@@ -18,6 +19,7 @@ import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart'; import '../widgets/piece_painter.dart';
import '../widgets/season_background.dart'; import '../widgets/season_background.dart';
import '../widgets/tray_widget.dart'; import '../widgets/tray_widget.dart';
import '../widgets/tutorial_overlay.dart';
/// Renders whatever session [gameSessionProvider] holds; callers start the /// Renders whatever session [gameSessionProvider] holds; callers start the
/// stage (via SeasonFlowNotifier) before navigating here. /// stage (via SeasonFlowNotifier) before navigating here.
@@ -38,6 +40,8 @@ class _GameScreenState extends ConsumerState<GameScreen>
duration: const Duration(milliseconds: 350), duration: const Duration(milliseconds: 350),
); );
bool _tutorialStartChecked = false;
int? _dragIndex; int? _dragIndex;
Offset? _dragGlobal; Offset? _dragGlobal;
@@ -126,6 +130,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
audio.play(Sfx.place); audio.play(Sfx.place);
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
} }
ref.read(tutorialProvider.notifier).onPlaced();
if (placement.linesCleared > 0) {
ref.read(tutorialProvider.notifier).onLineCleared();
}
final boardBox = _boardBox; final boardBox = _boardBox;
final stackBox = final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?; _stackKey.currentContext?.findRenderObject() as RenderBox?;
@@ -175,6 +183,18 @@ class _GameScreenState extends ConsumerState<GameScreen>
return const Scaffold(body: Center(child: CircularProgressIndicator())); 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 ghost = _ghost(view);
final draggedTopLeft = _draggedPieceTopLeft(view); final draggedTopLeft = _draggedPieceTopLeft(view);
final boardBox = _boardBox; final boardBox = _boardBox;
@@ -235,6 +255,17 @@ class _GameScreenState extends ConsumerState<GameScreen>
_draggedPieceOverlay(view, draggedTopLeft, boardBox), _draggedPieceOverlay(view, draggedTopLeft, boardBox),
Positioned.fill(child: EffectsOverlay(key: _effectsKey)), Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
if (view.phase != GamePhase.playing) _resultOverlay(view), 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()) if (Navigator.of(context).canPop())
Positioned( Positioned(
top: 4, 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( Widget _draggedPieceOverlay(
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) { GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
final stackBox = final stackBox =
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/tutorial_notifier.dart';
import 'season_background.dart' show debugDisableLoopingAnimations;
/// Non-blocking guidance overlay: dim veil, message bubble, animated hand on
/// the drag step, skip always available. Input still reaches the game so the
/// player advances by actually doing the action.
class TutorialOverlay extends StatefulWidget {
const TutorialOverlay({
super.key,
required this.step,
required this.handFrom,
required this.handTo,
required this.onSkip,
required this.onDismissHud,
});
final TutorialStep step;
/// Hand animation path in this overlay's local coordinates
/// (tray slot 0 → suggested board anchor). Only used on dragPiece.
final Offset handFrom;
final Offset handTo;
final VoidCallback onSkip;
final VoidCallback onDismissHud;
@override
State<TutorialOverlay> createState() => _TutorialOverlayState();
}
class _TutorialOverlayState extends State<TutorialOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _hand = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
@override
void initState() {
super.initState();
if (!debugDisableLoopingAnimations) _hand.repeat();
}
@override
void dispose() {
_hand.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final message = switch (widget.step) {
TutorialStep.dragPiece => l10n.tutorialDrag,
TutorialStep.clearLine => l10n.tutorialClear,
TutorialStep.explainHud => l10n.tutorialHud,
};
return Stack(
children: [
// Veil that lets touches through.
IgnorePointer(
child: Container(color: Colors.black.withValues(alpha: 0.25)),
),
if (widget.step == TutorialStep.dragPiece)
Positioned.fill(
child: IgnorePointer(
child: AnimatedBuilder(
animation: _hand,
builder: (context, _) {
final t = Curves.easeInOut.transform(_hand.value);
final pos =
Offset.lerp(widget.handFrom, widget.handTo, t)!;
final fade =
_hand.value < 0.9 ? 1.0 : (1 - _hand.value) * 10;
return Stack(
children: [
Transform.translate(
offset: pos,
child: Opacity(
opacity: fade.clamp(0.0, 1.0),
child: const Icon(Icons.touch_app,
size: 44, color: Colors.white),
),
),
],
);
},
),
),
),
Positioned(
top: 70,
left: 24,
right: 24,
child: Card(
color: const Color(0xEE1C2340),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
if (widget.step == TutorialStep.explainHud) ...[
const SizedBox(height: 10),
FilledButton(
onPressed: widget.onDismissHud,
child: Text(l10n.gotIt),
),
],
],
),
),
),
),
Positioned(
top: 8,
right: 8,
child: TextButton(
onPressed: widget.onSkip,
child: Text(l10n.skip,
style: const TextStyle(color: Colors.white54)),
),
),
],
);
}
}