با فلاتر یک پازل کلمه بسازید

۱. قبل از شروع

تصور کنید از شما پرسیده شود که آیا می‌توان بزرگترین جدول کلمات متقاطع جهان را ایجاد کرد. شما برخی از تکنیک‌های هوش مصنوعی را که در مدرسه آموخته‌اید به یاد می‌آورید و از خود می‌پرسید که آیا می‌توانید از فلاتر برای بررسی گزینه‌های الگوریتمی جهت ایجاد راه‌حل‌هایی برای مسائل محاسباتی فشرده استفاده کنید.

در این آزمایشگاه کد، شما دقیقاً همین کار را انجام می‌دهید. در پایان، ابزاری برای بازی در فضای الگوریتم‌ها برای ساخت جدول کلمات متقاطع می‌سازید. تعاریف مختلفی از جدول کلمات متقاطع معتبر وجود دارد و این تکنیک‌ها به شما کمک می‌کنند تا جدول‌هایی بسازید که با تعریف شما مطابقت داشته باشند.

انیمیشنی از یک جدول کلمات متقاطع که در حال تولید است.

با استفاده از این ابزار به عنوان پایه، شما یک جدول کلمات متقاطع ایجاد می‌کنید که از مولد جدول کلمات متقاطع برای ساخت پازل برای حل توسط کاربر استفاده می‌کند. این پازل در اندروید، iOS، ویندوز، macOS و لینوکس قابل استفاده است. نسخه اندروید آن به شرح زیر است:

اسکرین‌شات از یک جدول کلمات متقاطع در حال حل شدن روی شبیه‌ساز پیکسل فولد.

پیش‌نیازها

آنچه یاد می‌گیرید

  • نحوه استفاده از ایزوله‌ها برای انجام کارهای محاسباتی سنگین بدون ایجاد مانع در حلقه رندر فلاتر با ترکیبی از تابع compute فلاتر و قابلیت‌های ذخیره‌سازی مقدار فیلتر بازسازی select Riverpod .
  • چگونه می‌توان از ساختارهای داده تغییرناپذیر با built_value و built_collection برای پیاده‌سازی تکنیک‌های هوش مصنوعی قدیمی مبتنی بر جستجو (GOFAI) مانند جستجوی عمقی و backtracking بهره برد.
  • نحوه استفاده از قابلیت‌های پکیج two_dimensional_scrollables برای نمایش داده‌های شبکه‌ای به روشی سریع و شهودی.

آنچه شما نیاز دارید

۲. ایجاد یک پروژه

اولین پروژه فلاتر خود را ایجاد کنید

  1. کد VS را اجرا کنید.
  2. پالت فرمان (Ctrl+Shift+P در ویندوز/لینوکس، Cmd+Shift+P در macOS) را باز کنید، عبارت "flutter new" را تایپ کنید و سپس Flutter: New Project را از منو انتخاب کنید.

VS Code با Flutter: پروژه جدید در پالت فرمان باز نشان داده می‌شود.

  1. گزینه Empty application را انتخاب کنید و سپس یک دایرکتوری برای ایجاد پروژه خود انتخاب کنید. این دایرکتوری باید هر دایرکتوری باشد که نیازی به دسترسی‌های بالا نداشته باشد یا در مسیر آن فاصله وجود نداشته باشد. به عنوان مثال می‌توان به دایرکتوری home یا C:\src\ اشاره کرد.

کد VS با برنامه خالی که به عنوان بخشی از جریان برنامه جدید انتخاب شده نشان داده شده است

  1. نام پروژه خود را generate_crossword . در ادامه این آموزش فرض بر این است که شما نام برنامه خود را generate_crossword گذاشته‌اید.

کد VS با generate_crossword که به عنوان نام پروژه جدید ایجاد شده نمایش داده شده است

فلاتر اکنون پوشه پروژه شما را ایجاد می‌کند و VS Code آن را باز می‌کند. اکنون محتویات دو فایل را با یک چارچوب اولیه از برنامه بازنویسی خواهید کرد.

برنامه اولیه را کپی و جایگذاری کنید

  1. در پنل سمت چپ VS Code، روی Explorer کلیک کنید و فایل pubspec.yaml را باز کنید.

یک اسکرین‌شات ناقص از VS Code به همراه فلش‌هایی که محل فایل pubspec.yaml را مشخص می‌کنند

  1. محتویات این فایل را با وابستگی‌های زیر که برای تولید جدول کلمات متقاطع مورد نیاز است، جایگزین کنید:

pubspec.yaml

name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.9.0

dependencies:
  flutter:
    sdk: flutter
  built_collection: ^5.1.1
  built_value: ^8.10.1
  characters: ^1.4.0
  flutter_riverpod: ^2.6.1
  intl: ^0.20.2
  riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1
  two_dimensional_scrollables: ^0.3.7

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  build_runner: ^2.5.4
  built_value_generator: ^8.10.1
  custom_lint: ^0.7.6
  riverpod_generator: ^2.6.5
  riverpod_lint: ^2.6.5

flutter:
  uses-material-design: true

