Files
BlockSeasons/lib/ui/widgets/tutorial_overlay.dart
T
airkjw 963d0d5dd6 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.
2026-06-11 22:58:14 +09:00

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)),
),
),
],
);
}
}