963d0d5dd6
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.
142 lines
4.2 KiB
Dart
142 lines
4.2 KiB
Dart
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)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|