فایل pubspec.yaml اطلاعات اولیه در مورد برنامه شما، مانند نسخه فعلی آن و وابستگی‌های آن را مشخص می‌کند. شما مجموعه‌ای از وابستگی‌ها را می‌بینید که بخشی از یک برنامه Flutter خالی معمولی نیستند. در مراحل بعدی از همه این بسته‌ها بهره‌مند خواهید شد.

درک وابستگی‌ها

قبل از اینکه به کدنویسی بپردازیم، بیایید بفهمیم که چرا این بسته‌های خاص انتخاب شده‌اند:

  • built_value : اشیاء تغییرناپذیری ایجاد می‌کند که حافظه را به طور مؤثر به اشتراک می‌گذارند، که برای الگوریتم backtracking ما بسیار مهم است.
  • Riverpod : مدیریت وضعیت دقیق را با select() فراهم می‌کند تا بازسازی‌ها را به حداقل برساند.
  • two_dimensional_scrollables : بدون افت عملکرد، شبکه‌های بزرگ را مدیریت می‌کند.
  1. فایل main.dart را در دایرکتوری lib/ باز کنید.

یک اسکرین‌شات ناقص از VS Code به همراه فلشی که محل فایل main.dart را نشان می‌دهد

  1. محتویات این فایل را با موارد زیر جایگزین کنید:

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(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: Scaffold(
          body: Center(
            child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
          ),
        ),
      ),
    ),
  );
}
  1. این کد را اجرا کنید تا بررسی شود که همه چیز درست کار می‌کند. باید یک پنجره جدید با عبارت شروع اجباری هر پروژه جدید در همه جا نمایش داده شود. یک ProviderScope وجود دارد که نشان می‌دهد این برنامه riverpod برای مدیریت وضعیت استفاده خواهد کرد.

یک پنجره برنامه با عبارت «سلام دنیا!» در مرکز

ایست بازرسی: اجرای برنامه پایه

در این مرحله، باید پنجره‌ی «سلام، دنیا!» را ببینید. اگر نه:

  • بررسی کنید که Flutter به درستی نصب شده باشد
  • اجرای برنامه را با flutter run تأیید کنید
  • اطمینان حاصل کنید که هیچ خطای کامپایل در ترمینال وجود ندارد

۳. کلمات را اضافه کنید

بلوک‌های ساختمانی برای یک جدول کلمات متقاطع

یک جدول کلمات متقاطع، در اصل، فهرستی از کلمات است. کلمات در یک شبکه، برخی در عرض و برخی در پایین، به گونه‌ای چیده شده‌اند که کلمات در هم تنیده شده‌اند. حل یک کلمه، سرنخ‌هایی در مورد کلماتی که از آن کلمه اول عبور می‌کنند، می‌دهد. بنابراین، یک بلوک سازنده خوب برای شروع، فهرستی از کلمات است.

منبع خوبی برای این کلمات، صفحه داده‌های پیکره زبان طبیعی پیتر نورویگ است. فهرست SOWPODS با ۲۶۷,۷۵۰ کلمه، نقطه شروع مفیدی است.

در این مرحله، شما لیستی از کلمات را دانلود می‌کنید، آن را به عنوان یک دارایی به برنامه Flutter خود اضافه می‌کنید و یک ارائه دهنده Riverpod را برای بارگذاری لیست در برنامه در هنگام راه‌اندازی تنظیم می‌کنید.

برای شروع، این مراحل را دنبال کنید:

  1. فایل pubspec.yaml پروژه خود را تغییر دهید تا عبارت asset declaration زیر را برای لیست کلمات انتخابی خود اضافه کنید. این لیست فقط بخش flutter از پیکربندی برنامه شما را نشان می‌دهد، زیرا بقیه موارد به همان شکل باقی مانده‌اند.

pubspec.yaml

flutter:
  uses-material-design: true
  assets:                                       # Add this line
    - assets/words.txt                          # And this one.

ویرایشگر شما احتمالاً این خط آخر را با یک هشدار هایلایت خواهد کرد زیرا هنوز این فایل را ایجاد نکرده‌اید.

  1. با استفاده از مرورگر و ویرایشگر خود، یک دایرکتوری assets در سطح بالای پروژه خود ایجاد کنید و یک فایل words.txt در آن ایجاد کنید که یکی از فهرست‌های کلماتی که قبلاً لینک شده‌اند را در خود جای داده باشد.

این کد با استفاده از فهرست SOWPODS که قبلاً ذکر شد طراحی شده است، اما باید با هر فهرست کلمه‌ای که فقط از کاراکترهای AZ تشکیل شده باشد، کار کند. گسترش این کدبیس برای کار با مجموعه کاراکترهای مختلف، به عنوان تمرین به خواننده واگذار شده است.

کلمات را بارگیری کنید

برای نوشتن کدی که مسئول بارگذاری لیست کلمات در هنگام راه‌اندازی برنامه است، این مراحل را دنبال کنید:

  1. یک فایل providers.dart در دایرکتوری lib ایجاد کنید.
  2. موارد زیر را به فایل اضافه کنید:

lib/providers.dart

import 'dart:convert';

