feat: add sounds
This commit is contained in:
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"type": "dart",
|
"type": "dart",
|
||||||
"program": "lib/main.dart",
|
"program": "lib/main.dart",
|
||||||
"args": [
|
"args": [
|
||||||
"--dart-define=BASE_URL=http://192.168.100.6:8080",
|
"--dart-define=BASE_URL=http://192.168.1.105:8080",
|
||||||
"--target-platform=android-arm64"
|
"--target-platform=android-arm64"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class AppConfig {
|
|||||||
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیشفرض را عوض کنید.
|
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیشفرض را عوض کنید.
|
||||||
static const String baseUrl = String.fromEnvironment(
|
static const String baseUrl = String.fromEnvironment(
|
||||||
'BASE_URL',
|
'BASE_URL',
|
||||||
defaultValue: 'http://192.168.100.6:8080',
|
defaultValue: 'http://192.168.1.105:8080',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get apiUrl => '$baseUrl/api';
|
static String get apiUrl => '$baseUrl/api';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flame_audio/flame_audio.dart';
|
||||||
|
|
||||||
|
/// سرویسِ متمرکزِ افکتهای صوتی (فایلها در assets/audio/).
|
||||||
|
///
|
||||||
|
/// از AudioPool استفاده میشود: برای هر صدا یک مجموعهی محدود از پخشکنندهها
|
||||||
|
/// ساخته و بازاستفاده میشود. این کار از نشتِ AudioPlayerها (که روی اندروید
|
||||||
|
/// باعثِ قطعِ کاملِ صدا و پرشِ فریم میشد) جلوگیری میکند.
|
||||||
|
class AppSounds {
|
||||||
|
AppSounds._();
|
||||||
|
|
||||||
|
static const _shuffle = 'shuffle.mp3'; // بُر زدن/پخشِ کارتها (شروع دست)
|
||||||
|
static const _placeCard = 'place_card.mp3'; // گذاشتنِ کارت روی میز (همهی بازیکنان)
|
||||||
|
static const _takeCard = 'take_card.mp3'; // جمعشدنِ دستِ کامل
|
||||||
|
static const _cut = 'cut.mp3'; // بریدن با حکم (شمشیر)
|
||||||
|
static const _win = 'win.mp3'; // برد بازی
|
||||||
|
static const _button = 'button.mp3'; // فشردنِ دکمهها
|
||||||
|
|
||||||
|
static const _all = [_shuffle, _placeCard, _takeCard, _cut, _win, _button];
|
||||||
|
|
||||||
|
static final Map<String, AudioPool> _pools = {};
|
||||||
|
static bool muted = false;
|
||||||
|
|
||||||
|
/// ساختِ Poolها (یکبار، در main). تا آمادهشدن، پخشها بیاثرند.
|
||||||
|
static Future<void> preload() async {
|
||||||
|
for (final f in _all) {
|
||||||
|
try {
|
||||||
|
_pools[f] = await FlameAudio.createPool(f, maxPlayers: 3);
|
||||||
|
} catch (_) {/* نبودِ فایل ⇒ بیصدا */}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// پخش از Pool؛ اگر cutoffMs داده شود، صدا پس از آن مدت قطع میشود (کلیکِ کوتاه).
|
||||||
|
static void _play(String file, double volume, {int? cutoffMs}) {
|
||||||
|
if (muted) return;
|
||||||
|
final pool = _pools[file];
|
||||||
|
if (pool == null) return;
|
||||||
|
pool.start(volume: volume).then((stop) {
|
||||||
|
if (cutoffMs != null) {
|
||||||
|
Future.delayed(Duration(milliseconds: cutoffMs), () => stop());
|
||||||
|
}
|
||||||
|
}).catchError((_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// صدای بُر زدن؛ cutoffMs را برابرِ مدتِ انیمیشنِ بُر بده تا همزمان تمام شوند.
|
||||||
|
static void shuffle({int? cutoffMs}) => _play(_shuffle, 0.7, cutoffMs: cutoffMs);
|
||||||
|
static void placeCard() => _play(_placeCard, 0.8);
|
||||||
|
static void takeCard() => _play(_takeCard, 0.7);
|
||||||
|
static void cut() => _play(_cut, 0.9);
|
||||||
|
static void win() => _play(_win, 0.9);
|
||||||
|
static void button() => _play(_button, 0.5, cutoffMs: 130); // کلیکِ کوتاه
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../service/app_sounds.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// مجموعهی ویجتهای گرافیکیِ مشترک برای ظاهرِ بازیِ (Unity-style):
|
/// مجموعهی ویجتهای گرافیکیِ مشترک برای ظاهرِ بازیِ (Unity-style):
|
||||||
@@ -111,7 +112,12 @@ class _GameButtonState extends State<GameButton> {
|
|||||||
onTapDown: enabled ? (_) => setState(() => _down = true) : null,
|
onTapDown: enabled ? (_) => setState(() => _down = true) : null,
|
||||||
onTapUp: enabled ? (_) => setState(() => _down = false) : null,
|
onTapUp: enabled ? (_) => setState(() => _down = false) : null,
|
||||||
onTapCancel: enabled ? () => setState(() => _down = false) : null,
|
onTapCancel: enabled ? () => setState(() => _down = false) : null,
|
||||||
onTap: widget.onTap,
|
onTap: enabled
|
||||||
|
? () {
|
||||||
|
AppSounds.button();
|
||||||
|
widget.onTap!();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
child: AnimatedScale(
|
child: AnimatedScale(
|
||||||
scale: _down ? 0.96 : 1.0,
|
scale: _down ? 0.96 : 1.0,
|
||||||
duration: const Duration(milliseconds: 90),
|
duration: const Duration(milliseconds: 90),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:pinput/pinput.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/game_ui.dart';
|
import '../../../../core/widgets/game_ui.dart';
|
||||||
@@ -10,7 +10,7 @@ import '../bloc/auth_event.dart';
|
|||||||
import '../bloc/auth_state.dart';
|
import '../bloc/auth_state.dart';
|
||||||
import '../bloc/otp_status.dart';
|
import '../bloc/otp_status.dart';
|
||||||
|
|
||||||
/// صفحهی ورود کد یکبارمصرف (۵ رقمی).
|
/// صفحهی ورود کد یکبارمصرف (۵ رقمی) با pinput و تکمیل خودکارِ پیامک.
|
||||||
class OtpScreen extends StatefulWidget {
|
class OtpScreen extends StatefulWidget {
|
||||||
const OtpScreen({super.key});
|
const OtpScreen({super.key});
|
||||||
|
|
||||||
@@ -29,8 +29,26 @@ class _OtpScreenState extends State<OtpScreen> {
|
|||||||
|
|
||||||
bool get _valid => _controller.text.trim().length == 5;
|
bool get _valid => _controller.text.trim().length == 5;
|
||||||
|
|
||||||
|
void _submit(BuildContext context) {
|
||||||
|
if (_valid) {
|
||||||
|
context.read<AuthBloc>().add(CheckOtpEvent(_controller.text.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final defaultPin = PinTheme(
|
||||||
|
width: 52,
|
||||||
|
height: 60,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgDark,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppColors.goldDark, width: 1.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: GameBackground(
|
body: GameBackground(
|
||||||
child: BlocConsumer<AuthBloc, AuthState>(
|
child: BlocConsumer<AuthBloc, AuthState>(
|
||||||
@@ -62,31 +80,39 @@ class _OtpScreenState extends State<OtpScreen> {
|
|||||||
Text('کد پیامکشده به ${state.mobile} را وارد کنید',
|
Text('کد پیامکشده به ${state.mobile} را وارد کنید',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.white70)),
|
style: const TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 22),
|
||||||
TextField(
|
Directionality(
|
||||||
controller: _controller,
|
textDirection: TextDirection.ltr,
|
||||||
keyboardType: TextInputType.number,
|
child: Pinput(
|
||||||
textAlign: TextAlign.center,
|
length: 5,
|
||||||
style: const TextStyle(
|
controller: _controller,
|
||||||
fontSize: 28, letterSpacing: 12),
|
autofocus: true,
|
||||||
inputFormatters: [
|
keyboardType: TextInputType.number,
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
closeKeyboardWhenCompleted: true,
|
||||||
LengthLimitingTextInputFormatter(5),
|
defaultPinTheme: defaultPin,
|
||||||
],
|
focusedPinTheme: defaultPin.copyWith(
|
||||||
decoration:
|
decoration: defaultPin.decoration!.copyWith(
|
||||||
const InputDecoration(hintText: '- - - - -'),
|
border:
|
||||||
onChanged: (_) => setState(() {}),
|
Border.all(color: AppColors.gold, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
separatorBuilder: (_) => const SizedBox(width: 8),
|
||||||
|
pinAnimationType: PinAnimationType.fade,
|
||||||
|
// تکمیل خودکارِ کد از پیامک (Android: SMS User Consent).
|
||||||
|
androidSmsAutofillMethod:
|
||||||
|
AndroidSmsAutofillMethod.smsUserConsentApi,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
onCompleted: (_) => _submit(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 22),
|
||||||
GameButton(
|
GameButton(
|
||||||
label: 'ورود',
|
label: 'ورود',
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
|
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
|
||||||
onTap: (!_valid || loading)
|
onTap: (!_valid || loading)
|
||||||
? null
|
? null
|
||||||
: () => context
|
: () => _submit(context),
|
||||||
.read<AuthBloc>()
|
|
||||||
.add(CheckOtpEvent(_controller.text.trim())),
|
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: loading ? null : () => context.pop(),
|
onPressed: loading ? null : () => context.pop(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../../core/network/ws_client.dart';
|
import '../../../../core/network/ws_client.dart';
|
||||||
|
import '../../../../core/service/app_sounds.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
||||||
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
||||||
@@ -51,19 +52,32 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: BlocConsumer<GameBloc, GameUiState>(
|
body: BlocConsumer<GameBloc, GameUiState>(
|
||||||
listenWhen: (a, b) => a.notice != b.notice && b.notice != null,
|
listenWhen: (a, b) =>
|
||||||
|
(a.notice != b.notice && b.notice != null) ||
|
||||||
|
(a.gameOver == null && b.gameOver != null),
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (state.gameOver != null) {
|
||||||
SnackBar(
|
final won = state.gameOver!.winnerTeam ==
|
||||||
content: Text(state.notice!),
|
(state.state?.yourSeat ?? 0) % 2;
|
||||||
duration: const Duration(seconds: 2)),
|
if (won) AppSounds.win();
|
||||||
);
|
}
|
||||||
context.read<GameBloc>().clearNotice();
|
if (state.notice != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.notice!),
|
||||||
|
duration: const Duration(seconds: 2)),
|
||||||
|
);
|
||||||
|
context.read<GameBloc>().clearNotice();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.state != null && _introTimer == null) {
|
if (state.state != null && _introTimer == null) {
|
||||||
_introTimer = Timer(const Duration(milliseconds: 1600), () {
|
// کمی «حریفان پیدا شد» نشان بده، بعد اوورلی را کنار بزن و همان
|
||||||
if (mounted) setState(() => _introHidden = true);
|
// لحظه انیمیشنِ بُر زدن (با صدا) را اجرا کن.
|
||||||
|
_introTimer = Timer(const Duration(milliseconds: 700), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _introHidden = true);
|
||||||
|
_game.endIntro();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Stack(
|
return Stack(
|
||||||
@@ -130,6 +144,7 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _exitToLobby(BuildContext context) {
|
void _exitToLobby(BuildContext context) {
|
||||||
|
_game.silence(); // پس از خروج، صدای کارتهای بات پخش نشود
|
||||||
context.read<WalletBloc>().add(LoadWalletEvent());
|
context.read<WalletBloc>().add(LoadWalletEvent());
|
||||||
context.go('/lobby');
|
context.go('/lobby');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import 'dart:math' as math;
|
|||||||
import 'package:flame/components.dart';
|
import 'package:flame/components.dart';
|
||||||
import 'package:flame/effects.dart';
|
import 'package:flame/effects.dart';
|
||||||
import 'package:flame/game.dart';
|
import 'package:flame/game.dart';
|
||||||
import 'package:flame_audio/flame_audio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../../core/service/app_sounds.dart';
|
||||||
import '../../../domain/entities/game_entities.dart';
|
import '../../../domain/entities/game_entities.dart';
|
||||||
import '../../bloc/game_bloc.dart';
|
import '../../bloc/game_bloc.dart';
|
||||||
import 'card_codes.dart';
|
import 'card_codes.dart';
|
||||||
import 'card_component.dart';
|
import 'card_component.dart';
|
||||||
|
import 'shuffle_animation.dart';
|
||||||
import 'table_pieces.dart';
|
import 'table_pieces.dart';
|
||||||
|
|
||||||
/// صحنهی میز حکم با انیمیشن. شما همیشه پایین میز (rel=0) هستید.
|
/// صحنهی میز حکم با انیمیشن. شما همیشه پایین میز (rel=0) هستید.
|
||||||
@@ -42,6 +43,9 @@ class HokmGame extends FlameGame {
|
|||||||
double _t = 0;
|
double _t = 0;
|
||||||
List<String> _prevTrick = const [];
|
List<String> _prevTrick = const [];
|
||||||
int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن
|
int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن
|
||||||
|
bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود
|
||||||
|
bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف»
|
||||||
|
bool _pendingShuffle = false; // بُرِ دستِ اول که تا پایانِ اینترو به تعویق افتاده
|
||||||
|
|
||||||
HokmGame(this.cubit);
|
HokmGame(this.cubit);
|
||||||
|
|
||||||
@@ -51,14 +55,20 @@ class HokmGame extends FlameGame {
|
|||||||
@override
|
@override
|
||||||
Future<void> onLoad() async {
|
Future<void> onLoad() async {
|
||||||
add(Felt());
|
add(Felt());
|
||||||
// پیشبارگذاریِ صدای بُر زدن (در صورت نبودِ فایل، بیصدا رد میشود).
|
|
||||||
FlameAudio.audioCache.load('shuffle.mp3').catchError((_) => Uri());
|
|
||||||
_sub = cubit.stream.listen((ui) {
|
_sub = cubit.stream.listen((ui) {
|
||||||
if (ui.state == null) return;
|
if (ui.state == null) return;
|
||||||
final s = ui.state!;
|
final s = ui.state!;
|
||||||
_checkCut(s);
|
_checkTrickSounds(s);
|
||||||
// شروعِ دستِ جدید (پخشِ کارت): صدای بُر زدن.
|
// شروعِ دستِ جدید (پخشِ کارت): انیمیشن و صدای بُر زدن.
|
||||||
if (_prevHandSize == 0 && s.yourHand.isNotEmpty) _playShuffle();
|
// دستِ اول تا پایانِ نمایشِ «جستجوی حریف» به تعویق میافتد تا انیمیشن
|
||||||
|
// زیرِ اوورلی پنهان نشود و صدا با تصویر هماهنگ باشد.
|
||||||
|
if (_prevHandSize == 0 && s.yourHand.isNotEmpty && !_silent) {
|
||||||
|
if (_introActive) {
|
||||||
|
_pendingShuffle = true;
|
||||||
|
} else {
|
||||||
|
_shuffleNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
_prevHandSize = s.yourHand.length;
|
_prevHandSize = s.yourHand.length;
|
||||||
_s = s;
|
_s = s;
|
||||||
_relayout();
|
_relayout();
|
||||||
@@ -71,11 +81,6 @@ class HokmGame extends FlameGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _playShuffle() {
|
|
||||||
// اگر فایل صدا نباشد، خطا نادیده گرفته میشود (بازی بیصدا).
|
|
||||||
FlameAudio.play('shuffle.mp3', volume: 0.7).then((_) {}, onError: (_) {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onGameResize(Vector2 size) {
|
void onGameResize(Vector2 size) {
|
||||||
super.onGameResize(size);
|
super.onGameResize(size);
|
||||||
@@ -88,24 +93,57 @@ class HokmGame extends FlameGame {
|
|||||||
super.onRemove();
|
super.onRemove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== افکتِ بریدن با حکم (تکان + رعد) =====
|
// ===== صداها و افکتِ بریدن با حکم (تکان + رعد) =====
|
||||||
|
|
||||||
// اگر دقیقاً یک کارتِ تازه به زمین اضافه شده و آن کارت «بُرِش با حکم» باشد
|
// با هر تغییرِ زمین: افزودنِ کارت ⇒ صدای گذاشتن/انداختن (و بریدن با حکم ⇒
|
||||||
// (خالِ زمینه آتو نیست ولی کارتِ تازه آتوست)، همان لحظه تکان + رعد.
|
// شمشیر + تکان + رعد)؛ خالیشدنِ زمین ⇒ صدای جمعشدنِ دست.
|
||||||
void _checkCut(GameState s) {
|
void _checkTrickSounds(GameState s) {
|
||||||
final now = s.trick.map((t) => t.card).toList();
|
final now = s.trick.map((t) => t.card).toList();
|
||||||
final addedOne = now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now);
|
if (_silent) {
|
||||||
if (addedOne &&
|
_prevTrick = now;
|
||||||
s.trump != null &&
|
return;
|
||||||
s.leadSuit != null &&
|
}
|
||||||
s.leadSuit != s.trump &&
|
final addedOne =
|
||||||
suitName(now.last) == s.trump) {
|
now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now);
|
||||||
_shake = 1.0;
|
if (addedOne) {
|
||||||
add(Lightning());
|
final card = s.trick.last;
|
||||||
|
final isCut = s.trump != null &&
|
||||||
|
s.leadSuit != null &&
|
||||||
|
s.leadSuit != s.trump &&
|
||||||
|
suitName(card.card) == s.trump;
|
||||||
|
if (isCut) {
|
||||||
|
_shake = 1.0;
|
||||||
|
add(Lightning());
|
||||||
|
AppSounds.cut(); // بریدن با حکم (شمشیر)
|
||||||
|
} else {
|
||||||
|
AppSounds.placeCard(); // کارت روی میز نشست (همهی بازیکنان)
|
||||||
|
}
|
||||||
|
} else if (now.isEmpty && _prevTrick.isNotEmpty) {
|
||||||
|
AppSounds.takeCard(); // دستِ کامل جمع شد
|
||||||
}
|
}
|
||||||
_prevTrick = now;
|
_prevTrick = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// قطعِ صداهای بازی هنگام خروج از میز (جلوگیری از پخشِ صدای کارت پس از ترک).
|
||||||
|
void silence() => _silent = true;
|
||||||
|
|
||||||
|
/// پایانِ نمایشِ «جستجوی حریف»؛ اگر بُرِ دستِ اول به تعویق افتاده بود، اکنون
|
||||||
|
/// (که اوورلی کنار رفته) با صدا و تصویرِ هماهنگ اجرا میشود.
|
||||||
|
void endIntro() {
|
||||||
|
_introActive = false;
|
||||||
|
if (_pendingShuffle && !_silent) {
|
||||||
|
_pendingShuffle = false;
|
||||||
|
_shuffleNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// اجرای همزمانِ انیمیشن و صدای بُر زدن (صدا با پایانِ انیمیشن قطع میشود).
|
||||||
|
void _shuffleNow() {
|
||||||
|
if (_silent) return;
|
||||||
|
AppSounds.shuffle(cutoffMs: ShuffleAnimation.totalMs);
|
||||||
|
add(ShuffleAnimation(size));
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isPrefix(List<String> a, List<String> b) {
|
static bool _isPrefix(List<String> a, List<String> b) {
|
||||||
for (var i = 0; i < a.length; i++) {
|
for (var i = 0; i < a.length; i++) {
|
||||||
if (a[i] != b[i]) return false;
|
if (a[i] != b[i]) return false;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flame/components.dart';
|
||||||
|
import 'package:flame/effects.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// انیمیشنِ بُر زدن: دو دسته کارتِ پشترو در مرکز ریفل میشوند، کمی بالا/پایین
|
||||||
|
/// میروند (بُر) و سپس به سمت چهار بازیکن پخش میشوند. مدتِ کل [totalMs] است
|
||||||
|
/// تا صدای بُر زدن دقیقاً همزمان با پایانِ انیمیشن قطع شود.
|
||||||
|
class ShuffleAnimation extends PositionComponent {
|
||||||
|
final Vector2 area;
|
||||||
|
ShuffleAnimation(this.area);
|
||||||
|
|
||||||
|
/// مدتِ کلِ انیمیشن (مرجعِ هماهنگیِ صدا).
|
||||||
|
static const int totalMs = 2000;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onLoad() async {
|
||||||
|
priority = 6000;
|
||||||
|
final cw = area.x * 0.16;
|
||||||
|
final ch = cw * 1.45;
|
||||||
|
final center = Vector2(area.x / 2, area.y / 2);
|
||||||
|
const n = 10;
|
||||||
|
|
||||||
|
// جهتِ پخش به سمتِ ۴ بازیکن (پایین، چپ، بالا، راست).
|
||||||
|
final dirs = [
|
||||||
|
Vector2(0, area.y * 0.42),
|
||||||
|
Vector2(-area.x * 0.42, 0),
|
||||||
|
Vector2(0, -area.y * 0.42),
|
||||||
|
Vector2(area.x * 0.42, 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
final left = i.isEven;
|
||||||
|
final stack = center + Vector2((i - n / 2) * cw * 0.05, 0);
|
||||||
|
final delay = i * 0.02; // ریفلِ پلکانی
|
||||||
|
|
||||||
|
final card = _Back(Vector2(cw, ch))
|
||||||
|
..anchor = Anchor.center
|
||||||
|
..position = center + Vector2((left ? -1 : 1) * cw, 0)
|
||||||
|
..angle = (left ? -1 : 1) * 0.22;
|
||||||
|
add(card);
|
||||||
|
|
||||||
|
// صافشدنِ زاویه همزمان با ریفل.
|
||||||
|
card.add(RotateEffect.to(
|
||||||
|
0, EffectController(duration: 0.55, startDelay: delay)));
|
||||||
|
|
||||||
|
// دنباله: ریفل به مرکز ← بالا (بُر) ← نشستن ← پخش به سمت بازیکن.
|
||||||
|
card.add(SequenceEffect([
|
||||||
|
MoveToEffect(stack,
|
||||||
|
EffectController(duration: 0.55, curve: Curves.easeOut, startDelay: delay)),
|
||||||
|
MoveToEffect(stack + Vector2(0, -ch * 0.18),
|
||||||
|
EffectController(duration: 0.35, curve: Curves.easeInOut)),
|
||||||
|
MoveToEffect(stack,
|
||||||
|
EffectController(duration: 0.35, curve: Curves.easeInOut)),
|
||||||
|
MoveToEffect(center + dirs[i % 4],
|
||||||
|
EffectController(duration: 0.55, curve: Curves.easeIn)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// کوچکشدن هنگامِ پخش (همزمان با حرکتِ آخرِ دنباله).
|
||||||
|
card.add(ScaleEffect.to(
|
||||||
|
Vector2.all(0.5),
|
||||||
|
EffectController(duration: 0.55, startDelay: 1.25 + delay),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
add(RemoveEffect(delay: totalMs / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// پشتِ یک کارت (مستطیلِ گرد با حاشیهی طلایی).
|
||||||
|
class _Back extends PositionComponent {
|
||||||
|
_Back(Vector2 s) : super(size: s);
|
||||||
|
|
||||||
|
static final _fill = Paint()..color = const Color(0xFF7A1020);
|
||||||
|
static final _border = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2
|
||||||
|
..color = const Color(0xFFE9C76A);
|
||||||
|
static final _inner = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5
|
||||||
|
..color = const Color(0x88E9C76A);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void render(Canvas canvas) {
|
||||||
|
final rect = size.toRect();
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(rect, const Radius.circular(8)), _fill);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(rect, const Radius.circular(8)), _border);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(rect.deflate(6), const Radius.circular(6)),
|
||||||
|
_inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'core/locator/locator.dart';
|
import 'core/locator/locator.dart';
|
||||||
|
import 'core/service/app_sounds.dart';
|
||||||
import 'feature/auth/domain/repository/auth_repository.dart';
|
import 'feature/auth/domain/repository/auth_repository.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await setupLocator();
|
await setupLocator();
|
||||||
|
AppSounds.preload(); // پیشبارگذاریِ صداها (بدون انتظار)
|
||||||
final loggedIn = await locator<AuthRepository>().isLoggedIn();
|
final loggedIn = await locator<AuthRepository>().isLoggedIn();
|
||||||
runApp(HakemApp(loggedIn: loggedIn));
|
runApp(HakemApp(loggedIn: loggedIn));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,6 +480,14 @@ packages:
|
|||||||
url: "https://pub.myket.ir"
|
url: "https://pub.myket.ir"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
|
pinput:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pinput
|
||||||
|
sha256: "6d571e38a484f7515a52e89024ef416f11fa6171ac6f32303701374ab9890efa"
|
||||||
|
url: "https://pub.myket.ir"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -517,6 +525,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
smart_auth:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: smart_auth
|
||||||
|
sha256: "88aa8fe66e951c78a307f26d1c29672dce2e9eb3da2e12e853864d0e615a73ad"
|
||||||
|
url: "https://pub.myket.ir"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -581,6 +597,14 @@ packages:
|
|||||||
url: "https://pub.myket.ir"
|
url: "https://pub.myket.ir"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_platform
|
||||||
|
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||||
|
url: "https://pub.myket.ir"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.3.1
|
flutter_secure_storage: ^10.3.1
|
||||||
equatable: ^2.0.8
|
equatable: ^2.0.8
|
||||||
get_it: ^8.0.3
|
get_it: ^8.0.3
|
||||||
|
pinput: ^4.0.0
|
||||||
flame: ^1.30.1
|
flame: ^1.30.1
|
||||||
flame_audio: ^2.11.14
|
flame_audio: ^2.11.14
|
||||||
random_avatar: ^0.0.8
|
random_avatar: ^0.0.8
|
||||||
|
|||||||
Reference in New Issue
Block a user