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:
+6
-1
@@ -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
@@ -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": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user