import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.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(Ref 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 شما برای این کدبیس است.

نحوه کار این ارائه دهنده:

  1. لیست کلمات را از فایل‌ها به صورت غیرهمزمان بارگذاری می‌کند
  2. کلمات را فیلتر می‌کند تا فقط شامل کاراکترهای az با طول بیش از ۲ حرف باشند
  3. یک BuiltSet تغییرناپذیر برای دسترسی تصادفی کارآمد برمی‌گرداند.

این پروژه از تولید کد برای وابستگی‌های متعدد، از جمله Riverpod، استفاده می‌کند.

  1. برای شروع تولید کد، دستور زیر را اجرا کنید:
$ 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 که قبلاً تعریف کردید، معمولاً به صورت تنبل (lazyly) نمونه‌سازی می‌شوند. با این حال، برای اهداف این برنامه، شما نیاز دارید که لیست کلمات به صورت مشتاقانه (eagerly) بارگذاری شوند. مستندات Riverpod رویکرد زیر را برای برخورد با ارائه‌دهندگانی که نیاز دارید به صورت مشتاقانه بارگذاری شوند، پیشنهاد می‌کند. اکنون آن را پیاده‌سازی خواهید کرد.

  1. یک فایل crossword_generator_app.dart در دایرکتوری lib/widgets ایجاد کنید.
  2. موارد زیر را به فایل اضافه کنید:

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 در مورد مقداردهی اولیه ارائه‌دهندگان Eager بخوانید.

نکته جالب دوم که در این فایل قابل توجه است، نحوه مدیریت محتوای ناهمزمان توسط Riverpod است. همانطور که ممکن است به یاد داشته باشید، ارائه دهنده wordList به عنوان یک تابع ناهمزمان تعریف شده است، زیرا بارگیری محتوا از دیسک کند است. با مشاهده ارائه دهنده لیست کلمات در این کد، یک AsyncValue<BuiltSet<String>> دریافت می‌کنید. بخش AsyncValue از آن نوع، یک آداپتور بین دنیای ناهمزمان ارائه دهندگان و دنیای همزمان متد build ویجت است.

متد when در کلاس AsyncValue ، سه حالت بالقوه‌ای که مقدار آینده ممکن است در آن باشد را مدیریت می‌کند. ممکن است آینده با موفقیت حل شده باشد، که در این صورت فراخوانی data فراخوانی می‌شود، ممکن است در حالت خطا باشد، که در این صورت فراخوانی error فراخوانی می‌شود، یا در نهایت ممکن است هنوز در حال بارگیری باشد. انواع بازگشتی سه فراخوانی باید انواع بازگشتی سازگار داشته باشند، زیرا بازگشت فراخوانی فراخوانی شده توسط متد when بازگردانده می‌شود. در این مثال، نتیجه متد when به عنوان body ویجت Scaffold نمایش داده می‌شود.

یک برنامه لیست تقریباً نامتناهی ایجاد کنید

برای ادغام ویجت CrosswordGeneratorApp در برنامه خود، این مراحل را دنبال کنید:

  1. فایل 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(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordGeneratorApp(),                     // Remove what was here and replace
      ),
    ),
  );
}
  1. برنامه را مجدداً راه اندازی کنید. باید یک لیست پیمایشی ببینید که تمام ۲۶۷،۷۵۰+ کلمه موجود در فرهنگ لغت را مرور می‌کند.

یک پنجره برنامه با عنوان «تولیدکننده جدول کلمات متقاطع» و فهرستی از کلمات

آنچه در ادامه خواهید ساخت

اکنون شما با استفاده از اشیاء تغییرناپذیر، ساختارهای داده اصلی را برای جدول کلمات متقاطع خود ایجاد خواهید کرد. این پایه، الگوریتم‌های کارآمد و به‌روزرسانی‌های روان رابط کاربری را امکان‌پذیر می‌سازد.

۴. کلمات را در یک شبکه نمایش دهید

در این مرحله، شما با استفاده از پکیج‌های built_value و built_collection یک ساختار داده برای ایجاد یک جدول کلمات متقاطع ایجاد خواهید کرد. این دو پکیج امکان ساخت ساختارهای داده به صورت مقادیر تغییرناپذیر را فراهم می‌کنند که هم برای انتقال داده‌ها بین Isolates و هم برای آسان‌تر کردن پیاده‌سازی جستجوی عمقی و backtracking مفید خواهد بود.

برای شروع، این مراحل را دنبال کنید:

  1. یک فایل 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 ایجاد می‌شود.

