feat: add game graphic
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
/// تنظیماتِ سراسریِ اپ (فعلاً کیفیتِ گرافیک). با ذخیرهی محلی پایدار میماند.
|
||||||
|
class AppSettings {
|
||||||
|
AppSettings._();
|
||||||
|
static final AppSettings I = AppSettings._();
|
||||||
|
|
||||||
|
final _storage = const FlutterSecureStorage();
|
||||||
|
static const _kLowGfx = 'low_graphics';
|
||||||
|
|
||||||
|
/// حالتِ کممصرف: افکتهای اتمسفری (ذرات، شوکویوِ بریدن، پسزمینهی تصویری)
|
||||||
|
/// خاموش میشوند تا روی گوشیهای ضعیف روان بماند. با ValueNotifier تا UI واکنش دهد.
|
||||||
|
final ValueNotifier<bool> lowGraphics = ValueNotifier(false);
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
lowGraphics.value = (await _storage.read(key: _kLowGfx)) == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLowGraphics(bool v) async {
|
||||||
|
lowGraphics.value = v;
|
||||||
|
await _storage.write(key: _kLowGfx, value: v ? '1' : '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,8 +90,15 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
listenWhen:
|
listenWhen:
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.notice != b.notice && b.notice != null) ||
|
(a.notice != b.notice && b.notice != null) ||
|
||||||
(a.gameOver == null && b.gameOver != null),
|
(a.gameOver == null && b.gameOver != null) ||
|
||||||
|
(a.handResult == null && b.handResult != null),
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
|
// جشنِ کُت: هنگامِ دستِ کُتشدهی بُرده، ضربهی لمسی.
|
||||||
|
final hr = state.handResult;
|
||||||
|
if (hr != null && hr.kot) {
|
||||||
|
final myTeam = (state.state?.yourSeat ?? 0) % 2;
|
||||||
|
if (hr.winnerTeam == myTeam) HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
if (state.gameOver != null) {
|
if (state.gameOver != null) {
|
||||||
final won =
|
final won =
|
||||||
state.gameOver!.winnerTeam ==
|
state.gameOver!.winnerTeam ==
|
||||||
@@ -540,44 +547,77 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
final mySeat = s.state?.yourSeat ?? 0;
|
final mySeat = s.state?.yourSeat ?? 0;
|
||||||
final myTeam = mySeat % 2;
|
final myTeam = mySeat % 2;
|
||||||
final won = r.winnerTeam == myTeam;
|
final won = r.winnerTeam == myTeam;
|
||||||
|
final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: Center(
|
child: Stack(
|
||||||
child: Container(
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
|
if (kotWin) const Positioned.fill(child: Confetti(count: 40)),
|
||||||
decoration: BoxDecoration(
|
Center(
|
||||||
color: Colors.black87,
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(16),
|
padding:
|
||||||
border: Border.all(color: AppColors.gold),
|
const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
gradient: kotWin
|
||||||
mainAxisSize: MainAxisSize.min,
|
? const LinearGradient(
|
||||||
children: [
|
colors: [AppColors.lapisMid, AppColors.lapisInk])
|
||||||
Text(
|
: null,
|
||||||
won ? 'این دست را بردید!' : 'این دست را باختید',
|
color: kotWin ? null : Colors.black87,
|
||||||
style: TextStyle(
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: won ? AppColors.green : Colors.redAccent,
|
border: Border.all(
|
||||||
fontSize: 20,
|
color: AppColors.gold, width: kotWin ? 2 : 1),
|
||||||
fontWeight: FontWeight.bold,
|
boxShadow: kotWin
|
||||||
),
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.gold.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 20),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
if (r.kot)
|
child: Column(
|
||||||
Padding(
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: const EdgeInsets.only(top: 6),
|
children: [
|
||||||
child: Text(
|
if (kotWin)
|
||||||
r.hakemKot
|
Text(
|
||||||
? 'حاکمکُت! (${r.points} امتیاز)'
|
r.hakemKot ? 'حاکمکُت! 🔥' : 'کُت! 🔥',
|
||||||
: 'کُت! (${r.points} امتیاز)',
|
style: const TextStyle(
|
||||||
style: const TextStyle(color: AppColors.gold),
|
color: AppColors.gold,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
shadows: [Shadow(color: Colors.black, blurRadius: 8)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
won ? 'این دست را بردید!' : 'این دست را باختید',
|
||||||
|
style: TextStyle(
|
||||||
|
color: won ? AppColors.green : Colors.redAccent,
|
||||||
|
fontSize: kotWin ? 16 : 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (r.kot && !kotWin)
|
||||||
const SizedBox(height: 6),
|
Padding(
|
||||||
Text(
|
padding: const EdgeInsets.only(top: 6),
|
||||||
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}',
|
child: Text(
|
||||||
style: const TextStyle(color: Colors.white70),
|
r.hakemKot
|
||||||
|
? 'حاکمکُت! (${r.points} امتیاز)'
|
||||||
|
: 'کُت! (${r.points} امتیاز)',
|
||||||
|
style: const TextStyle(color: AppColors.gold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (kotWin)
|
||||||
|
Text('+${r.points} امتیاز',
|
||||||
|
style: const TextStyle(color: AppColors.gold)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}',
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart' show HapticFeedback;
|
import 'package:flutter/services.dart' show HapticFeedback;
|
||||||
|
|
||||||
import '../../../../../core/service/app_sounds.dart';
|
import '../../../../../core/service/app_sounds.dart';
|
||||||
|
import '../../../../../core/settings/app_settings.dart';
|
||||||
import '../../../../../core/theme/app_theme.dart';
|
import '../../../../../core/theme/app_theme.dart';
|
||||||
import '../../../domain/entities/game_entities.dart';
|
import '../../../domain/entities/game_entities.dart';
|
||||||
import '../../bloc/game_bloc.dart';
|
import '../../bloc/game_bloc.dart';
|
||||||
@@ -84,6 +85,9 @@ class HokmGame extends FlameGame {
|
|||||||
@override
|
@override
|
||||||
Future<void> onLoad() async {
|
Future<void> onLoad() async {
|
||||||
add(Felt(carpet: carpet));
|
add(Felt(carpet: carpet));
|
||||||
|
if (!AppSettings.I.lowGraphics.value) {
|
||||||
|
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کممصرف خاموش)
|
||||||
|
}
|
||||||
_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!;
|
||||||
@@ -172,6 +176,9 @@ class HokmGame extends FlameGame {
|
|||||||
_bestTrumpRank = rank;
|
_bestTrumpRank = rank;
|
||||||
_shake = 1.0;
|
_shake = 1.0;
|
||||||
add(Lightning());
|
add(Lightning());
|
||||||
|
if (!AppSettings.I.lowGraphics.value) {
|
||||||
|
add(CutBurst()); // فلاش + شوکویو + جرقه (کممصرف: خاموش)
|
||||||
|
}
|
||||||
AppSounds.cut(); // بریدن با حکم (شمشیر)
|
AppSounds.cut(); // بریدن با حکم (شمشیر)
|
||||||
HapticFeedback.heavyImpact(); // ضربهی لمسیِ بُرِش با حکم
|
HapticFeedback.heavyImpact(); // ضربهی لمسیِ بُرِش با حکم
|
||||||
} else {
|
} else {
|
||||||
@@ -379,7 +386,8 @@ class HokmGame extends FlameGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// حرکتِ نرمِ یک کارت به مقصد، بدون انباشتهشدنِ افکتها.
|
// حرکتِ نرمِ یک کارت به مقصد، بدون انباشتهشدنِ افکتها.
|
||||||
void _moveTo(CardComponent c, Vector2 target, {double dur = 0.38}) {
|
void _moveTo(CardComponent c, Vector2 target,
|
||||||
|
{double dur = 0.38, bool bounce = false}) {
|
||||||
c.children.whereType<MoveToEffect>().toList().forEach(
|
c.children.whereType<MoveToEffect>().toList().forEach(
|
||||||
(e) => e.removeFromParent(),
|
(e) => e.removeFromParent(),
|
||||||
);
|
);
|
||||||
@@ -387,10 +395,12 @@ class HokmGame extends FlameGame {
|
|||||||
c.position = target;
|
c.position = target;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// کارتِ تازهنشسته روی میز با کمی overshoot مینشیند (حسِ «جا افتادن»).
|
||||||
c.add(
|
c.add(
|
||||||
MoveToEffect(
|
MoveToEffect(
|
||||||
target,
|
target,
|
||||||
EffectController(duration: dur, curve: Curves.easeOutCubic),
|
EffectController(
|
||||||
|
duration: dur, curve: bounce ? Curves.easeOutBack : Curves.easeOutCubic),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -507,6 +517,7 @@ class HokmGame extends FlameGame {
|
|||||||
for (final tc in s.trick) {
|
for (final tc in s.trick) {
|
||||||
final slot = size / 2 + _trickOffset(_rel(tc.seat));
|
final slot = size / 2 + _trickOffset(_rel(tc.seat));
|
||||||
var c = _trick[tc.card];
|
var c = _trick[tc.card];
|
||||||
|
final isNew = c == null; // فقط کارتِ تازهنشسته bounce میشود
|
||||||
if (c == null) {
|
if (c == null) {
|
||||||
// اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط).
|
// اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط).
|
||||||
c = _hand.remove(tc.card);
|
c = _hand.remove(tc.card);
|
||||||
@@ -530,7 +541,7 @@ class HokmGame extends FlameGame {
|
|||||||
c.size = Vector2(_cardW, _cardH);
|
c.size = Vector2(_cardW, _cardH);
|
||||||
c.angle = 0; // کارتهای روی زمین صافاند (چرخشِ بادبزنیِ دست حذف میشود)
|
c.angle = 0; // کارتهای روی زمین صافاند (چرخشِ بادبزنیِ دست حذف میشود)
|
||||||
c.priority = 5;
|
c.priority = 5;
|
||||||
_moveTo(c, slot);
|
_moveTo(c, slot, bounce: isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
// دستِ کامل (۴ کارت) ⇒ کارتِ برنده را طلایی هایلایت کن و یکبار «پاپ» بزن،
|
// دستِ کامل (۴ کارت) ⇒ کارتِ برنده را طلایی هایلایت کن و یکبار «پاپ» بزن،
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flame/components.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../../core/network/card_images.dart';
|
import '../../../../../core/network/card_images.dart';
|
||||||
|
import '../../../../../core/settings/app_settings.dart';
|
||||||
import '../../../../../core/theme/app_theme.dart';
|
import '../../../../../core/theme/app_theme.dart';
|
||||||
|
|
||||||
// رنگهای مشترکِ میز (منبعِ واحد در AppColors).
|
// رنگهای مشترکِ میز (منبعِ واحد در AppColors).
|
||||||
@@ -19,6 +20,7 @@ class Felt extends PositionComponent with HasGameReference {
|
|||||||
Felt({this.carpet = 'classic'});
|
Felt({this.carpet = 'classic'});
|
||||||
|
|
||||||
Sprite? _carpetSprite;
|
Sprite? _carpetSprite;
|
||||||
|
Sprite? _bgSprite; // پسزمینهی نقاشیشده (اختیاری، قابلِ تعویض)
|
||||||
|
|
||||||
bool get _hasCarpet => carpet != 'classic';
|
bool get _hasCarpet => carpet != 'classic';
|
||||||
|
|
||||||
@@ -27,6 +29,15 @@ class Felt extends PositionComponent with HasGameReference {
|
|||||||
if (_hasCarpet) {
|
if (_hasCarpet) {
|
||||||
_carpetSprite = await CardImages.carpet(carpet);
|
_carpetSprite = await CardImages.carpet(carpet);
|
||||||
}
|
}
|
||||||
|
// پسزمینهی سفارشی (تالار/کاخ): فایلِ assets/images/game_bg.jpg را بگذارید.
|
||||||
|
// اگر نبود یا حالتِ کممصرف باشد، به گرادیانِ لاجوردیِ پیشفرض برمیگردد.
|
||||||
|
if (!AppSettings.I.lowGraphics.value) {
|
||||||
|
try {
|
||||||
|
_bgSprite = await Sprite.load('game_bg.jpg');
|
||||||
|
} catch (_) {
|
||||||
|
_bgSprite = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,18 +45,36 @@ class Felt extends PositionComponent with HasGameReference {
|
|||||||
final w = game.size.x, h = game.size.y;
|
final w = game.size.x, h = game.size.y;
|
||||||
final center = Offset(w / 2, h / 2);
|
final center = Offset(w / 2, h / 2);
|
||||||
|
|
||||||
// ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. همیشه کشیده
|
// ۱) پسزمینه: تصویرِ سفارشیِ نقاشیشده (اگر موجود باشد) یا گرادیانِ لاجوردیِ
|
||||||
// میشود تا زیرِ میز/فرش یکدست باشد و لبههای فرش با پسزمینه جور شود.
|
// پیشفرض. تصویر با پوششِ کامل (cover) کشیده و کمی تیره میشود تا کارتها خوانا
|
||||||
canvas.drawRect(
|
// بمانند و وینیتِ شعاعی هم رویش میآید تا لبهها با تم جور شود.
|
||||||
Rect.fromLTWH(0, 0, w, h),
|
final full = Rect.fromLTWH(0, 0, w, h);
|
||||||
Paint()
|
final bg = _bgSprite;
|
||||||
..shader = RadialGradient(
|
if (bg != null) {
|
||||||
center: Alignment.center,
|
_drawCover(canvas, bg, w, h);
|
||||||
radius: 1.0,
|
canvas.drawRect(full, Paint()..color = const Color(0x66081428));
|
||||||
colors: const [AppColors.lapisHi, AppColors.lapisNight],
|
canvas.drawRect(
|
||||||
stops: const [0.5, 1.0],
|
full,
|
||||||
).createShader(Rect.fromLTWH(0, 0, w, h)),
|
Paint()
|
||||||
);
|
..shader = RadialGradient(
|
||||||
|
center: Alignment.center,
|
||||||
|
radius: 1.1,
|
||||||
|
colors: const [Color(0x00000000), AppColors.lapisNight],
|
||||||
|
stops: const [0.55, 1.0],
|
||||||
|
).createShader(full),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
canvas.drawRect(
|
||||||
|
full,
|
||||||
|
Paint()
|
||||||
|
..shader = RadialGradient(
|
||||||
|
center: Alignment.center,
|
||||||
|
radius: 1.0,
|
||||||
|
colors: const [AppColors.lapisHi, AppColors.lapisNight],
|
||||||
|
stops: const [0.5, 1.0],
|
||||||
|
).createShader(full),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ۲) میزِ مرکزی با قابِ طلایی.
|
// ۲) میزِ مرکزی با قابِ طلایی.
|
||||||
final rect = Rect.fromCenter(
|
final rect = Rect.fromCenter(
|
||||||
@@ -123,6 +152,78 @@ class Felt extends PositionComponent with HasGameReference {
|
|||||||
..color = _gold.withValues(alpha: 0.65),
|
..color = _gold.withValues(alpha: 0.65),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// تصویرِ پسزمینه را با حفظِ نسبت، پوششِ کاملِ صفحه (cover) و مرکز-برش میکشد.
|
||||||
|
void _drawCover(Canvas canvas, Sprite sprite, double w, double h) {
|
||||||
|
final img = sprite.image;
|
||||||
|
final iw = img.width.toDouble(), ih = img.height.toDouble();
|
||||||
|
final scale = math.max(w / iw, h / ih);
|
||||||
|
final dw = iw * scale, dh = ih * scale;
|
||||||
|
sprite.render(
|
||||||
|
canvas,
|
||||||
|
position: Vector2((w - dw) / 2, (h - dh) / 2),
|
||||||
|
size: Vector2(dw, dh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ذراتِ طلاییِ معلق (اتمسفرِ سبک). چند نقطهی کمرنگ که آرام بالا میروند و
|
||||||
|
/// چشمک میزنند؛ عمق میدهد بدونِ بارِ گرافیکیِ محسوس (بدونِ blur).
|
||||||
|
class Motes extends PositionComponent with HasGameReference {
|
||||||
|
final int count;
|
||||||
|
Motes({this.count = 12}) {
|
||||||
|
priority = 1; // بالای Felt، زیرِ کارتها (priority ۵)
|
||||||
|
}
|
||||||
|
|
||||||
|
final _rng = math.Random();
|
||||||
|
double _t = 0;
|
||||||
|
late final List<_Mote> _motes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onLoad() async {
|
||||||
|
_motes = List.generate(
|
||||||
|
count,
|
||||||
|
(_) => _Mote(
|
||||||
|
x: _rng.nextDouble(),
|
||||||
|
y: _rng.nextDouble(),
|
||||||
|
r: 1.0 + _rng.nextDouble() * 1.8,
|
||||||
|
speed: 0.006 + _rng.nextDouble() * 0.014,
|
||||||
|
drift: (_rng.nextDouble() - 0.5) * 0.03,
|
||||||
|
phase: _rng.nextDouble() * 6.28,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void update(double dt) => _t += dt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void render(Canvas canvas) {
|
||||||
|
final w = game.size.x, h = game.size.y;
|
||||||
|
for (final m in _motes) {
|
||||||
|
var y = (m.y - m.speed * _t) % 1.0;
|
||||||
|
if (y < 0) y += 1;
|
||||||
|
final xx = (m.x + m.drift * math.sin(_t * 0.3 + m.phase)) * w;
|
||||||
|
final tw = (0.35 + 0.28 * math.sin(_t * 1.4 + m.phase)).clamp(0.0, 1.0);
|
||||||
|
canvas.drawCircle(
|
||||||
|
Offset(xx, y * h),
|
||||||
|
m.r,
|
||||||
|
Paint()..color = _gold.withValues(alpha: tw * 0.45),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Mote {
|
||||||
|
final double x, y, r, speed, drift, phase;
|
||||||
|
_Mote({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.r,
|
||||||
|
required this.speed,
|
||||||
|
required this.drift,
|
||||||
|
required this.phase,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// دیسکِ فلزیِ شمارش (امتیاز تیم) با عددِ وسط؛ از ۰ تا ۷.
|
/// دیسکِ فلزیِ شمارش (امتیاز تیم) با عددِ وسط؛ از ۰ تا ۷.
|
||||||
@@ -161,6 +262,71 @@ class ScorePuck extends PositionComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// افکت رعد روی زمین هنگام «بریدن با حکم»؛ پس از مدت کوتاهی خودش حذف میشود.
|
/// افکت رعد روی زمین هنگام «بریدن با حکم»؛ پس از مدت کوتاهی خودش حذف میشود.
|
||||||
|
/// افکتِ بریدن با حکم: فلاشِ کوتاهِ صفحه + حلقهی شوکویوِ طلایی + جرقهها.
|
||||||
|
/// همراهِ Lightning اضافه میشود تا بُرِش «قدرتمند» حس شود.
|
||||||
|
class CutBurst extends PositionComponent with HasGameReference {
|
||||||
|
static const _max = 0.5;
|
||||||
|
double _life = _max;
|
||||||
|
final _rng = math.Random();
|
||||||
|
late final List<_Spark> _sparks;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onLoad() async {
|
||||||
|
size = game.size;
|
||||||
|
_sparks = List.generate(16, (_) {
|
||||||
|
final ang = _rng.nextDouble() * math.pi * 2;
|
||||||
|
final spd = 0.10 + _rng.nextDouble() * 0.17;
|
||||||
|
return _Spark(ang, spd, 1.5 + _rng.nextDouble() * 2.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void update(double dt) {
|
||||||
|
_life -= dt;
|
||||||
|
if (_life <= 0) removeFromParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void render(Canvas canvas) {
|
||||||
|
final t = (1 - _life / _max).clamp(0.0, 1.0); // پیشرفت ۰..۱
|
||||||
|
final center = Offset(size.x / 2, size.y / 2);
|
||||||
|
|
||||||
|
// ۱) فلاشِ کوتاهِ صفحه (فقط اوایل).
|
||||||
|
final flash = (1 - t * 3).clamp(0.0, 1.0);
|
||||||
|
if (flash > 0) {
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(0, 0, size.x, size.y),
|
||||||
|
Paint()..color = const Color(0xFFFFF3C4).withValues(alpha: flash * 0.45),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final fade = (1 - t).clamp(0.0, 1.0);
|
||||||
|
// ۲) حلقهی شوکویوِ طلایی که باز میشود.
|
||||||
|
canvas.drawCircle(
|
||||||
|
center,
|
||||||
|
t * size.x * 0.55,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = size.x * 0.022 * fade
|
||||||
|
..color = _gold.withValues(alpha: fade * 0.8)
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ۳) جرقههای طلایی که به بیرون میپاشند.
|
||||||
|
final sp = Paint()..color = const Color(0xFFFFF3C4).withValues(alpha: fade);
|
||||||
|
for (final s in _sparks) {
|
||||||
|
final d = s.speed * size.x * t;
|
||||||
|
final p = center + Offset(math.cos(s.ang), math.sin(s.ang)) * d;
|
||||||
|
canvas.drawCircle(p, s.r * fade, sp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Spark {
|
||||||
|
final double ang, speed, r;
|
||||||
|
_Spark(this.ang, this.speed, this.r);
|
||||||
|
}
|
||||||
|
|
||||||
class Lightning extends PositionComponent with HasGameReference {
|
class Lightning extends PositionComponent with HasGameReference {
|
||||||
static const _max = 0.55;
|
static const _max = 0.55;
|
||||||
double _life = _max;
|
double _life = _max;
|
||||||
|
|||||||
@@ -211,6 +211,22 @@ class TableHud extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// گرادیانِ قابِ آواتار بر اساسِ نشانِ رتبه (برنز→پادشاه). پیشفرض: طلایی.
|
||||||
|
List<Color> _rankFrame(String tier) {
|
||||||
|
switch (tier) {
|
||||||
|
case 'bronze':
|
||||||
|
return const [Color(0xFFCD9B6A), Color(0xFF7A4A22)];
|
||||||
|
case 'silver':
|
||||||
|
return const [Color(0xFFEDF1F4), Color(0xFF98A2AD)];
|
||||||
|
case 'diamond':
|
||||||
|
return const [Color(0xFF9BE7F0), Color(0xFF2E8BA0)];
|
||||||
|
case 'king':
|
||||||
|
return const [Color(0xFFCE9BEA), Color(0xFF6A2FA0)];
|
||||||
|
default: // gold و ناشناخته/بات
|
||||||
|
return const [Color(0xFFF3D27A), AppColors.goldDark];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _PlayerSeat extends StatelessWidget {
|
class _PlayerSeat extends StatelessWidget {
|
||||||
final GamePlayer player;
|
final GamePlayer player;
|
||||||
final double diameter;
|
final double diameter;
|
||||||
@@ -271,19 +287,24 @@ class _PlayerSeat extends StatelessWidget {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
// قابِ طلایی + آواتار.
|
// قابِ رتبهای + آواتار (رنگِ قاب بر اساسِ نشانِ رتبهی بازیکن).
|
||||||
Container(
|
Container(
|
||||||
width: diameter,
|
width: diameter,
|
||||||
height: diameter,
|
height: diameter,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: const LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0xFFF3D27A), AppColors.goldDark],
|
colors: _rankFrame(player.bot ? '' : player.rankTier),
|
||||||
),
|
),
|
||||||
boxShadow: const [
|
boxShadow: [
|
||||||
BoxShadow(color: Colors.black54, blurRadius: 6),
|
const BoxShadow(color: Colors.black54, blurRadius: 6),
|
||||||
|
if (isTurn)
|
||||||
|
BoxShadow(
|
||||||
|
color: _rankFrame(player.rankTier)[0]
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
blurRadius: 12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.all(ringW),
|
padding: EdgeInsets.all(ringW),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:random_avatar/random_avatar.dart';
|
import 'package:random_avatar/random_avatar.dart';
|
||||||
|
|
||||||
|
import '../../../../core/settings/app_settings.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';
|
||||||
import '../../../../core/widgets/rank_badge.dart';
|
import '../../../../core/widgets/rank_badge.dart';
|
||||||
@@ -198,6 +199,8 @@ class ProfileScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_statsSection(context, d),
|
_statsSection(context, d),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
const _GraphicsToggle(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
@@ -490,3 +493,40 @@ class _EditProfileSheetState extends State<_EditProfileSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// سوئیچِ «گرافیک کممصرف»: افکتهای اتمسفری را برای گوشیهای ضعیف خاموش میکند.
|
||||||
|
class _GraphicsToggle extends StatelessWidget {
|
||||||
|
const _GraphicsToggle();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: AppSettings.I.lowGraphics,
|
||||||
|
builder: (context, low, _) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.panel.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.goldFaint),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.bolt, color: AppColors.gold, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'گرافیک کممصرف',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: low,
|
||||||
|
activeThumbColor: AppColors.gold,
|
||||||
|
onChanged: (v) => AppSettings.I.setLowGraphics(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../bloc/wallet_bloc.dart';
|
|||||||
import '../bloc/wallet_event.dart';
|
import '../bloc/wallet_event.dart';
|
||||||
import '../bloc/wallet_state.dart';
|
import '../bloc/wallet_state.dart';
|
||||||
import '../bloc/wallet_status.dart';
|
import '../bloc/wallet_status.dart';
|
||||||
|
import '../widgets/coin_fly.dart';
|
||||||
import '../widgets/streak_dialog.dart';
|
import '../widgets/streak_dialog.dart';
|
||||||
|
|
||||||
/// لابی اصلی: کیفپول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه.
|
/// لابی اصلی: کیفپول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه.
|
||||||
@@ -24,6 +25,9 @@ class LobbyScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LobbyScreenState extends State<LobbyScreen> {
|
class _LobbyScreenState extends State<LobbyScreen> {
|
||||||
|
final _coinKey = GlobalKey(); // چیپِ سکه (مقصدِ پروازِ سکهها)
|
||||||
|
final _dailyKey = GlobalKey(); // کارتِ هدیهی روزانه (مبدأ)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -32,6 +36,20 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
|||||||
|
|
||||||
void _reload() => context.read<WalletBloc>().add(LoadWalletEvent());
|
void _reload() => context.read<WalletBloc>().add(LoadWalletEvent());
|
||||||
|
|
||||||
|
Offset? _center(GlobalKey k) {
|
||||||
|
final box = k.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null || !box.attached) return null;
|
||||||
|
return box.localToGlobal(box.size.center(Offset.zero));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flyCoins() {
|
||||||
|
final from = _center(_dailyKey);
|
||||||
|
final to = _center(_coinKey);
|
||||||
|
if (from != null && to != null && mounted) {
|
||||||
|
CoinFly.play(context, from: from, to: to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -41,6 +59,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
|||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
final d = state.dailyStatus;
|
final d = state.dailyStatus;
|
||||||
if (d is DailySuccess) {
|
if (d is DailySuccess) {
|
||||||
|
_flyCoins(); // پروازِ سکهها از کارتِ هدیه به چیپِ سکه
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')),
|
SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')),
|
||||||
);
|
);
|
||||||
@@ -56,7 +75,10 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
|||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_TopBar(wallet: wallet, onCoinTap: () => _openShop(context)),
|
_TopBar(
|
||||||
|
wallet: wallet,
|
||||||
|
coinKey: _coinKey,
|
||||||
|
onCoinTap: () => _openShop(context)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 8),
|
||||||
@@ -121,6 +143,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_DailyCard(
|
_DailyCard(
|
||||||
|
key: _dailyKey,
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
onTap:
|
onTap:
|
||||||
() => StreakDialog.show(
|
() => StreakDialog.show(
|
||||||
@@ -347,7 +370,7 @@ class _ActionTile extends StatelessWidget {
|
|||||||
class _DailyCard extends StatelessWidget {
|
class _DailyCard extends StatelessWidget {
|
||||||
final WalletEntity? wallet;
|
final WalletEntity? wallet;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _DailyCard({required this.wallet, required this.onTap});
|
const _DailyCard({super.key, required this.wallet, required this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -478,7 +501,8 @@ class _DailyCard extends StatelessWidget {
|
|||||||
class _TopBar extends StatelessWidget {
|
class _TopBar extends StatelessWidget {
|
||||||
final WalletEntity? wallet;
|
final WalletEntity? wallet;
|
||||||
final VoidCallback onCoinTap;
|
final VoidCallback onCoinTap;
|
||||||
const _TopBar({required this.wallet, required this.onCoinTap});
|
final Key? coinKey;
|
||||||
|
const _TopBar({required this.wallet, required this.onCoinTap, this.coinKey});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -582,6 +606,7 @@ class _TopBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
key: coinKey,
|
||||||
onTap: onCoinTap,
|
onTap: onCoinTap,
|
||||||
child: StatChip(
|
child: StatChip(
|
||||||
icon: Icons.monetization_on,
|
icon: Icons.monetization_on,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// انیمیشنِ «پروازِ سکهها به کیفپول»: چند سکه از مبدأ (مثلاً کارتِ هدیه) روی یک
|
||||||
|
/// قوس به سمتِ مقصد (چیپِ سکه) پرواز میکنند. با Overlay نمایش داده میشود.
|
||||||
|
class CoinFly {
|
||||||
|
static void play(
|
||||||
|
BuildContext context, {
|
||||||
|
required Offset from,
|
||||||
|
required Offset to,
|
||||||
|
int count = 9,
|
||||||
|
}) {
|
||||||
|
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||||||
|
if (overlay == null) return;
|
||||||
|
late OverlayEntry entry;
|
||||||
|
entry = OverlayEntry(
|
||||||
|
builder: (_) => _CoinFlyOverlay(
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
count: count,
|
||||||
|
onDone: () => entry.remove(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
overlay.insert(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CoinFlyOverlay extends StatefulWidget {
|
||||||
|
final Offset from, to;
|
||||||
|
final int count;
|
||||||
|
final VoidCallback onDone;
|
||||||
|
const _CoinFlyOverlay({
|
||||||
|
required this.from,
|
||||||
|
required this.to,
|
||||||
|
required this.count,
|
||||||
|
required this.onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CoinFlyOverlay> createState() => _CoinFlyOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CoinFlyOverlayState extends State<_CoinFlyOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _c;
|
||||||
|
late final List<_Coin> _coins;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final r = math.Random();
|
||||||
|
_coins = List.generate(widget.count, (_) {
|
||||||
|
return _Coin(
|
||||||
|
jitter: Offset((r.nextDouble() - 0.5) * 46, (r.nextDouble() - 0.5) * 46),
|
||||||
|
delay: r.nextDouble() * 0.28,
|
||||||
|
arc: -60 - r.nextDouble() * 90, // ارتفاعِ قوس (به بالا)
|
||||||
|
size: 16 + r.nextDouble() * 8,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_c = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 950),
|
||||||
|
)
|
||||||
|
..addStatusListener((s) {
|
||||||
|
if (s == AnimationStatus.completed) widget.onDone();
|
||||||
|
})
|
||||||
|
..forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_c.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _c,
|
||||||
|
builder: (_, __) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
for (final coin in _coins) _buildCoin(coin),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCoin(_Coin coin) {
|
||||||
|
final local = ((_c.value - coin.delay) / (1 - coin.delay)).clamp(0.0, 1.0);
|
||||||
|
if (local <= 0) return const SizedBox.shrink();
|
||||||
|
final start = widget.from + coin.jitter;
|
||||||
|
final end = widget.to;
|
||||||
|
// قوسِ درجهدو: نقطهی کنترل بالای میانه.
|
||||||
|
final mid = Offset((start.dx + end.dx) / 2, (start.dy + end.dy) / 2 + coin.arc);
|
||||||
|
final t = Curves.easeInCubic.transform(local);
|
||||||
|
final p = _quad(start, mid, end, t);
|
||||||
|
final opacity = local < 0.85 ? 1.0 : (1 - (local - 0.85) / 0.15);
|
||||||
|
final scale = 1.0 - 0.35 * local;
|
||||||
|
return Positioned(
|
||||||
|
left: p.dx - coin.size / 2,
|
||||||
|
top: p.dy - coin.size / 2,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: opacity.clamp(0.0, 1.0),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: Icon(Icons.monetization_on,
|
||||||
|
color: AppColors.gold, size: coin.size),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _quad(Offset a, Offset b, Offset c, double t) {
|
||||||
|
final u = 1 - t;
|
||||||
|
return a * (u * u) + b * (2 * u * t) + c * (t * t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Coin {
|
||||||
|
final Offset jitter;
|
||||||
|
final double delay, arc, size;
|
||||||
|
_Coin({
|
||||||
|
required this.jitter,
|
||||||
|
required this.delay,
|
||||||
|
required this.arc,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,11 +3,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 'core/service/app_sounds.dart';
|
||||||
|
import 'core/settings/app_settings.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();
|
||||||
|
await AppSettings.I.load(); // تنظیماتِ گرافیک
|
||||||
AppSounds.preload(); // پیشبارگذاریِ صداها (بدون انتظار)
|
AppSounds.preload(); // پیشبارگذاریِ صداها (بدون انتظار)
|
||||||
final loggedIn = await locator<AuthRepository>().isLoggedIn();
|
final loggedIn = await locator<AuthRepository>().isLoggedIn();
|
||||||
runApp(HakemApp(loggedIn: loggedIn));
|
runApp(HakemApp(loggedIn: loggedIn));
|
||||||
|
|||||||