1. قبل البدء
تخيل أن يتم سؤالك عمّا إذا كان من الممكن إنشاء أكبر لغز كلمات متقاطعة في العالم. تتذكّر بعض أساليب الذكاء الاصطناعي التي درستها في المدرسة وتتساءل عمّا إذا كان بإمكانك استخدام Flutter لاستكشاف الخيارات الخوارزمية لإيجاد حلول للمشاكل الحاسوبية الحاسوبية.
تفعل ذلك بالضبط في هذا الدرس التطبيقي حول الترميز. وفي النهاية، يمكنك إنشاء أداة لتلعبها في مجال الخوارزميات لإنشاء ألغاز شبكة الكلمات. تتوفّر تعريفات مختلفة لألغاز الكلمات المتقاطعة الصالحة، وتساعدك هذه الأساليب في إنشاء ألغاز تناسب تعريفك الخاص.
يمكنك صياغة ألغاز كلمات متقاطعة كقاعدة لها، وذلك باستخدام أداة إنشاء الكلمات المتقاطعة لإنشاء اللغز ليحلّه المستخدم. يمكن استخدام هذا اللغز على Android وiOS وWindows وmacOS وLinux. إليك هذه الميزة على Android:
المتطلبات الأساسية
- إكمال الدرس التطبيقي حول الترميز أول تطبيق Flutter
المعلومات التي تطّلع عليها
- كيفية استخدام عناصر العزل لتنفيذ إجراءات باهظة التكلفة من الناحية الحسابية بدون التأثير في حلقة عرض Flutter، وذلك من خلال الجمع بين وظيفة
compute
في Flutter وselect
لإعادة إنشاء إمكانات التخزين المؤقت للقيمة في Flutter - كيفية الاستفادة من هياكل البيانات غير القابلة للتغيير باستخدام
built_value
وbuilt_collection
لتسهيل تنفيذ تقنيات الذكاء الاصطناعي (GOFAI) المستندة إلى عمليات البحث، مثل البحث المعمّق أولاً والتتبّع العكسي - كيفية استخدام إمكانات حزمة
two_dimensional_scrollables
لعرض بيانات الشبكة بطريقة سريعة وسهلة
ما تحتاج إليه
- Flutter SDK
- Visual Studio Code (رمز VS) مع المكوّنَين الإضافيَين Flutter وDart
- برنامج التحويل البرمجي لهدف التطوير الذي اخترته يمكن استخدام هذا الدرس التطبيقي على جميع الأنظمة الأساسية لأجهزة كمبيوتر سطح المكتب وأجهزة Android وiOS. ويجب أن يكون لديك رمز VS Code لاستهداف أنظمة التشغيل Windows وXcode لاستهداف أنظمة التشغيل macOS أو iOS، كما يجب استخدام "استوديو Android" لاستهداف Android.
2. إنشاء مشروع
إنشاء مشروعك الأول على Flutter
- قم بتشغيل VS Code.
- في سطر الأوامر، أدخِل Flutter new ثم اختَر Flutter: مشروع جديد في القائمة.
- اختَر إفراغ التطبيق، ثم اختَر الدليل الذي تريد إنشاء مشروعك فيه. ويجب أن يكون هذا الدليل أي دليل لا يتطلب أذونات مميزة وعالية المستوى أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو
C:\src\
.
- أدخِل اسمًا لمشروعك "
generate_crossword
". تفترض بقية هذه الدروس التطبيقية حول الترميز أنّك أطلقت اسم التطبيق "generate_crossword
".
ينشئ Flutter الآن مجلد المشاريع ويفتحه رمز VS. ستقوم الآن بالكتابة فوق محتويات ملفين باستخدام مخزن أساسي للتطبيق.
نسخ التطبيق الأولي ولصقه
- في الجزء الأيمن من رمز VS، انقر على Explorer (المستكشف) وافتح ملف
pubspec.yaml
.
- استبدِل محتوى هذا الملف بما يلي:
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
built_collection: ^5.1.1
built_value: ^8.9.2
characters: ^1.3.0
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
intl: ^0.19.0
riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
two_dimensional_scrollables: ^0.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
build_runner: ^2.4.9
built_value_generator: ^8.9.2
custom_lint: ^0.6.4
riverpod_generator: ^2.4.0
riverpod_lint: ^2.3.10
flutter:
uses-material-design: true
يحدِّد ملف pubspec.yaml
المعلومات الأساسية عن تطبيقك، مثل إصداره الحالي وتبعياته. تظهر لك مجموعة من الموارد التابعة التي ليست جزءًا من تطبيق Flutter الفارغ العادي. يمكنك الاستفادة من كل هذه الحزم في الخطوات القادمة.
- افتح ملف
main.dart
في الدليلlib/
.
- استبدِل محتوى هذا الملف بما يلي:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text(
'Hello, World!',
style: TextStyle(fontSize: 24),
),
),
),
),
),
);
}
- شغِّل هذا الرمز للتأكّد من أنّ كل شيء يعمل على ما يرام. يجب أن يعرض نافذة جديدة بعبارة البدء الإلزامية لكل مشروع جديد في كل مكان. هناك
ProviderScope
يشير إلى أنّ هذا التطبيق سيستخدمriverpod
لإدارة الولايات.
3- إضافة كلمات
الوحدات الأساسية لألغاز الكلمات المتقاطعة
الكلمات المتقاطعة هي، في صميمها، قائمة من الكلمات. يتم ترتيب الكلمات في شبكة، بعضها متقاطع، والبعض الآخر لأسفل، بحيث تتشابك الكلمات. إن حل كلمة واحدة يعطي أدلة على الكلمات التي تتقاطع مع تلك الكلمة الأولى. وبالتالي، يجب أن تكون الوحدة الأساسية الأولى قائمة من الكلمات.
تُعدّ صفحة بيانات مجموعة اللغة الطبيعية مصدرًا جيدًا لهذه الكلمات. تشكّل قائمة SOWPODS نقطة بداية مفيدة تضم 267,750 كلمة.
في هذه الخطوة، عليك تنزيل قائمة الكلمات وإضافتها كمادة عرض إلى تطبيق Flutter، ثم ترتيب مزوِّد خدمة Riverpod لتحميل القائمة في التطبيق عند بدء تشغيله.
للبدء في ذلك، اتبع الخطوات التالية:
- عليك تعديل ملف
pubspec.yaml
الخاص بمشروعك لإضافة بيان مواد العرض التالي لقائمة الكلمات التي اختَرتها. تعرض بطاقة بيانات المتجر هذه فقط مجموعة الصور المميّزة لإعدادات تطبيقك، لأنّ بقية العناصر بقيت على حالها.
pubspec.yaml
flutter:
uses-material-design: true
assets: // Add this line
- assets/words.txt // And this one.
من المحتمل أن يقوم المحرر لديك بتمييز هذا السطر الأخير مع تحذير لأنك لم تقم بعد بإنشاء هذا الملف.
- باستخدام المتصفّح والمحرّر، أنشِئ دليل
assets
في المستوى الأعلى من مشروعك وأنشِئ ملفwords.txt
فيه باستخدام إحدى قوائم الكلمات التي تمت إضافة رابط لها أعلاه.
تم تصميم هذا الرمز باستخدام قائمة SOWPODS المذكورة أعلاه، ولكن يجب أن يعمل مع أي قائمة كلمات تتكون من أحرف A-Z فقط. يُترك تمديد قاعدة الرموز هذه للعمل مع مجموعات أحرف مختلفة كتمرين للقارئ.
تحميل الكلمات
لكتابة الرمز البرمجي المسؤول عن تحميل قائمة الكلمات عند بدء تشغيل التطبيق، اتّبِع الخطوات التالية:
- أنشِئ ملف
providers.dart
في الدليلlib
. - أضِف ما يلي إلى الملف:
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
هذا هو أول مزود Riverpod لقاعدة الرموز هذه. ستلاحظ أن هناك العديد من الجوانب التي سيشكلها المحرر الخاص بك كفئة غير محددة أو هدف لم يتم إنشاؤه. يستخدم هذا المشروع عملية إنشاء التعليمات البرمجية لتبعيات متعددة، بما في ذلك Riverpod، لذا من المتوقع حدوث أخطاء فئة غير محددة.
- لبدء إنشاء الرمز، شغِّل الأمر التالي:
$ dart run build_runner watch -d [INFO] Generating build script completed, took 174ms [INFO] Setting up file watchers completed, took 5ms [INFO] Waiting for all file watchers to be ready completed, took 202ms [INFO] Reading cached asset graph completed, took 65ms [INFO] Checking for updates since last build completed, took 680ms [INFO] Running build completed, took 2.3s [INFO] Caching finalized dependency graph completed, took 42ms [INFO] Succeeded after 2.3s with 122 outputs (243 actions)
وسيستمر تشغيله في الخلفية، مع تحديث الملفات التي تم إنشاؤها أثناء إجراء تغييرات على المشروع. بعد إنشاء هذا الأمر للرمز في providers.g.dart
، من المفترض أن يكون المحرر الذي تتعامل معه موافقًا على الرمز الذي أضفته إلى providers.dart
أعلاه.
في Riverpod، يتم عادةً إنشاء مثيل كسول لمقدّمي الخدمات، مثل الدالة wordList
التي حدّدتها أعلاه. ومع ذلك، لأغراض هذا التطبيق، سيلزم تحميل قائمة الكلمات بحرص. تقترح مستندات Riverpod الأسلوب التالي للتعامل مع مقدّمي الخدمات الذين تحتاج إلى تحميلهم بحزم. ستنفذ ذلك الآن.
- أنشِئ ملف
crossword_generator_app.dart
في دليلlib/widgets
. - أضِف ما يلي إلى الملف:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer(
builder: (context, ref, _) {
final wordListAsync = ref.watch(wordListProvider);
return wordListAsync.when(
data: (wordList) => ListView.builder(
itemCount: wordList.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(wordList.elementAt(index)),
);
},
),
error: (error, stackTrace) => Center(
child: Text('$error'),
),
loading: () => Center(
child: CircularProgressIndicator(),
),
);
},
),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
هذا الملف مثير للاهتمام من اتجاهين منفصلين. الأولى هي التطبيق المصغّر _EagerInitialization
، ومهمته الوحيدة هي أن تطلب من موفِّر خدمة wordList
الذي أنشأته أعلاه تحميل قائمة الكلمات. تحقّق هذه الأداة هذا الهدف من خلال الاستماع إلى مقدّم الخدمة باستخدام طلب ref.watch()
. يمكنك قراءة المزيد من المعلومات عن هذا الأسلوب في مستندات Riverpod حول الإعداد السريع لمقدّمي الخدمات.
تجدر الإشارة إلى أن النقطة الثانية المثيرة للاهتمام في هذا الملف هي كيفية تعامل Riverpod مع المحتوى غير المتزامن. كما هو موضح، يتم تعريف موفِّر wordList
على أنّه دالة غير متزامنة، لأنّ تحميل المحتوى من القرص بطيء. عند مشاهدة موفِّر قائمة الكلمات في هذا الرمز، ستتلقّى AsyncValue<BuiltSet<String>>
. يُعد الجزء AsyncValue
من هذا النوع محوّلاً بين العالم غير المتزامن لموفري الخدمات والعالم المتزامن لطريقة build
في الأداة.
تعالج طريقة when
في AsyncValue
الحالات الثلاث المحتمَلة التي قد تكون فيها القيمة المستقبلية. ربما تم حلّ المسألة المستقبلية بنجاح، وفي هذه الحالة تم استدعاء استدعاء data
، قد تكون هناك حالة خطأ، وفي هذه الحالة تم استدعاء معاودة الاتصال error
، أو قد يكون التحميل جاريًا في النهاية. يجب أن تحتوي أنواع الإرجاع للاستدعاءات الثلاثة على أنواع إرجاع متوافقة، حيث يتم إرجاع ما يسمى معاودة الاتصال باستخدام الطريقة when
. في هذه الحالة، يتم عرض نتيجة الطريقة when على أنّها body
للأداة Scaffold
.
إنشاء تطبيق قائمة شبه لانهائية
لدمج تطبيق "CrosswordGeneratorApp
" المصغّر في تطبيقك، يُرجى اتّباع الخطوات التالية:
- عدِّل ملف
lib/main.dart
من خلال إضافة الرمز التالي:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- أعِد تشغيل التطبيق. من المفترض أن تظهر لك قائمة تمرير ستظل متاحة إلى الأبد تقريبًا.
4. عرض الكلمات في شبكة
في هذه الخطوة، ستُنشئ بنية بيانات لإنشاء ألغاز كلمات متقاطعة باستخدام الحِزمتَين built_value
وbuilt_collection
. تمكن هاتان الباقتان إنشاء هياكل البيانات كقيم غير قابلة للتغيير، والتي ستكون مفيدة لكل من تمرير البيانات بسهولة بين المعزولات، وتسهيل تنفيذ البحث المتعمق أولاً والتتبع العكسي كثيرًا.
للبدء في ذلك، اتبع الخطوات التالية:
- أنشِئ ملف
model.dart
في دليلlib
، ثم أضِف المحتوى التالي إلى الملف:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) { 0 => compareRows, _ => compareColumns };
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord((b) => b
..word = word
..direction = direction
..location.replace(location));
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter(
[void Function(CrosswordCharacterBuilder)? updates]) =
_$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
])
final Serializers serializers = _$serializers;
يصف هذا الملف بداية هيكل البيانات التي ستستخدمها لإنشاء الكلمات المتقاطعة. في جوهره، أحجية الكلمات المتقاطعة هي قائمة من الكلمات الأفقية والرأسية المتشابكة في شبكة. لاستخدام بنية البيانات هذه، عليك إنشاء Crossword
بالحجم المناسب باستخدام الدالة الإنشائية Crossword.crossword
المُسماة، ثم إضافة كلمات باستخدام الطريقة addWord
. كجزء من إنشاء القيمة النهائية، يتم إنشاء شبكة مكوّنة من CrosswordCharacter
باستخدام الطريقة _fillCharacters
.
لاستخدام هيكل البيانات هذا، اتبع الخطوات التالية:
- أنشِئ ملف
utils
في دليلlib
، ثم أضِف المحتوى التالي إلى الملف:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
هذه إضافة على BuiltSet
تتيح إمكانية استرداد عنصر عشوائي من المجموعة بسهولة. تسهِّل طرق الإضافات توسيع الصفوف باستخدام وظائف إضافية. يجب تسمية الامتداد لإتاحة الإضافة خارج ملف utils.dart
.
- في ملف
lib/providers.dart
، أضِف عمليات الاستيراد التالية:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
تعرض عمليات الاستيراد هذه النموذج المحدّد أعلاه لموفّري الخدمات الذين أنت على وشك إنشاءهم. يتم تضمين استيراد dart:math
لـ Random
، بينما يتم تضمين flutter/foundation.dart
للاستيراد لـ debugPrint
، وmodel.dart
للنموذج، وutils.dart
للإضافة BuiltSet
.
- في نهاية الملف نفسه، أضِف الموفّرين التاليين:
lib/providers.dart
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
crossword = crossword.addWord(
word: word, direction: direction, location: location);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
تؤدي هذه التغييرات إلى إضافة اثنين من مقدّمي الخدمة إلى تطبيقك. الأول هو Size
، وهو متغير عمومي يحتوي على القيمة المحددة حاليًا للتعداد CrosswordSize
. سيسمح ذلك لواجهة المستخدم بعرض وتعيين حجم الكلمات المتقاطعة قيد الإنشاء. مقدم الخدمة الثاني، crossword
، هو إنشاء أكثر تشويقًا. وهي دالة تعرض سلسلة من Crossword
. وقد بنيت باستخدام دعم Dart للمولدات، كما هو محدد في async*
في الدالة. وهذا يعني أنّه بدلاً من الإنهاء بعائد، ينتج عن سلسلة من Crossword
، وهي طريقة أسهل بكثير لكتابة عملية حسابية تعرض نتائج متوسطة.
بسبب توفُّر زوج من مكالمتَي ref.watch
في بداية وظيفة مزوِّد crossword
، سيعيد نظام Riverpod بث Crossword
في كل مرة يتغيّر فيها الحجم المحدَّد من الكلمات المتقاطعة وعند انتهاء تحميل قائمة الكلمات.
الآن بعد أن أصبح لديك تعليمة برمجية لإنشاء كلمات متقاطعة، وإن كانت مليئة بالكلمات العشوائية، فسيكون من الجيد عرضها لمستخدم الأداة.
- أنشئ ملف
crossword_widget.dart
في الدليلlib/widgets
بالمحتوى التالي:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
crosswordProvider.select(
(crosswordAsync) => crosswordAsync.when(
data: (crossword) => crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
if (character != null) {
return Container(
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: Text(
character.character,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
بما أنّ هذه الأداة ConsumerWidget
، يمكنها الاعتماد مباشرةً على موفِّر خدمة Size
لتحديد حجم الشبكة التي سيتم عرض أحرف Crossword
عليها. يتم عرض هذه الشبكة باستخدام التطبيق المصغّر TableView
من حزمة two_dimensional_scrollables
.
يُرجى العِلم أنّ الخلايا الفردية التي تعرضها الدوال المساعدة _buildCell
تحتوي على كل تطبيق مصغّر Consumer
في شجرة Widget
المعروضة. ويكون هذا بمثابة حدود تحديث. تتم إعادة إنشاء كل المحتوى داخل التطبيق المصغّر Consumer
عند تغيُّر قيمة ref.watch
المعروضة. من المُغري إعادة إنشاء الشجرة بأكملها في كل مرة تتغيّر فيها Crossword
، إلا أنّها تتسبب في حدوث الكثير من العمليات الحسابية التي يمكن تخطّيها باستخدام هذا الإعداد.
إذا نظرت إلى معلَمة ref.watch
، ستلاحظ أنّ هناك طبقة أخرى يمكنك تجنّب إعادة حساب التنسيقات، وذلك باستخدام crosswordProvider.select
. يعني هذا أنّ ref.watch
لن يؤدي إلى إعادة إنشاء محتوى TableViewCell
إلا عندما تكون الخلية مسؤولة عن عرض التغييرات. ويشكّل هذا التقليل في إعادة العرض جزءًا أساسيًا من سرعة استجابة واجهة المستخدم.
لعرض موفِّري CrosswordWidget
وSize
للمستخدم، يمكنك تغيير ملف crossword_generator_app.dart
على النحو التالي:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: CrosswordWidget(), // Replaces everything that was here before
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
تغيّرت بعض الأمور هنا. أولاً، تم استبدال الرمز المسؤول عن عرض wordList
على أنّه ListView
بطلب CrosswordWidget
المحدَّد في الملف السابق. التغيير الرئيسي الآخر هو بدء قائمة لتغيير سلوك التطبيق، بدءًا من تغيير حجم الكلمات المتقاطعة. ستتم إضافة المزيد من MenuItemButton
في الخطوات المستقبلية. شغِّل تطبيقك، وسترى شيئًا مثل هذا:
هناك أحرف معروضة في شبكة وقائمة تمكّن المستخدم من تغيير حجم الشبكة. ولكن لا ترسم الكلمات على شكل أحجية كلمات متقاطعة. ويرجع ذلك إلى عدم فرض أي قيود على كيفية إضافة الكلمات إلى الكلمات المتقاطعة. باختصار، هناك فوضى. شيء ستبدأ في السيطرة عليه في الخطوة التالية!
5- فرض قيود
إن إضافة التعليمات البرمجية إلى النموذج لفرض قيود الكلمات المتقاطعة هي الهدف من هذه الخطوة. هناك أنواع مختلفة من ألغاز الكلمات المتقاطعة، ويعتمد هذا الدرس التطبيقي على تقاليد ألغاز الكلمات المتقاطعة الإنجليزية. واعلم أنّ تعديل هذا الرمز البرمجي لإنشاء أنماط أخرى من ألغاز الكلمات المتقاطعة هو تمرين للقارئ.
للبدء في ذلك، اتبع الخطوات التالية:
- افتح ملف
model.dart
واستبدِل نموذجCrossword
بما يلي:
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
كتذكير سريع، تتطلب التغييرات التي تجريها على ملفَي model.dart
وproviders.dart
تفعيل build_runner
لتعديل ملف model.g.dart
وproviders.g.dart
المعنيَّين. إذا لم يتم تعديل هذه الملفات تلقائيًا، حان الوقت لبدء "build_runner
" مجددًا باستخدام "dart run build_runner watch -d
".
للاستفادة من هذه الإمكانية الجديدة في طبقة النموذج، ستحتاج إلى تحديث طبقة الموفِّر للمطابقة.
- عدِّل ملف
providers.dart
على النحو التالي:
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- شغِّل تطبيقك. لا يحدث الكثير في واجهة المستخدم، ولكن سيحدث الكثير عند النظر إلى السجلات.
إذا فكرت فيما يحدث هنا، فإننا نرى كلمة متقاطعة تظهر عن طريق الصدفة العشوائية. ترفض طريقة addWord
في النموذج Crossword
أي كلمة مقترحة لا تتلاءم مع الكلمات المتقاطعة الحالية، لذا من المدهش أن نرى أي شيء يظهر على الإطلاق.
استعدادًا لمراعاة اختيار الكلمات التي يجب تجربتها، سيكون من المفيد جدًا نقل هذه العملية الحسابية خارج سلسلة واجهة المستخدم إلى وحدة عزل في الخلفية. يتضمّن Flutter برنامج تضمين مفيدًا جدًا لتنفيذ قدر من العمل وتنفيذه في عزل الخلفية، وهو عبارة عن الوظيفة compute
.
- في ملف
providers.dart
، عدِّل موفِّر الكلمات المتقاطعة على النحو التالي:
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
try {
var candidate = await compute( // Edit from here.
((String, model.Direction, model.Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word, direction: direction, location: location);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
هذا الرمز يعمل. لكنه يحتوي على فخ. إذا بقيت على هذا المسار، فسينتهي بك الأمر إلى ظهور خطأ مسجَّل مثل ما يلي:
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
يحدث هذا نتيجة إغلاق التطبيق compute
الذي يتم تسليمه إلى ميزة إغلاق عزل الخلفية لدى موفِّر، ولا يمكن إرساله عبر SendPort.send()
. لحلّ هذه المشكلة، عليك التأكّد من أنّه لا يمكن إرسال إغلاق المرآب.
الخطوة الأولى هي فصل المزودين عن رمز العزل.
- أنشئ ملف
isolates.dart
في دليلlib
، ثم أضِف المحتوى التالي إليه:
lib/isolates.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
final _random = Random();
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
while (
crossword.characters.length < crossword.width * crossword.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool() ? Direction.across : Direction.down;
final location = Location.at(
_random.nextInt(crossword.width), _random.nextInt(crossword.height));
try {
var candidate = await compute(((String, Direction, Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word, direction: direction, location: location);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
}
يجب أن يبدو هذا الرمز مألوفًا بشكل معقول. وهو جوهر ما كان في مزوِّد crossword
، ولكنّه أصبح الآن دالة إنشاء مستقلّة. يمكنك الآن تعديل ملف providers.dart
لاستخدام هذه الوظيفة الجديدة لإنشاء مثيل عزل الخلفية.
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = // Edit from here
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
تتيح لك هذه الأداة إنشاء ألغاز الكلمات المتقاطعة بأحجام مختلفة، بالإضافة إلى حلّ الألغاز في الخلفية من خلال ميزة "compute
". والآن، أتمنى لو كان الرمز أكثر كفاءة عند تحديد الكلمات التي تريد إضافتها إلى أحجية الكلمات المتقاطعة.
6- إدارة قائمة انتظار العمل
يتمثل جزء من المشكلة في التعليمة البرمجية كما هي في أن المشكلة التي يتم حلها هي مشكلة البحث بشكل فعال، والحل الحالي هو البحث المكفوفين. إذا كان الرمز يركز على العثور على الكلمات التي سوف تُلحق بالكلمات الحالية، بدلاً من محاولة وضع الكلمات عشوائيًا في أي مكان على الشبكة، فسيجد النظام الحلول بشكل أسرع. هناك طريقة للتعامل مع هذا الأمر تتمثل في تقديم قائمة انتظار للعمل تضمّ المواقع الجغرافية لمحاولة العثور على كلمات لها.
ينشئ الكود حاليًا حلولاً مرشحة، ويتحقق مما إذا كان الحل المرشح صالحًا، ويعتمد الاعتماد على مدى الصلاحية على دمج المرشح أو التخلص منه. هذا مثال على التنفيذ من مجموعة الخوارزميات العكسية. سهّلت built_value
وbuilt_collection
عملية التنفيذ هذه كثيرًا، ما يتيح إنشاء قيم جديدة غير قابلة للتغيير تستنتج وبالتالي تشترك في الحالة المشتركة مع القيمة غير القابلة للتغيير التي تم اشتقاقها منها. ويتيح ذلك الاستفادة بتكلفة منخفضة من المرشحين المحتملين بدون تكاليف الذاكرة المطلوبة للنسخ العميق.
للبدء في ذلك، اتبع الخطوات التالية:
- افتح ملف
model.dart
وأضِف إليه تعريفWorkQueue
التالي:
lib/model.dart
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;
/// The crossword the worker is working on.
Crossword get crossword;
/// The outstanding queue of locations to try.
BuiltMap<Location, Direction> get locationsToTry;
/// Known bad locations.
BuiltSet<Location> get badLocations;
/// The list of unused candidate words that can be added to this crossword.
BuiltSet<String> get candidateWords;
/// Returns true if the work queue is complete.
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;
/// Create a work queue from a crossword.
static WorkQueue from({
required Crossword crossword,
required Iterable<String> candidateWords,
required Location startLocation,
}) =>
WorkQueue((b) {
if (crossword.words.isEmpty) {
// Strip candidate words too long to fit in the crossword
b.candidateWords.addAll(candidateWords
.where((word) => word.characters.length <= crossword.width));
b.crossword.replace(crossword);
b.locationsToTry.addAll({startLocation: Direction.across});
} else {
// Assuming words have already been stripped to length
b.candidateWords.addAll(
candidateWords.toBuiltSet().rebuild(
(b) => b.removeAll(crossword.words.map((word) => word.word))),
);
b.crossword.replace(crossword);
crossword.characters
.rebuild((b) => b.removeWhere((location, character) {
if (character.acrossWord != null &&
character.downWord != null) {
return true;
}
final left = crossword.characters[location.left];
if (left != null && left.downWord != null) return true;
final right = crossword.characters[location.right];
if (right != null && right.downWord != null) return true;
final up = crossword.characters[location.up];
if (up != null && up.acrossWord != null) return true;
final down = crossword.characters[location.down];
if (down != null && down.acrossWord != null) return true;
return false;
}))
.forEach((location, character) {
b.locationsToTry.addAll({
location: switch ((character.acrossWord, character.downWord)) {
(null, null) =>
throw StateError('Character is not part of a word'),
(null, _) => Direction.across,
(_, null) => Direction.down,
(_, _) => throw StateError('Character is part of two words'),
}
});
});
}
});
WorkQueue remove(Location location) => rebuild((b) => b
..locationsToTry.remove(location)
..badLocations.add(location));
/// Update the work queue from a crossword derived from the current crossword
/// that this work queue is built from.
WorkQueue updateFrom(final Crossword crossword) => WorkQueue.from(
crossword: crossword,
candidateWords: candidateWords,
startLocation: locationsToTry.isNotEmpty
? locationsToTry.keys.first
: Location.at(0, 0),
).rebuild((b) => b
..badLocations.addAll(badLocations)
..locationsToTry
.removeWhere((location, _) => badLocations.contains(location)));
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue, // Add this line
])
final Serializers serializers = _$serializers;
- إذا كانت هناك خطوط مموجة حمراء متبقية في هذا الملف بعد إضافة هذا المحتوى الجديد لأكثر من بضع ثوانٍ، يُرجى التأكد من أنّ "
build_runner
" لا يزال قيد التشغيل. وإذا لم يكن الأمر كذلك، شغِّل الأمرdart run build_runner watch -d
.
أنت على وشك إدخال التسجيل في التعليمة البرمجية لإظهار المدة التي يستغرقها إنشاء الكلمات المتقاطعة بأحجام مختلفة. سيكون من الرائع أن يكون للمدد شكل ما من أشكال العرض المنسقة بشكل جيد. لحسن الحظ، باستخدام طرق التمديد يمكننا إضافة الطريقة الدقيقة التي نحتاجها.
- عدِّل ملف
utils.dart
على النحو التالي:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
// Add from here
/// An extension on [Duration] that adds a method to format the duration.
extension DurationFormat on Duration {
/// A human-readable string representation of the duration.
/// This format is tuned for durations in the seconds to days range.
String get formatted {
final hours = inHours.remainder(24).toString().padLeft(2, '0');
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
return switch ((inDays, inHours, inMinutes, inSeconds)) {
(0, 0, 0, _) => '${inSeconds}s',
(0, 0, _, _) => '$inMinutes:$seconds',
(0, _, _, _) => '$inHours:$minutes:$seconds',
_ => '$inDays days, $hours:$minutes:$seconds',
};
}
} // To here.
تستفيد طريقة الإضافة هذه من تعبيرات التبديل ومطابقة الأنماط عبر السجلات لتحديد الطريقة المناسبة لعرض فترات مختلفة تتراوح من ثوانٍ إلى أيام. لمزيد من المعلومات حول هذا النمط من الرموز، يمكنك الاطّلاع على الدرس التطبيقي حول الترميز الاطّلاع على أنماط وسجلات Dart.
- لدمج هذه الوظيفة الجديدة، استبدِل الملف
isolates.dart
لإعادة تحديد طريقة تعريف الدالةexploreCrosswordSolutions
على النحو التالي:
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword);
yield crossword;
} else {
workQueue = workQueue.remove(location);
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
سيؤدي تشغيل هذا الرمز إلى إنشاء تطبيق يبدو متطابقًا في السطح، لكن الاختلاف هو المدة التي يستغرقها العثور على لغز كلمات متقاطعة منتهية. أنشئ في دقيقة واحدة و29 ثانية لعبة ألغاز كلمات متقاطعة بحجم 80 × 44.
والسؤال الواضح هو بالطبع، هل يمكننا التحرك بشكل أسرع؟ أوه نعم، نعم يمكننا ذلك.
7. عرض الإحصاءات
عند إضفاء السرعة على شيء ما، من المفيد معرفة ما يحدث. الشيء الوحيد الذي يساعد في ذلك هو عرض معلومات حول العملية أثناء تنفيذها. لذا، حان الوقت الآن لإضافة الأدوات وعرض تلك المعلومات على شكل لوحة معلومات قابلة للتحريك.
يجب استخراج المعلومات التي ستعرضها من Workقائمة الانتظار وعرضها في واجهة المستخدم.
تتمثل الخطوة الأولى المفيدة في تحديد فئة نموذج جديدة تحتوي على المعلومات التي تريد عرضها.
للبدء في ذلك، اتبع الخطوات التالية:
- عدِّل ملف
model.dart
على النحو التالي لإضافة الفئةDisplayInfo
:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
import 'package:intl/intl.dart'; // Add this import
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
- في نهاية الملف، أدخِل التغييرات التالية لإضافة الفئة
DisplayInfo
:
lib/model.dart
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
}
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;
/// The number of words in the grid.
String get wordsInGridCount;
/// The number of candidate words.
String get candidateWordsCount;
/// The number of locations to explore.
String get locationsToExploreCount;
/// The number of known bad locations.
String get knownBadLocationsCount;
/// The percentage of the grid filled.
String get gridFilledPercentage;
/// Construct a [DisplayInfo] instance from a [WorkQueue].
factory DisplayInfo.from({required WorkQueue workQueue}) {
final gridFilled = (workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height));
final fmt = NumberFormat.decimalPattern();
return DisplayInfo((b) => b
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%');
}
/// An empty [DisplayInfo] instance.
static DisplayInfo get empty => DisplayInfo((b) => b
..wordsInGridCount = '0'
..candidateWordsCount = '0'
..locationsToExploreCount = '0'
..knownBadLocationsCount = '0'
..gridFilledPercentage = '0%');
factory DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
_$DisplayInfo;
DisplayInfo._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo, // Add this line.
])
final Serializers serializers = _$serializers;
- عدِّل الملف
isolates.dart
لعرض نموذجWorkQueue
على النحو التالي:
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({ // Modify this line
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword); // Drop the yield crossword;
} else {
workQueue = workQueue.remove(location);
}
yield workQueue; // Add this line.
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
والآن بعد أن كشفت أنظمة عزل الخلفية عن قائمة انتظار العمل، أصبح الأمر الآن يتساءل عن كيفية استخلاص الإحصاءات من مصدر البيانات هذا ومكان استخلاصها.
- استبدِل موفِّر الكلمات المتقاطعة القديم بموفِّر قائمة انتظار العمل، ثم أضِف المزيد من الموفِّرين الذين يستمدون المعلومات من ساحة المشاركات لموفِّر قائمة انتظار العمل:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(ExpectedRemainingTimeRef ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage));
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
المزودون الجدد عبارة عن مزيج من الحالة العامة، في شكل ما إذا كان يجب وضع عرض المعلومات فوق شبكة الكلمات المتقاطعة، والبيانات المستمدة مثل وقت تشغيل إنشاء الكلمات المتقاطعة. وكل هذا معقّد لأنّ مستمعي بعض هذه الحالة عابرون. لا شيء يستمع إلى وقت بدء وانتهاء حساب الكلمات المتقاطعة إذا كانت المعلومات المعروضة مخفيّة، ولكن يجب أن تبقى هذه المعلومات في الذاكرة إذا كانت العمليات الحسابية دقيقة عند عرض المعلومات. تعتبر المعلمة keepAlive
للسمة Riverpod
مفيدة جدًا في هذه الحالة.
هناك تجعّد طفيف في الشاشة أثناء عرض المعلومات. نريد القدرة على إظهار وقت التشغيل المنقضي حاليًا، ولكن لا يوجد شيء هنا لفرض التحديث المستمر للوقت المنقضي حاليًا بسهولة. بالعودة إلى الدرس التطبيقي حول إنشاء واجهات المستخدم من الجيل التالي في Flutter، إليك أداة مفيدة لتحقيق هذا الشرط فقط.
- أنشِئ ملف
ticker_builder.dart
في دليلlib/widgets
، ثم أضِف المحتوى التالي إليه:
lib/widgets/ticker_builder.dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// A Builder widget that invokes its [builder] function on every animation frame.
class TickerBuilder extends StatefulWidget {
const TickerBuilder({super.key, required this.builder});
final Widget Function(BuildContext context) builder;
@override
State<TickerBuilder> createState() => _TickerBuilderState();
}
class _TickerBuilderState extends State<TickerBuilder>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
@override
void initState() {
super.initState();
_ticker = createTicker(_handleTick)..start();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
void _handleTick(Duration elapsed) {
setState(() {
// Force a rebuild without changing the widget tree.
});
}
@override
Widget build(BuildContext context) => widget.builder.call(context);
}
هذه الأداة عبارة عن مطرقة ثقيلة. يعيد إنشاء المحتوى في كل إطار. وهذا أمر استياء عمومًا، ولكن مقارنةً بالحمل الحسابية للبحث عن ألغاز الكلمات المتقاطعة، من المحتمل أن يختفي الحِمل الحاسوبي لإعادة طلاء الوقت المنقضي في كل إطار بدون أي تشويش. للاستفادة من هذه المعلومات الناتجة حديثًا، حان الوقت لإنشاء أداة جديدة.
- أنشئ ملف
crossword_info_widget.dart
في دليلlib/widgets
، ثم أضِف المحتوى التالي إليه:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
right: 32.0,
bottom: 32.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.primary),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}'),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining', value: remaining.formatted),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(
text: '$label ',
style: DefaultTextStyle.of(context).style,
),
TextSpan(
text: value,
style: DefaultTextStyle.of(context)
.style
.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
هذا التطبيق المصغّر هو مثال رئيسي على مزايا موفّري Riverpod. سيتم وضع علامة على هذه الأداة لإعادة إنشائها عند تحديث أي من مزودي الخدمة الخمسة. التغيير الأخير المطلوب في هذه الخطوة هو دمج هذه الأداة الجديدة في واجهة المستخدم.
- عدِّل ملف
crossword_generator_app.dart
على النحو التالي:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(
child: CrosswordWidget(),
),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menu Children: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () =>
ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
يوضح التغييران المذكوران هنا طرقًا مختلفة لدمج مقدّمي الخدمة. في طريقة build
في CrosswordGeneratorApp
، قدّمت أداة إنشاء Consumer
جديدة لاحتواء المنطقة المفروضة على إعادة البناء عند عرض المعلومات أو إخفائها. من ناحية أخرى، القائمة المنسدلة بأكملها هي ConsumerWidget
واحدة، وستتم إعادة تصميمها سواء تمّ تغيير حجم الكلمات المتقاطعة أو إظهار المعلومات أو إخفاؤها. والأسلوب الذي يجب اتخاذه دائمًا هو المقايضة الهندسية بين البساطة وتكلفة إعادة حساب التنسيقات لأشجار الأدوات التي أُعيد تصميمها.
يمنح تشغيل التطبيق الآن المستخدم المزيد من الرؤى حول كيفية تقدم إنشاء الكلمات المتقاطعة. ومع ذلك، اقتربنا من نهاية جيل الكلمات المتقاطعة نرى أن هناك فترة تتغير فيها الأرقام، لكن هناك تغيُّر بسيط جدًا في شبكة الأحرف.
سيكون من المفيد الحصول على إحصاءات إضافية حول ما يحدث وسبب ذلك.
8. التوازي مع سلاسل المحادثات
لفهم سبب بطء الأمور في النهاية، من المفيد أن تكون قادرًا على تصور ما تفعله الخوارزمية. وتشكّل locationsToTry
المعلّقة في WorkQueue
بشكل أساسي. يمنحنا TableView طريقة مفيدة للتحقيق في هذا الأمر. يمكننا تغيير لون الخلية بناءً على ما إذا كانت في locationsToTry
.
للبدء في ذلك، اتبع الخطوات التالية:
- عدِّل ملف
crossword_widget.dart
على النحو التالي:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
عند تشغيل هذه التعليمة البرمجية، سترى تصورًا للمواقع المعلقة التي لم تتحقق الخوارزمية بعد.
الشيء المثير للاهتمام في ملاحظة هذا أثناء تقدم الكلمات المتقاطعة نحو الانتهاء هو أن هناك مجموعة من النقاط المتبقية للتحقيق فيها ولن تؤدي إلى أي شيء مفيد. هناك خياران هنا؛ أحدهما هو تقييد التحقيق بمجرد ملء نسبة معينة من خلايا الكلمات المتقاطعة والثاني هو التحقيق في نقاط اهتمام متعددة في وقت واحد. يبدو المسار الثاني أكثر متعة، لذلك دعونا نفعل ذلك.
- عدِّل ملف
isolates.dart
. وهذا إجراء إعادة كتابة كاملة للرمز البرمجي لتقسيم ما كان يتم حسابه في خلفية واحدة منفصلة إلى مجموعة من عناصر عزل الخلفية.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.');
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild((b) => b
..shuffle()
..take(maxWorkerCount));
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(compute(_generateCandidate,
(workQueue.crossword, workQueue.candidateWords, location, direction)));
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location, word: word, direction: direction);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
ومن المفترض أن يكون معظم هذا الرمز مألوفًا لأن منطق العمل الأساسي لم يتغير. ما تم تغييره هو توفُّر طبقتين من طلبات "compute
" الآن. تكون الطبقة الأولى مسؤولة عن توزيع المواضع الفردية للبحث عن عزل العمال N، ثم إعادة دمج النتائج عند إنهاء جميع عمليات عزل العمال N. تتكون الطبقة الثانية من عزل العامل N. يعتمد ضبط N للحصول على أفضل أداء على كل من جهاز الكمبيوتر والبيانات المعنية. كلما زادت الشبكة، زاد عدد العمال الذين يمكنهم العمل معًا دون اعتراض بعضهم البعض.
يتمثل أحد التجاعيد المثيرة للاهتمام في ملاحظة كيفية تعامل هذه التعليمات البرمجية الآن مع مشكلة عمليات الإغلاق التي تسجل الأشياء التي لا ينبغي التقاطها. ما من حالات إغلاق في الوقت الحالي. يتم تعريف الدالتَين _generate
و_generateWorker
كدالتَين من المستوى الأعلى لا تتوفّر فيهما بيئة محيطة يمكن التعرّف عليها. تكون الوسيطات في ونتائج كلتا الدالتين في شكل سجلات Dart. هذه طريقة سهلة لإيجاد قيمة واحدة في دلالات قيمة واحدة من استدعاء الدالة compute
.
والآن بعد أن أصبح لديك القدرة على إنشاء مجموعة من العاملين في الخلفية للبحث عن الكلمات التي تتشابك في شبكة لتكوين أحجية كلمات متقاطعة، حان الوقت لتعرض هذه القدرة لبقية أداة إنشاء الكلمات المتقاطعة.
- يمكنك تعديل ملف
providers.dart
من خلال تعديل موفِّر Workplaylist على النحو التالي:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- أضِف موفِّر "
WorkerCount
" إلى نهاية الملف على النحو التالي:
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
من خلال هذين التغييرين، تكشف طبقة الموفّر الآن عن طريقة لضبط الحد الأقصى لعدد العاملين في مجموعة عزل الخلفية بطريقة يتم بها ضبط وظائف العزل بشكل صحيح.
- عدِّل ملف
crossword_info_widget.dart
من خلال تعديلCrosswordInfoWidget
على النحو التالي:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final workerCount = ref.watch(workerCountProvider).label; // Add this line
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
right: 32.0,
bottom: 32.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.primary),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}'),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage),
_CrosswordInfoRichText( // Add these two lines
label: 'Max worker count', value: workerCount),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining', value: remaining.formatted),
],
),
),
),
),
),
),
);
}
}
- عدِّل الملف
crossword_generator_app.dart
من خلال إضافة القسم التالي إلى تطبيق_CrosswordGeneratorMenu
المصغّر:
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () =>
ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
في حال تشغيل التطبيق الآن، ستتمكّن من تعديل عدد عمليات عزل الخلفية التي يتم إنشاء مثيل لها للبحث عن كلمات لإضافتها إلى الكلمات المتقاطعة.
- انقر على رمز الترس في القائمة السياقية التي تحتوي على مقاسات الكلمات المتقاطعة، وما إذا كان سيتم عرض إحصاءات حول الكلمات المتقاطعة التي تم إنشاؤها حاليًا، والآن، عدد العناصر المعزولة المطلوب استخدامها.
أدى تشغيل أداة إنشاء الكلمات المتقاطعة إلى انخفاض كبير في وقت الحوسبة لكلمة متقاطعة بحجم 80x44 بفضل استخدام نوى متعددة في الوقت نفسه.
9. تحويل اللعبة إلى لعبة
هذا القسم الأخير هو جولة إضافية حقًا. وستأخذ كل الأساليب التي تعلمتها أثناء إنشاء منشئ الكلمات المتقاطعة وتستخدم هذه التقنيات لبناء لعبة. ستستخدم أداة إنشاء الكلمات المتقاطعة لإنشاء لغز كلمات متقاطعة. ستقوم بإعادة استخدام عبارات القائمة السياقية لتمكين المستخدم من تحديد الكلمات وإلغاء تحديدها لوضعها في الثقوب المختلفة على شكل كلمات في الشبكة. كل ذلك بهدف إكمال الكلمات المتقاطعة.
لن أقول إن هذه اللعبة مصقولة أو منتهية، بل شيء أبعد ما يكون في الواقع. هناك مشكلات في التوازن والصعوبة يمكن حلها من خلال تحسين اختيار الكلمات البديلة. لا يوجد برنامج تعليمي لتوجيه المستخدمين إليها، كما أن الرسوم المتحركة المفكرة تترك الكثير من الأمور غير المرغوب فيها. لن أذكر عبارة "لقد فزت" الشاشة.
والمفاضلة هنا هي أن صقل هذه اللعبة الأولية بشكل صحيح إلى لعبة كاملة يتطلب المزيد من الرموز. يجب توفير رموز برمجية أكثر من تلك المطلوبة في درس تطبيقي واحد حول الترميز. وبدلاً من ذلك، تهدف هذه الخطوة إلى تعزيز الأساليب التي تم تعلّمها حتى الآن في هذا الدرس التطبيقي حول الترميز من خلال تغيير مكان وكيفية استخدامها. نأمل أن يعزز ذلك الدروس المستفادة سابقًا في هذا الدرس التطبيقي حول الترميز. بدلاً من ذلك، يمكنك المتابعة وإنشاء تجاربك الخاصة بناءً على هذا الرمز. نود أن نرى ما تقوم بإنشائه!
للبدء في ذلك، اتبع الخطوات التالية:
- احذف جميع البيانات من الدليل
lib/widgets
. ستتمكّن من إنشاء تطبيقات مصغّرة جديدة ورائعة للعبتك. يحدث ذلك لاستعارة الكثير من التطبيقات المصغّرة القديمة. - عدِّل ملف
model.dart
لتعديل طريقةaddWord
فيCrossword
على النحو التالي:
lib/model.dart
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
bool requireOverlap = true, // Add this parameter
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
// Edit from here
// If overlap is required, make sure that the word overlaps with an existing
// word. Skip this test if the crossword is empty.
if (words.isNotEmpty && !overlap && requireOverlap) { // To here.
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
يتيح هذا التعديل البسيط في نموذج الكلمات المتقاطعة إضافة كلمات لا تتداخل. من المفيد السماح للّاعبين باللعب في أي مكان على اللوح وسيظلّ بإمكانهم استخدام Crossword
كنموذج أساسي لتخزين حركات اللاعب. هي مجرد قائمة كلمات من مواقع محددة يتم وضعها في اتجاه محدد.
- أضِف فئة النموذج
CrosswordPuzzleGame
إلى نهاية ملفmodel.dart
.
lib/model.dart
/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
static Serializer<CrosswordPuzzleGame> get serializer =>
_$crosswordPuzzleGameSerializer;
/// The [Crossword] that this puzzle is based on.
Crossword get crossword;
/// The alternate words for each [CrosswordWord] in the crossword.
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;
/// The player's selected words.
BuiltList<CrosswordWord> get selectedWords;
bool canSelectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return true;
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild((b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
));
}
return null !=
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false);
}
CrosswordPuzzleGame? selectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return rebuild((b) => b.selectedWords.remove(crosswordWord));
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild((b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
));
}
// Check if the selected word meshes with the already selected words.
// Note this version of the crossword does not enforce overlap to
// allow the player to select words anywhere on the grid. Enforcing words
// to be solved in order is a possible alternative.
final updatedSelectedWordsCrossword =
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
// Make sure the selected word is in the crossword or is an alternate word.
if (updatedSelectedWordsCrossword != null) {
if (puzzle.crossword.words.contains(crosswordWord) ||
puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
return puzzle.rebuild((b) => b
..selectedWords.add(CrosswordWord.word(
word: word, location: location, direction: direction)));
}
}
return null;
}
/// The crossword from the selected words.
Crossword get crosswordFromSelectedWords => Crossword.crossword(
width: crossword.width, height: crossword.height, words: selectedWords);
/// Test if the puzzle is solved. Note, this allows for the possibility of
/// multiple solutions.
bool get solved =>
crosswordFromSelectedWords.valid &&
crosswordFromSelectedWords.words.length == crossword.words.length &&
crossword.words.isNotEmpty;
/// Create a crossword puzzle game from a crossword and a set of candidate
/// words.
factory CrosswordPuzzleGame.from({
required Crossword crossword,
required BuiltSet<String> candidateWords,
}) {
// Remove all of the currently used words from the list of candidates
candidateWords = candidateWords
.rebuild((p0) => p0.removeAll(crossword.words.map((p1) => p1.word)));
// This is the list of alternate words for each word in the crossword
var alternates =
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();
// Build the alternate words for each word in the crossword
for (final crosswordWord in crossword.words) {
final alternateWords = candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.length == crosswordWord.word.length)
..shuffle()
..take(4)
..sort());
candidateWords =
candidateWords.rebuild((b) => b.removeAll(alternateWords));
alternates = alternates.rebuild(
(b) => b.updateValue(
crosswordWord.location,
(b) => b.rebuild(
(b) => b.updateValue(
crosswordWord.direction,
(b) => b.rebuild((b) => b.replace(alternateWords)),
ifAbsent: () => alternateWords,
),
),
ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
),
);
}
return CrosswordPuzzleGame((b) {
b
..crossword.replace(crossword)
..alternateWords.replace(alternates);
});
}
factory CrosswordPuzzleGame(
[void Function(CrosswordPuzzleGameBuilder)? updates]) =
_$CrosswordPuzzleGame;
CrosswordPuzzleGame._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo,
CrosswordPuzzleGame, // Add this line
])
final Serializers serializers = _$serializers;
التغييرات التي تم إجراؤها على ملف providers.dart
هي حزمة مفيدة من التغييرات. تمّت إزالة معظم مقدّمي الخدمة الذين كانوا حاضرين لدعم جمع الإحصاءات. تمت إزالة إمكانية تغيير عدد عمليات عزل الخلفية واستبدالها بعدد ثابت. يتوفّر أيضًا مقدّم خدمة جديد يتيح الوصول إلى نموذج "CrosswordPuzzleGame
" الجديد الذي أضفته للتوّ.
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (workQueue.crossword, wordList))
.then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(
_puzzleSelectWordTrampoline, (_puzzle, location, word, direction));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(
model.CrosswordPuzzleGame,
model.Location,
String,
model.Direction
) args) =>
args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
إنّ أهم التفاصيل التي يستند إليها مقدّم خدمة Puzzle
هي الاستراتيجيات التي يتم اتّباعها لتحسين تكلفة إنشاء CrosswordPuzzleGame
من Crossword
وwordList
، بالإضافة إلى تكاليف اختيار الكلمات. يتسبب كلا الإجراءين عند تنفيذهما بدون مساعدة في عزل الخلفية في حدوث تفاعل بطيئًا مع واجهة المستخدم. باستخدام بعض خفة اليد لدفع نتيجة متوسطة أثناء حساب النتيجة النهائية في الخلفية، ينتهي بك الأمر مع واجهة مستخدم سريعة الاستجابة بينما تحدث العمليات الحسابية المطلوبة في الخلفية.
- في دليل
lib/widgets
الفارغ الآن، يمكنك إنشاء ملفcrossword_puzzle_app.dart
يتضمّن المحتوى التالي:
lib/widgets/crossword_puzzle_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';
class CrosswordPuzzleApp extends StatelessWidget {
const CrosswordPuzzleApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordPuzzleAppMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Puzzle'),
),
body: SafeArea(
child: Consumer(builder: (context, ref, _) {
final workQueueAsync = ref.watch(workQueueProvider);
final puzzleSolved =
ref.watch(puzzleProvider.select((puzzle) => puzzle.solved));
return workQueueAsync.when(
data: (workQueue) {
if (puzzleSolved) {
return PuzzleCompletedWidget();
}
if (workQueue.isCompleted &&
workQueue.crossword.characters.isNotEmpty) {
return CrosswordPuzzleWidget();
}
return CrosswordGeneratorWidget();
},
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('$error')),
);
}),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordPuzzleAppMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
من المفترض أن يكون معظم هذا الملف مألوفًا إلى حد ما الآن. نعم، ستظهر تطبيقات مصغّرة غير محدّدة، وستبدأ الآن في إصلاحها.
- أنشئ ملف
crossword_generator_widget.dart
وأضِف المحتوى التالي إليه:
lib/widgets/crossword_generator_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordGeneratorWidget extends ConsumerWidget {
const CrosswordGeneratorWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
);
if (character != null) {
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text('•'), // https://www.compart.com/en/unicode/U+2022
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
يجب أن يكون هذا أيضًا مألوفًا بشكل معقول. الاختلاف الأساسي هو أنه بدلاً من عرض حروف الكلمات التي يتم إنشاؤها، يتم الآن عرض حرف يونيكود للإشارة إلى وجود حرف غير معروف. هذا يمكن حقًا استخدام بعض العمل لتحسين الجماليات.
- أنشئ ملف
crossword_puzzle_widget.dart
وأضِف المحتوى التالي إليه:
lib/widgets/crossword_puzzle_widget.dart
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordPuzzleWidget extends ConsumerWidget {
const CrosswordPuzzleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(puzzleProvider
.select((puzzle) => puzzle.crossword.characters[location]));
final selectedCharacter = ref.watch(puzzleProvider.select((puzzle) =>
puzzle.crosswordFromSelectedWords.characters[location]));
final alternateWords = ref
.watch(puzzleProvider.select((puzzle) => puzzle.alternateWords));
if (character != null) {
final acrossWord = character.acrossWord;
var acrossWords = BuiltList<String>();
if (acrossWord != null) {
acrossWords = acrossWords.rebuild((b) => b
..add(acrossWord.word)
..addAll(alternateWords[acrossWord.location]
?[acrossWord.direction] ??
[])
..sort());
}
final downWord = character.downWord;
var downWords = BuiltList<String>();
if (downWord != null) {
downWords = downWords.rebuild((b) => b
..add(downWord.word)
..addAll(alternateWords[downWord.location]
?[downWord.direction] ??
[])
..sort());
}
return MenuAnchor(
builder: (context, controller, _) {
return GestureDetector(
onTapDown: (details) =>
controller.open(position: details.localPosition),
child: AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
child: Text(selectedCharacter?.character ?? ''),
),
),
),
);
},
menuChildren: [
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Across'),
),
for (final word in acrossWords)
_WordSelectMenuItem(
location: acrossWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.across,
),
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Down'),
),
for (final word in downWords)
_WordSelectMenuItem(
location: downWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.down,
),
],
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
class _WordSelectMenuItem extends ConsumerWidget {
const _WordSelectMenuItem({
required this.location,
required this.word,
required this.selectedCharacter,
required this.direction,
});
final Location location;
final String word;
final CrosswordCharacter? selectedCharacter;
final Direction direction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(puzzleProvider.notifier);
return MenuItemButton(
onPressed: ref.watch(puzzleProvider.select((puzzle) =>
puzzle.canSelectWord(
location: location, word: word, direction: direction)))
? () => notifier.selectWord(
location: location, word: word, direction: direction)
: null,
leadingIcon: switch (direction) {
Direction.across => selectedCharacter?.acrossWord?.word == word,
Direction.down => selectedCharacter?.downWord?.word == word,
}
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(word),
);
}
}
وتتميز هذه الأداة بكثافة أكبر من الأداة السابقة، على الرغم من أنها قد تم بناؤها من أجزاء سبق لك استخدامها في أماكن أخرى في الماضي. الآن، تنتج كل خلية معبأة قائمة سياق عند النقر عليها، والتي تسرد الكلمات التي يمكن للمستخدم تحديدها. إذا تم تحديد الكلمات، فلن يكون من الممكن اختيار الكلمات المتعارضة. لإلغاء اختيار كلمة، ينقر المستخدم على عنصر القائمة لتلك الكلمة.
على افتراض أن اللاعب يمكنه اختيار الكلمات لملء الكلمات المتقاطعة بالكامل، ستحتاج إلى عبارة "لقد فزت!" الشاشة.
- أنشئ ملف
puzzle_completed_widget.dart
ثم أضِف المحتوى التالي إليه:
lib/widgets/puzzle_completed_widget.dart
import 'package:flutter/material.dart';
class PuzzleCompletedWidget extends StatelessWidget {
const PuzzleCompletedWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Puzzle Completed!',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
);
}
}
أنا متأكد من أنه يمكنك الاستعانة به وجعله أكثر تشويقًا. لمزيد من المعلومات حول أدوات الصور المتحركة، يُرجى الاطّلاع على الدرس التطبيقي حول الترميز إنشاء واجهات مستخدم من الجيل التالي في Flutter.
- عدِّل ملف
lib/main.dart
على النحو التالي:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
عند تشغيل هذا التطبيق، ستظهر لك الحركة أثناء إنشاء لغزك من خلال أداة إنشاء الكلمات المتقاطعة. بعد ذلك، سيظهر لك لغز فارغ لحله. بافتراض أنك تحلها، يجب أن تظهر لك شاشة تبدو على النحو التالي:
10. تهانينا
تهانينا! لقد نجحت في إنشاء لعبة ألغاز باستخدام Flutter
لقد أنشأت أداة إنشاء كلمات متقاطعة وتحوّلت إلى لعبة ألغاز. لقد أتقنت إجراء العمليات الحسابية في الخلفية ضمن مجموعة من وحدات العزل. لقد استخدمت هياكل بيانات غير قابلة للتغيير لتيسير تنفيذ خوارزمية التتبع العكسي. وقضيت وقتًا ممتعًا مع TableView
، وسيكون مفيدًا في المرة القادمة التي تحتاج فيها إلى عرض بيانات جدولية.