برای استفاده از این ساختار داده، مراحل زیر را دنبال کنید:

  1. یک فایل 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 ضروری است.

  1. در فایل 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 گنجانده شده است.

  1. در انتهای همان فایل، provider های زیر را اضافه کنید:

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(Ref 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* روی تابع مشخص شده است. این بدان معناست که به جای پایان دادن به یک return، مجموعه‌ای از Crossword ها را ارائه می‌دهد، روشی بسیار آسان‌تر برای نوشتن محاسباتی که نتایج میانی را برمی‌گرداند.

به دلیل وجود یک جفت فراخوانی ref.watch در ابتدای تابع ارائه دهنده crossword ، جریان جدول‌های Crossword هر بار که اندازه انتخاب شده جدول کلمات متقاطع تغییر کند و بارگذاری لیست کلمات به پایان برسد، توسط سیستم Riverpod مجدداً راه‌اندازی می‌شود.

حالا که کدی برای تولید جدول کلمات متقاطع دارید، هرچند پر از کلمات تصادفی، خوب است که آنها را به کاربر ابزار نشان دهید.

  1. یک فایل 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 رندر می‌شود، در درخت Widget برگشتی خود حاوی یک ویجت Consumer است. این به عنوان یک مرز refresh عمل می‌کند. هر چیزی که در داخل ویجت Consumer قرار دارد، با تغییر مقدار برگشتی ref.watch دوباره ایجاد می‌شود. وسوسه‌انگیز است که هر بار که Crossword تغییر می‌کند، کل درخت دوباره ایجاد شود، اما این کار باعث محاسبات زیادی می‌شود که می‌توان با استفاده از این تنظیم از آنها صرف نظر کرد.

اگر به پارامتر ref.watch نگاه کنید، خواهید دید که با استفاده از crosswordProvider.select ، لایه دیگری از اجتناب از محاسبه مجدد طرح‌بندی‌ها وجود دارد. این بدان معناست که ref.watch فقط زمانی باعث بازسازی محتوای TableViewCell می‌شود که کاراکتری که سلول مسئول رندر کردن آن است تغییر کند. این کاهش در رندر مجدد، بخش اساسی در واکنش‌گرا نگه داشتن رابط کاربری است.

برای نمایش CrosswordWidget و Size provider به کاربر، فایل 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()),             // Replace what 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 که در فایل lib/widgets/crossword_widget.dart تعریف شده بود، جایگزین شده است. تغییر عمده دیگر، شروع منویی برای تغییر رفتار برنامه است که با تغییر اندازه جدول کلمات متقاطع شروع می‌شود. در مراحل آینده MenuItemButton های بیشتری اضافه خواهند شد. برنامه خود را اجرا کنید، چیزی شبیه به این را خواهید دید:

یک پنجره برنامه با عنوان تولیدکننده جدول کلمات متقاطع و شبکه‌ای از کاراکترها که به صورت کلمات همپوشانی بدون هیچ قافیه یا دلیلی چیده شده‌اند

کاراکترهایی در یک جدول نمایش داده می‌شوند و منویی وجود دارد که به کاربر امکان تغییر اندازه جدول را می‌دهد. اما کلمات مانند یک جدول کلمات متقاطع چیده نشده‌اند. این نتیجه عدم اعمال هیچ محدودیتی در نحوه اضافه کردن کلمات به جدول کلمات متقاطع است. به طور خلاصه، این یک آشفتگی است. چیزی که در مرحله بعدی شروع به کنترل آن خواهید کرد!

۵. اعمال محدودیت‌ها

چه چیزی را تغییر می‌دهیم و چرا

در حال حاضر جدول کلمات متقاطع شما امکان همپوشانی کلمات را بدون اعتبارسنجی فراهم می‌کند. شما بررسی محدودیت را اضافه خواهید کرد تا مطمئن شوید کلمات مانند یک جدول کلمات متقاطع واقعی به درستی در هم تنیده شده‌اند.

هدف این مرحله، افزودن کد به مدل برای اعمال محدودیت‌های جدول کلمات متقاطع است. انواع مختلفی از جدول کلمات متقاطع وجود دارد و سبکی که این آزمایشگاه کد اعمال خواهد کرد، از سنت‌های جدول کلمات متقاطع انگلیسی پیروی می‌کند. اصلاح این کد برای تولید سبک‌های دیگر جدول کلمات متقاطع، مانند همیشه، به عنوان یک تمرین به خواننده واگذار شده است.

برای شروع، این مراحل را دنبال کنید:

  1. فایل 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 ایجاد می‌کنید، برای به‌روزرسانی فایل‌های model.g.dart و providers.g.dart مربوطه، نیاز به اجرای build_runner دارند. اگر این فایل‌ها به صورت خودکار به‌روزرسانی نشده‌اند، اکنون زمان مناسبی برای شروع مجدد build_runner با dart run build_runner watch -d است.

برای بهره‌گیری از این قابلیت جدید در لایه مدل، باید لایه ارائه‌دهنده را برای مطابقت به‌روزرسانی کنید.

  1. فایل 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;
    },
  );
}
  1. برنامه خود را اجرا کنید. اتفاق زیادی در رابط کاربری نمی‌افتد، اما اگر به لاگ‌ها نگاه کنید، اتفاقات زیادی رخ می‌دهد.

پنجره برنامه تولیدکننده جدول کلمات متقاطع با کلماتی که به صورت عمودی و افقی قرار گرفته‌اند و در نقاط تصادفی یکدیگر را قطع می‌کنند

اگر به آنچه اینجا اتفاق می‌افتد فکر کنید، ما شاهد ظاهر شدن تصادفی یک جدول کلمات متقاطع هستیم. متد addWord در مدل Crossword هر کلمه پیشنهادی را که در جدول کلمات متقاطع فعلی جای نمی‌گیرد، رد می‌کند، بنابراین شگفت‌انگیز است که ما شاهد ظاهر شدن هر چیزی هستیم.

چرا به پردازش پس‌زمینه برویم؟

