1. قبل البدء
يُعدّ إطار عمل Flutter رائعًا في تمكين المطوّرين من إنشاء واجهات مستخدم جديدة بشكل متكرّر وبسرعة باستخدام مزيج من إعادة التحميل السريع وواجهة المستخدم التعريفية. ومع ذلك، سيأتي وقت تحتاج فيه إلى إضافة تفاعلية إضافية إلى الواجهة. يمكن أن تكون هذه اللمسات سريعة مثل تحريك زر عند النقر عليه، أو مؤثرة مثل تظليل يشوّه واجهة المستخدم باستخدام قوة وحدة معالجة الرسومات.
في هذا الدرس العملي، ستنشئ تطبيق Flutter يستفيد من قوة الرسوم المتحركة والمظلّلات ومجالات الجسيمات لإنشاء واجهة مستخدم تستحضر أفلام الخيال العلمي والبرامج التلفزيونية التي نحب جميعًا مشاهدتها عندما لا نكون بصدد كتابة الرموز البرمجية.
ما ستنشئه
ستنشئ صفحة القائمة الأولية للعبة خيال علمي تدور أحداثها بعد نهاية العالم. يتضمّن التطبيق عنوانًا مع برنامج تظليل مجزّأ يعرض عيّنات من النص لتحريكه بصريًا، وقائمة مستوى الصعوبة التي تغيّر مظهر الألوان في الصفحة مع الكثير من الصور المتحركة، وكرة متحركة مرسومة باستخدام برنامج تظليل مجزّأ ثانٍ. إذا لم يكن ذلك كافيًا، ستضيف في نهاية الدرس العملي تأثيرًا بسيطًا للجسيمات لإضفاء الحركة والتشويق على الصفحة.
تعرض لقطات الشاشة التالية التطبيق الذي ستنشئه على أنظمة تشغيل أجهزة الكمبيوتر الثلاثة المتوافقة: Windows وLinux وmacOS. لإكمال العملية، يتم توفير إصدار متوافق من متصفّح الويب. صور متحركة وبرامج تظليل مجزأة في كل مكان
المتطلبات الأساسية
- معرفة أساسية بتطوير تطبيقات Flutter باستخدام Dart، كما هو موضّح في الدرس التطبيقي تطبيق Flutter الأول
المُعطيات
- كيفية استخدام
flutter_animate
لإنشاء صور متحركة معبّرة - كيفية استخدام ميزة دعم Flutter لبرامج تظليل الأجزاء على أجهزة الكمبيوتر والويب
- كيفية إضافة صور متحركة للجسيمات إلى تطبيقك باستخدام
particle_field
المتطلبات
- حزمة تطوير البرامج (SDK) في Flutter
- إعداد VS Code لاستخدام Flutter وDart
- إعداد دعم الكمبيوتر المكتبي في Flutter لنظام التشغيل Windows أو Linux أو macOS
- إعداد دعم الويب في Flutter
2. البدء
تنزيل الرمز الأوّلي
- انتقِل إلى مستودع GitHub هذا.
- انقر على الرمز > تنزيل ملف zip لتنزيل كل الرمز البرمجي لهذا الدرس التطبيقي حول الترميز.
- استخرِج ملف ZIP الذي تم تنزيله لفك ضغط مجلد
codelabs-main
الجذر. ما عليك سوى استخدام الدليل الفرعيnext-gen-ui/
الذي يحتوي على المجلّدات منstep_01
إلىstep_06
، والتي تحتوي على رمز المصدر الذي ستستند إليه في كل خطوة من خطوات هذا الدرس العملي.
تنزيل تبعيات المشروع
- في VS Code، انقر على ملف > فتح مجلد > codelabs-main > next-gen-uis > step_01 لفتح مشروع البداية.
- إذا ظهر لك مربّع حوار في VS Code يطلب منك تنزيل الحِزم المطلوبة لتطبيق المبتدئين، انقر على الحصول على الحِزم.
- إذا لم يظهر لك مربّع حوار VS Code يطلب منك تنزيل الحِزم المطلوبة لتطبيق المبتدئين، افتح نافذة الأوامر، ثم انتقِل إلى المجلد
step_01
ونفِّذ الأمرflutter pub get
.
تشغيل التطبيق النموذجي
- في VS Code، اختَر نظام التشغيل الذي تستخدمه على الكمبيوتر أو Chrome إذا كنت تريد اختبار تطبيقك في متصفّح ويب.
على سبيل المثال، إليك ما يظهر عند استخدام macOS كهدف نشر:
في ما يلي ما يظهر لك عند استخدام Chrome كهدف النشر:
- افتح ملف
lib/main.dart
وانقر علىبدء تصحيح الأخطاء. يتم تشغيل التطبيق على نظام التشغيل الخاص بجهاز الكمبيوتر المكتبي أو في متصفّح Chrome.
استكشاف التطبيق التجريبي
في تطبيق البداية، لاحظ ما يلي:
- واجهة المستخدم جاهزة للإنشاء.
- يحتوي الدليل
assets
على مواد عرض الرسومات ورموز تظليل الأجزاء التي ستستخدمها. - يسرد ملف
pubspec.yaml
مواد العرض ومجموعة من حِزم pub التي ستستخدمها. - يحتوي الدليل
lib
على الملف الإلزاميmain.dart
، والملفassets.dart
الذي يسرد مسار مواد العرض الفنية وبرامج تظليل الأجزاء، والملفstyles.dart
الذي يسرد TextStyles والألوان التي ستستخدمها. - يحتوي دليل
lib
أيضًا على دليلcommon
، والذي يتضمّن بعض الأدوات المساعدة التي ستستخدمها في هذا الدرس التطبيقي حول الترميز، ودليلorb_shader
الذي يحتوي علىWidget
سيتم استخدامه لعرض الكرة باستخدام برنامج تظليل الرؤوس.
إليك ما سيظهر لك بعد بدء تشغيل التطبيق.
3- رسم المشهد
في هذه الخطوة، يمكنك وضع جميع مواد عرض الخلفية على الشاشة في طبقات. من المتوقّع أن تظهر الصورة بلون واحد في البداية، ولكن يمكنك إضافة الألوان إلى المشهد في نهاية هذه الخطوة.
إضافة مواد عرض إلى المشهد
- أنشئ مجلد
title_screen
في مجلدlib
، ثم أضِف ملفtitle_screen.dart
. أضِف المحتوى التالي إلى الملف:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart';
import '../assets.dart';
class TitleScreen extends StatelessWidget {
const TitleScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
Image.asset(AssetPaths.titleBgReceive),
/// Mg-Base
Image.asset(AssetPaths.titleMgBase),
/// Mg-Receive
Image.asset(AssetPaths.titleMgReceive),
/// Mg-Emit
Image.asset(AssetPaths.titleMgEmit),
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
Image.asset(AssetPaths.titleFgReceive),
/// Fg-Emit
Image.asset(AssetPaths.titleFgEmit),
],
),
),
);
}
}
تحتوي هذه الأداة على المشهد الذي تتراكم فيه مواد العرض في طبقات. يتم تمثيل طبقات الخلفية والوسط والمقدمة بمجموعة من صورتَين أو ثلاث صور. سيتم إضاءة هذه الصور بألوان مختلفة لتسجيل كيفية انتقال الضوء عبر المشهد.
- في ملف
main.dart
، أضِف المحتوى التالي:
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:window_size/window_size.dart';
// Remove 'styles.dart' import
import 'title_screen/title_screen.dart'; // Add this import
void main() {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
WidgetsFlutterBinding.ensureInitialized();
setWindowMinSize(const Size(800, 500));
}
runApp(const NextGenApp());
}
class NextGenApp extends StatelessWidget {
const NextGenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
themeMode: ThemeMode.dark,
darkTheme: ThemeData(brightness: Brightness.dark),
home: const TitleScreen(), // Replace with this widget
);
}
}
يؤدي ذلك إلى استبدال واجهة مستخدم التطبيق بالمشهد الأحادي اللون الذي تنشئه مواد العرض الفنية. بعد ذلك، يمكنك تلوين كل طبقة.
إضافة أداة تلوين الصور
أضِف أداة لتلوين الصور من خلال إضافة المحتوى التالي إلى ملف title_screen.dart
:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart';
import '../assets.dart';
class TitleScreen extends StatelessWidget {
const TitleScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
Image.asset(AssetPaths.titleBgReceive),
/// Mg-Base
Image.asset(AssetPaths.titleMgBase),
/// Mg-Receive
Image.asset(AssetPaths.titleMgReceive),
/// Mg-Emit
Image.asset(AssetPaths.titleMgEmit),
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
Image.asset(AssetPaths.titleFgReceive),
/// Fg-Emit
Image.asset(AssetPaths.titleFgEmit),
],
),
),
);
}
}
class _LitImage extends StatelessWidget { // Add from here...
const _LitImage({
required this.color,
required this.imgSrc,
required this.lightAmt,
});
final Color color;
final String imgSrc;
final double lightAmt;
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(color);
return Image.asset(
imgSrc,
color: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
colorBlendMode: BlendMode.modulate,
);
}
} // to here.
تعيد أداة _LitImage
المساعدة تلوين كل مواد العرض الفنية، استنادًا إلى ما إذا كانت تصدر الضوء أو تستقبله. قد يؤدي ذلك إلى ظهور تحذير من أداة التدقيق اللغوي، لأنّك لم تستخدم هذه الأداة الجديدة بعد.
الرسم بالألوان
ارسم بالألوان من خلال تعديل الملف title_screen.dart
على النحو التالي:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart';
import '../assets.dart';
import '../styles.dart'; // Add this import
class TitleScreen extends StatelessWidget {
const TitleScreen({super.key});
final _finalReceiveLightAmt = 0.7; // Add this attribute
final _finalEmitLightAmt = 0.5; // And this attribute
@override
Widget build(BuildContext context) {
final orbColor = AppColors.orbColors[0]; // Add this final variable
final emitColor = AppColors.emitColors[0]; // And this one
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
_LitImage( // Modify from here...
color: orbColor,
imgSrc: AssetPaths.titleBgReceive,
lightAmt: _finalReceiveLightAmt,
), // to here.
/// Mg-Base
_LitImage( // Modify from here...
imgSrc: AssetPaths.titleMgBase,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
), // to here.
/// Mg-Receive
_LitImage( // Modify from here...
imgSrc: AssetPaths.titleMgReceive,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
), // to here.
/// Mg-Emit
_LitImage( // Modify from here...
imgSrc: AssetPaths.titleMgEmit,
color: emitColor,
lightAmt: _finalEmitLightAmt,
), // to here.
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
_LitImage( // Modify from here...
imgSrc: AssetPaths.titleFgReceive,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
), // to here.
/// Fg-Emit
_LitImage( // Modify from here...
imgSrc: AssetPaths.titleFgEmit,
color: emitColor,
lightAmt: _finalEmitLightAmt,
), // to here.
],
),
),
);
}
}
class _LitImage extends StatelessWidget {
const _LitImage({
required this.color,
required this.imgSrc,
required this.lightAmt,
});
final Color color;
final String imgSrc;
final double lightAmt;
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(color);
return Image.asset(
imgSrc,
color: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
colorBlendMode: BlendMode.modulate,
);
}
}
إليك التطبيق مرة أخرى، ولكن هذه المرة مع مواد العرض الفنية الملوّنة باللون الأخضر.
4. إضافة واجهة مستخدم
في هذه الخطوة، ستضع واجهة مستخدم فوق المشهد الذي تم إنشاؤه في الخطوة السابقة. يشمل ذلك العنوان وأزرار اختيار مستوى الصعوبة وزر البدء المهم للغاية.
إضافة عنوان
- أنشئ ملف
title_screen_ui.dart
داخل دليلlib/title_screen
وأضِف المحتوى التالي إلى الملف:
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';
class TitleScreenUi extends StatelessWidget {
const TitleScreenUi({
super.key,
});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 40, horizontal: 50),
child: Stack(
children: [
/// Title Text
TopLeft(
child: UiScaler(
alignment: Alignment.topLeft,
child: _TitleText(),
),
),
],
),
);
}
}
class _TitleText extends StatelessWidget {
const _TitleText();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Transform.translate(
offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
child: Text('OUTPOST', style: TextStyles.h1),
),
Image.asset(AssetPaths.titleSelectedLeft, height: 65),
Text('57', style: TextStyles.h2),
Image.asset(AssetPaths.titleSelectedRight, height: 65),
],
),
Text('INTO THE UNKNOWN', style: TextStyles.h3),
],
);
}
}
تحتوي هذه الأداة على العنوان وجميع الأزرار التي تشكّل واجهة مستخدم هذا التطبيق.
- عدِّل ملف
lib/title_screen/title_screen.dart
على النحو التالي:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart';
import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart'; // Add this import
class TitleScreen extends StatelessWidget {
const TitleScreen({super.key});
final _finalReceiveLightAmt = 0.7;
final _finalEmitLightAmt = 0.5;
@override
Widget build(BuildContext context) {
final orbColor = AppColors.orbColors[0];
final emitColor = AppColors.emitColors[0];
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
_LitImage(
color: orbColor,
imgSrc: AssetPaths.titleBgReceive,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Base
_LitImage(
imgSrc: AssetPaths.titleMgBase,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Receive
_LitImage(
imgSrc: AssetPaths.titleMgReceive,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Emit
_LitImage(
imgSrc: AssetPaths.titleMgEmit,
color: emitColor,
lightAmt: _finalEmitLightAmt,
),
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
_LitImage(
imgSrc: AssetPaths.titleFgReceive,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Fg-Emit
_LitImage(
imgSrc: AssetPaths.titleFgEmit,
color: emitColor,
lightAmt: _finalEmitLightAmt,
),
/// UI
const Positioned.fill( // Add from here...
child: TitleScreenUi(),
), // to here.
],
),
),
);
}
}
class _LitImage extends StatelessWidget {
const _LitImage({
required this.color,
required this.imgSrc,
required this.lightAmt,
});
final Color color;
final String imgSrc;
final double lightAmt;
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(color);
return Image.asset(
imgSrc,
color: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
colorBlendMode: BlendMode.modulate,
);
}
}
سيؤدي تشغيل هذا الرمز إلى عرض العنوان، وهو بداية واجهة المستخدم.
إضافة أزرار مستوى الصعوبة
- عدِّل
title_screen_ui.dart
من خلال إضافة عملية استيراد جديدة للحزمةfocusable_control_builder
:
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:focusable_control_builder/focusable_control_builder.dart'; // Add import
import 'package:gap/gap.dart';
import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';
- أضِف ما يلي إلى التطبيق المصغّر
TitleScreenUi
:
lib/title_screen/title_screen_ui.dart
class TitleScreenUi extends StatelessWidget {
const TitleScreenUi({
super.key,
required this.difficulty, // Edit from here...
required this.onDifficultyPressed,
required this.onDifficultyFocused,
});
final int difficulty;
final void Function(int difficulty) onDifficultyPressed;
final void Function(int? difficulty) onDifficultyFocused; // to here.
@override
Widget build(BuildContext context) {
return Padding( // Move this const...
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50), // to here.
child: Stack(
children: [
/// Title Text
const TopLeft( // Add a const here, as well
child: UiScaler(
alignment: Alignment.topLeft,
child: _TitleText(),
),
),
/// Difficulty Btns
BottomLeft( // Add from here...
child: UiScaler(
alignment: Alignment.bottomLeft,
child: _DifficultyBtns(
difficulty: difficulty,
onDifficultyPressed: onDifficultyPressed,
onDifficultyFocused: onDifficultyFocused,
),
),
), // to here.
],
),
);
}
}
- أضِف العنصرَين التاليَين لتنفيذ أزرار مستوى الصعوبة:
lib/title_screen/title_screen_ui.dart
class _DifficultyBtns extends StatelessWidget {
const _DifficultyBtns({
required this.difficulty,
required this.onDifficultyPressed,
required this.onDifficultyFocused,
});
final int difficulty;
final void Function(int difficulty) onDifficultyPressed;
final void Function(int? difficulty) onDifficultyFocused;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_DifficultyBtn(
label: 'Casual',
selected: difficulty == 0,
onPressed: () => onDifficultyPressed(0),
onHover: (over) => onDifficultyFocused(over ? 0 : null),
),
_DifficultyBtn(
label: 'Normal',
selected: difficulty == 1,
onPressed: () => onDifficultyPressed(1),
onHover: (over) => onDifficultyFocused(over ? 1 : null),
),
_DifficultyBtn(
label: 'Hardcore',
selected: difficulty == 2,
onPressed: () => onDifficultyPressed(2),
onHover: (over) => onDifficultyFocused(over ? 2 : null),
),
const Gap(20),
],
);
}
}
class _DifficultyBtn extends StatelessWidget {
const _DifficultyBtn({
required this.selected,
required this.onPressed,
required this.onHover,
required this.label,
});
final String label;
final bool selected;
final VoidCallback onPressed;
final void Function(bool hasFocus) onHover;
@override
Widget build(BuildContext context) {
return FocusableControlBuilder(
onPressed: onPressed,
onHoverChanged: (_, state) => onHover.call(state.isHovered),
builder: (_, state) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
width: 250,
height: 60,
child: Stack(
children: [
/// Bg with fill and outline
Container(
decoration: BoxDecoration(
color: const Color(0xFF00D1FF).withOpacity(.1),
border: Border.all(color: Colors.white, width: 5),
),
),
if (state.isHovered || state.isFocused) ...[
Container(
decoration: BoxDecoration(
color: const Color(0xFF00D1FF).withOpacity(.1),
),
),
],
/// cross-hairs (selected state)
if (selected) ...[
CenterLeft(
child: Image.asset(AssetPaths.titleSelectedLeft),
),
CenterRight(
child: Image.asset(AssetPaths.titleSelectedRight),
),
],
/// Label
Center(
child: Text(label.toUpperCase(), style: TextStyles.btn),
),
],
),
),
);
},
);
}
}
- حوِّل أداة
TitleScreen
من أداة بلا حالة إلى أداة ذات حالة، وأضِف حالة لتفعيل تغيير نظام الألوان استنادًا إلى مستوى الصعوبة:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart';
import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';
class TitleScreen extends StatefulWidget {
const TitleScreen({super.key});
@override
State<TitleScreen> createState() => _TitleScreenState();
}
class _TitleScreenState extends State<TitleScreen> {
Color get _emitColor =>
AppColors.emitColors[_difficultyOverride ?? _difficulty];
Color get _orbColor =>
AppColors.orbColors[_difficultyOverride ?? _difficulty];
/// Currently selected difficulty
int _difficulty = 0;
/// Currently focused difficulty (if any)
int? _difficultyOverride;
void _handleDifficultyPressed(int value) {
setState(() => _difficulty = value);
}
void _handleDifficultyFocused(int? value) {
setState(() => _difficultyOverride = value);
}
final _finalReceiveLightAmt = 0.7;
final _finalEmitLightAmt = 0.5;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
_LitImage(
color: _orbColor,
imgSrc: AssetPaths.titleBgReceive,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Base
_LitImage(
imgSrc: AssetPaths.titleMgBase,
color: _orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Receive
_LitImage(
imgSrc: AssetPaths.titleMgReceive,
color: _orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Emit
_LitImage(
imgSrc: AssetPaths.titleMgEmit,
color: _emitColor,
lightAmt: _finalEmitLightAmt,
),
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
_LitImage(
imgSrc: AssetPaths.titleFgReceive,
color: _orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Fg-Emit
_LitImage(
imgSrc: AssetPaths.titleFgEmit,
color: _emitColor,
lightAmt: _finalEmitLightAmt,
),
/// UI
Positioned.fill(
child: TitleScreenUi(
difficulty: _difficulty,
onDifficultyFocused: _handleDifficultyFocused,
onDifficultyPressed: _handleDifficultyPressed,
),
),
],
),
),
);
}
}
class _LitImage extends StatelessWidget {
const _LitImage({
required this.color,
required this.imgSrc,
required this.lightAmt,
});
final Color color;
final String imgSrc;
final double lightAmt;
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(color);
return Image.asset(
imgSrc,
color: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
colorBlendMode: BlendMode.modulate,
);
}
}
في ما يلي واجهة المستخدم بإعدادَي صعوبة مختلفَين. لاحظ أنّ ألوان مستوى الصعوبة المطبَّقة كأقنعة على الصور ذات التدرّج الرمادي تُنشئ تأثيرًا واقعيًا وعاكسًا.
إضافة زر البدء
- عدِّل ملف
title_screen_ui.dart
. أضِف ما يلي إلى التطبيق المصغّرTitleScreenUi
:
lib/title_screen/title_screen_ui.dart
class TitleScreenUi extends StatelessWidget {
const TitleScreenUi({
super.key,
required this.difficulty,
required this.onDifficultyPressed,
required this.onDifficultyFocused,
});
final int difficulty;
final void Function(int difficulty) onDifficultyPressed;
final void Function(int? difficulty) onDifficultyFocused;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
child: Stack(
children: [
/// Title Text
const TopLeft(
child: UiScaler(
alignment: Alignment.topLeft,
child: _TitleText(),
),
),
/// Difficulty Btns
BottomLeft(
child: UiScaler(
alignment: Alignment.bottomLeft,
child: _DifficultyBtns(
difficulty: difficulty,
onDifficultyPressed: onDifficultyPressed,
onDifficultyFocused: onDifficultyFocused,
),
),
),
/// StartBtn
BottomRight( // Add from here...
child: UiScaler(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 20, right: 40),
child: _StartBtn(onPressed: () {}),
),
),
), // to here.
],
),
);
}
}
- أضِف الأداة التالية لتنفيذ زر البدء:
lib/title_screen/title_screen_ui.dart
class _StartBtn extends StatefulWidget {
const _StartBtn({required this.onPressed});
final VoidCallback onPressed;
@override
State<_StartBtn> createState() => _StartBtnState();
}
class _StartBtnState extends State<_StartBtn> {
AnimationController? _btnAnim;
bool _wasHovered = false;
@override
Widget build(BuildContext context) {
return FocusableControlBuilder(
cursor: SystemMouseCursors.click,
onPressed: widget.onPressed,
builder: (_, state) {
if ((state.isHovered || state.isFocused) &&
!_wasHovered &&
_btnAnim?.status != AnimationStatus.forward) {
_btnAnim?.forward(from: 0);
}
_wasHovered = (state.isHovered || state.isFocused);
return SizedBox(
width: 520,
height: 100,
child: Stack(
children: [
Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)),
if (state.isHovered || state.isFocused) ...[
Positioned.fill(
child: Image.asset(AssetPaths.titleStartBtnHover)),
],
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('START MISSION',
style: TextStyles.btn
.copyWith(fontSize: 24, letterSpacing: 18)),
],
),
),
],
),
);
},
);
}
}
وفي ما يلي التطبيق الذي يتم تشغيله مع مجموعة كاملة من الأزرار.
5- إضافة صورة متحركة
في هذه الخطوة، يمكنك تحريك واجهة المستخدم وانتقالات الألوان لأصول العمل الفني.
تلاشي العنوان للداخل
في هذه الخطوة، ستستخدم طرقًا متعددة لتحريك تطبيق Flutter، إحدى هذه الطرق هي استخدام flutter_animate
. يمكن إعادة تشغيل الرسوم المتحركة التي يتم تشغيلها بواسطة هذه الحزمة تلقائيًا كلما أعدت التحميل السريع لتطبيقك من أجل تسريع عمليات التطوير.
- عدِّل الرمز في
lib/main.dart
على النحو التالي:
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; // Add this import
import 'package:window_size/window_size.dart';
import 'title_screen/title_screen.dart';
void main() {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
WidgetsFlutterBinding.ensureInitialized();
setWindowMinSize(const Size(800, 500));
}
Animate.restartOnHotReload = true; // Add this line
runApp(const NextGenApp());
}
class NextGenApp extends StatelessWidget {
const NextGenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
themeMode: ThemeMode.dark,
darkTheme: ThemeData(brightness: Brightness.dark),
home: const TitleScreen(),
);
}
}
- للاستفادة من حزمة
flutter_animate
، يجب استيرادها. أضِف عملية الاستيراد فيlib/title_screen/title_screen_ui.dart
على النحو التالي:
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; // Add this import
import 'package:focusable_control_builder/focusable_control_builder.dart';
import 'package:gap/gap.dart';
import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';
class TitleScreenUi extends StatelessWidget {
- أضِف صورة متحركة إلى العنوان من خلال تعديل أداة
_TitleText
باتّباع الخطوات التالية:
lib/title_screen/title_screen_ui.dart
class _TitleText extends StatelessWidget {
const _TitleText();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Transform.translate(
offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
child: Text('OUTPOST', style: TextStyles.h1),
),
Image.asset(AssetPaths.titleSelectedLeft, height: 65),
Text('57', style: TextStyles.h2),
Image.asset(AssetPaths.titleSelectedRight, height: 65),
], // Edit from here...
).animate().fadeIn(delay: .8.seconds, duration: .7.seconds),
Text('INTO THE UNKNOWN', style: TextStyles.h3)
.animate()
.fadeIn(delay: 1.seconds, duration: .7.seconds),
], // to here.
);
}
}
- اضغط على إعادة التحميل لظهور العنوان تدريجيًا.
تتلاشى أزرار مستوى الصعوبة تدريجيًا
- أضِف حركة إلى المظهر الأوّلي لأزرار مستوى الصعوبة من خلال تعديل الأداة
_DifficultyBtns
على النحو التالي:
lib/title_screen/title_screen_ui.dart
class _DifficultyBtns extends StatelessWidget {
const _DifficultyBtns({
required this.difficulty,
required this.onDifficultyPressed,
required this.onDifficultyFocused,
});
final int difficulty;
final void Function(int difficulty) onDifficultyPressed;
final void Function(int? difficulty) onDifficultyFocused;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_DifficultyBtn(
label: 'Casual',
selected: difficulty == 0,
onPressed: () => onDifficultyPressed(0),
onHover: (over) => onDifficultyFocused(over ? 0 : null),
) // Add from here...
.animate()
.fadeIn(delay: 1.3.seconds, duration: .35.seconds)
.slide(begin: const Offset(0, .2)), // to here
_DifficultyBtn(
label: 'Normal',
selected: difficulty == 1,
onPressed: () => onDifficultyPressed(1),
onHover: (over) => onDifficultyFocused(over ? 1 : null),
) // Add from here...
.animate()
.fadeIn(delay: 1.5.seconds, duration: .35.seconds)
.slide(begin: const Offset(0, .2)), // to here
_DifficultyBtn(
label: 'Hardcore',
selected: difficulty == 2,
onPressed: () => onDifficultyPressed(2),
onHover: (over) => onDifficultyFocused(over ? 2 : null),
) // Add from here...
.animate()
.fadeIn(delay: 1.7.seconds, duration: .35.seconds)
.slide(begin: const Offset(0, .2)), // to here
const Gap(20),
],
);
}
}
- انقر على إعادة التحميل لترى أزرار مستوى الصعوبة تظهر بالترتيب مع تمرير بسيط للأعلى كمكافأة.
تتلاشى في زر البدء
- أضِف رسومًا متحركة إلى زر البدء من خلال تعديل فئة الحالة
_StartBtnState
، كما يلي:
lib/title_screen/title_screen_ui.dart
class _StartBtnState extends State<_StartBtn> {
AnimationController? _btnAnim;
bool _wasHovered = false;
@override
Widget build(BuildContext context) {
return FocusableControlBuilder(
cursor: SystemMouseCursors.click,
onPressed: widget.onPressed,
builder: (_, state) {
if ((state.isHovered || state.isFocused) &&
!_wasHovered &&
_btnAnim?.status != AnimationStatus.forward) {
_btnAnim?.forward(from: 0);
}
_wasHovered = (state.isHovered || state.isFocused);
return SizedBox(
width: 520,
height: 100,
child: Stack(
children: [
Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)),
if (state.isHovered || state.isFocused) ...[
Positioned.fill(
child: Image.asset(AssetPaths.titleStartBtnHover)),
],
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('START MISSION',
style: TextStyles.btn
.copyWith(fontSize: 24, letterSpacing: 18)),
],
),
),
],
) // Edit from here...
.animate(autoPlay: false, onInit: (c) => _btnAnim = c)
.shimmer(duration: .7.seconds, color: Colors.black),
)
.animate()
.fadeIn(delay: 2.3.seconds)
.slide(begin: const Offset(0, .2));
}, // to here.
);
}
}
- انقر على إعادة التحميل لترى أزرار مستوى الصعوبة تظهر بالترتيب مع تمرير بسيط للأعلى كمكافأة.
تحريك تأثير التمرير فوق مستوى الصعوبة
أضِف حركة إلى حالة التمرير فوق أزرار مستوى الصعوبة من خلال تعديل فئة الحالة _DifficultyBtn
، على النحو التالي:
lib/title_screen/title_screen_ui.dart
class _DifficultyBtn extends StatelessWidget {
const _DifficultyBtn({
required this.selected,
required this.onPressed,
required this.onHover,
required this.label,
});
final String label;
final bool selected;
final VoidCallback onPressed;
final void Function(bool hasFocus) onHover;
@override
Widget build(BuildContext context) {
return FocusableControlBuilder(
onPressed: onPressed,
onHoverChanged: (_, state) => onHover.call(state.isHovered),
builder: (_, state) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
width: 250,
height: 60,
child: Stack(
children: [
/// Bg with fill and outline
AnimatedOpacity( // Edit from here
opacity: (!selected && (state.isHovered || state.isFocused))
? 1
: 0,
duration: .3.seconds,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF00D1FF).withOpacity(.1),
border: Border.all(color: Colors.white, width: 5),
),
),
), // to here.
if (state.isHovered || state.isFocused) ...[
Container(
decoration: BoxDecoration(
color: const Color(0xFF00D1FF).withOpacity(.1),
),
),
],
/// cross-hairs (selected state)
if (selected) ...[
CenterLeft(
child: Image.asset(AssetPaths.titleSelectedLeft),
),
CenterRight(
child: Image.asset(AssetPaths.titleSelectedRight),
),
],
/// Label
Center(
child: Text(label.toUpperCase(), style: TextStyles.btn),
),
],
),
),
);
},
);
}
}
تعرض أزرار مستوى الصعوبة الآن BoxDecoration
عند تمرير مؤشر الماوس فوق زر لم يتم اختياره.
تحريك تغيير اللون
- يتغيّر لون الخلفية بشكل فوري وقاسٍ. من الأفضل تحريك الصور المضاءة بين أنظمة الألوان. إضافة
flutter_animate
إلىlib/title_screen/title_screen.dart
:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; // Add this import
import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';
class TitleScreen extends StatefulWidget {
- إضافة تطبيق
_AnimatedColors
مصغّر فيlib/title_screen/title_screen.dart
:
lib/title_screen/title_screen.dart
class _AnimatedColors extends StatelessWidget {
const _AnimatedColors({
required this.emitColor,
required this.orbColor,
required this.builder,
});
final Color emitColor;
final Color orbColor;
final Widget Function(BuildContext context, Color orbColor, Color emitColor)
builder;
@override
Widget build(BuildContext context) {
final duration = .5.seconds;
return TweenAnimationBuilder(
tween: ColorTween(begin: emitColor, end: emitColor),
duration: duration,
builder: (_, emitColor, __) {
return TweenAnimationBuilder(
tween: ColorTween(begin: orbColor, end: orbColor),
duration: duration,
builder: (context, orbColor, __) {
return builder(context, orbColor!, emitColor!);
},
);
},
);
}
}
- استخدِم الأداة التي أنشأتها للتو لتحريك ألوان الصور المضيئة من خلال تعديل طريقة
build
في_TitleScreenState
، كما يلي:
lib/title_screen/title_screen.dart
class _TitleScreenState extends State<TitleScreen> {
Color get _emitColor =>
AppColors.emitColors[_difficultyOverride ?? _difficulty];
Color get _orbColor =>
AppColors.orbColors[_difficultyOverride ?? _difficulty];
/// Selected difficulty
int _difficulty = 0;
/// Focused difficulty (if any)
int? _difficultyOverride;
void _handleDifficultyPressed(int value) {
setState(() => _difficulty = value);
}
void _handleDifficultyFocused(int? value) {
setState(() => _difficultyOverride = value);
}
final _finalReceiveLightAmt = 0.7;
final _finalEmitLightAmt = 0.5;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: _AnimatedColors( // Edit from here...
orbColor: _orbColor,
emitColor: _emitColor,
builder: (_, orbColor, emitColor) {
return Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
_LitImage(
color: orbColor,
imgSrc: AssetPaths.titleBgReceive,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Base
_LitImage(
imgSrc: AssetPaths.titleMgBase,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Receive
_LitImage(
imgSrc: AssetPaths.titleMgReceive,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Emit
_LitImage(
imgSrc: AssetPaths.titleMgEmit,
color: emitColor,
lightAmt: _finalEmitLightAmt,
),
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
_LitImage(
imgSrc: AssetPaths.titleFgReceive,
color: orbColor,
lightAmt: _finalReceiveLightAmt,
),
/// Fg-Emit
_LitImage(
imgSrc: AssetPaths.titleFgEmit,
color: emitColor,
lightAmt: _finalEmitLightAmt,
),
/// UI
Positioned.fill(
child: TitleScreenUi(
difficulty: _difficulty,
onDifficultyFocused: _handleDifficultyFocused,
onDifficultyPressed: _handleDifficultyPressed,
),
),
],
).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
},
), // to here.
),
);
}
}
من خلال هذا التعديل النهائي، أضفت رسومًا متحركة إلى كل عنصر على الشاشة، وأصبحت تبدو أفضل بكثير.
6. إضافة برامج تظليل الأجزاء
في هذه الخطوة، ستضيف برامج تظليل الأجزاء إلى التطبيق. أولاً، ستستخدم برنامج تظليل لتعديل العنوان ومنحه طابعًا أكثر تشاؤمًا. بعد ذلك، يمكنك إضافة برنامج تظليل ثانٍ لإنشاء كرة مضيئة تعمل كنقطة التركيز المركزية في الصفحة.
تشويه العنوان باستخدام برنامج تظليل الأجزاء
من خلال هذا التغيير، يمكنك تقديم حزمة provider
، ما يتيح تمرير برامج التظليل المجمَّعة إلى أسفل شجرة التطبيق المصغّر. إذا كنت مهتمًا بمعرفة كيفية تحميل برامج التظليل، يمكنك الاطّلاع على عملية التنفيذ في lib/assets.dart
.
- عدِّل الرمز في
lib/main.dart
على النحو التالي:
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart'; // Add this import
import 'package:window_size/window_size.dart';
import 'assets.dart'; // Add this import
import 'title_screen/title_screen.dart';
void main() {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
WidgetsFlutterBinding.ensureInitialized();
setWindowMinSize(const Size(800, 500));
}
Animate.restartOnHotReload = true;
runApp( // Edit from here...
FutureProvider<FragmentPrograms?>(
create: (context) => loadFragmentPrograms(),
initialData: null,
child: const NextGenApp(),
),
); // to here.
}
class NextGenApp extends StatelessWidget {
const NextGenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
themeMode: ThemeMode.dark,
darkTheme: ThemeData(brightness: Brightness.dark),
home: const TitleScreen(),
);
}
}
- للاستفادة من حزمة
provider
وأدوات التظليل المضمّنة فيstep_01
، عليك استيرادها. أضِف عمليات استيراد جديدة فيlib/title_screen/title_screen_ui.dart
، على النحو التالي:
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:focusable_control_builder/focusable_control_builder.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart'; // Add this import
import '../assets.dart';
import '../common/shader_effect.dart'; // And this import
import '../common/ticking_builder.dart'; // And this import
import '../common/ui_scaler.dart';
import '../styles.dart';
class TitleScreenUi extends StatelessWidget {
- شوِّه العنوان باستخدام برنامج التظليل من خلال تعديل أداة
_TitleText
على النحو التالي:
lib/title_screen/title_screen_ui.dart
class _TitleText extends StatelessWidget {
const _TitleText();
@override
Widget build(BuildContext context) {
Widget content = Column( // Modify this line
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Transform.translate(
offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
child: Text('OUTPOST', style: TextStyles.h1),
),
Image.asset(AssetPaths.titleSelectedLeft, height: 65),
Text('57', style: TextStyles.h2),
Image.asset(AssetPaths.titleSelectedRight, height: 65),
],
).animate().fadeIn(delay: .8.seconds, duration: .7.seconds),
Text('INTO THE UNKNOWN', style: TextStyles.h3)
.animate()
.fadeIn(delay: 1.seconds, duration: .7.seconds),
],
);
return Consumer<FragmentPrograms?>( // Add from here...
builder: (context, fragmentPrograms, _) {
if (fragmentPrograms == null) return content;
return TickingBuilder(
builder: (context, time) {
return AnimatedSampler(
(image, size, canvas) {
const double overdrawPx = 30;
final shader = fragmentPrograms.ui.fragmentShader();
shader
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, time)
..setImageSampler(0, image);
Rect rect = Rect.fromLTWH(-overdrawPx, -overdrawPx,
size.width + overdrawPx, size.height + overdrawPx);
canvas.drawRect(rect, Paint()..shader = shader);
},
child: content,
);
},
);
},
); // to here.
}
}
من المفترض أن يظهر العنوان مشوّهًا، كما هو متوقّع في مستقبل بائس.
إضافة الكرة
الآن، أضِف الكرة في منتصف النافذة. يجب إضافة عملية ردّ الاتصال onPressed
إلى زر البدء.
- في
lib/title_screen/title_screen_ui.dart
، عدِّلTitleScreenUi
على النحو التالي:
lib/title_screen/title_screen_ui.dart
class TitleScreenUi extends StatelessWidget {
const TitleScreenUi({
super.key,
required this.difficulty,
required this.onDifficultyPressed,
required this.onDifficultyFocused,
required this.onStartPressed, // Add this argument
});
final int difficulty;
final void Function(int difficulty) onDifficultyPressed;
final void Function(int? difficulty) onDifficultyFocused;
final VoidCallback onStartPressed; // Add this attribute
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
child: Stack(
children: [
/// Title Text
const TopLeft(
child: UiScaler(
alignment: Alignment.topLeft,
child: _TitleText(),
),
),
/// Difficulty Btns
BottomLeft(
child: UiScaler(
alignment: Alignment.bottomLeft,
child: _DifficultyBtns(
difficulty: difficulty,
onDifficultyPressed: onDifficultyPressed,
onDifficultyFocused: onDifficultyFocused,
),
),
),
/// StartBtn
BottomRight(
child: UiScaler(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 20, right: 40),
child: _StartBtn(onPressed: onStartPressed), // Edit this line
),
),
),
],
),
);
}
}
بعد تعديل زر البدء باستخدام دالة رد الاتصال، عليك إجراء تعديلات كبيرة على الملف lib/title_screen/title_screen.dart
.
- عدِّل عمليات الاستيراد على النحو التالي:
lib/title_screen/title_screen.dart
import 'dart:math'; // Add this import
import 'dart:ui'; // And this import
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // Add this import
import 'package:flutter_animate/flutter_animate.dart';
import '../assets.dart';
import '../orb_shader/orb_shader_config.dart'; // And this import
import '../orb_shader/orb_shader_widget.dart'; // And this import too
import '../styles.dart';
import 'title_screen_ui.dart';
class TitleScreen extends StatefulWidget {
- عدِّل
_TitleScreenState
ليتطابق مع ما يلي. يتم تعديل كل جزء من الصف تقريبًا بطريقة ما.
lib/title_screen/title_screen.dart
class _TitleScreenState extends State<TitleScreen>
with SingleTickerProviderStateMixin {
final _orbKey = GlobalKey<OrbShaderWidgetState>();
/// Editable Settings
/// 0-1, receive lighting strength
final _minReceiveLightAmt = .35;
final _maxReceiveLightAmt = .7;
/// 0-1, emit lighting strength
final _minEmitLightAmt = .5;
final _maxEmitLightAmt = 1;
/// Internal
var _mousePos = Offset.zero;
Color get _emitColor =>
AppColors.emitColors[_difficultyOverride ?? _difficulty];
Color get _orbColor =>
AppColors.orbColors[_difficultyOverride ?? _difficulty];
/// Selected difficulty
int _difficulty = 0;
/// Focused difficulty (if any)
int? _difficultyOverride;
double _orbEnergy = 0;
double _minOrbEnergy = 0;
double get _finalReceiveLightAmt {
final light =
lerpDouble(_minReceiveLightAmt, _maxReceiveLightAmt, _orbEnergy) ?? 0;
return light + _pulseEffect.value * .05 * _orbEnergy;
}
double get _finalEmitLightAmt {
return lerpDouble(_minEmitLightAmt, _maxEmitLightAmt, _orbEnergy) ?? 0;
}
late final _pulseEffect = AnimationController(
vsync: this,
duration: _getRndPulseDuration(),
lowerBound: -1,
upperBound: 1,
);
Duration _getRndPulseDuration() => 100.ms + 200.ms * Random().nextDouble();
double _getMinEnergyForDifficulty(int difficulty) => switch (difficulty) {
1 => 0.3,
2 => 0.6,
_ => 0,
};
@override
void initState() {
super.initState();
_pulseEffect.forward();
_pulseEffect.addListener(_handlePulseEffectUpdate);
}
void _handlePulseEffectUpdate() {
if (_pulseEffect.status == AnimationStatus.completed) {
_pulseEffect.reverse();
_pulseEffect.duration = _getRndPulseDuration();
} else if (_pulseEffect.status == AnimationStatus.dismissed) {
_pulseEffect.duration = _getRndPulseDuration();
_pulseEffect.forward();
}
}
void _handleDifficultyPressed(int value) {
setState(() => _difficulty = value);
_bumpMinEnergy();
}
Future<void> _bumpMinEnergy([double amount = 0.1]) async {
setState(() {
_minOrbEnergy = _getMinEnergyForDifficulty(_difficulty) + amount;
});
await Future<void>.delayed(.2.seconds);
setState(() {
_minOrbEnergy = _getMinEnergyForDifficulty(_difficulty);
});
}
void _handleStartPressed() => _bumpMinEnergy(0.3);
void _handleDifficultyFocused(int? value) {
setState(() {
_difficultyOverride = value;
if (value == null) {
_minOrbEnergy = _getMinEnergyForDifficulty(_difficulty);
} else {
_minOrbEnergy = _getMinEnergyForDifficulty(value);
}
});
}
/// Update mouse position so the orbWidget can use it, doing it here prevents
/// btns from blocking the mouse-move events in the widget itself.
void _handleMouseMove(PointerHoverEvent e) {
setState(() {
_mousePos = e.localPosition;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: MouseRegion(
onHover: _handleMouseMove,
child: _AnimatedColors(
orbColor: _orbColor,
emitColor: _emitColor,
builder: (_, orbColor, emitColor) {
return Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
_LitImage(
color: orbColor,
imgSrc: AssetPaths.titleBgReceive,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Orb
Positioned.fill(
child: Stack(
children: [
// Orb
OrbShaderWidget(
key: _orbKey,
mousePos: _mousePos,
minEnergy: _minOrbEnergy,
config: OrbShaderConfig(
ambientLightColor: orbColor,
materialColor: orbColor,
lightColor: orbColor,
),
onUpdate: (energy) => setState(() {
_orbEnergy = energy;
}),
),
],
),
),
/// Mg-Base
_LitImage(
imgSrc: AssetPaths.titleMgBase,
color: orbColor,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Receive
_LitImage(
imgSrc: AssetPaths.titleMgReceive,
color: orbColor,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Emit
_LitImage(
imgSrc: AssetPaths.titleMgEmit,
color: emitColor,
pulseEffect: _pulseEffect,
lightAmt: _finalEmitLightAmt,
),
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
_LitImage(
imgSrc: AssetPaths.titleFgReceive,
color: orbColor,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Fg-Emit
_LitImage(
imgSrc: AssetPaths.titleFgEmit,
color: emitColor,
pulseEffect: _pulseEffect,
lightAmt: _finalEmitLightAmt,
),
/// UI
Positioned.fill(
child: TitleScreenUi(
difficulty: _difficulty,
onDifficultyFocused: _handleDifficultyFocused,
onDifficultyPressed: _handleDifficultyPressed,
onStartPressed: _handleStartPressed,
),
),
],
).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
},
),
),
),
);
}
}
- عدِّل
_LitImage
على النحو التالي:
lib/title_screen/title_screen.dart
class _LitImage extends StatelessWidget {
const _LitImage({
required this.color,
required this.imgSrc,
required this.pulseEffect, // Add this parameter
required this.lightAmt,
});
final Color color;
final String imgSrc;
final AnimationController pulseEffect; // Add this attribute
final double lightAmt;
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(color);
return ListenableBuilder( // Edit from here...
listenable: pulseEffect,
builder: (context, child) {
return Image.asset(
imgSrc,
color: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
colorBlendMode: BlendMode.modulate,
);
},
); // to here.
}
}
هذه هي نتيجة هذه الإضافة.
7. إضافة رسوم متحركة للجسيمات
في هذه الخطوة، ستضيف رسومًا متحركة للجسيمات لإنشاء حركة نبضية خفيفة في التطبيق.
إضافة جسيمات في كل مكان
- أنشئ ملف
lib/title_screen/particle_overlay.dart
جديدًا، ثم أضِف الرمز التالي:
lib/title_screen/particle_overlay.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:particle_field/particle_field.dart';
import 'package:rnd/rnd.dart';
class ParticleOverlay extends StatelessWidget {
const ParticleOverlay({super.key, required this.color, required this.energy});
final Color color;
final double energy;
@override
Widget build(BuildContext context) {
return ParticleField(
spriteSheet: SpriteSheet(
image: const AssetImage('assets/images/particle-wave.png'),
),
// blend the image's alpha with the specified color:
blendMode: BlendMode.dstIn,
// this runs every tick:
onTick: (controller, _, size) {
List<Particle> particles = controller.particles;
// add a new particle with random angle, distance & velocity:
double a = rnd(pi * 2);
double dist = rnd(1, 4) * 35 + 150 * energy;
double vel = rnd(1, 2) * (1 + energy * 1.8);
particles.add(Particle(
// how many ticks this particle will live:
lifespan: rnd(1, 2) * 20 + energy * 15,
// starting distance from center:
x: cos(a) * dist,
y: sin(a) * dist,
// starting velocity:
vx: cos(a) * vel,
vy: sin(a) * vel,
// other starting values:
rotation: a,
scale: rnd(1, 2) * 0.6 + energy * 0.5,
));
// update all of the particles:
for (int i = particles.length - 1; i >= 0; i--) {
Particle p = particles[i];
if (p.lifespan <= 0) {
// particle is expired, remove it:
particles.removeAt(i);
continue;
}
p.update(
scale: p.scale * 1.025,
vx: p.vx * 1.025,
vy: p.vy * 1.025,
color: color.withOpacity(p.lifespan * 0.001 + 0.01),
lifespan: p.lifespan - 1,
);
}
},
);
}
}
- عدِّل عمليات الاستيراد الخاصة بـ
lib/title_screen/title_screen.dart
على النحو التالي:
lib/title_screen/title_screen.dart
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../assets.dart';
import '../orb_shader/orb_shader_config.dart';
import '../orb_shader/orb_shader_widget.dart';
import '../styles.dart';
import 'particle_overlay.dart'; // Add this import
import 'title_screen_ui.dart';
class TitleScreen extends StatefulWidget {
- أضِف
ParticleOverlay
إلى واجهة المستخدم عن طريق تعديل طريقةbuild
في_TitleScreenState
، كما يلي:
lib/title_screen/title_screen.dart
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: MouseRegion(
onHover: _handleMouseMove,
child: _AnimatedColors(
orbColor: _orbColor,
emitColor: _emitColor,
builder: (_, orbColor, emitColor) {
return Stack(
children: [
/// Bg-Base
Image.asset(AssetPaths.titleBgBase),
/// Bg-Receive
_LitImage(
color: orbColor,
imgSrc: AssetPaths.titleBgReceive,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Orb
Positioned.fill(
child: Stack(
children: [
// Orb
OrbShaderWidget(
key: _orbKey,
mousePos: _mousePos,
minEnergy: _minOrbEnergy,
config: OrbShaderConfig(
ambientLightColor: orbColor,
materialColor: orbColor,
lightColor: orbColor,
),
onUpdate: (energy) => setState(() {
_orbEnergy = energy;
}),
),
],
),
),
/// Mg-Base
_LitImage(
imgSrc: AssetPaths.titleMgBase,
color: orbColor,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Receive
_LitImage(
imgSrc: AssetPaths.titleMgReceive,
color: orbColor,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Mg-Emit
_LitImage(
imgSrc: AssetPaths.titleMgEmit,
color: emitColor,
pulseEffect: _pulseEffect,
lightAmt: _finalEmitLightAmt,
),
/// Particle Field
Positioned.fill( // Add from here...
child: IgnorePointer(
child: ParticleOverlay(
color: orbColor,
energy: _orbEnergy,
),
),
), // to here.
/// Fg-Rocks
Image.asset(AssetPaths.titleFgBase),
/// Fg-Receive
_LitImage(
imgSrc: AssetPaths.titleFgReceive,
color: orbColor,
pulseEffect: _pulseEffect,
lightAmt: _finalReceiveLightAmt,
),
/// Fg-Emit
_LitImage(
imgSrc: AssetPaths.titleFgEmit,
color: emitColor,
pulseEffect: _pulseEffect,
lightAmt: _finalEmitLightAmt,
),
/// UI
Positioned.fill(
child: TitleScreenUi(
difficulty: _difficulty,
onDifficultyFocused: _handleDifficultyFocused,
onDifficultyPressed: _handleDifficultyPressed,
onStartPressed: _handleStartPressed,
),
),
],
).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
},
),
),
),
);
}
تتضمّن النتيجة النهائية صورًا متحركة وظلالًا مجزّأة وتأثيرات جسيمات على منصات متعددة.
إضافة جسيمات في كل مكان، حتى على الويب
هناك مشكلة بسيطة في الرمز كما هو. عند تشغيل Flutter على الويب، يمكن استخدام محرّكَين بديلَين للعرض: محرّك CanvasKit الذي يتم استخدامه تلقائيًا على المتصفّحات المتوافقة مع أجهزة الكمبيوتر، ومحرّك HTML DOM الذي يتم استخدامه تلقائيًا على الأجهزة الجوّالة. المشكلة هي أنّ أداة العرض HTML DOM لا تتوافق مع برامج تظليل الأجزاء.
الحلّ هو إنشاء تطبيق للويب باستخدام أداة العرض CanvasKit فقط. لإجراء ذلك، أضِف علامة إلى أمر الإنشاء على النحو التالي:
$ flutter build web --web-renderer canvaskit Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 7692 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app. Font asset "CupertinoIcons.ttf" was tree-shaken, reducing it from 257628 to 1172 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app. Compiling lib/main.dart for the Web... 15.6s ✓ Built build/web
إليك كل عملك الجاد، معروضًا هذه المرة في متصفّح Chrome.
8. تهانينا
لقد أنشأت شاشة تمهيدية للعبة كاملة الميزات تتضمّن رسومات متحركة وبرامج تظليل مجزأة ورسومات متحركة للجسيمات. يمكنك الآن استخدام هذه الأساليب على جميع المنصات التي يتيحها Flutter.
مزيد من المعلومات
- الاطّلاع على حزمة
flutter_animate
- راجِع مستندات توافق Flutter مع Fragment Shaders.
- The Book of Shaders من تأليف "باتريسيو غونزاليس فيفو" و"جين لو"
- Shader toy، وهي ساحة لعب تعاونية لبرامج التظليل
- simple_shader، وهو مشروع عيّنة لبرامج تظليل الأجزاء في Flutter