From 963d0d5dd68b0c98c1f37091852dd60d25384ffa Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 22:58:14 +0900 Subject: [PATCH] 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. --- lib/l10n/app_en.arb | 7 +- lib/l10n/app_ko.arb | 7 +- lib/ui/screens/game_screen.dart | 61 ++++++++++++ lib/ui/widgets/tutorial_overlay.dart | 141 +++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 lib/ui/widgets/tutorial_overlay.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b68f2bf..eee50b0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -37,5 +37,10 @@ "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!" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6753c8a..a4bce51 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -16,5 +16,10 @@ "streakMilestone": "{days}일 연속 플레이! 대단해요!", "almostThere": "{percent}% 달성!", "seasonLabel": "SEASON", - "seasonStages": "{count}개 스테이지" + "seasonStages": "{count}개 스테이지", + "skip": "건너뛰기", + "gotIt": "알겠어요!", + "tutorialDrag": "블록을 보드로 끌어다 놓아보세요!", + "tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!", + "tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!" } diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 64fb891..24a058e 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -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 duration: const Duration(milliseconds: 350), ); + bool _tutorialStartChecked = false; + int? _dragIndex; Offset? _dragGlobal; @@ -126,6 +130,10 @@ class _GameScreenState extends ConsumerState 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 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 _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 ); } + 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 = diff --git a/lib/ui/widgets/tutorial_overlay.dart b/lib/ui/widgets/tutorial_overlay.dart new file mode 100644 index 0000000..675481d --- /dev/null +++ b/lib/ui/widgets/tutorial_overlay.dart @@ -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 createState() => _TutorialOverlayState(); +} + +class _TutorialOverlayState extends State + 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)), + ), + ), + ], + ); + } +}