ممکن است متوجه شوید که رابط کاربری در حین تولید جدول کلمات متقاطع از کار می‌افتد. این اتفاق می‌افتد زیرا تولید جدول کلمات متقاطع شامل هزاران بررسی اعتبارسنجی است. این محاسبات حلقه رندر ۶۰ فریم بر ثانیه فلاتر را مسدود می‌کند، بنابراین محاسبات سنگین را به ایزوله‌های پس‌زمینه منتقل خواهید کرد. این مزیت را دارد که رابط کاربری در حین تولید پازل در پس‌زمینه روان باقی می‌ماند.

برای اینکه بتوانیم در انتخاب کلمات و محل امتحان کردن آنها روشمندتر عمل کنیم، انتقال این محاسبه از نخ رابط کاربری به یک ایزوله پس‌زمینه بسیار مفید خواهد بود. فلاتر یک پوشش بسیار مفید برای گرفتن بخشی از کار و اجرای آن در یک ایزوله پس‌زمینه دارد - تابع compute .

  1. در فایل providers.dart ، ارائه دهنده جدول کلمات متقاطع را به صورت زیر تغییر دهید:

lib/providers.dart

@riverpod
Stream<model.Crossword> crossword(Ref 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 {                                              // Edit from here
          var candidate = await compute((
            (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() ارسال شود. یک راه حل برای این مشکل این است که مطمئن شوید هیچ چیزی که قابل ارسال نباشد برای کلوژر وجود ندارد که روی آن بسته شود.

اولین قدم، جدا کردن ارائه‌دهندگان از کد Isolate است.

  1. یک فایل 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/riverpod.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(Ref 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(Ref ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  final emptyCrossword = model.Crossword.crossword(        // Edit from here
    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 حل جدول در پس‌زمینه‌ی ایزوله انجام می‌شود. حال، کاش کد می‌توانست هنگام تصمیم‌گیری در مورد اینکه چه کلماتی را سعی کنید به جدول کلمات متقاطع اضافه کنید، کارآمدتر باشد.

۶. مدیریت صف کار

درک استراتژی جستجو

تولید جدول کلمات متقاطع شما از روش بازگشت به عقب ، یک رویکرد سیستماتیک آزمون و خطا، استفاده می‌کند. ابتدا برنامه شما سعی می‌کند یک کلمه را در یک مکان قرار دهد، سپس بررسی می‌کند که آیا با کلمات موجود مطابقت دارد یا خیر. اگر مطابقت داشت، آن را نگه دارید و کلمه بعدی را امتحان کنید. در غیر این صورت، آن را حذف کنید و در جای دیگری امتحان کنید.

عقبگرد برای کلمات متقاطع کار می‌کند زیرا هر جایگذاری کلمه محدودیت‌هایی برای کلمات بعدی ایجاد می‌کند، که در آن جایگذاری‌های نامعتبر به سرعت شناسایی و رها می‌شوند. ساختارهای داده تغییرناپذیر، «لغو» تغییرات را کارآمد می‌کنند.

بخشی از مشکل کد فعلی این است که مسئله‌ای که قرار است حل شود، عملاً یک مسئله جستجو است و راه‌حل فعلی، جستجوی کورکورانه است. اگر کد به جای تلاش تصادفی برای قرار دادن کلمات در هر کجای شبکه، بر یافتن کلماتی که به کلمات فعلی متصل می‌شوند تمرکز کند، سیستم سریع‌تر راه‌حل‌ها را پیدا می‌کند. یک راه برای رسیدن به این هدف، معرفی یک صف کاری از مکان‌ها برای تلاش برای یافتن کلمات است.

این کد راه‌حل‌های کاندید را می‌سازد، بررسی می‌کند که آیا راه‌حل کاندید معتبر است یا خیر، و بسته به اعتبار، یا کاندید را در برنامه قرار می‌دهد یا آن را کنار می‌گذارد. این یک پیاده‌سازی نمونه از خانواده الگوریتم‌های backtracking است. این پیاده‌سازی با built_value و built_collection بسیار آسان‌تر شده است، که امکان ایجاد مقادیر تغییرناپذیر جدیدی را فراهم می‌کنند که از مقدار تغییرناپذیری که از آن مشتق شده‌اند، مشتق می‌شوند و در نتیجه، حالت مشترکی با آن دارند. این امر امکان بهره‌برداری ارزان از کاندیدهای بالقوه را بدون هزینه‌های حافظه مورد نیاز برای کپی عمیق فراهم می‌کند.

برای شروع، این مراحل را دنبال کنید:

  1. فایل 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;
  1. اگر بعد از اضافه کردن این محتوای جدید، بیش از چند ثانیه خطوط موج‌دار قرمز در این فایل باقی ماندند، تأیید کنید که build_runner شما هنوز در حال اجرا است. در غیر این صورت، دستور dart run build_runner watch -d اجرا کنید.

در کدی که قرار است معرفی کنید، ثبت وقایع (logging) برای نشان دادن مدت زمان لازم برای ایجاد جدول کلمات متقاطع در اندازه‌های مختلف، بسیار مفید خواهد بود. اگر Durations نوعی نمایش با قالب‌بندی زیبا داشت، عالی می‌شد. خوشبختانه، با متدهای افزونه می‌توانیم دقیقاً همان متدی را که نیاز داریم اضافه کنیم.

  1. فایل 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.

این روش افزونه از عبارات سوئیچ و تطبیق الگو روی رکوردها برای انتخاب روش مناسب برای نمایش مدت زمان‌های مختلف از ثانیه تا روز استفاده می‌کند. برای اطلاعات بیشتر در مورد این سبک کد، به آزمایشگاه کد الگوها و رکوردهای Dive into Dart مراجعه کنید.

  1. برای ادغام این قابلیت جدید، فایل 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}',
  );
}

اجرای این کد منجر به برنامه‌ای می‌شود که در ظاهر یکسان به نظر می‌رسد، اما تفاوت در مدت زمانی است که طول می‌کشد تا یک جدول کلمات متقاطع کامل پیدا شود. در اینجا یک جدول کلمات متقاطع ۸۰ در ۴۴ را مشاهده می‌کنید که در ۱ دقیقه و ۲۹ ثانیه تولید شده است.

ایست بازرسی: الگوریتم کارآمد

اکنون به لطف موارد زیر، تولید جدول کلمات متقاطع شما باید به طور قابل توجهی سریع‌تر شود:

  • جایگذاری هوشمند کلمات با هدف قرار دادن نقاط تقاطع
  • بازگشت کارآمد به عقب در صورت عدم موفقیت در جایگذاری‌ها
  • مدیریت صف کار برای جلوگیری از جستجوهای اضافی

تولیدکننده جدول کلمات متقاطع، با تعداد زیادی کلمه متقاطع. در حالت بزرگنمایی، کلمات برای خواندن بسیار کوچک هستند.

سوال واضح البته این است که آیا می‌توانیم سریع‌تر برویم؟ بله، بله، می‌توانیم.

۷. آمار سطحی

چرا آمار اضافه کنیم؟

در ساخت سریع چیزی، مشاهده‌ی روند کار مفید است. آمار به شما کمک می‌کند تا پیشرفت را رصد کنید و ببینید الگوریتم در لحظه چگونه عمل می‌کند. این ابزار به شما امکان می‌دهد با درک اینکه الگوریتم زمان خود را کجا صرف می‌کند، گلوگاه‌ها را شناسایی کنید. این به شما امکان می‌دهد با تصمیم‌گیری آگاهانه در مورد رویکردهای بهینه‌سازی، عملکرد را تنظیم کنید.

اطلاعاتی که نمایش خواهید داد باید از WorkQueue استخراج شده و در رابط کاربری نمایش داده شود. اولین قدم مفید، تعریف یک کلاس مدل جدید است که شامل اطلاعاتی باشد که می‌خواهید نمایش دهید.

برای شروع، این مراحل را دنبال کنید:

  1. فایل 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> {
  1. در انتهای فایل، تغییرات زیر را برای اضافه کردن کلاس 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;
  1. فایل 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}',
  );
}

حالا که ایزوله پس‌زمینه صف کار را نمایش می‌دهد، این سوال مطرح می‌شود که چگونه و از کجا می‌توان آمار را از این منبع داده استخراج کرد.

  1. ارائه‌دهنده‌ی جدول کلمات متقاطع قدیمی را با یک ارائه‌دهنده‌ی صف کار جایگزین کنید و سپس ارائه‌دهندگان بیشتری را اضافه کنید که اطلاعات را از جریان ارائه‌دهنده‌ی صف کار استخراج می‌کنند:

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/riverpod.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(Ref 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(Ref 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(Ref 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 در این مورد بسیار مفید است.

در نمایش نمایش اطلاعات، یک ایراد جزئی وجود دارد. ما می‌خواهیم قابلیت نمایش زمان اجرای سپری‌شده وجود داشته باشد، اما در اینجا چیزی برای اعمال به‌روزرسانی مداوم زمان سپری‌شده وجود ندارد. برگردیم به بحث ساخت رابط‌های کاربری نسل بعدی در فلاتر ، در اینجا یک ویجت مفید برای همین نیاز وجود دارد.

  1. یک فایل 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);
}

این ویجت مثل یک پتک عمل می‌کند. محتوای خود را در هر فریم از نو می‌سازد. این کار عموماً مورد انتقاد قرار می‌گیرد، اما در مقایسه با بار محاسباتی جستجوی جدول کلمات متقاطع، بار محاسباتی بازسازی زمان سپری شده در هر فریم احتمالاً در میان نویز ناپدید می‌شود. برای بهره‌برداری از این اطلاعات تازه به دست آمده، زمان ایجاد یک ویجت جدید فرا رسیده است.

  1. یک فایل crossword_info_widget.dart در دایرکتوری lib/widgets خود ایجاد کنید و سپس محتوای زیر را به آن اضافه کنید:

lib/widgets/crossword_info_widget.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.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 است. این ویجت با به‌روزرسانی هر یک از پنج ارائه‌دهنده، برای بازسازی علامت‌گذاری می‌شود. آخرین تغییر مورد نیاز در این مرحله، ادغام این ویجت جدید در رابط کاربری است.

  1. فایل 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(
    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(                                      // 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 است که چه در تغییر اندازه جدول کلمات متقاطع و چه در نمایش یا پنهان شدن نمایش اطلاعات، بازسازی خواهد شد. اینکه کدام رویکرد را انتخاب کنید، همیشه یک بده‌بستان مهندسی بین سادگی و هزینه محاسبه مجدد طرح‌بندی درخت‌های ویجت بازسازی‌شده است.

اجرای برنامه اکنون به کاربر بینش بیشتری در مورد نحوه پیشرفت تولید جدول کلمات متقاطع می‌دهد. با این حال، نزدیک به پایان تولید جدول کلمات متقاطع، دوره‌ای را می‌بینیم که اعداد در حال تغییر هستند، اما تغییر بسیار کمی در شبکه کاراکترها وجود دارد.

پنجره برنامه Crossword Generator، این بار کلمات کوچکتر و قابل تشخیص، و یک پوشش شناور در گوشه پایین سمت راست با آماری درباره اجرای نسل فعلی

مفید خواهد بود که بینش بیشتری در مورد آنچه اتفاق می‌افتد و چرایی آن به دست آوریم.

۸. موازی‌سازی با نخ‌ها

چرا عملکرد کاهش می‌یابد

با نزدیک شدن به اتمام جدول کلمات متقاطع، الگوریتم کند می‌شود زیرا گزینه‌های معتبر کمتری برای جایگذاری کلمات باقی مانده است. الگوریتم ترکیب‌های زیادی را امتحان می‌کند که کار نمی‌کنند. پردازش تک‌رشته‌ای نمی‌تواند به طور مؤثر چندین گزینه را بررسی کند.

تجسم الگوریتم

برای درک اینکه چرا در پایان کار، همه چیز کند می‌شود، مفید است که بتوانیم عملکرد الگوریتم را تجسم کنیم. بخش کلیدی، locationsToTry برجسته در WorkQueue است. TableView روش مفیدی برای بررسی این موضوع به ما می‌دهد. می‌توانیم رنگ سلول را بر اساس اینکه آیا در locationsToTry است یا خیر، تغییر دهیم.

برای شروع، این مراحل را دنبال کنید:

  1. فایل 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,
          ),
        ),
      ),
    );
  }
}

