first commit
This commit is contained in:
131
lib/adjust/adjust_bridge.dart
Normal file
131
lib/adjust/adjust_bridge.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:adjust_sdk/adjust.dart';
|
||||
import 'package:adjust_sdk/adjust_config.dart';
|
||||
import 'package:adjust_sdk/adjust_event.dart';
|
||||
import 'package:adjust_sdk/adjust_attribution.dart';
|
||||
import 'package:adjust_sdk/adjust_event_success.dart';
|
||||
import 'package:adjust_sdk/adjust_event_failure.dart';
|
||||
import 'package:adjust_sdk/adjust_session_success.dart';
|
||||
import 'package:adjust_sdk/adjust_session_failure.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
typedef AdjustAttributionHandler = void Function(AdjustAttribution a);
|
||||
typedef AdjustEventSuccessHandler = void Function(AdjustEventSuccess s);
|
||||
typedef AdjustEventFailureHandler = void Function(AdjustEventFailure f);
|
||||
typedef AdjustSessionSuccessHandler = void Function(AdjustSessionSuccess s);
|
||||
typedef AdjustSessionFailureHandler = void Function(AdjustSessionFailure f);
|
||||
|
||||
class AdjustBridge {
|
||||
AdjustBridge._();
|
||||
static final AdjustBridge I = AdjustBridge._();
|
||||
|
||||
bool _inited = false;
|
||||
|
||||
/// 统一初始化入口:外部只要调用一次
|
||||
Future<String?> ensureInitialized({
|
||||
required String appToken,
|
||||
bool production = false,
|
||||
AdjustLogLevel logLevel = AdjustLogLevel.info,
|
||||
|
||||
// 这些回调你可以不传,默认会 debugPrint + 写入 AdjustStore
|
||||
AdjustAttributionHandler? onAttribution,
|
||||
AdjustEventSuccessHandler? onEventSuccess,
|
||||
AdjustEventFailureHandler? onEventFailure,
|
||||
AdjustSessionSuccessHandler? onSessionSuccess,
|
||||
AdjustSessionFailureHandler? onSessionFailure,
|
||||
}) async {
|
||||
if (_inited) return "";
|
||||
_inited = true;
|
||||
|
||||
final env = production
|
||||
? AdjustEnvironment.production
|
||||
: AdjustEnvironment.sandbox;
|
||||
final config = AdjustConfig(appToken, env);
|
||||
config.logLevel = logLevel;
|
||||
config.attributionCallback = (AdjustAttribution a) {
|
||||
debugPrint("Adjust attribution: ${a.toString()}");
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setString("adjustAttribution", a.jsonResponse ?? ""),
|
||||
);
|
||||
onAttribution?.call(a);
|
||||
};
|
||||
config.eventSuccessCallback = (AdjustEventSuccess s) {
|
||||
debugPrint("Adjust event success: ${s.toString()}");
|
||||
onEventSuccess?.call(s);
|
||||
};
|
||||
config.eventFailureCallback = (AdjustEventFailure f) {
|
||||
debugPrint("Adjust event failure: ${f.toString()}");
|
||||
onEventFailure?.call(f);
|
||||
};
|
||||
|
||||
config.sessionSuccessCallback = (AdjustSessionSuccess s) {
|
||||
debugPrint("Adjust session success: ${s.toString()}");
|
||||
onSessionSuccess?.call(s);
|
||||
};
|
||||
config.sessionFailureCallback = (AdjustSessionFailure f) {
|
||||
debugPrint("Adjust session failure: ${f.toString()}");
|
||||
onSessionFailure?.call(f);
|
||||
};
|
||||
|
||||
Adjust.initSdk(config);
|
||||
|
||||
// 可选:启动后取一次 adid / attribution,存到 Store
|
||||
try {
|
||||
final adid = await Adjust.getAdid();
|
||||
debugPrint("Adjust adid: $adid");
|
||||
if (adid != null && adid.isNotEmpty) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString("adjustAdId", adid);
|
||||
}
|
||||
return adid;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 打普通事件
|
||||
void track(
|
||||
String eventToken, {
|
||||
Map<String, String>? callbackParams,
|
||||
Map<String, String>? partnerParams,
|
||||
}) {
|
||||
final e = AdjustEvent(eventToken);
|
||||
callbackParams?.forEach(e.addCallbackParameter);
|
||||
partnerParams?.forEach(e.addPartnerParameter);
|
||||
Adjust.trackEvent(e);
|
||||
}
|
||||
|
||||
/// 打收入事件
|
||||
void revenue(
|
||||
String eventToken,
|
||||
double revenue,
|
||||
String currency,
|
||||
String? transactionId, { // 可选:防重复扣款(如果你使用)
|
||||
Map<String, String>? callbackParams,
|
||||
Map<String, String>? partnerParams,
|
||||
}) {
|
||||
final e = AdjustEvent(eventToken);
|
||||
e.setRevenue(revenue, currency);
|
||||
if (transactionId != null && transactionId.isNotEmpty) {
|
||||
e.deduplicationId = transactionId;
|
||||
}
|
||||
callbackParams?.forEach(e.addCallbackParameter);
|
||||
partnerParams?.forEach(e.addPartnerParameter);
|
||||
Adjust.trackEvent(e);
|
||||
}
|
||||
|
||||
/// 关闭/开启 Adjust(例如用户拒绝隐私)
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
if (enabled) {
|
||||
Adjust.enable();
|
||||
} else {
|
||||
Adjust.disable();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isEnabled() => Adjust.isEnabled();
|
||||
|
||||
Future<String?> getAdid() => Adjust.getAdid();
|
||||
|
||||
Future<AdjustAttribution?> getAttribution() => Adjust.getAttribution();
|
||||
}
|
||||
177
lib/adjust/adjust_service.dart
Normal file
177
lib/adjust/adjust_service.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:adjust_sdk/adjust.dart';
|
||||
import 'package:adjust_sdk/adjust_attribution.dart';
|
||||
import 'package:adjust_sdk/adjust_config.dart';
|
||||
import 'package:adjust_sdk/adjust_event.dart';
|
||||
import 'package:adjust_sdk/adjust_event_failure.dart';
|
||||
import 'package:adjust_sdk/adjust_event_success.dart';
|
||||
import 'package:adjust_sdk/adjust_session_failure.dart';
|
||||
import 'package:adjust_sdk/adjust_session_success.dart';
|
||||
|
||||
typedef EventParams = Map<String, dynamic>;
|
||||
|
||||
class AdjustService {
|
||||
Map<String, String> _eventNameToToken = {};
|
||||
Map<String, String> get eventNameToToken => _eventNameToToken;
|
||||
set eventNameToToken(Map<String, String> m) {
|
||||
_eventNameToToken = Map<String, String>.from(m);
|
||||
}
|
||||
|
||||
AdjustService._();
|
||||
|
||||
static final AdjustService instance = AdjustService._();
|
||||
|
||||
bool _inited = false;
|
||||
|
||||
String _toStr(dynamic v) => '$v';
|
||||
|
||||
double? _toNum(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse('$v');
|
||||
}
|
||||
|
||||
Future<void> init(String appToken, [bool isProd = true]) async {
|
||||
developer.log(appToken, name: 'Adjust');
|
||||
|
||||
if (_inited) {
|
||||
developer.log('[Adjust] already inited', name: 'Adjust');
|
||||
return;
|
||||
}
|
||||
|
||||
if (appToken.isEmpty) return;
|
||||
|
||||
final environment = isProd
|
||||
? AdjustEnvironment.production
|
||||
: AdjustEnvironment.sandbox;
|
||||
|
||||
final config = AdjustConfig(appToken, environment);
|
||||
|
||||
config.attributionCallback = (AdjustAttribution a) {
|
||||
debugPrint('Adjust attribution: ${a.toString()}');
|
||||
};
|
||||
config.eventSuccessCallback = (AdjustEventSuccess s) {
|
||||
debugPrint('Adjust event success: ${s.toString()}');
|
||||
};
|
||||
config.eventFailureCallback = (AdjustEventFailure f) {
|
||||
debugPrint('Adjust event failure: ${f.toString()}');
|
||||
};
|
||||
config.sessionSuccessCallback = (AdjustSessionSuccess s) {
|
||||
debugPrint('Adjust session success: ${s.toString()}');
|
||||
};
|
||||
config.sessionFailureCallback = (AdjustSessionFailure f) {
|
||||
debugPrint('Adjust session failure: ${f.toString()}');
|
||||
};
|
||||
|
||||
// 可选:如果你希望缓存更多 deduplicationId,可调大
|
||||
config.eventDeduplicationIdsMaxSize = 20;
|
||||
|
||||
config.logLevel = isProd ? AdjustLogLevel.info : AdjustLogLevel.verbose;
|
||||
|
||||
Adjust.initSdk(config);
|
||||
_inited = true;
|
||||
}
|
||||
|
||||
Future<void> trackEventWithName(
|
||||
String eventName, [
|
||||
EventParams? params,
|
||||
]) async {
|
||||
debugPrint('call trackEventWithName eventName $eventName params $params');
|
||||
// 你可以在这里做个映射,eventName -> eventToken
|
||||
// 也可以直接让外部传 eventToken 过来
|
||||
final eventToken = _eventNameToToken[eventName];
|
||||
if (eventToken == null) {
|
||||
debugPrint('not find eventToken $eventName');
|
||||
return;
|
||||
}
|
||||
await trackEvent(eventToken, params);
|
||||
}
|
||||
|
||||
Future<void> trackEvent(String eventToken, [EventParams? params]) async {
|
||||
try {
|
||||
debugPrint("track event eventToken $eventToken params $params");
|
||||
if (eventToken.isEmpty) return;
|
||||
|
||||
final event = AdjustEvent(eventToken);
|
||||
|
||||
if (params != null && params.isNotEmpty) {
|
||||
double? revenue;
|
||||
String? currency;
|
||||
|
||||
// 优先级:deduplicationId > orderId > transactionId
|
||||
String? dedupId;
|
||||
|
||||
for (final entry in params.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
|
||||
if (value == null) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'revenue':
|
||||
case 'amount':
|
||||
revenue = _toNum(value);
|
||||
break;
|
||||
|
||||
case 'currency':
|
||||
currency = _toStr(value);
|
||||
break;
|
||||
|
||||
case 'deduplicationId':
|
||||
case 'orderId':
|
||||
dedupId ??= _toStr(value);
|
||||
break;
|
||||
|
||||
case 'callbackId':
|
||||
event.callbackId = _toStr(value);
|
||||
break;
|
||||
|
||||
case 'transactionId':
|
||||
final tid = _toStr(value);
|
||||
event.transactionId = tid;
|
||||
dedupId ??= tid;
|
||||
break;
|
||||
|
||||
case 'productId':
|
||||
event.productId = _toStr(value);
|
||||
break;
|
||||
|
||||
case 'purchaseToken':
|
||||
event.purchaseToken = _toStr(value);
|
||||
break;
|
||||
default:
|
||||
final v = _toStr(value);
|
||||
|
||||
if (key.startsWith('partner:')) {
|
||||
final realKey = key.substring('partner:'.length);
|
||||
if (realKey.isNotEmpty) {
|
||||
event.addPartnerParameter(realKey, v);
|
||||
}
|
||||
} else {
|
||||
event.addCallbackParameter(key, v);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dedupId != null && dedupId.isNotEmpty) {
|
||||
event.deduplicationId = dedupId;
|
||||
}
|
||||
|
||||
if (revenue != null && currency != null && currency.isNotEmpty) {
|
||||
event.setRevenue(revenue, currency);
|
||||
}
|
||||
}
|
||||
|
||||
Adjust.trackEvent(event);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'[Adjust] trackEvent error: $e',
|
||||
name: 'Adjust',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
288
lib/audio/audio_manager.dart
Normal file
288
lib/audio/audio_manager.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../game/music.dart';
|
||||
|
||||
class AudioManager {
|
||||
static final AudioManager instance = AudioManager._();
|
||||
AudioManager._();
|
||||
|
||||
late final AudioPlayer _bgm = AudioPlayer(playerId: 'bgm');
|
||||
|
||||
final List<AudioPlayer> _sfxPlayers = [];
|
||||
int _sfxIndex = 0;
|
||||
|
||||
bool _initialized = false;
|
||||
bool _initializing = false;
|
||||
|
||||
bool _bgmMuted = false;
|
||||
bool _sfxMuted = false;
|
||||
|
||||
double _bgmVolume = 0.6;
|
||||
double _sfxVolume = 1.0;
|
||||
|
||||
static const int _sfxPoolSize = 6;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized || _initializing) return;
|
||||
_initializing = true;
|
||||
|
||||
try {
|
||||
_sfxPlayers.clear();
|
||||
_sfxIndex = 0;
|
||||
|
||||
await _initBgmPlayer();
|
||||
await _initSfxPlayers();
|
||||
|
||||
if (_sfxPlayers.isEmpty) {
|
||||
final fallback = AudioPlayer(playerId: 'sfx_fallback');
|
||||
await _safeSetSfxPlayerDefaults(fallback);
|
||||
_sfxPlayers.add(fallback);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
} catch (e, st) {
|
||||
debugPrint('AudioManager init failed: $e');
|
||||
debugPrint('$st');
|
||||
_initialized = false;
|
||||
_sfxPlayers.clear();
|
||||
_sfxIndex = 0;
|
||||
} finally {
|
||||
_initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initBgmPlayer() async {
|
||||
try {
|
||||
await _bgm.setReleaseMode(ReleaseMode.loop);
|
||||
} catch (e) {
|
||||
debugPrint('BGM setReleaseMode failed: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await _bgm.setPlayerMode(PlayerMode.mediaPlayer);
|
||||
} catch (e) {
|
||||
debugPrint('BGM setPlayerMode failed: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await _bgm.setAudioContext(
|
||||
AudioContext(
|
||||
android: AudioContextAndroid(
|
||||
audioFocus: AndroidAudioFocus.gain,
|
||||
contentType: AndroidContentType.music,
|
||||
usageType: AndroidUsageType.media,
|
||||
audioMode: AndroidAudioMode.normal,
|
||||
isSpeakerphoneOn: false,
|
||||
stayAwake: false,
|
||||
),
|
||||
iOS: AudioContextIOS(
|
||||
category: AVAudioSessionCategory.playback,
|
||||
options: const {AVAudioSessionOptions.mixWithOthers},
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('BGM setAudioContext failed: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await _bgm.setVolume(_bgmVolume);
|
||||
} catch (e) {
|
||||
debugPrint('BGM setVolume failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initSfxPlayers() async {
|
||||
for (int i = 0; i < _sfxPoolSize; i++) {
|
||||
final player = AudioPlayer(playerId: 'sfx_$i');
|
||||
|
||||
await _safeSetSfxPlayerDefaults(player, index: i);
|
||||
|
||||
_sfxPlayers.add(player);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _safeSetSfxPlayerDefaults(
|
||||
AudioPlayer player, {
|
||||
int? index,
|
||||
}) async {
|
||||
final tag = index == null ? 'SFX[fallback]' : 'SFX[$index]';
|
||||
|
||||
try {
|
||||
await player.setReleaseMode(ReleaseMode.stop);
|
||||
} catch (e) {
|
||||
debugPrint('$tag setReleaseMode failed: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await player.setPlayerMode(PlayerMode.lowLatency);
|
||||
} catch (e) {
|
||||
debugPrint('$tag lowLatency failed, fallback to mediaPlayer: $e');
|
||||
try {
|
||||
await player.setPlayerMode(PlayerMode.mediaPlayer);
|
||||
} catch (e2) {
|
||||
debugPrint('$tag mediaPlayer fallback failed: $e2');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await player.setAudioContext(
|
||||
AudioContext(
|
||||
android: AudioContextAndroid(
|
||||
audioFocus: AndroidAudioFocus.none,
|
||||
contentType: AndroidContentType.sonification,
|
||||
usageType: AndroidUsageType.game,
|
||||
audioMode: AndroidAudioMode.normal,
|
||||
isSpeakerphoneOn: false,
|
||||
stayAwake: false,
|
||||
),
|
||||
iOS: AudioContextIOS(
|
||||
category: AVAudioSessionCategory.playback,
|
||||
options: const {AVAudioSessionOptions.mixWithOthers},
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('$tag setAudioContext failed: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await player.setVolume(_sfxVolume);
|
||||
} catch (e) {
|
||||
debugPrint('$tag setVolume failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureInit() async {
|
||||
if (!_initialized) {
|
||||
await init();
|
||||
}
|
||||
if (_sfxPlayers.isEmpty) {
|
||||
final fallback = AudioPlayer(playerId: 'sfx_emergency');
|
||||
await _safeSetSfxPlayerDefaults(fallback);
|
||||
_sfxPlayers.add(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
AudioPlayer _nextSfxPlayer() {
|
||||
if (_sfxPlayers.isEmpty) {
|
||||
throw StateError('SFX players not initialized');
|
||||
}
|
||||
final player = _sfxPlayers[_sfxIndex % _sfxPlayers.length];
|
||||
_sfxIndex = (_sfxIndex + 1) % _sfxPlayers.length;
|
||||
return player;
|
||||
}
|
||||
|
||||
Future<void> setBgmMuted(bool muted) async {
|
||||
await _ensureInit();
|
||||
_bgmMuted = muted;
|
||||
if (muted) {
|
||||
await _bgm.pause();
|
||||
} else {
|
||||
await _bgm.resume();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setSfxMuted(bool muted) async {
|
||||
await _ensureInit();
|
||||
_sfxMuted = muted;
|
||||
if (muted) {
|
||||
for (final p in _sfxPlayers) {
|
||||
try {
|
||||
await p.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setBgmVolume(double value) async {
|
||||
await _ensureInit();
|
||||
_bgmVolume = value.clamp(0.0, 1.0);
|
||||
await _bgm.setVolume(_bgmVolume);
|
||||
}
|
||||
|
||||
Future<void> setSfxVolume(double value) async {
|
||||
await _ensureInit();
|
||||
_sfxVolume = value.clamp(0.0, 1.0);
|
||||
for (final p in _sfxPlayers) {
|
||||
try {
|
||||
await p.setVolume(_sfxVolume);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playBgm([BgmKey key = BgmKey.bgm]) async {
|
||||
await _ensureInit();
|
||||
if (_bgmMuted) return;
|
||||
|
||||
final path = MusicMap.bgm.replaceFirst('assets/', '');
|
||||
await _bgm.stop();
|
||||
await _bgm.play(AssetSource(path), volume: _bgmVolume);
|
||||
}
|
||||
|
||||
Future<void> pauseBgm() async {
|
||||
if (!_initialized) return;
|
||||
await _bgm.pause();
|
||||
}
|
||||
|
||||
Future<void> resumeBgm() async {
|
||||
await _ensureInit();
|
||||
if (_bgmMuted) return;
|
||||
await _bgm.resume();
|
||||
}
|
||||
|
||||
Future<void> stopBgm() async {
|
||||
if (!_initialized) return;
|
||||
await _bgm.stop();
|
||||
}
|
||||
|
||||
Future<void> playSfx(SfxKey key) async {
|
||||
await _ensureInit();
|
||||
if (_sfxMuted) return;
|
||||
if (_sfxPlayers.isEmpty) return;
|
||||
|
||||
final path = MusicMap.sfx[key]!.replaceFirst('assets/', '');
|
||||
final player = _nextSfxPlayer();
|
||||
|
||||
try {
|
||||
await player.stop();
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await player.setVolume(_sfxVolume);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await player.play(AssetSource(path), volume: _sfxVolume);
|
||||
} catch (e) {
|
||||
debugPrint('playSfx failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopAllSfx() async {
|
||||
if (!_initialized) return;
|
||||
for (final p in _sfxPlayers) {
|
||||
try {
|
||||
await p.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> releaseAll() async {
|
||||
try {
|
||||
await _bgm.dispose();
|
||||
} catch (_) {}
|
||||
|
||||
for (final p in _sfxPlayers) {
|
||||
try {
|
||||
await p.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_sfxPlayers.clear();
|
||||
_sfxIndex = 0;
|
||||
_initialized = false;
|
||||
_initializing = false;
|
||||
}
|
||||
}
|
||||
182
lib/components/flip_card.dart
Normal file
182
lib/components/flip_card.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FlipCard extends StatefulWidget {
|
||||
final bool isFlipped;
|
||||
final bool isMatched;
|
||||
final int shakeToken;
|
||||
final bool shouldShake;
|
||||
final bool disabled;
|
||||
final VoidCallback? onPress;
|
||||
final Widget front;
|
||||
final Widget back;
|
||||
final double borderRadius;
|
||||
final Duration flipDuration;
|
||||
|
||||
const FlipCard({
|
||||
super.key,
|
||||
required this.isFlipped,
|
||||
required this.isMatched,
|
||||
required this.shakeToken,
|
||||
required this.shouldShake,
|
||||
required this.disabled,
|
||||
required this.onPress,
|
||||
required this.front,
|
||||
required this.back,
|
||||
this.borderRadius = 16,
|
||||
this.flipDuration = const Duration(milliseconds: 220),
|
||||
});
|
||||
|
||||
@override
|
||||
State<FlipCard> createState() => _FlipCardState();
|
||||
}
|
||||
|
||||
class _FlipCardState extends State<FlipCard> with TickerProviderStateMixin {
|
||||
late final AnimationController _flipController;
|
||||
late final AnimationController _popController;
|
||||
late final AnimationController _glowController;
|
||||
late final AnimationController _shakeController;
|
||||
|
||||
late Animation<double> _flip;
|
||||
late Animation<double> _pop;
|
||||
late Animation<double> _glow;
|
||||
late Animation<double> _shake;
|
||||
|
||||
int _prevShakeToken = 0;
|
||||
bool _prevMatched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prevMatched = widget.isMatched;
|
||||
_prevShakeToken = widget.shakeToken;
|
||||
|
||||
_flipController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.flipDuration,
|
||||
value: widget.isFlipped ? 1 : 0,
|
||||
);
|
||||
_popController = AnimationController(vsync: this, duration: const Duration(milliseconds: 210));
|
||||
_glowController = AnimationController(vsync: this, duration: const Duration(milliseconds: 340));
|
||||
_shakeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 180));
|
||||
|
||||
_flip = CurvedAnimation(parent: _flipController, curve: Curves.easeInOut);
|
||||
_pop = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.08), weight: 90),
|
||||
TweenSequenceItem(tween: Tween(begin: 1.08, end: 1.0), weight: 120),
|
||||
]).animate(_popController);
|
||||
_glow = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 120),
|
||||
TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 220),
|
||||
]).animate(_glowController);
|
||||
_shake = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0, end: 8), weight: 45),
|
||||
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 45),
|
||||
TweenSequenceItem(tween: Tween(begin: -8, end: 8), weight: 45),
|
||||
TweenSequenceItem(tween: Tween(begin: 8, end: 0), weight: 45),
|
||||
]).animate(_shakeController);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlipCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isFlipped != oldWidget.isFlipped) {
|
||||
if (widget.isFlipped) {
|
||||
_flipController.forward();
|
||||
} else {
|
||||
_flipController.reverse();
|
||||
}
|
||||
}
|
||||
if (!_prevMatched && widget.isMatched) {
|
||||
_popController
|
||||
..reset()
|
||||
..forward();
|
||||
_glowController
|
||||
..reset()
|
||||
..forward();
|
||||
}
|
||||
_prevMatched = widget.isMatched;
|
||||
|
||||
if (widget.shakeToken != _prevShakeToken) {
|
||||
_prevShakeToken = widget.shakeToken;
|
||||
if (widget.shouldShake) {
|
||||
_shakeController
|
||||
..reset()
|
||||
..forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_flipController.dispose();
|
||||
_popController.dispose();
|
||||
_glowController.dispose();
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: widget.disabled ? null : widget.onPress,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_flipController,
|
||||
_popController,
|
||||
_glowController,
|
||||
_shakeController,
|
||||
]),
|
||||
builder: (context, child) {
|
||||
final angle = _flip.value * math.pi;
|
||||
final showBack = angle > math.pi / 2;
|
||||
final scale = _popController.isAnimating ? _pop.value : 1.0;
|
||||
final dx = _shakeController.isAnimating ? _shake.value : 0.0;
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(dx, 0),
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.0012)
|
||||
..rotateY(angle),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: showBack
|
||||
? Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()..rotateY(math.pi),
|
||||
child: widget.back,
|
||||
)
|
||||
: widget.front,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: _glow.value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(120, 255, 255, 0.9),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/components/progress_bar.dart
Normal file
65
lib/components/progress_bar.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProgressBar extends StatelessWidget {
|
||||
final double percent;
|
||||
final double width;
|
||||
final double height;
|
||||
final double insetX;
|
||||
final double insetY;
|
||||
|
||||
const ProgressBar({
|
||||
super.key,
|
||||
required this.percent,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.insetX = 8,
|
||||
this.insetY = 6,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = percent.clamp(0.0, 1.0);
|
||||
final innerW = (width - insetX * 2).clamp(0.0, double.infinity);
|
||||
final innerH = (height - insetY * 2).clamp(0.0, double.infinity);
|
||||
final fillW = innerW * p;
|
||||
final fillAsset = p >= 1
|
||||
? 'assets/images/04ac514f79905c0fd3fd97534829cc34.png'
|
||||
: 'assets/images/4ea0244a9d7ad2e9357424465415f8b4.png';
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/18a74842e653d3118c500a2248a2fda7.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: insetX,
|
||||
top: insetY,
|
||||
width: innerW,
|
||||
height: innerH,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ClipRect(
|
||||
child: SizedBox(
|
||||
width: fillW,
|
||||
height: innerH,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: innerW,
|
||||
maxWidth: innerW,
|
||||
child: Image.asset(fillAsset, fit: BoxFit.fill),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/components/result_modal.dart
Normal file
216
lib/components/result_modal.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef RewardAddTimeHandler =
|
||||
Future<void> Function(void Function(int sec) grant);
|
||||
|
||||
class ResultModal extends StatefulWidget {
|
||||
final bool visible;
|
||||
final String status;
|
||||
final int score;
|
||||
final int bestScore;
|
||||
final bool hasNext;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onNext;
|
||||
final bool showAddTime;
|
||||
final int addTimeSeconds;
|
||||
final RewardAddTimeHandler? onRequestRewardAddTime;
|
||||
|
||||
const ResultModal({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.status,
|
||||
required this.score,
|
||||
required this.bestScore,
|
||||
required this.hasNext,
|
||||
required this.onRetry,
|
||||
required this.onNext,
|
||||
this.showAddTime = false,
|
||||
this.addTimeSeconds = 30,
|
||||
this.onRequestRewardAddTime,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ResultModal> createState() => _ResultModalState();
|
||||
}
|
||||
|
||||
class _ResultModalState extends State<ResultModal> {
|
||||
String? toast;
|
||||
bool rewardLoading = false;
|
||||
|
||||
void showToast(String msg) {
|
||||
setState(() => toast = msg);
|
||||
Future.delayed(const Duration(milliseconds: 1800), () {
|
||||
if (mounted && toast == msg) {
|
||||
setState(() => toast = null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleRewardAddTime() async {
|
||||
if (rewardLoading) return;
|
||||
setState(() => rewardLoading = true);
|
||||
try {
|
||||
if (widget.onRequestRewardAddTime != null) {
|
||||
await widget.onRequestRewardAddTime!((_) {});
|
||||
}
|
||||
} catch (_) {
|
||||
showToast('Rewarded ad failed to load. Please try again.');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => rewardLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.visible) return const SizedBox.shrink();
|
||||
final isWin = widget.status == 'won';
|
||||
final showNext = isWin && widget.hasNext;
|
||||
final showAddTime = !isWin && widget.showAddTime;
|
||||
|
||||
return Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
||||
child: Container(color: const Color.fromRGBO(0, 0, 0, 0.35)),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width.clamp(0, 520).toDouble(),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 0.67,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
isWin
|
||||
? 'assets/images/settlement/win.png'
|
||||
: 'assets/images/settlement/lose.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(34, 168, 34, 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Best Score',
|
||||
style: TextStyle(
|
||||
fontSize: isWin ? 22 : 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isWin
|
||||
? const Color(0xFFB944D7)
|
||||
: const Color(0xFF4F6BDE),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Image.asset(
|
||||
'assets/images/settlement/gold.png',
|
||||
width: 118,
|
||||
height: 118,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${widget.bestScore}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isWin ? 38 : 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isWin
|
||||
? const Color(0xFFFF6CA8)
|
||||
: const Color(0xFF4F6BDE),
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
if (showNext) ...[
|
||||
Expanded(
|
||||
child: _ActionImageButton(
|
||||
asset: 'assets/images/settlement/next.png',
|
||||
onTap: widget.onNext,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
if (showAddTime) ...[
|
||||
Expanded(
|
||||
child: _ActionImageButton(
|
||||
asset: 'assets/images/settlement/addTime.png',
|
||||
onTap: widget.onRequestRewardAddTime == null
|
||||
? null
|
||||
: _handleRewardAddTime,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
Expanded(
|
||||
child: _ActionImageButton(
|
||||
asset: 'assets/images/settlement/retry.png',
|
||||
onTap: widget.onRetry,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (toast != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
toast!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionImageButton extends StatelessWidget {
|
||||
const _ActionImageButton({
|
||||
required this.asset,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String asset;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Opacity(
|
||||
opacity: onTap == null ? 0.55 : 1,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Image.asset(asset, fit: BoxFit.fill),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/components/reward_add_time_button.dart
Normal file
79
lib/components/reward_add_time_button.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef RewardRequest = Future<void> Function(void Function(int sec) grant);
|
||||
|
||||
class RewardAddTimeButton extends StatefulWidget {
|
||||
final int extraSeconds;
|
||||
final bool disabled;
|
||||
final RewardRequest onRequestReward;
|
||||
|
||||
const RewardAddTimeButton({
|
||||
super.key,
|
||||
required this.extraSeconds,
|
||||
required this.disabled,
|
||||
required this.onRequestReward,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RewardAddTimeButton> createState() => _RewardAddTimeButtonState();
|
||||
}
|
||||
|
||||
class _RewardAddTimeButtonState extends State<RewardAddTimeButton> {
|
||||
bool loading = false;
|
||||
|
||||
void safeGrant(int sec) {
|
||||
if (mounted) {
|
||||
setState(() => loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = widget.disabled || loading;
|
||||
return GestureDetector(
|
||||
onTap: disabled
|
||||
? null
|
||||
: () async {
|
||||
setState(() => loading = true);
|
||||
await widget.onRequestReward(safeGrant);
|
||||
},
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
child: Container(
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(255, 190, 40, 0.92),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color.fromRGBO(255, 255, 255, 0.22), width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
loading ? 'LOADING...' : 'ADD TIME +${widget.extraSeconds}s',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'Watch Rewarded Ad',
|
||||
style: TextStyle(
|
||||
color: Color.fromRGBO(255, 255, 255, 0.92),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/components/score_text.dart
Normal file
61
lib/components/score_text.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ScoreText extends StatefulWidget {
|
||||
final int value;
|
||||
final TextStyle? style;
|
||||
|
||||
const ScoreText({super.key, required this.value, this.style});
|
||||
|
||||
@override
|
||||
State<ScoreText> createState() => _ScoreTextState();
|
||||
}
|
||||
|
||||
class _ScoreTextState extends State<ScoreText>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late Animation<double> _scale;
|
||||
int _prev = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prev = widget.value;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 210),
|
||||
);
|
||||
_scale = const AlwaysStoppedAnimation(1.0);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ScoreText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value == _prev) return;
|
||||
final begin = widget.value > _prev ? 1.12 : 0.88;
|
||||
_prev = widget.value;
|
||||
_scale = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 1.0, end: begin), weight: 90),
|
||||
TweenSequenceItem(tween: Tween(begin: begin, end: 1.0), weight: 120),
|
||||
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
_controller
|
||||
..reset()
|
||||
..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(scale: _scale.value, child: child);
|
||||
},
|
||||
child: Text('${widget.value}', style: widget.style),
|
||||
);
|
||||
}
|
||||
}
|
||||
335
lib/flame/memory_card_component.dart
Normal file
335
lib/flame/memory_card_component.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/effects.dart';
|
||||
import 'package:flame/events.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../game/types.dart';
|
||||
import 'memory_match_flame_game.dart';
|
||||
|
||||
class MemoryCardComponent extends PositionComponent
|
||||
with TapCallbacks, HasGameReference<MemoryMatchFlameGame> {
|
||||
MemoryCardComponent({
|
||||
required this.model,
|
||||
required this.frontAsset,
|
||||
required this.backAsset,
|
||||
required this.disabled,
|
||||
}) : super(anchor: Anchor.topLeft);
|
||||
|
||||
CardModel model;
|
||||
final String frontAsset;
|
||||
final String backAsset;
|
||||
bool disabled;
|
||||
|
||||
late final RectangleComponent _base;
|
||||
late final SpriteComponent _sprite;
|
||||
|
||||
late final Sprite _frontSprite;
|
||||
late final Sprite _backSprite;
|
||||
|
||||
bool _showBack = false;
|
||||
bool _isAnimatingFlip = false;
|
||||
bool _pendingMatchEffect = false;
|
||||
int _lastShakeToken = 0;
|
||||
bool _isEntering = false;
|
||||
double _entryElapsed = 0;
|
||||
double _entryDelay = 0;
|
||||
double _entryDuration = 0.42;
|
||||
double _entryStartAngle = 0;
|
||||
Vector2 _entryStartPosition = Vector2.zero();
|
||||
Vector2 _entryTargetPosition = Vector2.zero();
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
|
||||
_frontSprite = await game.loadSprite(frontAsset);
|
||||
_backSprite = await game.loadSprite(backAsset);
|
||||
|
||||
_base = RectangleComponent(
|
||||
anchor: Anchor.topLeft,
|
||||
position: Vector2.zero(),
|
||||
size: Vector2.all(size.x > 0 ? size.x : 80),
|
||||
paint: Paint()..color = const Color.fromRGBO(8, 16, 30, 0.28),
|
||||
);
|
||||
add(_base);
|
||||
|
||||
_sprite = SpriteComponent(
|
||||
sprite: _frontSprite,
|
||||
anchor: Anchor.topLeft,
|
||||
position: Vector2.zero(),
|
||||
size: Vector2.all((size.x > 0 ? size.x : 80) - 10),
|
||||
);
|
||||
add(_sprite);
|
||||
|
||||
_applyLayout();
|
||||
_applyImmediateVisual();
|
||||
}
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
final r = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(0, 0, size.x, size.y),
|
||||
const Radius.circular(16),
|
||||
);
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRRect(r);
|
||||
super.render(canvas);
|
||||
canvas.restore();
|
||||
|
||||
final borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..color = const Color.fromRGBO(120, 255, 255, 0.9);
|
||||
|
||||
canvas.drawRRect(r, borderPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool containsLocalPoint(Vector2 point) {
|
||||
return point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= size.x &&
|
||||
point.y <= size.y;
|
||||
}
|
||||
|
||||
@override
|
||||
void onMount() {
|
||||
super.onMount();
|
||||
_applyLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void onGameResize(Vector2 gameSize) {
|
||||
super.onGameResize(gameSize);
|
||||
_applyLayout();
|
||||
}
|
||||
|
||||
void updateCardSize(Vector2 newSize) {
|
||||
size = newSize;
|
||||
_applyLayout();
|
||||
}
|
||||
|
||||
void resetEntranceVisual() {
|
||||
_isEntering = false;
|
||||
_entryElapsed = 0;
|
||||
position = position.clone();
|
||||
scale = Vector2.all(1.0);
|
||||
angle = 0;
|
||||
}
|
||||
|
||||
void _applyLayout() {
|
||||
if (!isLoaded) return;
|
||||
|
||||
_base.size = size.clone();
|
||||
_base.position = Vector2.zero();
|
||||
|
||||
final padding = 5.0;
|
||||
final innerW = (size.x - padding * 2).clamp(1.0, double.infinity);
|
||||
final innerH = (size.y - padding * 2).clamp(1.0, double.infinity);
|
||||
|
||||
_sprite.size = Vector2(innerW, innerH);
|
||||
_sprite.position = Vector2(padding, padding);
|
||||
}
|
||||
|
||||
void _setSpriteOpacity(double opacity) {
|
||||
_sprite.paint = Paint()
|
||||
..color = Color.fromRGBO(255, 255, 255, opacity.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
void updateFromModel(
|
||||
CardModel nextModel, {
|
||||
required bool disablePlay,
|
||||
required bool shouldShake,
|
||||
required int shakeToken,
|
||||
}) {
|
||||
final wasFlippedOrMatched = model.isFlipped || model.isMatched;
|
||||
final wasMatched = model.isMatched;
|
||||
|
||||
model = nextModel;
|
||||
disabled = disablePlay || nextModel.isMatched;
|
||||
|
||||
if (!isLoaded) {
|
||||
_showBack = model.isFlipped || model.isMatched;
|
||||
return;
|
||||
}
|
||||
|
||||
final nowFlippedOrMatched = nextModel.isFlipped || nextModel.isMatched;
|
||||
final becameMatched = !wasMatched && nextModel.isMatched;
|
||||
|
||||
if (nowFlippedOrMatched != wasFlippedOrMatched) {
|
||||
if (becameMatched) {
|
||||
_pendingMatchEffect = true;
|
||||
}
|
||||
_runFlip(nowFlippedOrMatched);
|
||||
} else {
|
||||
_applyImmediateVisual();
|
||||
if (becameMatched) {
|
||||
_runMatchEffect();
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShake && shakeToken > _lastShakeToken) {
|
||||
_lastShakeToken = shakeToken;
|
||||
_runShakeEffect();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyImmediateVisual() {
|
||||
_showBack = model.isFlipped || model.isMatched;
|
||||
_updateSprite();
|
||||
_setSpriteOpacity(model.isMatched ? 0.68 : 1.0);
|
||||
scale = Vector2.all(1.0);
|
||||
angle = 0;
|
||||
}
|
||||
|
||||
void _updateSprite() {
|
||||
if (!isLoaded) return;
|
||||
_sprite.sprite = _showBack ? _backSprite : _frontSprite;
|
||||
}
|
||||
|
||||
void _runFlip(bool toBack) {
|
||||
if (_isAnimatingFlip) {
|
||||
_showBack = toBack;
|
||||
_applyImmediateVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
_isAnimatingFlip = true;
|
||||
|
||||
final half = EffectController(duration: 0.11, curve: Curves.easeInOut);
|
||||
|
||||
add(
|
||||
SequenceEffect(
|
||||
[
|
||||
ScaleEffect.to(Vector2(0.04, 1.0), half),
|
||||
_SwapCardFaceEffect(
|
||||
onSwap: () {
|
||||
_showBack = toBack;
|
||||
_updateSprite();
|
||||
},
|
||||
),
|
||||
ScaleEffect.to(Vector2(1.0, 1.0), half),
|
||||
],
|
||||
onComplete: () {
|
||||
_isAnimatingFlip = false;
|
||||
scale = Vector2.all(1.0);
|
||||
_setSpriteOpacity(model.isMatched ? 0.68 : 1.0);
|
||||
|
||||
if (_pendingMatchEffect) {
|
||||
_pendingMatchEffect = false;
|
||||
_runMatchEffect();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _runMatchEffect() {
|
||||
add(
|
||||
SequenceEffect(
|
||||
[
|
||||
ScaleEffect.to(
|
||||
Vector2.all(1.08),
|
||||
EffectController(duration: 0.09, curve: Curves.easeOut),
|
||||
),
|
||||
ScaleEffect.to(
|
||||
Vector2.all(1.0),
|
||||
EffectController(duration: 0.12, curve: Curves.easeIn),
|
||||
),
|
||||
],
|
||||
onComplete: () {
|
||||
scale = Vector2.all(1.0);
|
||||
_setSpriteOpacity(model.isMatched ? 0.68 : 1.0);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _runShakeEffect() {
|
||||
add(
|
||||
SequenceEffect([
|
||||
MoveByEffect(Vector2(8, 0), EffectController(duration: 0.045)),
|
||||
MoveByEffect(Vector2(-16, 0), EffectController(duration: 0.045)),
|
||||
MoveByEffect(Vector2(16, 0), EffectController(duration: 0.045)),
|
||||
MoveByEffect(Vector2(-8, 0), EffectController(duration: 0.045)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void playEntranceEffect({
|
||||
required Vector2 startPosition,
|
||||
required Vector2 targetPosition,
|
||||
required double delay,
|
||||
}) {
|
||||
_isEntering = true;
|
||||
_entryElapsed = 0;
|
||||
_entryDelay = delay;
|
||||
_entryDuration = 0.42;
|
||||
_entryStartPosition = startPosition.clone();
|
||||
_entryTargetPosition = targetPosition.clone();
|
||||
_entryStartAngle = startPosition.x <= targetPosition.x ? -0.24 : 0.24;
|
||||
|
||||
position = startPosition.clone();
|
||||
scale = Vector2.all(0.72);
|
||||
angle = _entryStartAngle;
|
||||
}
|
||||
|
||||
@override
|
||||
void update(double dt) {
|
||||
super.update(dt);
|
||||
|
||||
if (!_isEntering) return;
|
||||
|
||||
_entryElapsed += dt;
|
||||
|
||||
if (_entryElapsed < _entryDelay) return;
|
||||
|
||||
final rawT = ((_entryElapsed - _entryDelay) / _entryDuration).clamp(0.0, 1.0);
|
||||
final moveT = Curves.easeOutCubic.transform(rawT);
|
||||
final scaleT = Curves.easeOutBack.transform(rawT);
|
||||
|
||||
position = Vector2(
|
||||
_lerp(_entryStartPosition.x, _entryTargetPosition.x, moveT),
|
||||
_lerp(_entryStartPosition.y, _entryTargetPosition.y, moveT),
|
||||
);
|
||||
final currentScale = _lerp(0.72, 1.0, scaleT);
|
||||
scale = Vector2.all(currentScale);
|
||||
angle = _lerp(_entryStartAngle, 0, moveT);
|
||||
|
||||
if (rawT >= 1.0) {
|
||||
_isEntering = false;
|
||||
position = _entryTargetPosition.clone();
|
||||
scale = Vector2.all(1.0);
|
||||
angle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
double _lerp(double a, double b, double t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
@override
|
||||
void onTapUp(TapUpEvent event) {
|
||||
super.onTapUp(event);
|
||||
if (disabled) return;
|
||||
game.onCardTapped(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwapCardFaceEffect extends ComponentEffect<PositionComponent> {
|
||||
_SwapCardFaceEffect({required this.onSwap})
|
||||
: super(EffectController(duration: 0.0001));
|
||||
|
||||
final void Function() onSwap;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void apply(double progress) {
|
||||
if (_done) return;
|
||||
_done = true;
|
||||
onSwap();
|
||||
}
|
||||
|
||||
double measure() => 0.0001;
|
||||
}
|
||||
502
lib/flame/memory_match_flame_game.dart
Normal file
502
lib/flame/memory_match_flame_game.dart
Normal file
@@ -0,0 +1,502 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../audio/audio_manager.dart';
|
||||
import '../game/game_controller.dart';
|
||||
import '../game/levels.dart';
|
||||
import '../game/music.dart';
|
||||
import '../game/types.dart';
|
||||
import 'memory_card_component.dart';
|
||||
|
||||
class MemoryMatchFlameGame extends FlameGame {
|
||||
MemoryMatchFlameGame({
|
||||
required this.level,
|
||||
required this.onStateChanged,
|
||||
required this.onResult,
|
||||
});
|
||||
|
||||
LevelConfig level;
|
||||
final VoidCallback onStateChanged;
|
||||
final void Function(GameStatus status, int score) onResult;
|
||||
|
||||
late GameController controller;
|
||||
|
||||
bool _ready = false;
|
||||
bool get isReady => _ready;
|
||||
|
||||
final Map<String, MemoryCardComponent> _cardViews = {};
|
||||
double _countdownAccumulator = 0;
|
||||
|
||||
late final SpriteComponent _background;
|
||||
late final PositionComponent _boardRoot;
|
||||
|
||||
bool pausedByUser = false;
|
||||
bool showResult = false;
|
||||
bool mutedMusic = false;
|
||||
bool mutedSfx = false;
|
||||
int timeLeft = 0;
|
||||
int bestScore = 0;
|
||||
String resultStatus = 'lost';
|
||||
int finalScore = 0;
|
||||
bool _boardEntryAnimating = false;
|
||||
int _boardEntryToken = 0;
|
||||
final math.Random _entryRandom = math.Random();
|
||||
|
||||
int _lastMismatchToken = 0;
|
||||
int _lastStreak = 0;
|
||||
GameStatus _lastStatus = GameStatus.playing;
|
||||
|
||||
String get timeText {
|
||||
final m = (timeLeft ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (timeLeft % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Color backgroundColor() => const Color(0x00000000);
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
|
||||
_background = SpriteComponent(
|
||||
sprite: await loadSprite('a5c6f3813d0d23b4042dd130542753cc.png'),
|
||||
size: size,
|
||||
position: Vector2.zero(),
|
||||
priority: -100,
|
||||
);
|
||||
add(_background);
|
||||
|
||||
_boardRoot = PositionComponent(anchor: Anchor.topLeft);
|
||||
add(_boardRoot);
|
||||
|
||||
controller = GameController(level: level, allFaceIds: allFaceIds)
|
||||
..addListener(_onGameChanged);
|
||||
|
||||
timeLeft = level.timeSec ?? 120;
|
||||
_lastStatus = controller.status;
|
||||
_lastMismatchToken = controller.mismatch.token;
|
||||
_lastStreak = controller.streak;
|
||||
|
||||
_ready = true;
|
||||
|
||||
await AudioManager.instance.playBgm();
|
||||
|
||||
_syncBoard(rebuildAll: true, animateEntry: true);
|
||||
_startTicker();
|
||||
}
|
||||
|
||||
@override
|
||||
void onGameResize(Vector2 canvasSize) {
|
||||
super.onGameResize(canvasSize);
|
||||
|
||||
if (!isLoaded) return;
|
||||
|
||||
_background.size = canvasSize;
|
||||
_layoutBoard();
|
||||
}
|
||||
|
||||
@override
|
||||
void update(double dt) {
|
||||
super.update(dt);
|
||||
|
||||
if (!_ready) return;
|
||||
if (showResult || pausedByUser || controller.status != GameStatus.playing) {
|
||||
return;
|
||||
}
|
||||
if (timeLeft <= 0) return;
|
||||
|
||||
_countdownAccumulator += dt;
|
||||
|
||||
while (_countdownAccumulator >= 1.0) {
|
||||
_countdownAccumulator -= 1.0;
|
||||
|
||||
if (showResult ||
|
||||
pausedByUser ||
|
||||
controller.status != GameStatus.playing) {
|
||||
return;
|
||||
}
|
||||
if (timeLeft <= 0) return;
|
||||
|
||||
timeLeft -= 1;
|
||||
onStateChanged();
|
||||
|
||||
if (timeLeft == 0 &&
|
||||
!showResult &&
|
||||
controller.status == GameStatus.playing) {
|
||||
_finishAsLose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_countdownAccumulator = 0;
|
||||
}
|
||||
|
||||
Future<void> onCardTapped(String cardId) async {
|
||||
if (!_ready) return;
|
||||
if (showResult ||
|
||||
pausedByUser ||
|
||||
_boardEntryAnimating ||
|
||||
controller.status != GameStatus.playing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await AudioManager.instance.playSfx(SfxKey.click);
|
||||
controller.flip(cardId);
|
||||
}
|
||||
|
||||
Future<void> _onGameChanged() async {
|
||||
if (!_ready) return;
|
||||
|
||||
if (controller.mismatch.token > _lastMismatchToken) {
|
||||
_lastMismatchToken = controller.mismatch.token;
|
||||
await AudioManager.instance.playSfx(SfxKey.mismatch);
|
||||
}
|
||||
|
||||
if (controller.streak > _lastStreak) {
|
||||
_lastStreak = controller.streak;
|
||||
await AudioManager.instance.playSfx(SfxKey.match);
|
||||
}
|
||||
if (controller.streak == 0) {
|
||||
_lastStreak = 0;
|
||||
}
|
||||
|
||||
if (controller.status != _lastStatus) {
|
||||
_lastStatus = controller.status;
|
||||
if (controller.status == GameStatus.won) {
|
||||
await AudioManager.instance.playSfx(SfxKey.win);
|
||||
} else if (controller.status == GameStatus.lost) {
|
||||
await AudioManager.instance.playSfx(SfxKey.lose);
|
||||
}
|
||||
}
|
||||
|
||||
if (!showResult &&
|
||||
!pausedByUser &&
|
||||
controller.status == GameStatus.playing &&
|
||||
level.maxMistakes != null &&
|
||||
controller.mistakes >= level.maxMistakes!) {
|
||||
_finishAsLose();
|
||||
}
|
||||
|
||||
if (!showResult && controller.status == GameStatus.won) {
|
||||
final score = controller.score + timeLeft * 10;
|
||||
resultStatus = 'won';
|
||||
finalScore = score;
|
||||
bestScore = math.max(bestScore, score);
|
||||
showResult = true;
|
||||
paused = true;
|
||||
onResult(GameStatus.won, score);
|
||||
}
|
||||
|
||||
_syncBoard();
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
void _finishAsLose() {
|
||||
resultStatus = 'lost';
|
||||
finalScore = controller.score;
|
||||
bestScore = math.max(bestScore, controller.score);
|
||||
showResult = true;
|
||||
paused = true;
|
||||
onResult(GameStatus.lost, finalScore);
|
||||
}
|
||||
|
||||
void _syncBoard({bool rebuildAll = false, bool animateEntry = false}) {
|
||||
if (!_ready) return;
|
||||
|
||||
if (rebuildAll && animateEntry) {
|
||||
_boardEntryAnimating = true;
|
||||
}
|
||||
|
||||
final disablePlay =
|
||||
showResult ||
|
||||
controller.status != GameStatus.playing ||
|
||||
pausedByUser ||
|
||||
_boardEntryAnimating;
|
||||
|
||||
if (rebuildAll) {
|
||||
_boardRoot.removeAll(_boardRoot.children.toList());
|
||||
_cardViews.clear();
|
||||
|
||||
for (final card in controller.cards) {
|
||||
final component = MemoryCardComponent(
|
||||
model: card,
|
||||
frontAsset: 'faces/b1c14934b04bf12e674abfffcb87cf6e.png',
|
||||
backAsset: faceMap[card.faceId]!,
|
||||
disabled: disablePlay || card.isMatched,
|
||||
);
|
||||
_cardViews[card.id] = component;
|
||||
_boardRoot.add(component);
|
||||
}
|
||||
|
||||
_layoutBoard(animateEntry: animateEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final card in controller.cards) {
|
||||
_cardViews[card.id]?.updateFromModel(
|
||||
card,
|
||||
disablePlay: disablePlay,
|
||||
shouldShake: controller.mismatch.ids.contains(card.id),
|
||||
shakeToken: controller.mismatch.token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _layoutBoard({bool animateEntry = false}) {
|
||||
if (_cardViews.isEmpty || size.x <= 0 || size.y <= 0) return;
|
||||
|
||||
final topReserve = size.y * 0.30;
|
||||
final bottomReserve = size.y * 0.18;
|
||||
final horizontalPadding = 18.0;
|
||||
|
||||
final rows = level.rowPattern.length;
|
||||
final widest = level.rowPattern.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
final isTabletWide = size.x >= 768;
|
||||
final gap = isTabletWide ? 10.0 : 12.0;
|
||||
|
||||
final usableWidth = size.x - horizontalPadding * 2;
|
||||
final usableHeight = size.y - topReserve - bottomReserve;
|
||||
|
||||
final cellByWidth = (usableWidth - gap * (widest - 1)) / widest;
|
||||
final cellByHeight = (usableHeight - gap * (rows - 1)) / rows;
|
||||
|
||||
final cell = math
|
||||
.min(cellByWidth, cellByHeight)
|
||||
.clamp(38.0, isTabletWide ? 120.0 : 110.0);
|
||||
|
||||
final gridWidth = widest * cell + (widest - 1) * gap;
|
||||
final gridHeight = rows * cell + (rows - 1) * gap;
|
||||
|
||||
final boardLeft = (size.x - gridWidth) / 2;
|
||||
final boardTop = topReserve + (usableHeight - gridHeight) / 2;
|
||||
|
||||
_boardRoot.position = Vector2(boardLeft, boardTop);
|
||||
_boardRoot.size = Vector2(gridWidth, gridHeight);
|
||||
|
||||
int cardIndex = 0;
|
||||
final cardLayouts = <({
|
||||
MemoryCardComponent view,
|
||||
Vector2 target,
|
||||
Vector2 start,
|
||||
int row,
|
||||
int indexInRow,
|
||||
})>[];
|
||||
|
||||
for (int row = 0; row < rows; row++) {
|
||||
final count = level.rowPattern[row];
|
||||
final rowWidth = count * cell + (count - 1) * gap;
|
||||
|
||||
final startX = (gridWidth - rowWidth) / 2;
|
||||
final y = row * (cell + gap);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
final card = controller.cards[cardIndex++];
|
||||
final view = _cardViews[card.id]!;
|
||||
final target = Vector2(startX + i * (cell + gap), y);
|
||||
|
||||
view.updateCardSize(Vector2.all(cell));
|
||||
cardLayouts.add((
|
||||
view: view,
|
||||
target: target,
|
||||
start: _randomEntryStartPosition(target, gridWidth, gridHeight, cell),
|
||||
row: row,
|
||||
indexInRow: i,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (animateEntry) {
|
||||
_playBoardEntryAnimation(cardLayouts);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final layout in cardLayouts) {
|
||||
layout.view.position = layout.target;
|
||||
layout.view.resetEntranceVisual();
|
||||
}
|
||||
}
|
||||
|
||||
void _playBoardEntryAnimation(
|
||||
List<
|
||||
({
|
||||
MemoryCardComponent view,
|
||||
Vector2 target,
|
||||
Vector2 start,
|
||||
int row,
|
||||
int indexInRow,
|
||||
})
|
||||
> cardLayouts,
|
||||
) {
|
||||
_boardEntryToken += 1;
|
||||
final token = _boardEntryToken;
|
||||
|
||||
const rowDelayStep = 0.12;
|
||||
const itemDelayStep = 0.035;
|
||||
const duration = 0.42;
|
||||
|
||||
for (final layout in cardLayouts) {
|
||||
layout.view.playEntranceEffect(
|
||||
startPosition: layout.start,
|
||||
targetPosition: layout.target,
|
||||
delay: layout.row * rowDelayStep + layout.indexInRow * itemDelayStep,
|
||||
);
|
||||
}
|
||||
|
||||
onStateChanged();
|
||||
|
||||
final totalSeconds =
|
||||
(level.rowPattern.length - 1) * rowDelayStep +
|
||||
(widestRowCount() - 1) * itemDelayStep +
|
||||
duration +
|
||||
0.04;
|
||||
Future.delayed(
|
||||
Duration(milliseconds: (totalSeconds * 1000).round()),
|
||||
() {
|
||||
if (!_ready || token != _boardEntryToken) return;
|
||||
_boardEntryAnimating = false;
|
||||
_syncBoard();
|
||||
onStateChanged();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
int widestRowCount() {
|
||||
return level.rowPattern.reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
|
||||
Vector2 _randomEntryStartPosition(
|
||||
Vector2 target,
|
||||
double gridWidth,
|
||||
double gridHeight,
|
||||
double cell,
|
||||
) {
|
||||
final side = _entryRandom.nextInt(4);
|
||||
final extraX = cell * (1.2 + _entryRandom.nextDouble() * 1.8);
|
||||
final extraY = cell * (1.0 + _entryRandom.nextDouble() * 2.2);
|
||||
|
||||
switch (side) {
|
||||
case 0:
|
||||
return Vector2(
|
||||
-extraX,
|
||||
target.y - extraY - _entryRandom.nextDouble() * cell,
|
||||
);
|
||||
case 1:
|
||||
return Vector2(
|
||||
gridWidth + extraX,
|
||||
target.y - extraY - _entryRandom.nextDouble() * cell,
|
||||
);
|
||||
case 2:
|
||||
return Vector2(
|
||||
target.x + (_entryRandom.nextDouble() - 0.5) * cell * 1.8,
|
||||
-extraY,
|
||||
);
|
||||
default:
|
||||
return Vector2(
|
||||
target.x + (_entryRandom.nextDouble() - 0.5) * cell * 1.6,
|
||||
gridHeight + extraY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restartSameLevel() async {
|
||||
if (!_ready) return;
|
||||
|
||||
showResult = false;
|
||||
paused = false;
|
||||
pausedByUser = false;
|
||||
timeLeft = level.timeSec ?? 120;
|
||||
_countdownAccumulator = 0;
|
||||
|
||||
controller.reset();
|
||||
_lastStatus = controller.status;
|
||||
_lastMismatchToken = controller.mismatch.token;
|
||||
_lastStreak = controller.streak;
|
||||
|
||||
_syncBoard(rebuildAll: true, animateEntry: true);
|
||||
_startTicker();
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
Future<void> nextLevel(LevelConfig next) async {
|
||||
if (!_ready) return;
|
||||
|
||||
level = next;
|
||||
showResult = false;
|
||||
paused = false;
|
||||
pausedByUser = false;
|
||||
_countdownAccumulator = 0;
|
||||
|
||||
controller.removeListener(_onGameChanged);
|
||||
controller.dispose();
|
||||
|
||||
controller = GameController(level: level, allFaceIds: allFaceIds)
|
||||
..addListener(_onGameChanged);
|
||||
|
||||
timeLeft = level.timeSec ?? 120;
|
||||
_lastStatus = controller.status;
|
||||
_lastMismatchToken = controller.mismatch.token;
|
||||
_lastStreak = controller.streak;
|
||||
|
||||
_syncBoard(rebuildAll: true, animateEntry: true);
|
||||
_startTicker();
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
Future<int> reviveWithReward() async {
|
||||
if (!_ready) return 0;
|
||||
|
||||
final sec = controller.reviveWithReward(
|
||||
baseSeconds: 30,
|
||||
penaltyPerMove: 10,
|
||||
rollbackMistakes: controller.mistakes,
|
||||
);
|
||||
|
||||
if (sec > 0) {
|
||||
timeLeft += sec;
|
||||
_countdownAccumulator = 0;
|
||||
showResult = false;
|
||||
paused = false;
|
||||
resultStatus = 'lost';
|
||||
finalScore = 0;
|
||||
_syncBoard();
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
return sec;
|
||||
}
|
||||
|
||||
Future<void> togglePause() async {
|
||||
if (!_ready) return;
|
||||
pausedByUser = !pausedByUser;
|
||||
paused = pausedByUser;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
Future<void> toggleMusic() async {
|
||||
mutedMusic = !mutedMusic;
|
||||
await AudioManager.instance.setBgmMuted(mutedMusic);
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
void toggleSfx() {
|
||||
mutedSfx = !mutedSfx;
|
||||
AudioManager.instance.setSfxMuted(mutedSfx);
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void onRemove() {
|
||||
if (_ready) {
|
||||
controller.removeListener(_onGameChanged);
|
||||
controller.dispose();
|
||||
}
|
||||
super.onRemove();
|
||||
}
|
||||
}
|
||||
66
lib/game/engine.dart
Normal file
66
lib/game/engine.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'types.dart';
|
||||
|
||||
final Random _random = Random();
|
||||
|
||||
List<T> shuffleList<T>(List<T> arr) {
|
||||
final a = List<T>.from(arr);
|
||||
for (int i = a.length - 1; i > 0; i--) {
|
||||
final int j = _random.nextInt(i + 1);
|
||||
final T temp = a[i];
|
||||
a[i] = a[j];
|
||||
a[j] = temp;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
List<CardModel> createDeck({
|
||||
required int rows,
|
||||
required int totalCards,
|
||||
required int poolSize,
|
||||
required List<String> allFaceIds,
|
||||
}) {
|
||||
if (totalCards <= 0) {
|
||||
throw Exception('totalCards must be > 0');
|
||||
}
|
||||
if (totalCards.isOdd) {
|
||||
throw Exception('totalCards must be even');
|
||||
}
|
||||
if (allFaceIds.isEmpty) {
|
||||
throw Exception('allFaceIds must not be empty');
|
||||
}
|
||||
|
||||
final pairs = totalCards ~/ 2;
|
||||
final actualPoolSize = poolSize.clamp(1, allFaceIds.length);
|
||||
final pool = shuffleList(allFaceIds).take(actualPoolSize).toList();
|
||||
|
||||
if (pool.isEmpty) {
|
||||
throw Exception('pool must not be empty');
|
||||
}
|
||||
|
||||
final List<String> picked = [];
|
||||
if (pool.length >= pairs) {
|
||||
picked.addAll(shuffleList(pool).take(pairs));
|
||||
} else {
|
||||
picked.addAll(pool);
|
||||
final remain = pairs - pool.length;
|
||||
for (int i = 0; i < remain; i++) {
|
||||
picked.add(pool[_random.nextInt(pool.length)]);
|
||||
}
|
||||
}
|
||||
|
||||
final doubled = picked.expand((e) => [e, e]).toList();
|
||||
final shuffled = shuffleList(doubled);
|
||||
|
||||
return shuffled.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final faceId = entry.value;
|
||||
return CardModel(
|
||||
id: '$faceId-$idx-${_random.nextInt(1 << 32).toRadixString(16)}',
|
||||
faceId: faceId,
|
||||
isFlipped: false,
|
||||
isMatched: false,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
209
lib/game/game_controller.dart
Normal file
209
lib/game/game_controller.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'engine.dart';
|
||||
import 'levels.dart';
|
||||
import 'types.dart';
|
||||
|
||||
class MismatchState {
|
||||
final int token;
|
||||
final List<String> ids;
|
||||
|
||||
const MismatchState({required this.token, required this.ids});
|
||||
|
||||
MismatchState copyWith({int? token, List<String>? ids}) {
|
||||
return MismatchState(token: token ?? this.token, ids: ids ?? this.ids);
|
||||
}
|
||||
}
|
||||
|
||||
class GameController extends ChangeNotifier {
|
||||
final LevelConfig level;
|
||||
final List<String> allFaceIds;
|
||||
late final int flipBackDelayMs;
|
||||
late final int? maxMistakes;
|
||||
|
||||
GameStatus status = GameStatus.playing;
|
||||
List<CardModel> cards = [];
|
||||
List<String> flippedIds = [];
|
||||
int moves = 0;
|
||||
int mistakes = 0;
|
||||
MismatchState mismatch = const MismatchState(token: 0, ids: []);
|
||||
int score = 0;
|
||||
int streak = 0;
|
||||
|
||||
bool _locked = false;
|
||||
Timer? _timer;
|
||||
|
||||
GameController({required this.level, required this.allFaceIds}) {
|
||||
flipBackDelayMs = level.flipBackDelayMs ?? 600;
|
||||
maxMistakes = level.maxMistakes;
|
||||
cards = _build();
|
||||
}
|
||||
|
||||
List<CardModel> _build() {
|
||||
assert(
|
||||
level.totalCards % 2 == 0,
|
||||
'level.totalCards must be even, current=${level.totalCards}, level=${level.id}',
|
||||
);
|
||||
|
||||
return createDeck(
|
||||
rows: level.rows,
|
||||
totalCards: level.totalCards,
|
||||
poolSize: level.poolSize,
|
||||
allFaceIds: allFaceIds,
|
||||
);
|
||||
}
|
||||
|
||||
int _pow2(int n) => 1 << n;
|
||||
|
||||
void _clearTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_clearTimer();
|
||||
_locked = false;
|
||||
status = GameStatus.playing;
|
||||
cards = _build();
|
||||
flippedIds = [];
|
||||
moves = 0;
|
||||
mistakes = 0;
|
||||
mismatch = const MismatchState(token: 0, ids: []);
|
||||
score = 0;
|
||||
streak = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reduceMistakes(int n) {
|
||||
if (n <= 0) return;
|
||||
mistakes = (mistakes - n).clamp(0, 1 << 30);
|
||||
if (status == GameStatus.lost) {
|
||||
status = GameStatus.playing;
|
||||
}
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reduceMoves(int n) {
|
||||
if (n <= 0) return;
|
||||
moves = (moves - n).clamp(0, 1 << 30);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void flip(String cardId) {
|
||||
if (status != GameStatus.playing || _locked) return;
|
||||
|
||||
final index = cards.indexWhere((e) => e.id == cardId);
|
||||
if (index < 0) return;
|
||||
|
||||
final target = cards[index];
|
||||
if (target.isMatched || target.isFlipped) return;
|
||||
|
||||
cards = cards
|
||||
.map((e) => e.id == cardId ? e.copyWith(isFlipped: true) : e)
|
||||
.toList();
|
||||
flippedIds = [...flippedIds, cardId];
|
||||
|
||||
if (flippedIds.length < 2) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_locked = true;
|
||||
|
||||
final aId = flippedIds[0];
|
||||
final bId = flippedIds[1];
|
||||
final a = cards.firstWhere((e) => e.id == aId);
|
||||
final b = cards.firstWhere((e) => e.id == bId);
|
||||
|
||||
moves += 1;
|
||||
|
||||
if (a.faceId == b.faceId) {
|
||||
cards = cards.map((e) {
|
||||
if (e.id == aId || e.id == bId) {
|
||||
return e.copyWith(isMatched: true);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
streak += 1;
|
||||
score += _pow2(streak - 1);
|
||||
flippedIds = [];
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
|
||||
if (cards.every((e) => e.isMatched)) {
|
||||
status = GameStatus.won;
|
||||
}
|
||||
|
||||
_locked = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
mistakes += 1;
|
||||
score = score > 0 ? score - 1 : 0;
|
||||
streak = 0;
|
||||
mismatch = MismatchState(token: mismatch.token + 1, ids: [aId, bId]);
|
||||
|
||||
final lostNow = maxMistakes != null && mistakes >= maxMistakes!;
|
||||
if (lostNow) {
|
||||
status = GameStatus.lost;
|
||||
}
|
||||
|
||||
_clearTimer();
|
||||
_timer = Timer(Duration(milliseconds: flipBackDelayMs), () {
|
||||
cards = cards.map((e) {
|
||||
if (e.id == aId || e.id == bId) {
|
||||
return e.copyWith(isFlipped: false);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
flippedIds = [];
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
|
||||
final lost = maxMistakes != null && mistakes >= maxMistakes!;
|
||||
if (lost) {
|
||||
status = GameStatus.lost;
|
||||
}
|
||||
|
||||
_locked = false;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int reviveWithReward({
|
||||
int baseSeconds = 30,
|
||||
int penaltyPerMove = 10,
|
||||
int? rollbackMistakes,
|
||||
int? rollbackMoves,
|
||||
}) {
|
||||
final backMistakes = rollbackMistakes ?? mistakes;
|
||||
final rewardSec = baseSeconds < 0 ? 0 : baseSeconds;
|
||||
|
||||
_locked = false;
|
||||
_clearTimer();
|
||||
mistakes = (mistakes - backMistakes).clamp(0, 1 << 30);
|
||||
|
||||
if (rollbackMoves != null) {
|
||||
moves = (moves - rollbackMoves).clamp(0, 1 << 30);
|
||||
}
|
||||
|
||||
flippedIds = [];
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
status = GameStatus.playing;
|
||||
notifyListeners();
|
||||
|
||||
return rewardSec;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clearTimer();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
208
lib/game/levels.dart
Normal file
208
lib/game/levels.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
class LevelConfig {
|
||||
final int id;
|
||||
final List<int> rowPattern;
|
||||
final int poolSize;
|
||||
final int? flipBackDelayMs;
|
||||
final int? maxMistakes;
|
||||
final int? timeSec;
|
||||
const LevelConfig({
|
||||
required this.id,
|
||||
required this.rowPattern,
|
||||
required this.poolSize,
|
||||
this.flipBackDelayMs,
|
||||
this.maxMistakes,
|
||||
this.timeSec,
|
||||
});
|
||||
int get rows => rowPattern.length;
|
||||
int get maxCols {
|
||||
if (rowPattern.isEmpty) return 0;
|
||||
int max = rowPattern.first;
|
||||
for (final v in rowPattern) {
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
int get totalCards {
|
||||
int sum = 0;
|
||||
for (final v in rowPattern) {
|
||||
sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
// 15关高难度配置:16张牌起步,容错/时间/延迟全程递减,布局多样,行数≤8
|
||||
const List<LevelConfig> levels = [
|
||||
// 第1关:16张(4×4),基础高难起步
|
||||
LevelConfig(
|
||||
id: 1,
|
||||
rowPattern: [4, 4, 4, 4],
|
||||
poolSize: 8,
|
||||
flipBackDelayMs: 600,
|
||||
maxMistakes: 50,
|
||||
timeSec: 90,
|
||||
),
|
||||
// 第2关:16张(5+3+5+3),4行交错非对称,记忆干扰提升
|
||||
LevelConfig(
|
||||
id: 2,
|
||||
rowPattern: [5, 3, 5, 3],
|
||||
poolSize: 8,
|
||||
flipBackDelayMs: 580,
|
||||
maxMistakes: 45,
|
||||
timeSec: 85,
|
||||
),
|
||||
// 第3关:18张(3×6),6行紧凑布局,行数增加+牌数提升
|
||||
LevelConfig(
|
||||
id: 3,
|
||||
rowPattern: [3, 3, 3, 3, 3, 3],
|
||||
poolSize: 9,
|
||||
flipBackDelayMs: 550,
|
||||
maxMistakes: 40,
|
||||
timeSec: 80,
|
||||
),
|
||||
// 第4关:18张(6+4+4+4),4行非对称,宽列+短列混合
|
||||
LevelConfig(
|
||||
id: 4,
|
||||
rowPattern: [6, 4, 4, 4],
|
||||
poolSize: 9,
|
||||
flipBackDelayMs: 520,
|
||||
maxMistakes: 38,
|
||||
timeSec: 78,
|
||||
),
|
||||
// 第5关:20张(5×4),4行满列,牌数再提升+列数增加
|
||||
LevelConfig(
|
||||
id: 5,
|
||||
rowPattern: [5, 5, 5, 5],
|
||||
poolSize: 10,
|
||||
flipBackDelayMs: 500,
|
||||
maxMistakes: 35,
|
||||
timeSec: 75,
|
||||
),
|
||||
// 第6关:20张(4+6+4+6),4行交错宽列,视觉复杂度升级
|
||||
LevelConfig(
|
||||
id: 6,
|
||||
rowPattern: [4, 6, 4, 6],
|
||||
poolSize: 10,
|
||||
flipBackDelayMs: 480,
|
||||
maxMistakes: 32,
|
||||
timeSec: 70,
|
||||
),
|
||||
// 第7关:24张(4×6),6行满列,牌数大幅提升+行数拉满6行
|
||||
LevelConfig(
|
||||
id: 7,
|
||||
rowPattern: [4, 4, 4, 4, 4, 4],
|
||||
poolSize: 12,
|
||||
flipBackDelayMs: 450,
|
||||
maxMistakes: 30,
|
||||
timeSec: 68,
|
||||
),
|
||||
// 第8关:24张(7+5+7+5),4行极限交错,宽列差提升记忆难度
|
||||
LevelConfig(
|
||||
id: 8,
|
||||
rowPattern: [6, 5, 6, 5],
|
||||
poolSize: 12,
|
||||
flipBackDelayMs: 420,
|
||||
maxMistakes: 28,
|
||||
timeSec: 65,
|
||||
),
|
||||
// 第9关:28张(4×7),7行布局,行数接近上限+牌数再提升
|
||||
LevelConfig(
|
||||
id: 9,
|
||||
rowPattern: [4, 4, 4, 4, 4, 4, 4],
|
||||
poolSize: 14,
|
||||
flipBackDelayMs: 400,
|
||||
maxMistakes: 25,
|
||||
timeSec: 60,
|
||||
),
|
||||
// 第10关:28张(6+5+6+5+6),5行非对称,不规则布局增加记忆压力
|
||||
LevelConfig(
|
||||
id: 10,
|
||||
rowPattern: [6, 5, 6, 5, 6],
|
||||
poolSize: 14,
|
||||
flipBackDelayMs: 380,
|
||||
maxMistakes: 22,
|
||||
timeSec: 58,
|
||||
),
|
||||
// 第11关:30张(5×6),6行满列,牌数达中高段峰值+列数5列
|
||||
LevelConfig(
|
||||
id: 11,
|
||||
rowPattern: [5, 5, 5, 5, 5, 5],
|
||||
poolSize: 15,
|
||||
flipBackDelayMs: 350,
|
||||
maxMistakes: 20,
|
||||
timeSec: 55,
|
||||
),
|
||||
// 第12关:30张(7+8+7+8),4行超宽列,列数拉满8列+极端非对称
|
||||
LevelConfig(
|
||||
id: 12,
|
||||
rowPattern: [6, 6, 6, 6],
|
||||
poolSize: 15,
|
||||
flipBackDelayMs: 320,
|
||||
maxMistakes: 18,
|
||||
timeSec: 50,
|
||||
),
|
||||
// 第13关:36张(6×6),6行满列,牌数大幅提升+高列数,记忆量拉满
|
||||
LevelConfig(
|
||||
id: 13,
|
||||
rowPattern: [6, 6, 6, 6, 6, 6],
|
||||
poolSize: 18,
|
||||
flipBackDelayMs: 300,
|
||||
maxMistakes: 15,
|
||||
timeSec: 48,
|
||||
),
|
||||
// 第14关:40张(5×8),8行上限,行数拉满+牌数峰值,布局压力拉满
|
||||
LevelConfig(
|
||||
id: 14,
|
||||
rowPattern: [5, 5, 5, 5, 5, 5, 5, 5],
|
||||
poolSize: 20,
|
||||
flipBackDelayMs: 280,
|
||||
maxMistakes: 10,
|
||||
timeSec: 45,
|
||||
),
|
||||
// 第15关:40张(8+7+8+7+8+7),6行极致交错,全关难度顶峰
|
||||
LevelConfig(
|
||||
id: 15,
|
||||
rowPattern: [6, 6, 6, 6, 6, 6],
|
||||
poolSize: 20,
|
||||
flipBackDelayMs: 250,
|
||||
maxMistakes: 5,
|
||||
timeSec: 40,
|
||||
),
|
||||
];
|
||||
|
||||
const Map<String, String> faceMap = {
|
||||
'dx': 'faces/96a3be3cf272e017046d1b2674a52bd3.png',
|
||||
'ht': 'faces/a2ef406e2c2351e0b9e80029c909242d.png',
|
||||
'hx': 'faces/e45ee7ce7e88149af8dd32b27f9512ce.png',
|
||||
'hl': 'faces/7d0665438e81d8eceb98c1e31fca80c1.png',
|
||||
'js': 'faces/751d31dd6b56b26b29dac2c0e1839e34.png',
|
||||
'kl': 'faces/faeac4e1eef307c2ab7b0a3821e6c667.png',
|
||||
'm': 'faces/d72d187df41e10ea7d9fcdc7f5909205.png',
|
||||
'mf': 'faces/fad6f4e614a212e80c67249a666d2b09.png',
|
||||
'mty': 'faces/0a8005f5594bd67041f88c6196192646.png',
|
||||
'xg': 'faces/d3d9446802a44259755d38e6d163e820.png',
|
||||
'xj': 'faces/6512bd43d9caa6e02c990b0a82652dca.png',
|
||||
'xm': 'faces/c20ad4d76fe97759aa27a0c99bff6710.png',
|
||||
'xt': 'faces/c51ce410c124a10e0db5e4b97fc2af39.png',
|
||||
'xy': 'faces/aab3238922bcc25a6f606eb525ffdc56.png',
|
||||
'xz': 'faces/9bf31c7ff062936a96d3c8bd1f8f2ff3.png',
|
||||
'xz1': 'faces/c74d97b01eae257e44aa9d5bade97baf.png',
|
||||
'xz2': 'faces/70efdf2ec9b086079795c442636b55fb.png',
|
||||
'xz3': 'faces/6f4922f45568161a8cdf4ad2299f6d23.png',
|
||||
'xz4': 'faces/1f0e3dad99908345f7439f8ffabdffc4.png',
|
||||
'xz5': 'faces/98f13708210194c475687be6106a3b84.png',
|
||||
'xz6': 'faces/3c59dc048e8850243be8079a5c74d079.png',
|
||||
'22': 'faces/b6d767d2f8ed5d21a44b0e5886680cb9.png',
|
||||
'23': 'faces/37693cfc748049e45d87b8c7d8b9aacd.png',
|
||||
'24': 'faces/1ff1de774005f8da13f42943881c655f.png',
|
||||
'25': 'faces/8e296a067a37563370ded05f5a3bf3ec.png',
|
||||
'26': 'faces/4e732ced3463d06de0ca9a15b6153677.png',
|
||||
'27': 'faces/02e74f10e0327ad868d138f2b4fdd6f0.png',
|
||||
'28': 'faces/33e75ff09dd601bbe69f351039152189.png',
|
||||
'29': 'faces/6ea9ab1baa0efb9e19094440c317e21b.png',
|
||||
'30': 'faces/34173cb38f07f89ddbebc2ac9128303f.png',
|
||||
'31': 'faces/c16a5320fa475530d9583c34fd356ef5.png',
|
||||
};
|
||||
|
||||
final List<String> allFaceIds = faceMap.keys.toList();
|
||||
14
lib/game/music.dart
Normal file
14
lib/game/music.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
enum SfxKey { click, match, mismatch, win, lose }
|
||||
|
||||
enum BgmKey { bgm }
|
||||
|
||||
class MusicMap {
|
||||
static const String bgm = 'assets/audio/6a7975d3dc974fd522610adfc8b1ce38.mp3';
|
||||
static const Map<SfxKey, String> sfx = {
|
||||
SfxKey.click: 'assets/audio/a8affc088cbca89fa20dbd98c91362e4.mp3',
|
||||
SfxKey.match: 'assets/audio/e3cc92c14a5e6dd1a7d94b6ff634d7fc.mp3',
|
||||
SfxKey.mismatch: 'assets/audio/1d1c5b76da944b44db42c9d0558021c5.mp3',
|
||||
SfxKey.win: 'assets/audio/0b08bd98d279b88859b628cd8c061ae0.mp3',
|
||||
SfxKey.lose: 'assets/audio/f7a2e8166a663d6a5fc86c3ccd320f0b.mp3',
|
||||
};
|
||||
}
|
||||
29
lib/game/types.dart
Normal file
29
lib/game/types.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
enum GameStatus { playing, won, lost }
|
||||
|
||||
class CardModel {
|
||||
final String id;
|
||||
final String faceId;
|
||||
final bool isFlipped;
|
||||
final bool isMatched;
|
||||
|
||||
const CardModel({
|
||||
required this.id,
|
||||
required this.faceId,
|
||||
required this.isFlipped,
|
||||
required this.isMatched,
|
||||
});
|
||||
|
||||
CardModel copyWith({
|
||||
String? id,
|
||||
String? faceId,
|
||||
bool? isFlipped,
|
||||
bool? isMatched,
|
||||
}) {
|
||||
return CardModel(
|
||||
id: id ?? this.id,
|
||||
faceId: faceId ?? this.faceId,
|
||||
isFlipped: isFlipped ?? this.isFlipped,
|
||||
isMatched: isMatched ?? this.isMatched,
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/main.dart
Normal file
59
lib/main.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'adjust/adjust_service.dart';
|
||||
import 'screens/game_screen_flame.dart';
|
||||
import 'screens/loading_screen.dart';
|
||||
import 'screens/start_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AdjustService.instance.init('ewkk4whk2mtc');
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
static const String routeLoading = '/loading';
|
||||
static const String routeStart = '/start';
|
||||
static const String routeGame = '/game';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Kawaii Flip Friends',
|
||||
theme: ThemeData(useMaterial3: false),
|
||||
initialRoute: routeLoading,
|
||||
onGenerateRoute: (settings) {
|
||||
switch (settings.name) {
|
||||
case routeLoading:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => const LoadingScreen(),
|
||||
settings: settings,
|
||||
);
|
||||
|
||||
case routeStart:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => const StartScreen(),
|
||||
settings: settings,
|
||||
);
|
||||
|
||||
case routeGame:
|
||||
final args = settings.arguments as Map<String, dynamic>?;
|
||||
final levelId = args?['levelId'] as int? ?? 1;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => GameScreenFlame(levelId: levelId),
|
||||
settings: settings,
|
||||
);
|
||||
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => const LoadingScreen(),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
473
lib/screens/game_screen_flame.dart
Normal file
473
lib/screens/game_screen_flame.dart
Normal file
@@ -0,0 +1,473 @@
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../components/result_modal.dart';
|
||||
import '../flame/memory_match_flame_game.dart';
|
||||
import '../game/levels.dart';
|
||||
|
||||
class GameScreenFlame extends StatefulWidget {
|
||||
const GameScreenFlame({super.key, this.levelId = 1});
|
||||
|
||||
final int levelId;
|
||||
|
||||
@override
|
||||
State<GameScreenFlame> createState() => _GameScreenFlameState();
|
||||
}
|
||||
|
||||
class _GameScreenFlameState extends State<GameScreenFlame> {
|
||||
late int currentLevelId;
|
||||
late MemoryMatchFlameGame flameGame;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentLevelId = widget.levelId;
|
||||
flameGame = _buildGame(currentLevelId);
|
||||
}
|
||||
|
||||
MemoryMatchFlameGame _buildGame(int levelId) {
|
||||
final level = levels[(levelId - 1).clamp(0, levels.length - 1)];
|
||||
return MemoryMatchFlameGame(
|
||||
level: level,
|
||||
onStateChanged: () {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
onResult: (status, score) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _restartSameLevel() async {
|
||||
await flameGame.restartSameLevel();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _goNext() async {
|
||||
if (currentLevelId >= levels.length) {
|
||||
await _restartSameLevel();
|
||||
return;
|
||||
}
|
||||
currentLevelId += 1;
|
||||
await flameGame.nextLevel(levels[currentLevelId - 1]);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isReady = flameGame.isReady;
|
||||
final scoreText = isReady ? '${flameGame.controller.score}' : '0';
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
GameWidget(game: flameGame),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: MediaQuery.of(context).size.width >= 768
|
||||
? 22
|
||||
: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: _TopHud(
|
||||
flameGame: flameGame,
|
||||
onPause: () {
|
||||
flameGame.togglePause();
|
||||
},
|
||||
onMusic: () {
|
||||
flameGame.toggleMusic();
|
||||
},
|
||||
onSfx: () {
|
||||
flameGame.toggleSfx();
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Level ${flameGame.level.id}',
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF56F0FF),
|
||||
letterSpacing: 1,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color.fromRGBO(80, 255, 255, 0.55),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: MediaQuery.of(context).size.width >= 768
|
||||
? 22
|
||||
: 18,
|
||||
),
|
||||
child: _BottomHud(
|
||||
flameGame: flameGame,
|
||||
scoreText: scoreText,
|
||||
onRetry: () {
|
||||
_restartSameLevel();
|
||||
},
|
||||
),
|
||||
),
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
// child: Text(
|
||||
// 'Moves: $moves Mistakes: $mistakes${flameGame.level.maxMistakes != null ? ' / ${flameGame.level.maxMistakes}' : ''} Grid: ${flameGame.level.rowPattern.join('-')}',
|
||||
// style: const TextStyle(
|
||||
// color: Color.fromRGBO(255, 255, 255, 0.85),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (flameGame.pausedByUser)
|
||||
Positioned.fill(
|
||||
child: _PauseOverlay(
|
||||
onResume: () {
|
||||
flameGame.togglePause();
|
||||
},
|
||||
onRetry: () {
|
||||
_restartSameLevel();
|
||||
},
|
||||
onHome: () {
|
||||
Navigator.of(context).pushReplacementNamed('/start');
|
||||
},
|
||||
),
|
||||
),
|
||||
ResultModal(
|
||||
visible: flameGame.showResult,
|
||||
status: flameGame.resultStatus,
|
||||
score: flameGame.finalScore,
|
||||
bestScore: flameGame.bestScore,
|
||||
hasNext: currentLevelId + 1 <= levels.length,
|
||||
onRetry: _restartSameLevel,
|
||||
onNext: _goNext,
|
||||
showAddTime: flameGame.resultStatus == 'lost',
|
||||
addTimeSeconds: 30,
|
||||
onRequestRewardAddTime: (grant) async {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
final sec = await flameGame.reviveWithReward();
|
||||
grant(sec);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopHud extends StatelessWidget {
|
||||
const _TopHud({
|
||||
required this.flameGame,
|
||||
required this.onPause,
|
||||
required this.onMusic,
|
||||
required this.onSfx,
|
||||
});
|
||||
|
||||
final MemoryMatchFlameGame flameGame;
|
||||
final VoidCallback onPause;
|
||||
final VoidCallback onMusic;
|
||||
final VoidCallback onSfx;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 380;
|
||||
final iconSize = compact ? 42.0 : 46.0;
|
||||
final itemGap = compact ? 6.0 : 10.0;
|
||||
final groupGap = compact ? 8.0 : 12.0;
|
||||
final topBadgeHeight = compact ? 52.0 : 58.0;
|
||||
final rightGroupWidth = iconSize * 2 + itemGap;
|
||||
final timerWidth =
|
||||
(constraints.maxWidth - iconSize - rightGroupWidth - groupGap * 2)
|
||||
.clamp(118.0, 176.0);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_NeonIconButton(
|
||||
iconAsset: flameGame.pausedByUser
|
||||
? 'assets/images/0f3f8a4a21cdb16db2f080e4d7695cdc.png'
|
||||
: 'assets/images/e86bdd5d8908079d1ed4c06015f37eb0.png',
|
||||
onTap: onPause,
|
||||
size: iconSize,
|
||||
),
|
||||
SizedBox(width: groupGap),
|
||||
_HudValueBadge(
|
||||
backgroundAsset: 'assets/images/b5bf27b2555de44e3df2230080db5a1d.png',
|
||||
text: flameGame.timeText,
|
||||
width: timerWidth,
|
||||
height: topBadgeHeight,
|
||||
textLeftInset: compact ? 64 : 76,
|
||||
textRightInset: compact ? 12 : 16,
|
||||
textStyle: TextStyle(
|
||||
color: const Color(0xFF69F6FF),
|
||||
fontSize: compact ? 16 : 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: compact ? 0.5 : 1,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_NeonIconButton(
|
||||
iconAsset: flameGame.mutedMusic
|
||||
? 'assets/images/5efe427a4e20b0ab12afe98c3cb50a60.png'
|
||||
: 'assets/images/5efe427a4e20b0ab12afe98c3cb50a60.png',
|
||||
active: !flameGame.mutedMusic,
|
||||
onTap: onMusic,
|
||||
size: iconSize,
|
||||
),
|
||||
SizedBox(width: itemGap),
|
||||
_NeonIconButton(
|
||||
iconAsset: flameGame.mutedSfx
|
||||
? 'assets/images/849ade26e1b6da2ed96d00db639a0985.png'
|
||||
: 'assets/images/1548af1c94ad45584324df8f08baf227.png',
|
||||
active: !flameGame.mutedSfx,
|
||||
onTap: onSfx,
|
||||
size: iconSize,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomHud extends StatelessWidget {
|
||||
const _BottomHud({
|
||||
required this.flameGame,
|
||||
required this.scoreText,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final MemoryMatchFlameGame flameGame;
|
||||
final String scoreText;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_HudValueBadge(
|
||||
backgroundAsset: 'assets/images/5e36941b3d856737e81516acd45edc50.png',
|
||||
text: scoreText,
|
||||
width: 118,
|
||||
height: 44,
|
||||
textLeftInset: 54,
|
||||
textRightInset: 10,
|
||||
textStyle: const TextStyle(
|
||||
color: Color(0xFF56F0FF),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
_NeonIconButton(
|
||||
iconAsset: 'assets/images/161747ec4dc9f55f1760195593742232.png',
|
||||
onTap: onRetry,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HudValueBadge extends StatelessWidget {
|
||||
const _HudValueBadge({
|
||||
required this.backgroundAsset,
|
||||
required this.text,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.textLeftInset,
|
||||
required this.textRightInset,
|
||||
required this.textStyle,
|
||||
});
|
||||
|
||||
final String backgroundAsset;
|
||||
final String text;
|
||||
final double width;
|
||||
final double height;
|
||||
final double textLeftInset;
|
||||
final double textRightInset;
|
||||
final TextStyle textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(backgroundAsset, fit: BoxFit.fill),
|
||||
),
|
||||
Positioned.fill(
|
||||
left: textLeftInset,
|
||||
right: textRightInset,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(text, maxLines: 1, style: textStyle),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NeonIconButton extends StatelessWidget {
|
||||
const _NeonIconButton({
|
||||
required this.iconAsset,
|
||||
required this.onTap,
|
||||
this.active = true,
|
||||
this.size = 46,
|
||||
});
|
||||
|
||||
final String iconAsset;
|
||||
final VoidCallback onTap;
|
||||
final bool active;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: active ? 1 : 0.45,
|
||||
child: Image.asset(iconAsset, width: size, height: size),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PauseOverlay extends StatelessWidget {
|
||||
const _PauseOverlay({
|
||||
required this.onResume,
|
||||
required this.onRetry,
|
||||
required this.onHome,
|
||||
});
|
||||
|
||||
final VoidCallback onResume;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onHome;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0.65),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width.clamp(0, 420).toDouble(),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(20, 24, 60, 0.96),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(120, 255, 255, 0.25),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Paused',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PauseButton(
|
||||
text: 'Resume',
|
||||
color: const Color.fromRGBO(50, 160, 255, 0.9),
|
||||
onTap: onResume,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PauseButton(
|
||||
text: 'Retry',
|
||||
color: const Color.fromRGBO(255, 255, 255, 0.1),
|
||||
onTap: onRetry,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PauseButton(
|
||||
text: 'Home',
|
||||
color: const Color.fromRGBO(255, 120, 120, 0.22),
|
||||
onTap: onHome,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PauseButton extends StatelessWidget {
|
||||
const _PauseButton({
|
||||
required this.text,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: Color.fromRGBO(255, 255, 255, 0.22),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
onPressed: onTap,
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
502
lib/screens/loading_screen.dart
Normal file
502
lib/screens/loading_screen.dart
Normal file
@@ -0,0 +1,502 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flame_flip/utils/redirect_url_resolver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../adjust/adjust_service.dart';
|
||||
import '../audio/audio_manager.dart';
|
||||
import '../utils/http_util.dart';
|
||||
import '../utils/device_info.dart';
|
||||
|
||||
const String bridgeInjectScript =
|
||||
r'''(function(){if(window.__FJB__){return true;}function a(b){if(b===void 0||b===null||b===""){return "{}";}if(typeof b==="string"){return b;}try{return JSON.stringify(b);}catch(c){return String(b);}}function d(e,f){var g;try{g=JSON.stringify({type:'event',event:e,params:a(f)});}catch(h){g=JSON.stringify({type:'event',event:e||"",params:"{}",stringifyError:String(h)});}if(window.flutter_inappwebview&&typeof window.flutter_inappwebview.callHandler==='function'){window.flutter_inappwebview.callHandler('jsBridge',g);}}window.__FJB__=true;window.jsBridge=window.jsBridge||{};window.jsBridge.postMessage=d;return true;})();''';
|
||||
Future launchURL(
|
||||
String url, {
|
||||
LaunchMode mode = LaunchMode.externalApplication,
|
||||
}) async {
|
||||
var uri = Uri.parse(url);
|
||||
try {
|
||||
await launchUrl(uri, mode: mode);
|
||||
} catch (e) {
|
||||
debugPrint('cant launchUrl $e');
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapProgress {
|
||||
final String phase;
|
||||
final double percent;
|
||||
|
||||
const BootstrapProgress({required this.phase, required this.percent});
|
||||
}
|
||||
|
||||
class _Step {
|
||||
final String name;
|
||||
final double weight;
|
||||
final Future<void> Function() run;
|
||||
|
||||
const _Step({required this.name, required this.weight, required this.run});
|
||||
}
|
||||
|
||||
class LoadingScreen extends StatefulWidget {
|
||||
const LoadingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoadingScreen> createState() => _LoadingScreenState();
|
||||
}
|
||||
|
||||
class _LoadingScreenState extends State<LoadingScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _phase = 'Starting';
|
||||
double _progress = 0;
|
||||
double _targetProgress = 0;
|
||||
|
||||
bool _webviewShow = false;
|
||||
String? _redirectUrl;
|
||||
String _redirect = '';
|
||||
|
||||
Timer? _smoothTimer;
|
||||
|
||||
InAppWebViewController? _webViewController;
|
||||
URLRequest? _initialUrlRequest;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
AudioManager.instance.playBgm();
|
||||
_startSmoothProgress();
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smoothTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startSmoothProgress() {
|
||||
_smoothTimer?.cancel();
|
||||
_smoothTimer = Timer.periodic(const Duration(milliseconds: 16), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_progress < _targetProgress) {
|
||||
_progress = (_progress + 0.014).clamp(0, _targetProgress);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
double _clamp01(double v) {
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
Future<void> _delay(int ms) async {
|
||||
await Future.delayed(Duration(milliseconds: ms));
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
bool alive = true;
|
||||
|
||||
void onProgress(BootstrapProgress p) {
|
||||
if (!mounted || !alive) return;
|
||||
setState(() {
|
||||
_phase = p.phase;
|
||||
_targetProgress = p.percent;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await _bootstrapApp(onProgress);
|
||||
|
||||
if (!mounted || !alive) return;
|
||||
|
||||
if (_redirect.isEmpty) {
|
||||
Navigator.of(context).pushReplacementNamed('/start');
|
||||
}
|
||||
} finally {
|
||||
alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bootstrapApp(
|
||||
void Function(BootstrapProgress p) onProgress,
|
||||
) async {
|
||||
final steps = <_Step>[
|
||||
_Step(
|
||||
name: 'Prepare',
|
||||
weight: 0.08,
|
||||
run: () async {
|
||||
try {
|
||||
final rs = await DeviceInfo.I.deviceInfo();
|
||||
debugPrint('Device info: $rs');
|
||||
if (rs.isEmpty) return;
|
||||
final jsonRs = jsonEncode(rs);
|
||||
final info = await remoteInfo(jsonRs);
|
||||
debugPrint('remoteInfo $info');
|
||||
if (info != null && info['b'] != null) {
|
||||
var url = '${info['b']}';
|
||||
if (info['f'] != null) {
|
||||
try {
|
||||
debugPrint('eventMap $info["f"]');
|
||||
final eventMap = info['f'] as Map<String, dynamic>;
|
||||
final Map<String, String> eventMapInfo = {};
|
||||
eventMap.forEach((key, value) {
|
||||
if (value != null) {
|
||||
eventMapInfo[key] = value.toString();
|
||||
}
|
||||
});
|
||||
AdjustService.instance.eventNameToToken = eventMapInfo;
|
||||
} catch (e) {
|
||||
debugPrint('parseEventMap error $e');
|
||||
}
|
||||
}
|
||||
url = await resolveRedirectUrl(url);
|
||||
_redirect = url;
|
||||
_redirectUrl = url;
|
||||
|
||||
_initWebView(url);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
AudioManager.instance.stopBgm();
|
||||
_webviewShow = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
),
|
||||
_Step(
|
||||
name: 'Remote Config',
|
||||
weight: 0.18,
|
||||
run: () async {
|
||||
await _delay(450);
|
||||
},
|
||||
),
|
||||
_Step(
|
||||
name: 'Reporting',
|
||||
weight: 0.18,
|
||||
run: () async {
|
||||
await _delay(350);
|
||||
},
|
||||
),
|
||||
_Step(
|
||||
name: 'Ads Init',
|
||||
weight: 0.26,
|
||||
run: () async {
|
||||
try {
|
||||
//await UnityAd.init();
|
||||
} catch (_) {}
|
||||
await _delay(650);
|
||||
},
|
||||
),
|
||||
_Step(
|
||||
name: 'Audio Preload',
|
||||
weight: 0.18,
|
||||
run: () async {
|
||||
await AudioManager.instance.init();
|
||||
},
|
||||
),
|
||||
_Step(
|
||||
name: 'Finalize',
|
||||
weight: 0.12,
|
||||
run: () async {
|
||||
await _delay(250);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
final total = steps.fold<double>(0, (s, x) => s + x.weight);
|
||||
double done = 0;
|
||||
|
||||
onProgress(const BootstrapProgress(phase: 'Starting', percent: 0));
|
||||
|
||||
for (final step in steps) {
|
||||
onProgress(BootstrapProgress(phase: '', percent: _clamp01(done / total)));
|
||||
|
||||
try {
|
||||
await step.run();
|
||||
} catch (_) {}
|
||||
|
||||
done += step.weight;
|
||||
|
||||
onProgress(BootstrapProgress(phase: '', percent: _clamp01(done / total)));
|
||||
}
|
||||
|
||||
onProgress(const BootstrapProgress(phase: 'Done', percent: 1));
|
||||
}
|
||||
|
||||
void _initWebView(String url) {
|
||||
_initialUrlRequest = URLRequest(url: WebUri(url));
|
||||
}
|
||||
|
||||
Future<void> _handleWebMessage(String raw) async {
|
||||
try {
|
||||
debugPrint("raw request $raw");
|
||||
final rs = jsonDecode(raw);
|
||||
if (rs is! Map<String, dynamic>) return;
|
||||
|
||||
final type = rs['type'] as String?;
|
||||
if (type == null || type.isEmpty) return;
|
||||
|
||||
if (type == 'event') {
|
||||
final event = rs['event'] as String?;
|
||||
if (event == null || event.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final params = rs['params'] as String?;
|
||||
var parseParams = <String, dynamic>{};
|
||||
if (params != null && params.isNotEmpty) {
|
||||
try {
|
||||
parseParams = jsonDecode(params) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (event == "openWindow") {
|
||||
final url = parseParams['url'] ?? '';
|
||||
if (url.isEmpty) {
|
||||
debugPrint("openWindow url is empty");
|
||||
return;
|
||||
}
|
||||
launchURL(url);
|
||||
return;
|
||||
}
|
||||
AdjustService.instance.trackEventWithName(event, parseParams);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String _percentText() => '${(_progress * 100).round()}%';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final barW = width - 64 > 340 ? 340.0 : width - 64;
|
||||
const barH = 24.0;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/a5c6f3813d0d23b4042dd130542753cc.png',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(color: const Color.fromRGBO(0, 0, 0, 0)),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
).copyWith(top: 100),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 22,
|
||||
horizontal: 18,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(82, 197, 218, 0),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(120, 255, 255, 0),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Loading...',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_phase,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color.fromRGBO(255, 255, 255, 0.85),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
ProgressBar(
|
||||
percent: _progress,
|
||||
width: barW,
|
||||
height: barH,
|
||||
insetX: 10,
|
||||
insetY: 6,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_percentText(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF56F0FF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_webviewShow && _initialUrlRequest != null)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.black,
|
||||
child: InAppWebView(
|
||||
initialUrlRequest: _initialUrlRequest,
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
transparentBackground: false,
|
||||
useShouldOverrideUrlLoading: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
),
|
||||
initialUserScripts: UnmodifiableListView<UserScript>([
|
||||
UserScript(
|
||||
source: bridgeInjectScript,
|
||||
injectionTime:
|
||||
UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
]),
|
||||
onWebViewCreated: (controller) async {
|
||||
_webViewController = controller;
|
||||
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'jsBridge',
|
||||
callback: (args) async {
|
||||
try {
|
||||
if (args.isEmpty) return null;
|
||||
final raw = args.first;
|
||||
if (raw == null) return null;
|
||||
await _handleWebMessage(raw.toString());
|
||||
} catch (_) {}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
onLoadStart: (controller, url) {
|
||||
debugPrint('WebView started: $url');
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
debugPrint('WebView finished: $url');
|
||||
},
|
||||
onReceivedError: (controller, request, error) {
|
||||
debugPrint('WebView error: ${error.description}');
|
||||
},
|
||||
onConsoleMessage: (controller, consoleMessage) {
|
||||
debugPrint('WebView console: ${consoleMessage.message}');
|
||||
},
|
||||
shouldOverrideUrlLoading:
|
||||
(controller, navigationAction) async {
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressBar extends StatelessWidget {
|
||||
final double percent;
|
||||
final double width;
|
||||
final double height;
|
||||
final double insetX;
|
||||
final double insetY;
|
||||
|
||||
const ProgressBar({
|
||||
super.key,
|
||||
required this.percent,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.insetX = 10,
|
||||
this.insetY = 6,
|
||||
});
|
||||
|
||||
double _clamp01(double v) {
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _clamp01(percent);
|
||||
final innerW = (width - insetX * 2).clamp(0, double.infinity);
|
||||
final innerH = (height - insetY * 2).clamp(0, double.infinity);
|
||||
final clipW = (innerW * p).roundToDouble();
|
||||
final fillAsset = p >= 1
|
||||
? 'assets/images/04ac514f79905c0fd3fd97534829cc34.png'
|
||||
: 'assets/images/4ea0244a9d7ad2e9357424465415f8b4.png';
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/18a74842e653d3118c500a2248a2fda7.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: insetX,
|
||||
top: insetY,
|
||||
child: SizedBox(
|
||||
width: innerW.toDouble(),
|
||||
height: innerH.toDouble(),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ClipRect(
|
||||
child: SizedBox(
|
||||
width: clipW,
|
||||
height: innerH.toDouble(),
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: innerW.toDouble(),
|
||||
maxWidth: innerW.toDouble(),
|
||||
minHeight: innerH.toDouble(),
|
||||
maxHeight: innerH.toDouble(),
|
||||
child: Image.asset(
|
||||
fillAsset,
|
||||
width: innerW.toDouble(),
|
||||
height: innerH.toDouble(),
|
||||
fit: BoxFit.fill,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
421
lib/screens/start_screen.dart
Normal file
421
lib/screens/start_screen.dart
Normal file
@@ -0,0 +1,421 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '/audio/audio_manager.dart';
|
||||
|
||||
const String privacyKey = 'privacy_accepted_v1';
|
||||
|
||||
class StartScreen extends StatefulWidget {
|
||||
const StartScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StartScreen> createState() => _StartScreenState();
|
||||
}
|
||||
|
||||
class _StartScreenState extends State<StartScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool privacyVisible = false;
|
||||
bool privacyConfirmVisible = false;
|
||||
bool privacyAccepted = false;
|
||||
|
||||
late final AnimationController _scaleController;
|
||||
late final Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initPrivacyState();
|
||||
_playBgm();
|
||||
|
||||
_scaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_scaleController.repeat(reverse: true);
|
||||
}
|
||||
|
||||
Future<void> _initPrivacyState() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final v = prefs.getString(privacyKey);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
privacyAccepted = v == '1';
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _playBgm() async {
|
||||
try {
|
||||
AudioManager.instance.playBgm();
|
||||
debugPrint('BGM started');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to play BGM: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void goGame() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushReplacementNamed('/game', arguments: {'levelId': 1});
|
||||
}
|
||||
|
||||
void onStart() {
|
||||
if (!privacyAccepted) {
|
||||
setState(() {
|
||||
privacyConfirmVisible = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
goGame();
|
||||
}
|
||||
|
||||
Future<void> onAcceptPrivacy() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(privacyKey, '1');
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
privacyAccepted = true;
|
||||
privacyConfirmVisible = false;
|
||||
});
|
||||
|
||||
goGame();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildBackground() {
|
||||
return Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(color: Colors.black),
|
||||
child: Image.asset(
|
||||
'assets/images/5523c88dd347d1b7cc617f632b7efdb7.png',
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent() {
|
||||
return SafeArea(
|
||||
child: SizedBox.expand(
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: GestureDetector(
|
||||
onTap: onStart,
|
||||
child: Image.asset(
|
||||
'assets/images/c9f9d7dd806cf4122041837a80f47c64.png',
|
||||
width: 260,
|
||||
height: 90,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacyDialog() {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
width: double.infinity,
|
||||
height: MediaQuery.of(context).size.height * 0.82,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFDDDDDD), width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Privacy Policy',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
privacyVisible = false;
|
||||
});
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 6,
|
||||
horizontal: 10,
|
||||
),
|
||||
child: Text(
|
||||
'Close',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF007AFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Expanded(child: _PrivacyInAppWebView()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacyConfirmDialog() {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(20, 24, 60, 0.96),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(120, 255, 255, 0.25),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.35),
|
||||
blurRadius: 18,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Privacy Notice',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Please read and agree to the Privacy Policy before starting the game.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.45,
|
||||
color: Color.fromRGBO(255, 255, 255, 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
privacyVisible = true;
|
||||
});
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
||||
child: Text(
|
||||
'View Privacy Policy',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF56F0FF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 46,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
privacyConfirmVisible = false;
|
||||
});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: const Color.fromRGBO(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.08,
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: Color.fromRGBO(255, 255, 255, 0.18),
|
||||
width: 2,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 46,
|
||||
child: OutlinedButton(
|
||||
onPressed: onAcceptPrivacy,
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: const Color.fromRGBO(
|
||||
50,
|
||||
160,
|
||||
255,
|
||||
0.9,
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: Color.fromRGBO(255, 255, 255, 0.22),
|
||||
width: 2,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Agree & Start',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacyConfirmOverlay() {
|
||||
return Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0.65),
|
||||
child: SafeArea(child: _buildPrivacyConfirmDialog()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacyOverlay() {
|
||||
return Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: SafeArea(child: Center(child: _buildPrivacyDialog())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SizedBox.expand(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildBackground(),
|
||||
_buildMainContent(),
|
||||
if (privacyConfirmVisible) _buildPrivacyConfirmOverlay(),
|
||||
if (privacyVisible) _buildPrivacyOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivacyInAppWebView extends StatefulWidget {
|
||||
const _PrivacyInAppWebView();
|
||||
|
||||
@override
|
||||
State<_PrivacyInAppWebView> createState() => _PrivacyInAppWebViewState();
|
||||
}
|
||||
|
||||
class _PrivacyInAppWebViewState extends State<_PrivacyInAppWebView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InAppWebView(
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
transparentBackground: false,
|
||||
supportZoom: true,
|
||||
useShouldOverrideUrlLoading: true,
|
||||
),
|
||||
onWebViewCreated: (controller) async {
|
||||
await controller.loadFile(assetFilePath: 'assets/html/privacy.html');
|
||||
},
|
||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
debugPrint('Privacy page loaded: $url');
|
||||
},
|
||||
onReceivedError: (controller, request, error) {
|
||||
debugPrint('Privacy page load error: ${error.description}');
|
||||
},
|
||||
onConsoleMessage: (controller, consoleMessage) {
|
||||
debugPrint('WebView console: ${consoleMessage.message}');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
107
lib/utils/aes_decrypt.dart
Normal file
107
lib/utils/aes_decrypt.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
class AesDecrypt {
|
||||
static const String _cn = 'i0.a';
|
||||
|
||||
static const List<int> _obf = <int>[
|
||||
0xED,
|
||||
0x7A,
|
||||
0x7F,
|
||||
0x3F,
|
||||
0x9E,
|
||||
0x6E,
|
||||
0xE7,
|
||||
0xF9,
|
||||
0x06,
|
||||
0xA0,
|
||||
0xA8,
|
||||
0xE4,
|
||||
0x16,
|
||||
0xE5,
|
||||
0x69,
|
||||
0x70,
|
||||
];
|
||||
|
||||
/// Java String.hashCode() 等价实现(32-bit signed)
|
||||
static int javaStringHashCode(String str) {
|
||||
int h = 0;
|
||||
for (int i = 0; i < str.length; i++) {
|
||||
h = _toSigned32(h * 31 + str.codeUnitAt(i));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
static int _toSigned32(int value) {
|
||||
value &= 0xFFFFFFFF;
|
||||
if ((value & 0x80000000) != 0) {
|
||||
return value - 0x100000000;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static int _unsignedRightShift32(int value, int shift) {
|
||||
return (value & 0xFFFFFFFF) >> shift;
|
||||
}
|
||||
|
||||
static Uint8List deriveKeyBytes16() {
|
||||
final int seed = _toSigned32(javaStringHashCode(_cn) ^ 0x5f3759df);
|
||||
|
||||
final Uint8List keyBytes = Uint8List(16);
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
final int shift = (i & 3) * 8;
|
||||
final int shifted = _unsignedRightShift32(seed, shift);
|
||||
final int mask = (((shifted & 0xff) ^ ((i * 17 + 31) & 0xff)) & 0xff);
|
||||
keyBytes[i] = (_obf[i] ^ mask) & 0xff;
|
||||
}
|
||||
|
||||
return keyBytes;
|
||||
}
|
||||
|
||||
static Uint8List _aesEcbPkcs7(bool forEncryption, Uint8List input) {
|
||||
final key = deriveKeyBytes16();
|
||||
|
||||
final cipher = PaddedBlockCipherImpl(
|
||||
PKCS7Padding(),
|
||||
ECBBlockCipher(AESEngine()),
|
||||
);
|
||||
|
||||
cipher.init(
|
||||
forEncryption,
|
||||
PaddedBlockCipherParameters<CipherParameters, CipherParameters>(
|
||||
KeyParameter(key),
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
return cipher.process(input);
|
||||
}
|
||||
|
||||
/// 对应 JS / Java encrypt(String) -> Base64.NO_WRAP
|
||||
static String encrypt(String plainText) {
|
||||
try {
|
||||
final Uint8List input = Uint8List.fromList(utf8.encode(plainText));
|
||||
final Uint8List encrypted = _aesEcbPkcs7(true, input);
|
||||
return base64.encode(encrypted);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应 JS / Java decrypt(String base64Encrypted) -> UTF-8
|
||||
static String? decrypt(String base64Encrypted) {
|
||||
try {
|
||||
final Uint8List encrypted = Uint8List.fromList(
|
||||
base64.decode(base64Encrypted),
|
||||
);
|
||||
final Uint8List decrypted = _aesEcbPkcs7(false, encrypted);
|
||||
final String text = utf8.decode(decrypted, allowMalformed: false);
|
||||
return text.isNotEmpty ? text : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
lib/utils/device_info.dart
Normal file
124
lib/utils/device_info.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adjust_sdk/adjust.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:play_install_referrer/play_install_referrer.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class DeviceInfo {
|
||||
static const String _spInstallReferrer = "installReferrer";
|
||||
static const String _spAdjustAttribution = "adjustAttribution";
|
||||
DeviceInfo._();
|
||||
static final DeviceInfo I = DeviceInfo._();
|
||||
Future<Map<String, dynamic>> deviceInfo() async {
|
||||
final Map<String, dynamic> json = {};
|
||||
|
||||
if (!Platform.isAndroid) {
|
||||
return json;
|
||||
}
|
||||
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final devicePlugin = DeviceInfoPlugin();
|
||||
final androidInfo = await devicePlugin.androidInfo;
|
||||
|
||||
json["packageName"] = packageInfo.packageName;
|
||||
json["versionName"] = packageInfo.version;
|
||||
json["versionCode"] = packageInfo.buildNumber;
|
||||
json["osVersion"] = androidInfo.version.release;
|
||||
json["sdkInt"] = androidInfo.version.sdkInt.toString();
|
||||
json["brand"] = androidInfo.brand;
|
||||
json["model"] = androidInfo.model;
|
||||
json["device"] = androidInfo.device;
|
||||
|
||||
try {
|
||||
final results = await Future.wait<String?>([
|
||||
getInstallReferrer(),
|
||||
getAdjustAdId(),
|
||||
getAdjustAttribution(),
|
||||
]);
|
||||
|
||||
final installReferrer = results[0];
|
||||
final adid = results[1];
|
||||
final adjustAttributionJson = results[2];
|
||||
|
||||
if (installReferrer != null && installReferrer.isNotEmpty) {
|
||||
json["installReferrer"] = installReferrer;
|
||||
}
|
||||
|
||||
if (adid != null && adid.isNotEmpty) {
|
||||
json["adjustAdId"] = adid;
|
||||
}
|
||||
|
||||
if (adjustAttributionJson != null && adjustAttributionJson.isNotEmpty) {
|
||||
json["adjustInstall"] = adjustAttributionJson;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[DeviceInfo] load adjust/referrer failed: $e");
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
Future<String?> getInstallReferrer() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cachedReferrer = prefs.getString(_spInstallReferrer);
|
||||
if (cachedReferrer != null && cachedReferrer.isNotEmpty) {
|
||||
return cachedReferrer;
|
||||
}
|
||||
|
||||
final referrerDetails = await PlayInstallReferrer.installReferrer;
|
||||
final referrer = referrerDetails.installReferrer;
|
||||
|
||||
if (referrer != null && referrer.isNotEmpty) {
|
||||
await prefs.setString(_spInstallReferrer, referrer);
|
||||
}
|
||||
return referrer;
|
||||
} catch (e) {
|
||||
debugPrint("[DeviceInfo] get install referrer failed: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAdjustAdId() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cached = prefs.getString("adjustAdId");
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final adid = await Adjust.getAdidWithTimeout(2000);
|
||||
if (adid != null && adid.isNotEmpty) {
|
||||
await prefs.setString("adjustAdId", adid);
|
||||
}
|
||||
return adid;
|
||||
} catch (e) {
|
||||
debugPrint("[DeviceInfo] get adjust adid failed: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAdjustAttribution() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cachedAttribution = prefs.getString(_spAdjustAttribution);
|
||||
if (cachedAttribution != null && cachedAttribution.isNotEmpty) {
|
||||
return cachedAttribution;
|
||||
}
|
||||
|
||||
final attribution = await Adjust.getAttributionWithTimeout(2000);
|
||||
final attributionJson = attribution?.jsonResponse;
|
||||
|
||||
if (attributionJson != null && attributionJson.isNotEmpty) {
|
||||
await prefs.setString(_spAdjustAttribution, attributionJson);
|
||||
}
|
||||
return attributionJson;
|
||||
} catch (e) {
|
||||
debugPrint("[DeviceInfo] get adjust attribution failed: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/utils/http_util.dart
Normal file
57
lib/utils/http_util.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'aes_decrypt.dart';
|
||||
|
||||
Future<dynamic> remoteInfo(String info) async {
|
||||
const String urlInfo =
|
||||
'ueytXddgndQ0JKZhjGTnMxSgQxnO4xT+dZc9PgCEw4lhZ28TUxqzoupSMIewnKOSJSgmlDi1Xj084F7/wMUUWg==';
|
||||
// const String urlInfo =
|
||||
// "Pq2OF021zqiIoq2ViKE3FDuoTvR4owYUiN+/7wU9ZpfV+JogViY3cv6lGw3/2aEQcULb+mXEzKPU78+bTrLwSA==";
|
||||
final String? url = AesDecrypt.decrypt(urlInfo);
|
||||
final String request = AesDecrypt.encrypt(info);
|
||||
final Map<String, dynamic> requestInfo = <String, dynamic>{
|
||||
'request': request,
|
||||
};
|
||||
|
||||
if (url == null || url.isEmpty) {
|
||||
throw Exception('HTTP error url');
|
||||
}
|
||||
|
||||
return postJson(url, requestInfo);
|
||||
}
|
||||
|
||||
Future<dynamic> postJson(
|
||||
String url,
|
||||
Map<String, dynamic> data, {
|
||||
int timeout = 8000,
|
||||
}) async {
|
||||
http.Response res;
|
||||
|
||||
try {
|
||||
res = await http
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: <String, String>{'Content-Type': 'application/json'},
|
||||
body: jsonEncode(data),
|
||||
)
|
||||
.timeout(Duration(milliseconds: timeout));
|
||||
} on TimeoutException {
|
||||
throw Exception('Request timeout');
|
||||
}
|
||||
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw Exception('HTTP ${res.statusCode}');
|
||||
}
|
||||
|
||||
final String rs = res.body;
|
||||
final String? jsonStr = AesDecrypt.decrypt(rs);
|
||||
|
||||
if (jsonStr == null || jsonStr.isEmpty) {
|
||||
throw Exception('Decrypt failed');
|
||||
}
|
||||
|
||||
return jsonDecode(jsonStr);
|
||||
}
|
||||
125
lib/utils/redirect_url_resolver.dart
Normal file
125
lib/utils/redirect_url_resolver.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const SP_ORIGIN = "redirect_cache_origin";
|
||||
const SP_RESOLVED = "redirect_cache_resolved";
|
||||
|
||||
Future<void> sleep(int ms) async {
|
||||
await Future.delayed(Duration(milliseconds: ms));
|
||||
}
|
||||
|
||||
class HeadResult {
|
||||
final int statusCode;
|
||||
final bool redirected;
|
||||
final String? location;
|
||||
|
||||
HeadResult(this.statusCode, this.redirected, this.location);
|
||||
}
|
||||
|
||||
Future<HeadResult?> headRequest(String url) async {
|
||||
try {
|
||||
final client = HttpClient();
|
||||
final request = await client.openUrl("HEAD", Uri.parse(url));
|
||||
|
||||
request.followRedirects = false;
|
||||
request.headers.set("Cache-Control", "no-cache");
|
||||
request.headers.set("Pragma", "no-cache");
|
||||
|
||||
final response = await request.close();
|
||||
|
||||
final location = response.headers.value(HttpHeaders.locationHeader);
|
||||
|
||||
final redirected = response.statusCode >= 300 && response.statusCode < 400;
|
||||
|
||||
return HeadResult(response.statusCode, redirected, location);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> canConnect(String url) async {
|
||||
try {
|
||||
final res = await headRequest(url);
|
||||
return res != null;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getRedirect(String url) async {
|
||||
try {
|
||||
final res = await headRequest(url);
|
||||
|
||||
if (res == null) {
|
||||
return url;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
"[RedirectResolver] head $url "
|
||||
"status=${res.statusCode} "
|
||||
"redirected=${res.redirected} "
|
||||
"location=${res.location}",
|
||||
);
|
||||
|
||||
if (res.redirected && res.location != null) {
|
||||
try {
|
||||
return Uri.parse(url).resolve(res.location!).toString();
|
||||
} catch (_) {
|
||||
return res.location!;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
} catch (_) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> resolveWithRetry(String originUrl) async {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final redirect = await getRedirect(originUrl);
|
||||
|
||||
if (redirect == originUrl) {
|
||||
return originUrl;
|
||||
}
|
||||
|
||||
if (await canConnect(redirect)) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
if (i < 2) {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> resolveRedirectUrl(String originUrl) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final cacheOrigin = prefs.getString(SP_ORIGIN);
|
||||
final cacheResolved = prefs.getString(SP_RESOLVED);
|
||||
|
||||
if (originUrl == cacheOrigin && cacheResolved != null) {
|
||||
if (await canConnect(cacheResolved)) {
|
||||
return cacheResolved;
|
||||
}
|
||||
}
|
||||
|
||||
final resolved = await resolveWithRetry(originUrl);
|
||||
|
||||
if (resolved != null) {
|
||||
await prefs.setString(SP_ORIGIN, originUrl);
|
||||
await prefs.setString(SP_RESOLVED, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return originUrl;
|
||||
} catch (_) {
|
||||
return originUrl;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user