1. מבוא
Flame הוא מנוע משחקים דו-ממדי שמבוסס על Flutter. ב-codelab הזה תיצרו משחק בהשראת אחד מהמשחקים הקלאסיים של שנות ה-70, Breakout של סטיב ווזניאק. תשתמשו ברכיבים של Flame כדי לצייר את המחבט, הכדור והלבנים. תשתמשו באפקטים של Flame כדי להנפיש את התנועה של העטלף, ותראו איך לשלב את Flame עם מערכת ניהול המצב של Flutter.
בסיום, המשחק אמור להיראות כמו קובץ ה-GIF המונפש הזה, אבל קצת יותר לאט.
מה תלמדו
- הסבר על העקרונות הבסיסיים של Flame, החל מ-
GameWidget
. - איך משתמשים בלולאת משחק.
- איך פועלים ה-
Component
ים של Flame. הם דומים ל-Widget
s ב-Flutter. - איך לטפל בהתנגשויות.
- איך משתמשים ב-
Effect
s כדי להנפישComponent
s. - איך להוסיף שכבת-על של Flutter
Widget
מעל משחק Flame. - איך משלבים את Flame עם ניהול המצב של Flutter.
מה תפַתחו
בשיעור הזה תלמדו איך ליצור משחק דו-ממדי באמצעות Flutter ו-Flame. בסיום, המשחק צריך לעמוד בדרישות הבאות:
- הפונקציה פועלת בכל שש הפלטפורמות ש-Flutter תומכת בהן: Android, iOS, Linux, macOS, Windows והאינטרנט
- צריך לשמור על קצב של 60 פריימים לשנייה לפחות באמצעות לולאת המשחק של Flame.
- אפשר להשתמש ביכולות של Flutter כמו חבילת
google_fonts
ו-flutter_animate
כדי לשחזר את התחושה של משחקי ארקייד משנות ה-80.
2. הגדרת סביבת Flutter
הרשאת עריכה
כדי לפשט את ה-codelab הזה, אנחנו מניחים שסביבת הפיתוח שלכם היא Visual Studio Code (VS Code). השימוש ב-VS Code הוא בחינם, והוא פועל בכל הפלטפורמות העיקריות. אנחנו משתמשים ב-VS Code ב-codelab הזה כי ההוראות מוגדרות כברירת מחדל לקיצורי דרך ספציפיים ל-VS Code. המשימות הופכות לפשוטות יותר: "לחץ על הלחצן הזה" או "הקש על המקש הזה כדי לבצע את הפעולה X" במקום "בצע את הפעולה המתאימה בעורך כדי לבצע את הפעולה X".
אפשר להשתמש בכל עורך שרוצים: Android Studio, סביבות פיתוח משולבות (IDE) אחרות של IntelliJ, Emacs, Vim או Notepad++. כולם פועלים עם Flutter.
בחירת יעד פיתוח
Flutter יוצרת אפליקציות למספר פלטפורמות. האפליקציה יכולה לפעול בכל אחת ממערכות ההפעלה הבאות:
- iOS
- Android
- Windows
- macOS
- Linux
- אינטרנט
מקובל לבחור מערכת הפעלה אחת כיעד הפיתוח. זו מערכת ההפעלה שבה האפליקציה פועלת במהלך הפיתוח.
לדוגמה: נניח שאתם משתמשים במחשב נייד עם Windows כדי לפתח את אפליקציית Flutter. אחר כך אתם בוחרים ב-Android כיעד הפיתוח. כדי לצפות בתצוגה מקדימה של האפליקציה, מחברים מכשיר Android למחשב נייד עם Windows באמצעות כבל USB, והאפליקציה שבפיתוח פועלת במכשיר Android המחובר או באמולטור Android. יכולתם לבחור ב-Windows כיעד הפיתוח, ואז האפליקציה שבפיתוח תפעל כאפליקציית Windows לצד העורך.
צריך לבחור אפשרות לפני שממשיכים. תמיד אפשר להפעיל את האפליקציה במערכות הפעלה אחרות מאוחר יותר. בחירת יעד פיתוח תעזור לכם להמשיך לשלב הבא בצורה חלקה יותר.
התקנת Flutter
ההוראות העדכניות ביותר להתקנת Flutter SDK זמינות בכתובת docs.flutter.dev.
ההוראות באתר Flutter כוללות את התקנת ה-SDK, הכלים שקשורים ליעד הפיתוח ותוספי העורך. כדי לבצע את ה-codelab הזה, צריך להתקין את התוכנה הבאה:
- Flutter SDK
- קוד Visual Studio עם הפלאגין של Flutter
- תוכנת קומפיילר ליעד הפיתוח שבחרתם. (כדי לטרגט ל-Windows צריך Visual Studio, וכדי לטרגט ל-macOS או ל-iOS צריך Xcode)
בקטע הבא, תיצרו את פרויקט Flutter הראשון שלכם.
אם אתם צריכים לפתור בעיות, יכול להיות שחלק מהשאלות והתשובות האלה (מ-StackOverflow) יעזרו לכם.
שאלות נפוצות
- איך מוצאים את הנתיב של Flutter SDK?
- מה עושים אם הפקודה Flutter לא נמצאה?
- איך פותרים את הבעיה 'Waiting for another flutter command to release the startup lock' (המתנה לפקודת Flutter אחרת כדי לבטל את נעילת ההפעלה)?
- איך מציינים ב-Flutter את המיקום של Android SDK?
- איך מתמודדים עם שגיאת Java כשמפעילים את
flutter doctor --android-licenses
? - איך פותרים את הבעיה 'לא נמצא כלי Android
sdkmanager
'? - איך מתמודדים עם השגיאה 'הרכיב
cmdline-tools
חסר'? - איך מריצים CocoaPods ב-Apple Silicon (M1)?
- איך משביתים את העיצוב האוטומטי בשמירה ב-VS Code?
3. יצירת פרויקט
יצירת פרויקט Flutter ראשון
כדי לעשות את זה, צריך לפתוח את VS Code וליצור את תבנית אפליקציית Flutter בספרייה שתבחרו.
- מפעילים את Visual Studio Code.
- פותחים את לוח הפקודות (
F1
אוCtrl+Shift+P
אוShift+Cmd+P
) ומקלידים flutter new. כשמופיעה האפשרות Flutter: New Project, בוחרים בה.
- לוחצים על Empty Application (ריקון הבקשה). בוחרים את הספרייה שבה רוצים ליצור את הפרויקט. צריך לבחור ספרייה שלא דורשת הרשאות גבוהות או שלא מכילה רווח בנתיב שלה. לדוגמה, ספריית הבית או
C:\src\
.
- נותנים שם לפרויקט
brick_breaker
. בהמשך ה-codelab הזה נניח ששם האפליקציה הואbrick_breaker
.
מערכת Flutter יוצרת את תיקיית הפרויקט ופותחת אותה ב-VS Code. עכשיו תהיה החלפה של התוכן של שני קבצים עם פיגום בסיסי של האפליקציה.
העתקה והדבקה של האפליקציה הראשונית
פעולה זו תוסיף לאפליקציה שלכם את קוד הדוגמה שמופיע ב-codelab הזה.
- בחלונית הימנית של VS Code, לוחצים על Explorer ופותחים את הקובץ
pubspec.yaml
.
- מחליפים את התוכן של הקובץ בתוכן הבא:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.8.0
dependencies:
flutter:
sdk: flutter
flame: ^1.28.1
flutter_animate: ^4.5.2
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
קובץ ה-pubspec.yaml
מציין מידע בסיסי על האפליקציה, כמו הגרסה הנוכחית שלה, התלות שלה ונכסי ה-assets שייכללו בה.
- פותחים את הקובץ
main.dart
בספרייהlib/
.
- מחליפים את התוכן של הקובץ בתוכן הבא:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- מריצים את הקוד הזה כדי לוודא שהכול פועל. אם המצב הופעל, יוצג בו חלון חדש עם רקע שחור ריק. משחק הווידאו הכי גרוע בעולם מוצג עכשיו ב-60fps!
4. יצירת המשחק
הערכת גודל המשחק
משחק דו-ממדי (2D) צריך אזור משחק. תבנו אזור עם מידות ספציפיות, ואז תשתמשו במידות האלה כדי לקבוע את הגודל של היבטים אחרים במשחק.
יש כמה דרכים להציג את הקואורדינטות באזור המשחק. לפי מוסכמה אחת, אפשר למדוד את הכיוון ממרכז המסך, כאשר נקודת המוצא (0,0)
נמצאת במרכז המסך. הערכים החיוביים מעבירים פריטים ימינה לאורך ציר ה-x ולמעלה לאורך ציר ה-y. התקן הזה חל על רוב המשחקים של היום, במיוחד על משחקים שכוללים שלושה ממדים.
כשמשחק ה-Breakout המקורי נוצר, המוסכמה הייתה להגדיר את המקור בפינה הימנית העליונה. הכיוון החיובי של ציר x נשאר זהה, אבל ציר y התהפך. הכיוון החיובי של ציר x היה ימינה ושל ציר y היה למטה. כדי לשמור על נאמנות לתקופה, במשחק הזה נקודת המוצא מוגדרת בפינה הימנית העליונה.
יוצרים קובץ בשם config.dart
בספרייה חדשה בשם lib/src
. בשלבים הבאים יתווספו לקובץ הזה עוד קבועים.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
המשחק הזה יהיה ברוחב של 820 פיקסלים ובגובה של 1,600 פיקסלים. אזור המשחק מותאם לגודל החלון שבו הוא מוצג, אבל כל הרכיבים שנוספו למסך מותאמים לגובה ולרוחב האלה.
יצירת אזור משחק
במשחק Breakout, הכדור קופץ מהקירות של אזור המשחק. כדי לטפל בהתנגשויות, צריך קודם רכיב PlayArea
.
- יוצרים קובץ בשם
play_area.dart
בספרייה חדשה בשםlib/src/components
. - מוסיפים את הטקסט הבא לקובץ.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
ב-Flutter יש Widget
s, וב-Flame יש Component
s. באפליקציות Flutter יוצרים עצי ווידג'טים, ובמשחקי Flame יוצרים עצי רכיבים.
כאן טמון הבדל מעניין בין Flutter לבין Flame. עץ הווידג'טים של Flutter הוא תיאור חולף שנועד לעדכן את השכבה RenderObject
הקבועה והניתנת לשינוי. הרכיבים של Flame הם קבועים וניתנים לשינוי, והמפתחים אמורים להשתמש בהם כחלק ממערכת סימולציה.
הרכיבים של Flame מותאמים לביטוי של מנגנוני משחק. ב-codelab הזה נתחיל עם לולאת המשחק, שמוצגת בשלב הבא.
- כדי למנוע עומס, מוסיפים קובץ שמכיל את כל הרכיבים בפרויקט הזה. יוצרים קובץ
components.dart
ב-lib/src/components
ומוסיפים את התוכן הבא.
lib/src/components/components.dart
export 'play_area.dart';
ההנחיה export
ממלאת את התפקיד ההפוך של import
. הוא מציין אילו פונקציות הקובץ הזה חושף כשמייבאים אותו לקובץ אחר. כשתבצעו את השלבים הבאים ותוסיפו רכיבים חדשים, הקובץ יגדל.
יצירת משחק ב-Flame
כדי להסיר את הקו האדום הגלי מהשלב הקודם, יוצרים מחלקה משנית חדשה ל-Flame FlameGame
.
- יוצרים קובץ בשם
brick_breaker.dart
בתיקייהlib/src
ומוסיפים את הקוד הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
הקובץ הזה מתאם את הפעולות במשחק. במהלך בניית מופע המשחק, הקוד הזה מגדיר את המשחק לשימוש ברינדור ברזולוציה קבועה. המשחק משנה את הגודל שלו כדי למלא את המסך שבו הוא מוצג, ומוסיף letterboxing לפי הצורך.
אתם חושפים את הרוחב והגובה של המשחק כדי שרכיבי הילדים, כמו PlayArea
, יוכלו להגדיר את עצמם לגודל המתאים.
בשיטה onLoad
שהוחלפה, הקוד מבצע שתי פעולות.
- ההגדרה קובעת שהפינה השמאלית העליונה תהיה נקודת העיגון של העינית. כברירת מחדל, הרכיב
viewfinder
משתמש במרכז האזור כנקודת העיגון של(0,0)
. - הוספת
PlayArea
ל-world
. העולם מייצג את עולם המשחק. הוא מבצע הקרנה של כל הצאצאים שלו באמצעות טרנספורמציית התצוגה שלCameraComponent
.
הצגת המשחק במסך
כדי לראות את כל השינויים שביצעתם בשלב הזה, צריך לעדכן את קובץ lib/main.dart
עם השינויים הבאים.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
אחרי שמבצעים את השינויים האלה, מפעילים מחדש את המשחק. המשחק אמור להיראות כמו באיור הבא.
בשלב הבא, תוסיפו כדור לעולם ותגרמו לו לזוז.
5. הצגת הכדור
יצירת רכיב הכדור
כדי להציב כדור נע על המסך, צריך ליצור רכיב נוסף ולהוסיף אותו לעולם המשחק.
- עורכים את התוכן של הקובץ
lib/src/config.dart
באופן הבא.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
תבנית העיצוב של הגדרת קבועים בעלי שם כערכים נגזרים תחזור על עצמה הרבה פעמים ב-codelab הזה. כך תוכלו לשנות את הרמה העליונה gameWidth
ואת gameHeight
כדי לראות איך המשחק נראה ומרגיש כתוצאה מכך.
- יוצרים את הרכיב
Ball
בקובץ בשםball.dart
ב-lib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
קודם לכן הגדרת את PlayArea
באמצעות RectangleComponent
, ולכן הגיוני שיש עוד צורות. CircleComponent
, כמו RectangleComponent
, נגזר מ-PositionedComponent
, כך שאפשר למקם את הכדור על המסך. חשוב מכך, אפשר לעדכן את המיקום שלו.
הרכיב הזה מציג את המושג velocity
, או שינוי במיקום לאורך זמן. המהירות היא אובייקט Vector2
כי המהירות היא גם גודל וגם כיוון. כדי לעדכן את המיקום, מבטלים את שיטת update
, שמנוע המשחק קורא לכל פריים. dt
הוא משך הזמן בין הפריים הקודם לפריים הנוכחי. כך תוכלו להתאים את עצמכם לגורמים כמו קצבי פריימים שונים (60 הרץ או 120 הרץ) או פריימים ארוכים בגלל חישובים מוגזמים.
חשוב לשים לב לעדכון position += velocity * dt
. כך מטמיעים עדכון של סימולציה נפרדת של תנועה לאורך זמן.
- כדי לכלול את הרכיב
Ball
ברשימת הרכיבים, עורכים את הקובץlib/src/components/components.dart
באופן הבא.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
הוספת הכדור לעולם
תהנו. מציבים אותו בעולם ומגדירים אותו כך שיוכל לנוע באזור המשחק.
עורכים את הקובץ lib/src/brick_breaker.dart
באופן הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true; // To here.
}
}
במסגרת השינוי הזה, הרכיב Ball
נוסף ל-world
. כדי להגדיר את המיקום position
של הכדור במרכז אזור התצוגה, הקוד מחלק קודם את גודל המשחק לשניים, כי למאפיין Vector2
יש עומס יתר של אופרטורים (*
ו-/
) כדי לשנות את קנה המידה של Vector2
לפי ערך סקלרי.
הגדרת הכדור velocity
היא מורכבת יותר. המטרה היא להזיז את הכדור למטה במסך בכיוון אקראי ובמהירות סבירה. הקריאה ל-method normalized
יוצרת אובייקט Vector2
שמוגדר לאותו כיוון כמו Vector2
המקורי, אבל מוקטן למרחק של 1. כך מהירות הכדור נשארת קבועה לא משנה לאיזה כיוון הכדור נע. המהירות של הכדור מוגדלת כך שתהיה רבע מגובה המשחק.
כדי להגדיר את הערכים השונים האלה בצורה נכונה, צריך לבצע כמה איטרציות, שנקראות גם בדיקות משחק בתעשייה.
השורה האחרונה מפעילה את תצוגת ניפוי הבאגים, שמוסיפה לתצוגה מידע נוסף שיכול לעזור בניפוי הבאגים.
כשמפעילים את המשחק, הוא אמור להיראות כך:
גם לרכיב PlayArea
וגם לרכיב Ball
יש נתוני ניפוי באגים, אבל המסכות ברקע חותכות את המספרים של PlayArea
. הסיבה לכך שמוצגים נתוני ניפוי באגים לכל דבר היא שהפעלתם את האפשרות debugMode
לכל עץ הרכיבים. אפשר גם להפעיל ניפוי באגים רק לרכיבים נבחרים, אם זה יותר שימושי.
אם תפעילו מחדש את המשחק כמה פעמים, יכול להיות שתשימו לב שהכדור לא מגיב לקירות כמו שציפיתם. כדי ליצור את האפקט הזה, צריך להוסיף זיהוי התנגשות, וזה מה שתעשו בשלב הבא.
6. קופצים ממקום למקום
הוספת זיהוי התנגשות
התכונה 'זיהוי התנגשות' מוסיפה התנהגות שבה המשחק מזהה מתי שני אובייקטים באו במגע זה עם זה.
כדי להוסיף למשחק זיהוי התנגשות, מוסיפים את ה-mixin HasCollisionDetection
למשחק BrickBreaker
, כמו שמוצג בקוד הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true;
}
}
הוא עוקב אחרי תיבות הפגיעה של הרכיבים ומפעיל קריאות חוזרות (callbacks) של התנגשות בכל טיק במשחק.
כדי להתחיל לאכלס את תיבות הפגיעה של המשחק, משנים את רכיב PlayArea
כמו שמוצג:
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
הוספת רכיב RectangleHitbox
כרכיב צאצא של RectangleComponent
תיצור תיבת פגיעה לזיהוי התנגשות שתתאים לגודל של רכיב האב. יש ב-RectangleHitbox
בנאי (constructor) שנקרא relative
, שמאפשר ליצור תיבת פגיעה קטנה או גדולה יותר מהרכיב האב.
הקפצת הכדור
עד עכשיו, הוספת זיהוי התנגשויות לא השפיעה על המשחקיות. הוא משתנה רק כשמשנים את הרכיב Ball
. ההתנהגות של הכדור היא זו שצריכה להשתנות כשהוא מתנגש בPlayArea
.
משנים את הרכיב Ball
באופן הבא.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
בדוגמה הזו נעשה שינוי משמעותי עם הוספת הקריאה החוזרת onCollisionStart
. מערכת זיהוי ההתנגשויות שנוספה ל-BrickBreaker
בדוגמה הקודמת קוראת לקריאה החוזרת הזו.
קודם, הקוד בודק אם Ball
התנגש עם PlayArea
. נראה שזה מיותר כרגע, כי אין רכיבים אחרים בעולם המשחק. הדבר ישתנה בשלב הבא, כשתוסיפו עטלף לעולם. בנוסף, הוא מוסיף תנאי else
כדי לטפל במצב שבו הכדור מתנגש בדברים שהם לא המחבט. רצינו להזכיר לך להטמיע את הלוגיקה שנותרה, אם אפשר.
כשהכדור מתנגש בקיר התחתון, הוא פשוט נעלם ממשטח המשחק, אבל עדיין אפשר לראות אותו. תטפלו בארטיפקט הזה בשלב מאוחר יותר, באמצעות האפקטים של Flame.
עכשיו, אחרי שהכדור מתנגש בקירות של המשחק, יהיה שימושי לתת לשחקן מחבט כדי לחבוט בכדור...
7. Get bat on ball
יצירת קובץ ה-BAT
כדי להוסיף מחבט כדי שהכדור יישאר במשחק,
- מוסיפים כמה קבועים לקובץ
lib/src/config.dart
באופן הבא.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
הקבועים batHeight
ו-batWidth
הם ברורים מאליהם. לעומת זאת, הקבוע batStep
דורש הסבר. כדי לבצע פעולות עם הכדור במשחק הזה, השחקן יכול לגרור את המחבט באמצעות העכבר או האצבע, בהתאם לפלטפורמה, או להשתמש במקלדת. הקבוע batStep
מגדיר את המרחק שהמחבט נע בכל לחיצה על מקש החץ שמאלה או ימינה.
- מגדירים את מחלקת הרכיב
Bat
באופן הבא.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
_paint,
);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(
MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
),
);
}
}
הרכיב הזה כולל כמה יכולות חדשות.
קודם כל, רכיב ה-Bat הוא PositionComponent
, ולא RectangleComponent
או CircleComponent
. כלומר, הקוד הזה צריך להציג את Bat
על המסך. לשם כך, הוא מבטל את הקריאה החוזרת (callback) של render
.
אם תתבוננו מקרוב בקריאה canvas.drawRRect
(draw rounded rectangle), יכול להיות שתשאלו את עצמכם: "איפה המלבן?". Offset.zero & size.toSize()
משתמש בעומס יתר של operator &
בכיתה dart:ui
Offset
שיוצרת Rect
. יכול להיות שהקיצור הזה יבלבל אתכם בהתחלה, אבל הוא מופיע לעיתים קרובות בקוד Flutter ו-Flame ברמה נמוכה יותר.
בנוסף, אפשר לגרור את הרכיב Bat
באמצעות האצבע או העכבר, בהתאם לפלטפורמה. כדי להטמיע את הפונקציונליות הזו, מוסיפים את ה-mixin DragCallbacks
ומבטלים את ברירת המחדל של האירוע onDragUpdate
.
לבסוף, רכיב Bat
צריך להגיב לשליטה במקלדת. הפונקציה moveBy
מאפשרת לקוד אחר להורות לעטלף הזה לנוע שמאלה או ימינה במספר מסוים של פיקסלים וירטואליים. הפונקציה הזו מציגה יכולת חדשה של מנוע המשחקים Flame: Effect
s. אם מוסיפים את האובייקט MoveToEffect
כצאצא של הרכיב הזה, השחקן רואה את המחבט מונפש למיקום חדש. ב-Flame יש אוסף של Effect
s שזמינים לביצוע מגוון אפקטים.
ארגומנטי ה-constructor של האפקט כוללים הפניה ל-getter game
. לכן צריך לכלול את ה-mixin HasGameReference
בכיתה הזו. ה-mixin הזה מוסיף לרכיב הזה פונקציית גישה game
בטוחה לטיפוסים כדי לגשת למופע BrickBreaker
בחלק העליון של עץ הרכיבים.
- כדי להפוך את
Bat
לזמין ב-BrickBreaker
, מעדכנים את הקובץlib/src/components/components.dart
באופן הבא.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
הוספת העטלף לעולם
כדי להוסיף את הרכיב Bat
לעולם המשחק, מעדכנים את BrickBreaker
באופן הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add( // Add from here...
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
); // To here.
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here.
}
הוספת ה-mixin KeyboardEvents
והשיטה onKeyEvent
שהוגדרה מחדש מטפלות בקלט מהמקלדת. נזכרים בקוד שהוספתם קודם כדי להזיז את המחבט בסכום המתאים של הצעדים.
החלק הנותר של הקוד שנוסף מוסיף את העטלף לעולם המשחק במיקום המתאים ובפרופורציות הנכונות. ההגדרות האלה מוצגות בקובץ, וכך קל יותר לשנות את הגודל היחסי של המחבט והכדור כדי לקבל את התחושה הנכונה במשחק.
אם משחקים בשלב הזה, אפשר להזיז את המחבט כדי ליירט את הכדור, אבל לא מקבלים תגובה נראית לעין, מלבד רישום ניפוי הבאגים שהשארתם בקוד של Ball
לזיהוי התנגשות.
הגיע הזמן לפתור את הבעיה. עורכים את הרכיב Ball
באופן הבא.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35)); // Modify from here...
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
השינויים האלה בקוד פותרים שתי בעיות נפרדות.
קודם כול, הבעיה שבה הכדור נעלם ברגע שהוא נוגע בתחתית המסך נפתרה. כדי לפתור את הבעיה, מחליפים את הקריאה removeFromParent
בקריאה RemoveEffect
. הכדור מוסר מעולם המשחק אחרי שהוא יוצא מאזור המשחק שניתן לצפייה.RemoveEffect
שנית, השינויים האלה מתקנים את הטיפול בהתנגשות בין המחבט לכדור. קוד הטיפול הזה פועל לטובת השחקן. כל עוד הנגן נוגע בכדור עם המחבט, הכדור חוזר לחלק העליון של המסך. אם אתם מרגישים שהמערכת סלחנית מדי ואתם רוצים משהו יותר ריאליסטי, אתם יכולים לשנות את הטיפול הזה כדי להתאים אותו יותר לתחושה שאתם רוצים ליצור במשחק.
חשוב לציין את המורכבות של העדכון velocity
. הוא לא רק הופך את רכיב המהירות y
, כמו במקרה של התנגשויות עם הקיר. בנוסף, הוא מעדכן את הרכיב x
באופן שתלוי במיקום היחסי של המחבט והכדור בזמן המגע. כך השחקן מקבל יותר שליטה על מה שהכדור עושה, אבל לא מוסבר לו איך בדיוק, אלא רק במהלך המשחק.
עכשיו שיש לכם מחבט שאפשר להכות איתו בכדור, יהיה נחמד להוסיף כמה לבנים שאפשר לשבור עם הכדור!
8. לשבור את החומה
יצירת הלבנים
כדי להוסיף לבנים למשחק:
- מוסיפים כמה קבועים לקובץ
lib/src/config.dart
באופן הבא.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- מוסיפים את הרכיב
Brick
באופן הבא.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
בשלב הזה, רוב הקוד הזה אמור להיות מוכר. הקוד הזה משתמש ב-RectangleComponent
, עם זיהוי התנגשות והפניה בטוחה לסוג המשחק BrickBreaker
בחלק העליון של עץ הרכיבים.
המושג החדש והחשוב ביותר שמוצג בקוד הזה הוא איך השחקן משיג את תנאי הניצחון. בדיקת תנאי הניצחון שולחת שאילתה לעולם כדי למצוא לבנים, ומוודאת שנותרה רק לבנה אחת. יכול להיות שזה קצת מבלבל, כי בשורה הקודמת הלבנה הזו הוסרה מההורה שלה.
הנקודה החשובה להבנה היא שהסרת רכיב היא פקודה שנכנסת לתור. הוא מסיר את הלבנה אחרי שהקוד הזה מורץ, אבל לפני הטיק הבא בעולם המשחק.
כדי להפוך את הרכיב Brick
לנגיש ל-BrickBreaker
, עורכים את lib/src/components/components.dart
באופן הבא.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
הוספת לבנים לעולם
מעדכנים את הרכיב Ball
באופן הבא.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
השינוי היחיד הוא תוספת של משנה קושי, שמגדיל את מהירות הכדור אחרי כל התנגשות עם לבנה. כדי למצוא את עקומת הקושי המתאימה למשחק שלכם, צריך לבדוק את הפרמטר הזה.
עורכים את המשחק BrickBreaker
באופן הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
אם מריצים את המשחק, מוצגים כל המכניקות העיקריות של המשחק. אפשר להשבית את הניפוי באגים ולסמן את המשימה כהושלמה, אבל משהו חסר.
מה דעתך על מסך פתיחה, מסך סיום משחק ואולי גם ניקוד? אפשר להוסיף את התכונות האלה למשחק באמצעות Flutter, וזה מה שתעשו בשלב הבא.
9. ניצחון במשחק
הוספת מצבי הפעלה
בשלב הזה מטמיעים את המשחק Flame בתוך עטיפת Flutter, ואז מוסיפים שכבות-על של Flutter למסכי הפתיחה, סיום המשחק והניצחון.
קודם כול, משנים את קובצי המשחק והרכיבים כדי להטמיע מצב הפעלה שמשקף אם להציג שכבת-על, ואם כן, איזו שכבת-על.
- משנים את המשחק
BrickBreaker
באופן הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
הקוד הזה משנה חלק גדול מהמשחק BrickBreaker
. הוספת המספור playState
דורשת הרבה עבודה. הנתונים האלה מתעדים את השלב שבו השחקן נמצא – כניסה למשחק, משחק, הפסד או ניצחון. בחלק העליון של הקובץ, מגדירים את הספירה ואז יוצרים ממנה מופע כמצב מוסתר עם מתודות getter ו-setter תואמות. הפונקציות האלה מאפשרות לשנות את שכבות העל כשחלקים שונים במשחק מפעילים מעברים בין מצבי הפעלה.
לאחר מכן, מפצלים את הקוד ב-onLoad
ל-onLoad ולשיטה חדשה startGame
. לפני השינוי הזה, אפשר היה להתחיל משחק חדש רק על ידי הפעלה מחדש של המשחק. עם התוספות החדשות האלה, השחקן יכול להתחיל משחק חדש בלי לנקוט באמצעים כל כך קיצוניים.
כדי לאפשר לשחקן להתחיל משחק חדש, הגדרתם שני רכיבי handler חדשים למשחק. הוספתם מטפל בהקשה והרחבתם את המטפל במקלדת כדי לאפשר למשתמש להתחיל משחק חדש בכמה אופנים. אחרי שמגדירים את מצב ההפעלה, הגיוני לעדכן את הרכיבים כדי להפעיל מעברים בין מצבי הפעלה כשהשחקן מנצח או מפסיד.
- משנים את הרכיב
Ball
באופן הבא.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(
RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
},
),
); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
השינוי הקטן הזה מוסיף onComplete
קריאה חוזרת ל-RemoveEffect
שמפעילה את מצב ההפעלה gameOver
. התחושה הזו אמורה להיות נכונה אם השחקן מאפשר לכדור לצאת מתחתית המסך.
- עורכים את הרכיב
Brick
באופן הבא.
lib/src/components/brick.dart
impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
לעומת זאת, אם השחקן מצליח לשבור את כל הלבנים, הוא יראה מסך של 'ניצחון במשחק'. כל הכבוד, שחקן!
הוספת העטיפה של Flutter
כדי לספק מקום להטמעת המשחק ולהוסיף שכבות-על של מצב המשחק, מוסיפים את Flutter shell.
- יוצרים ספרייה בשם
widgets
מתחת ל-lib/src
. - מוסיפים קובץ
game_app.dart
ומזינים בו את התוכן הבא.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style: Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
רוב התוכן בקובץ הזה מבוסס על יצירה של עץ ווידג'טים רגיל ב-Flutter. החלקים שספציפיים ל-Flame כוללים שימוש ב-GameWidget.controlled
כדי ליצור ולנהל את מופע המשחק BrickBreaker
ואת הארגומנט החדש overlayBuilderMap
לפונקציה GameWidget
.
המקשים של overlayBuilderMap
צריכים להיות זהים לשכבות העל שפונקציית ההגדרה של playState
ב-BrickBreaker
הוסיפה או הסירה. ניסיון להגדיר שכבת-על שלא נמצאת במפה הזו מוביל לפרצופים לא מרוצים בכל מקום.
- כדי להציג את הפונקציונליות החדשה הזו במסך, מחליפים את הקובץ
lib/main.dart
בתוכן הבא.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
אם מריצים את הקוד הזה ב-iOS, ב-Linux, ב-Windows או באינטרנט, הפלט המיועד מוצג במשחק. אם מטרגטים macOS או Android, צריך לבצע עוד שינוי קטן כדי להפעיל את התצוגה של google_fonts
.
הפעלת גישה לגופנים
הוספת הרשאת גישה לאינטרנט ל-Android
ב-Android, צריך להוסיף הרשאת גישה לאינטרנט. צריך לערוך את AndroidManifest.xml
באופן הבא.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
עריכה של קובצי הרשאות ל-macOS
ב-macOS, צריך לערוך שני קבצים.
- עורכים את הקובץ
DebugProfile.entitlements
כך שיתאים לקוד הבא.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- עורכים את הקובץ
Release.entitlements
כך שיתאים לקוד הבא
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
אם מריצים את הקוד כמו שהוא, אמור להופיע מסך פתיחה ומסך של סיום המשחק או ניצחון בכל הפלטפורמות. יכול להיות שהמסכים האלה פשוטים מדי, ויהיה נחמד לקבל ציון. אז נחשו מה תצטרכו לעשות בשלב הבא!
10. ניהול הניקוד
הוספת ניקוד למשחק
בשלב הזה, חושפים את הניקוד במשחק להקשר של Flutter. בשלב הזה חושפים את המצב מהמשחק Flame לניהול המצב של Flutter. ההגדרה הזו מאפשרת לקוד המשחק לעדכן את הניקוד בכל פעם שהשחקן שובר לבנה.
- משנים את המשחק
BrickBreaker
באופן הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState;
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
כשמוסיפים את score
למשחק, מצב המשחק משויך לניהול המצב של Flutter.
- משנים את המחלקה
Brick
כדי להוסיף נקודה לניקוד כשהשחקן שובר לבנים.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
איך יוצרים משחק שנראה טוב
עכשיו, כשאתם יכולים לעקוב אחרי הניקוד ב-Flutter, הגיע הזמן להרכיב את הווידג'טים כדי שהמשחק ייראה טוב.
- יוצרים את
score_card.dart
ב-lib/src/widgets
ומוסיפים את הפרטים הבאים.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({super.key, required this.score});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- יוצרים את הקובץ
overlay_screen.dart
בתיקייהlib/src/widgets
ומוסיפים את הקוד הבא.
הפעולה הזו משפרת את השכבות העליונות באמצעות חבילת flutter_animate
, כדי להוסיף תנועה וסגנון למסכי השכבות העליונות.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({super.key, required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
כדי לקבל מבט מעמיק יותר על היכולות של flutter_animate
, כדאי לעיין ב-codelab Building next generation UIs in Flutter.
הקוד הזה השתנה מאוד ברכיב GameApp
. קודם כל, כדי לאפשר ל-ScoreCard
לגשת אל score
, צריך להמיר אותו מ-StatelessWidget
ל-StatefulWidget
. כדי להוסיף את כרטיס הניקוד, צריך להוסיף Column
כדי להציג את הניקוד מעל המשחק.
בנוסף, כדי לשפר את חוויית המשתמשים כשמצטרפים למשחק, כשמפסידים בו וכשמנצחים בו, הוספתם את הווידג'ט החדש OverlayScreen
.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
אחרי שמגדירים את כל זה, אמורה להיות לכם אפשרות להריץ את המשחק הזה בכל אחת משש פלטפורמות היעד של Flutter. המשחק אמור להיראות כך.
11. מזל טוב
ברכות, הצלחת ליצור משחק באמצעות Flutter ו-Flame!
יצרת משחק באמצעות מנוע המשחק הדו-ממדי Flame והטמעת אותו במעטפת Flutter. השתמשתם באפקטים של Flame כדי להנפיש ולהסיר רכיבים. השתמשת בחבילות Google Fonts ו-Flutter Animate כדי שהמשחק כולו ייראה מעוצב היטב.
מה השלב הבא?
כדאי לעיין בכמה מה-codelabs האלה…
- פיתוח ממשקי משתמש מהדור הבא ב-Flutter
- איך משדרגים את האפליקציה שנוצרה ב-Flutter מאפליקציה משעממת לאפליקציה יפה
- הוספת רכישות מתוך האפליקציה לאפליקציית Flutter