وقتی این کد را اجرا می‌کنید، تصویری از مکان‌های برجسته‌ای که الگوریتم هنوز بررسی نکرده است را مشاهده خواهید کرد.

جدول کلمات متقاطع که بخش پایانی یک نسل را نشان می‌دهد. برخی حروف متن سفید روی زمینه آبی تیره دارند، در حالی که برخی دیگر متن آبی روی زمینه سفید دارند.

نکته جالب در تماشای این روند، همزمان با پیشرفت جدول کلمات متقاطع به سمت تکمیل آن، این است که مجموعه‌ای از نکات برای بررسی باقی مانده است که به هیچ نتیجه مفیدی منجر نمی‌شود. در اینجا چند گزینه وجود دارد؛ یکی اینکه پس از پر شدن درصد مشخصی از خانه‌های جدول کلمات متقاطع، بررسی را متوقف کنید و دوم اینکه چندین نکته مورد علاقه را همزمان بررسی کنید. مسیر دوم سرگرم‌کننده‌تر به نظر می‌رسد، بنابراین وقت آن رسیده است که این کار را انجام دهید.

  1. فایل isolates.dart ویرایش کنید. این تقریباً یک بازنویسی کامل از کد است تا آنچه را که در یک ایزوله پس‌زمینه محاسبه می‌شد، به مجموعه‌ای از N ایزوله پس‌زمینه تقسیم کند.

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 ایزوله برای دستیابی به بهترین عملکرد، هم به رایانه شما و هم به داده‌های مورد نظر بستگی دارد. هرچه شبکه بزرگتر باشد، کارگران بیشتری می‌توانند بدون اینکه سر راه یکدیگر قرار بگیرند، با هم کار کنند.

نکته جالب توجه این است که چگونه این کد اکنون مسئله Closureها را که چیزهایی را که نباید ضبط کنند، ضبط می‌کنند، مدیریت می‌کند. اکنون هیچ Closure وجود ندارد. توابع _generate و _generateWorker به عنوان توابع سطح بالا تعریف شده‌اند که هیچ محیط اطرافی برای ضبط از آنها ندارند. آرگومان‌های ورودی و نتایج خروجی هر دوی این توابع به شکل رکوردهای Dart هستند. این روشی برای دور زدن مفهوم «یک مقدار ورودی، یک مقدار خروجی» در فراخوانی compute است.

حالا که شما توانایی ایجاد مجموعه‌ای از کارگران پس‌زمینه برای جستجوی کلماتی که در یک شبکه به هم متصل می‌شوند تا یک جدول کلمات متقاطع تشکیل دهند را دارید، وقت آن رسیده است که این قابلیت را در اختیار بقیه ابزار تولیدکننده جدول کلمات متقاطع قرار دهید.

  1. فایل providers.dart را با ویرایش ارائه دهنده workQueue به صورت زیر ویرایش کنید:

lib/providers.dart

@riverpod
Stream<model.WorkQueue> workQueue(Ref 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();
}
  1. ارائه دهنده 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.

With these two changes, the provider layer now exposes a way to set the maximum worker count for the background isolate pool in a way that the isolate functions are correctly configured.

  1. Update the crossword_info_widget.dart file by modifying the CrosswordInfoWidget as follows:

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 from here
                      label: 'Max worker count',
                      value: workerCount,
                    ),                                    // To here.
                    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,
                      ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
  1. Modify the crossword_generator_app.dart file by adding the following section to the _CrosswordGeneratorMenu widget:

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),
    ),
  );
}

If you run the app now, you will be able to modify the number of background isolates being instantiated to search for words to slot into the crossword.

  1. Click the gear icon in the to open the contextual menu containing sizing for the crossword, whether to display the statistics on the generated crossword, and now, the number of isolates to use.

Crossword Generator window with words and statistics

Checkpoint: Multi-threaded Performance

Running the crossword generator has significantly reduced the compute time for an 80x44 crossword by using multiple cores concurrently. You should notice:

  • Faster crossword generation with higher worker counts
  • Smooth UI responsiveness during generation
  • Real-time statistics showing generation progress
  • Visual feedback of algorithm exploration areas

9. Turn it into a game

What we're building: A Playable Crossword Game

This last section is really a bonus round. You will take all the techniques you have learned while constructing the crossword generator and use these techniques to build a game. You will:

  1. Generate puzzles : Use the crossword generator to create solvable puzzles
  2. Create word choices : Provide multiple word options for each position
  3. Enable interaction : Let users select and place words
  4. Validate solutions : Check if the completed crossword is correct

You will use the crossword generator to create a crossword puzzle. You will reuse the contextual menu idioms to enable the user to select and deselect words to put in the various word-shaped holes in the grid. All with the aim of completing the crossword.

I'm not going to say this game is polished or finished, it's far from in fact. There are balance and difficulty issues which can be solved with improving the choice of alternate words. There is no tutorial to lead users into the puzzle. I'm not even going to mention the bare bones "You've won!" screen.

The tradeoff here is that to properly polish this proto-game into a full game will take significantly more code. More code than should be in a single codelab. So, instead, this is a speed run step designed to reinforce the techniques learned so far in this codelab by changing where and how they are used. Hopefully this reinforces the lessons learned earlier in this codelab. Alternatively, you can go ahead and build your own experiences based on this code. We'd love to see what you build!

To begin, follow these steps:

  1. Delete everything in the lib/widgets directory. You will be creating shiny new widgets for your game. That just happens to borrow a lot from the old widgets.
  1. Edit your model.dart file to update Crossword 's addWord method as follows:

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;
    }
  }

This minor modification of your Crossword model enables words to be added that don't overlap. It's useful to allow players to play anywhere on a board and still be able to use Crossword as a base model for storing the player's moves. It is just a list of words at specific locations placed in a specific direction.

  1. Add the CrosswordPuzzleGame model class to the end of your model.dart file.

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;

The updates to the providers.dart file are an interesting grab bag of changes. Most of the providers that were present to support statistics gathering have been removed. The ability to change the number of background isolates has been removed and replaced with a constant. There is also a new provider that gives access to the new CrosswordPuzzleGame model you added previously.

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/riverpod.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(Ref 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(Ref 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);

The most interesting parts of the Puzzle provider are the stratagems undertaken to gloss over the expense of creating the CrosswordPuzzleGame from a Crossword and a wordList , and the expense of selecting a word. Both of these actions when undertaken without the aid of a background Isolate cause sluggish UI interaction. By using some sleight of hand to push out an intermediate result while computing the final result in the background, you wind up with a responsive UI while the required computations are taking place in the background.

  1. In the now-empty lib/widgets directory, create a crossword_puzzle_app.dart file with the following content:

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),
    ),
  );
}

Most of this file should be fairly familiar by now. Yes, there will be undefined widgets, which you will now start fixing.

  1. Create a crossword_generator_widget.dart file and add the following content to it:

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,
          ),
        ),
      ),
    );
  }
}

This should also be reasonably familiar. The primary difference is that instead of displaying the characters of the words being generated, you are now displaying a unicode character to denote the presence of an unknown character. This really could use some work to improve the aesthetics.

  1. Create crossword_puzzle_widget.dart file and add the following content to it:

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),
    );
  }
}

This widget is a bit more intense than the last one, even though it has been constructed from pieces you have seen used in other places in the past. Now, each populated cell produces a context menu when clicked, which lists the words a user can select. If words have been selected, then words that conflict aren't selectable. To deselect a word, the user taps on the menu item for that word.

Assuming the player can select words to fill the entire crossword, you need a "You've won!" screen.

  1. Create a puzzle_completed_widget.dart file and then add the following content to it:

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),
      ),
    );
  }
}

I'm sure you can take this and make it more interesting. To learn more about animation tools, see the Building next generation UIs in Flutter codelab.

  1. Edit your lib/main.dart file as follows:

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(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordPuzzleApp(),                         // Update this line
      ),
    ),
  );
}

When you run this app, you will see the animation as the crossword generator generates your puzzle. Then you will be presented with a blank puzzle to solve. Assuming you solve it, you should be presented with a screen that looks like this:

Crossword Puzzle app window showing the text 'Puzzle completed!'

10. Congratulations

Congratulations! You succeeded in building a puzzle game with Flutter!

You built a crossword generator that became a puzzle game. You mastered running background computations in a pool of isolates. You used immutable data structures to ease the implementation of a backtracking algorithm. And you spent quality time with TableView , which will come in handy the next time you need to display tabular data.

بیشتر بدانید