ไขปัญหาด้วย Flutter

1. ก่อนเริ่มต้น

ลองจินตนาการว่าเขาสามารถเป็นปริศนาครอสเวิร์ดที่ใหญ่ที่สุดในโลกได้ไหม คุณจำเทคนิค AI บางอย่างที่เคยเรียนที่โรงเรียนและสงสัยว่าจะใช้ Flutter เพื่อสำรวจตัวเลือกอัลกอริทึมเพื่อสร้างวิธีแก้ปัญหาด้านการคำนวณที่ซับซ้อนได้หรือไม่

ใน Codelab นี้ คุณก็ทำแบบนี้ได้เลย ในตอนจบ คุณจะได้สร้างเครื่องมือเพื่อให้ใช้อัลกอริทึมสำหรับสร้างปริศนาแบบตารางคำศัพท์ ปริศนาอักษรไขว้ที่ถูกต้องมีอยู่มากมายหลายแบบด้วยกัน และเทคนิคเหล่านี้จะช่วยคุณสร้างปริศนาที่เหมาะกับคำจำกัดความของคุณ

ภาพเคลื่อนไหวที่สร้างปริศนาอักษรไขว้

เมื่อมีเครื่องมือนี้เป็นฐานแล้ว คุณก็สามารถต่อจิ๊กซอว์ครอสเวิร์ดโดยใช้โปรแกรมสร้างเกมครอสเวิร์ดเพื่อสร้างปริศนาให้ผู้ใช้ไขได้ ปริศนานี้ใช้งานได้ใน Android, iOS, Windows, macOS และ Linux จะเปิดให้ใน Android นะ

ภาพหน้าจอของปริศนาอักษรไขว้ในกระบวนการไขปริศนาด้วยโปรแกรมจำลอง Pixel Fold

ข้อกำหนดเบื้องต้น

สิ่งที่ได้เรียนรู้

  • วิธีใช้ Isolated เพื่อทำงานด้านการคำนวณที่มีค่าใช้จ่ายสูงโดยไม่ขัดขวางการวนแสดงผลของ Flutter ด้วยการรวมฟังก์ชัน compute ของ Flutter และ select สร้างความสามารถในการแคชค่าของตัวกรองอีกครั้ง
  • วิธีใช้ประโยชน์จากโครงสร้างข้อมูลที่เปลี่ยนแปลงไม่ได้ด้วย built_value และ built_collection เพื่อทำให้เทคนิค Good Old Fashioned AI (GOFAI) ที่อิงตามการค้นหา เช่น การค้นหาแบบเน้นข้อมูลเชิงลึกและการย้อนกลับ ใช้งานได้ง่าย
  • วิธีใช้ความสามารถของแพ็กเกจ two_dimensional_scrollables เพื่อแสดงข้อมูลตารางกริดอย่างง่ายและรวดเร็ว

สิ่งที่ต้องมี

  • Flutter SDK
  • โค้ด Visual Studio (โค้ด VS) กับปลั๊กอิน Flutter และ Dart
  • ซอฟต์แวร์คอมไพเลอร์สำหรับเป้าหมายการพัฒนาที่คุณเลือก Codelab นี้ใช้งานได้กับทุกแพลตฟอร์มเดสก์ท็อป Android และ iOS คุณต้องใช้ VS Code เพื่อกำหนดเป้าหมาย Windows, Xcode เพื่อกำหนดเป้าหมายเป็น macOS หรือ iOS และใช้ Android Studio เพื่อกำหนดเป้าหมายเป็น Android

2. สร้างโปรเจ็กต์

สร้างโปรเจ็กต์ Flutter แรก

  1. เปิด VS Code
  2. ในบรรทัดคำสั่ง ให้ป้อน Flutter new แล้วเลือก Flutter: New Project ในเมนู

ภาพหน้าจอของ VS Code กับ

  1. เลือกแอปพลิเคชันว่าง แล้วเลือกไดเรกทอรีที่จะสร้างโปรเจ็กต์ ไดเรกทอรีนี้ควรเป็นไดเรกทอรีที่ไม่ต้องใช้สิทธิ์ในระดับสูงขึ้นหรือมีพื้นที่ว่างในเส้นทาง ตัวอย่างเช่น ไดเรกทอรีหน้าแรกหรือ C:\src\

ภาพหน้าจอของ VS Code ที่มีแอปพลิเคชันว่างเปล่าที่แสดงว่าเลือกไว้เป็นส่วนหนึ่งของขั้นตอนใหม่ของแอปพลิเคชัน

  1. ตั้งชื่อโปรเจ็กต์ของคุณว่า generate_crossword ส่วนที่เหลือของ Codelab จะถือว่าคุณตั้งชื่อแอปว่า generate_crossword

ภาพหน้าจอของ VS Code กับ

ตอนนี้ Flutter จะสร้างโฟลเดอร์โปรเจ็กต์และ VS Code จะเปิดขึ้น คุณจะเขียนทับเนื้อหาของไฟล์ 2 ไฟล์ด้วยโครงข่ายพื้นฐานของแอป

คัดลอกและวางแอปเริ่มต้น

  1. คลิก Explorer ในแผงด้านซ้ายของ VS Code แล้วเปิดไฟล์ 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.3.3 <4.0.0'

dependencies:
  built_collection: ^5.1.1
  built_value: ^8.9.2
  characters: ^1.3.0
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  intl: ^0.19.0
  riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5
  two_dimensional_scrollables: ^0.2.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.0
  build_runner: ^2.4.9
  built_value_generator: ^8.9.2
  custom_lint: ^0.6.4
  riverpod_generator: ^2.4.0
  riverpod_lint: ^2.3.10

flutter:
  uses-material-design: true

ไฟล์ pubspec.yaml ระบุข้อมูลพื้นฐานเกี่ยวกับแอป เช่น เวอร์ชันปัจจุบันและทรัพยากร Dependency คุณจะเห็นคอลเล็กชันทรัพยากร Dependency ที่ไม่ได้เป็นส่วนหนึ่งของแอป Flutter ที่ว่างเปล่าตามปกติ คุณจะได้รับประโยชน์จากแพ็กเกจเหล่านี้ทั้งหมดในเร็วๆ นี้

  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(
          useMaterial3: true,
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: Scaffold(
          body: Center(
            child: Text(
              'Hello, World!',
              style: TextStyle(fontSize: 24),
            ),
          ),
        ),
      ),
    ),
  );
}
  1. เรียกใช้โค้ดนี้เพื่อตรวจสอบว่าทุกอย่างทำงานได้ โดยควรแสดงหน้าต่างใหม่พร้อมวลีเริ่มต้นที่บังคับใช้ของทุกโปรเจ็กต์ใหม่ในทุกที่ มี ProviderScope ซึ่งบ่งชี้ว่าแอปนี้จะใช้ riverpod สำหรับการจัดการรัฐ

หน้าต่างแอปที่มีคำว่า &quot;สวัสดีทุกคน&quot; ตรงกลาง

3. เพิ่มคำ

องค์ประกอบที่ใช้สร้างสรรค์ปริศนาอักษรไขว้

ปริศนาอักษรไขว้คือรายการคำซึ่งมีอยู่ในหัวใจ คำถูกจัดเรียงเป็นรูปตาราง ชิดขวาง บางส่วน ลดบางส่วน จนทำให้คำสอดประสานกัน การแก้โจทย์คำเพียงคำเดียวจะให้เบาะแสเกี่ยวกับคำที่ข้ามคำแรกนั้น ดังนั้น องค์ประกอบพื้นฐานแรกจะต้องเป็นรายการคำ

แหล่งข้อมูลที่ดีของคำเหล่านี้คือหน้า Natural Language Corpus Data ของ Peter Norvig รายการ SOWPODS เป็นจุดเริ่มต้นที่มีประโยชน์ โดยมี 267,750 คำ

ในขั้นตอนนี้ คุณจะต้องดาวน์โหลดรายการคำ เพิ่มเป็นชิ้นงานในแอป Flutter และจัดเตรียมผู้ให้บริการ Riverpod ให้โหลดรายการลงในแอปเมื่อเริ่มต้นใช้งาน

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  1. แก้ไขไฟล์ pubspec.yaml ของโปรเจ็กต์เพื่อเพิ่มการประกาศชิ้นงานต่อไปนี้สำหรับรายการคําที่เลือก ข้อมูลนี้แสดงเฉพาะโครงสร้างที่กระจัดกระจายของการกำหนดค่าแอป เนื่องจากส่วนที่เหลือยังคงเหมือนเดิม

pubspec.yaml

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

โปรแกรมแก้ไขของคุณอาจไฮไลต์บรรทัดสุดท้ายพร้อมคำเตือนเนื่องจากคุณยังไม่ได้สร้างไฟล์นี้

  1. โดยใช้เบราว์เซอร์และเครื่องมือแก้ไข ให้สร้างไดเรกทอรี assets ที่ระดับบนสุดของโปรเจ็กต์และสร้างไฟล์ words.txt ในไฟล์โดยประกอบด้วยรายการคำรายการใดรายการหนึ่งที่ลิงก์ไว้ข้างต้น

โค้ดนี้ได้รับการออกแบบตามรายการ SOWPODS ที่กล่าวถึงข้างต้น แต่ควรใช้งานกับรายการคำใดๆ ที่มีเฉพาะอักขระ A-Z เท่านั้น การขยายฐานของโค้ดนี้ให้ทำงานกับชุดอักขระที่ต่างกันถือเป็นแบบฝึกหัดสำหรับผู้อ่าน

โหลดคำ

ในการเขียนโค้ดสำหรับโหลดรายการคำเมื่อเริ่มต้นแอป ให้ทำตามขั้นตอนต่อไปนี้

  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_annotation/riverpod_annotation.dart';

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

นี่คือผู้ให้บริการ Riverpod รายแรกสำหรับ Codebase นี้ คุณจะสังเกตเห็นว่ามีหลายส่วนที่ผู้แก้ไขของคุณจะบ่นว่าเป็นคลาสที่ไม่ได้กำหนดหรือเป้าหมายที่ยังไม่สร้างขึ้น โปรเจ็กต์นี้ใช้การสร้างโค้ดสำหรับทรัพยากร Dependency ต่างๆ หลายรายการ รวมถึง 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 ที่คุณกำหนดไว้ข้างต้นแบบ Lazy Loading แต่สำหรับจุดประสงค์ของแอปนี้ คุณจะต้องโหลดรายการคำให้ตั้งใจ เอกสารประกอบของ 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;
  }
}

ไฟล์นี้น่าสนใจจาก 2 ทิศทางที่แยกกัน อย่างแรกคือวิดเจ็ต _EagerInitialization ซึ่งมีเป้าหมายเพียงอย่างเดียวคือกำหนดให้ผู้ให้บริการ wordList ที่คุณสร้างไว้ข้างต้นโหลดรายการคำ วิดเจ็ตนี้บรรลุวัตถุประสงค์นี้ด้วยการฟังผู้ให้บริการโดยใช้การโทรของ ref.watch() อ่านข้อมูลเพิ่มเติมเกี่ยวกับเทคนิคนี้ได้ในเอกสารประกอบของ Riverpod เรื่องการเริ่มต้นผู้ให้บริการแบบตั้งใจ

ประเด็นที่น่าสนใจข้อที่ 2 ที่ควรทราบในไฟล์นี้คือวิธีที่ Riverpod จัดการเนื้อหาแบบไม่พร้อมกัน คุณอาจจำได้ว่าผู้ให้บริการ wordList จัดเป็นฟังก์ชันอะซิงโครนัส เนื่องจากการโหลดเนื้อหาจากดิสก์ทำได้ช้า เมื่อดูผู้ให้บริการรายการคำในโค้ดนี้ คุณจะได้รับ AsyncValue<BuiltSet<String>> AsyncValue ของประเภทดังกล่าวเป็นอะแดปเตอร์ระหว่างโลกแบบอะซิงโครนัสของผู้ให้บริการกับโลกแบบซิงโครนัสของเมธอด build ของวิดเจ็ต

เมธอด when ของ AsyncValue จะรองรับเงื่อนไขที่เป็นไปได้ 3 อย่างที่อาจอยู่ในค่าในอนาคต ในอนาคตอาจได้รับการแก้ไขเรียบร้อยแล้ว ซึ่งในกรณีนี้อาจมีการเรียกใช้ Callback ของ data อาจอยู่ในสถานะข้อผิดพลาด ซึ่งในกรณีนี้มีการเรียก error หรือสุดท้ายก็อาจจะยังคงโหลดอยู่ ประเภทการคืนสินค้าของ Callback ทั้ง 3 รายการต้องมีประเภทการแสดงผลที่เข้ากันได้ เนื่องจากเมธอด 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(
          useMaterial3: true,
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordGeneratorApp(),                     // Remove what was here and replace
      ),
    ),
  );
}
  1. รีสตาร์ทแอป คุณจะเห็นรายการแบบเลื่อนที่ต่อเนื่องไปเกือบตลอดกาล

หน้าต่างแอปที่มีชื่อว่า &quot;โปรแกรมสร้างข้ามคำ&quot; และรายการคำ

4. แสดงคำในตารางกริด

ในขั้นตอนนี้ คุณจะต้องสร้างโครงสร้างข้อมูลสำหรับการสร้างปริศนาอักษรไขว้โดยใช้แพ็กเกจ built_value และ built_collection แพ็กเกจทั้งสองนี้ทำให้สามารถสร้างโครงสร้างข้อมูลเป็นค่าที่เปลี่ยนแปลงไม่ได้ ซึ่งจะเป็นประโยชน์สำหรับทั้งการส่งข้อมูลระหว่าง Isolates ได้อย่างง่ายดาย และทำให้การใช้การค้นหาครั้งแรกแบบเจาะลึกและการย้อนกลับได้ง่ายดายขึ้นมาก

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  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 โดยเมธอด _fillCharacters จะสร้างตารางกริดของ CrosswordCharacter ขึ้นเป็นส่วนหนึ่งของการสร้างค่าที่สรุป

หากต้องการใช้โครงสร้างข้อมูลนี้ ให้ทำตามขั้นตอนต่อไปนี้

  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. เพิ่มผู้ให้บริการต่อไปนี้ต่อท้ายไฟล์เดียวกัน

lib/providers.dart

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}

final _random = Random();

@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  var crossword =
      model.Crossword.crossword(width: size.width, height: size.height);

  yield* wordListAsync.when(
    data: (wordList) async* {
      while (crossword.characters.length < size.width * size.height * 0.8) {
        final word = wordList.randomElement();
        final direction =
            _random.nextBool() ? model.Direction.across : model.Direction.down;
        final location = model.Location.at(
            _random.nextInt(size.width), _random.nextInt(size.height));

        crossword = crossword.addWord(
            word: word, direction: direction, location: location);
        yield crossword;
        await Future.delayed(Duration(milliseconds: 100));
      }

      yield crossword;
    },
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield crossword;
    },
    loading: () async* {
      yield crossword;
    },
  );
}

การเปลี่ยนแปลงเหล่านี้จะเพิ่มผู้ให้บริการ 2 รายในแอป ค่าแรกคือ Size ซึ่งเป็นตัวแปรร่วมที่มีประสิทธิภาพที่มีค่าที่เลือกไว้ในปัจจุบันของการแจกแจง CrosswordSize ซึ่งจะทำให้ UI สามารถแสดงและกำหนดขนาดของอักษรไขว้ที่อยู่ระหว่างการสร้างได้ ผู้ให้บริการรายที่ 2 ที่ชื่อว่า crossword เป็นผลงานที่น่าสนใจมากขึ้น เป็นฟังก์ชันที่แสดงผลชุดของ Crossword โดยสร้างโดยใช้การรองรับเครื่องกำเนิดไฟฟ้าของ Dart ตามเครื่องหมาย async* ในฟังก์ชัน ซึ่งหมายความว่าแทนที่จะสิ้นสุดด้วยการส่งคืนผลลัพธ์ จะได้ผลลัพธ์เป็น Crossword หลายชุด ซึ่งช่วยให้เขียนการคํานวณที่แสดงผลการค้นหาระดับกลางได้ง่ายขึ้น

เนื่องจากมีการเรียกใช้ ref.watch 2 คู่ที่จุดเริ่มต้นของฟังก์ชันผู้ให้บริการ crossword ระบบ Riverpod จึงจะรีสตาร์ทสตรีมของ Crosswords ทุกครั้งที่ขนาดที่เลือกของอักษรไขว้เปลี่ยนแปลงและเมื่อรายการคำโหลดเสร็จ

ตอนนี้คุณมีโค้ดสำหรับสร้างปริศนาอักษรไขว้แล้ว แม้จะมีคำแบบสุ่มอยู่มากมาย แต่ก็เป็นการดีที่จะให้แสดงคำเหล่านั้นกับผู้ใช้เครื่องมือ

  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 แต่ละเซลล์จะมีวิดเจ็ต Consumer อยู่ในแผนผัง Widget ที่แสดงผล ซึ่งทำหน้าที่เป็นขอบเขตการรีเฟรช ทุกอย่างในวิดเจ็ต Consumer จะสร้างขึ้นใหม่เมื่อค่าที่ ref.watch แสดงผลมีการเปลี่ยนแปลง คุณอาจอยากสร้างแผนผังต้นไม้ทั้งหมดขึ้นใหม่ทุกครั้งที่ Crossword เปลี่ยนแปลง แต่วิธีนี้ทำให้เกิดการคำนวณจำนวนมาก ซึ่งคุณสามารถข้ามได้โดยใช้การตั้งค่านี้

หากดูพารามิเตอร์ของ ref.watch คุณจะเห็นว่ามีชั้นป้องกันการคำนวณเลย์เอาต์ใหม่อีกชั้นหนึ่งโดยใช้ crosswordProvider.select ซึ่งหมายความว่า ref.watch จะทริกเกอร์การสร้างเนื้อหาของ TableViewCell ใหม่ต่อเมื่ออักขระที่เซลล์รับผิดชอบในการแสดงผลมีการเปลี่ยนแปลงเท่านั้น ซึ่งการลดการแสดงผลซ้ำนี้เป็นส่วนสำคัญที่ทำให้ UI ปรับเปลี่ยนตามอุปกรณ์อยู่เสมอ

หากต้องการแสดงผู้ให้บริการ CrosswordWidget และ Size ต่อผู้ใช้ ให้เปลี่ยนไฟล์ crossword_generator_app.dart ดังนี้

lib/widgets/crossword_generator_app.dart

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

import '../providers.dart';
import 'crossword_widget.dart';                               // Add this import

class CrosswordGeneratorApp extends StatelessWidget {
  const CrosswordGeneratorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return _EagerInitialization(
      child: Scaffold(
        appBar: AppBar(
          actions: [_CrosswordGeneratorMenu()],               // Add this line
          titleTextStyle: TextStyle(
            color: Theme.of(context).colorScheme.primary,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
          title: Text('Crossword Generator'),
        ),
        body: SafeArea(
          child: CrosswordWidget(),                           // Replaces everything that was here before
        ),
      ),
    );
  }
}

class _EagerInitialization extends ConsumerWidget {
  const _EagerInitialization({required this.child});
  final Widget child;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(wordListProvider);
    return child;
  }
}

class _CrosswordGeneratorMenu extends ConsumerWidget {        // Add from here
  @override
  Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
        menuChildren: [
          for (final entry in CrosswordSize.values)
            MenuItemButton(
              onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
              leadingIcon: entry == ref.watch(sizeProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              child: Text(entry.label),
            ),
        ],
        builder: (context, controller, child) => IconButton(
          onPressed: () => controller.open(),
          icon: Icon(Icons.settings),
        ),
      );                                                      // To here.
}

มีบางสิ่งเปลี่ยนแปลงที่นี่ ประการแรก โค้ดที่ทำหน้าที่แสดงผล wordList เป็น ListView ถูกแทนที่ด้วยการเรียก CrosswordWidget ที่กำหนดไว้ในไฟล์ก่อนหน้า การเปลี่ยนแปลงสำคัญอีกอย่างคือจุดเริ่มต้นของเมนูสำหรับเปลี่ยนลักษณะการทำงานของแอป โดยเริ่มจากการเปลี่ยนขนาดของอักษรไขว้ เราจะเพิ่มMenuItemButtonอื่นๆ อีกในขั้นตอนต่อๆ ไป เรียกใช้แอป คุณจะเห็นบางสิ่งดังนี้

หน้าต่างแอปที่มีชื่อว่า Crossword Generator และตารางอักขระที่วางเป็นคำทับซ้อนกันโดยไม่มีคำคล้องจองหรือเหตุผล

มีอักขระที่แสดงในตารางกริดและเมนูที่ให้ผู้ใช้เปลี่ยนขนาดของตารางได้ แต่ข้อความไม่ได้เรียงออกมาเหมือนปริศนาอักษรไขว้ นี่เป็นผลมาจากการไม่บังคับใช้ข้อจำกัดใดๆ ในการเพิ่มคำลงในปริศนาอักษรไขว้ พูดง่ายๆ ก็คืองานยุ่ง สิ่งที่คุณจะเริ่มนำมาควบคุมได้ในขั้นตอนถัดไป

5. บังคับใช้ข้อจำกัด

เป้าหมายของขั้นตอนนี้คือการเพิ่มโค้ดไปยังโมเดลเพื่อบังคับใช้ข้อจำกัดแบบครอสเวิร์ด เกมไขปัญหาครอสเวิร์ดมีหลายประเภท และรูปแบบที่ Codelab นี้จะบังคับใช้ตามธรรมเนียมเกมไขปัญหาครอสเวิร์ดภาษาอังกฤษ การปรับเปลี่ยนโค้ดนี้เพื่อสร้างปริศนาอักษรไขว้รูปแบบอื่นๆ เป็นแบบฝึกหัดสำหรับผู้อ่านเช่นเคย

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  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 จะต้องเรียกใช้ build_runner เพื่ออัปเดตไฟล์ model.g.dart และ providers.g.dart ที่เกี่ยวข้อง หากไฟล์เหล่านี้ไม่ได้อัปเดตตัวเองโดยอัตโนมัติ ตอนนี้ก็เป็นโอกาสดีที่จะเริ่มต้น 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. เรียกใช้แอป แทบไม่มีสิ่งใดเกิดขึ้นใน UI แต่มีอะไรเกิดขึ้นมากมายถ้าคุณดูบันทึก

หน้าต่างแอปครอสเวิร์ด Generator ที่มีคำวางซ้อนกันและตัดกันตามจุดแบบสุ่ม

ถ้าคุณคิดว่าเกิดอะไรขึ้นที่นี่ เราจะเห็นอักษรไขว้ปรากฏขึ้นโดยบังเอิญ เมธอด addWord ในรูปแบบ Crossword จะปฏิเสธคำที่เสนอซึ่งไม่เหมาะกับคำไขว้ปัจจุบัน ดังนั้นจึงเป็นเรื่องน่ามหัศจรรย์ที่เราเห็นทุกอย่างปรากฏขึ้น

เพื่อเป็นการเตรียมพร้อมสำหรับความมีระเบียบมากขึ้นในการเลือกคำที่จะลองใช้ในส่วนไหน การย้ายการคำนวณนี้ออกจากเธรด UI และการไปแยกไว้เบื้องหลังจะช่วยได้มาก Flutter มี Wrapper ที่มีประโยชน์อย่างยิ่งสำหรับการรวบรวมงานบางส่วนและเรียกใช้ในเบื้องหลัง ซึ่งก็คือฟังก์ชัน compute

  1. ในไฟล์ providers.dart ให้แก้ไขผู้ให้บริการคำไขว้ดังนี้

lib/providers.dart

@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  var crossword =
      model.Crossword.crossword(width: size.width, height: size.height);

  yield* wordListAsync.when(
    data: (wordList) async* {
      while (crossword.characters.length < size.width * size.height * 0.8) {
        final word = wordList.randomElement();
        final direction =
            _random.nextBool() ? model.Direction.across : model.Direction.down;
        final location = model.Location.at(
            _random.nextInt(size.width), _random.nextInt(size.height));
        try {
          var candidate = await compute(                   // Edit from here.
              ((String, model.Direction, model.Location) wordToAdd) {
            final (word, direction, location) = wordToAdd;
            return crossword.addWord(
                word: word, direction: direction, location: location);
          }, (word, direction, location));

          if (candidate != null) {
            crossword = candidate;
            yield crossword;
          }
        } catch (e) {
          debugPrint('Error running isolate: $e');
        }                                                  // To here.
      }

      yield crossword;
    },
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield crossword;
    },
    loading: () async* {
      yield crossword;
    },
  );
}

โค้ดนี้ใช้งานได้ อย่างไรก็ตาม แท็กดังกล่าวมีกับดัก หากยังคงเดินตามเส้นทางนี้ ท้ายที่สุดแล้วจะมีข้อผิดพลาดที่บันทึกไว้ดังตัวอย่างต่อไปนี้

flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information)
flutter:  <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart)
flutter:  <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 }
flutter:  <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } }
flutter:  <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)

นี่เป็นผลมาจากการปิดที่ compute ส่งมอบให้กับพื้นหลังโดยแยกปิดผู้ให้บริการ ซึ่งส่งผ่าน SendPort.send() ไม่ได้ วิธีแก้ไขอย่างหนึ่งคือตรวจสอบว่าไม่มีข้อมูลการปิดระบบที่ส่งข้อความไม่ได้

ขั้นตอนแรกคือการแยกผู้ให้บริการออกจากรหัส "แยก"

  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_annotation/riverpod_annotation.dart';

import 'isolates.dart';                                    // Add this import
import 'model.dart' as model;
                                                           // Drop the utils.dart import

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}
                                                           // Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  final emptyCrossword =                                   // Edit from here
      model.Crossword.crossword(width: size.width, height: size.height);

  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyCrossword;
    },
    loading: () async* {
      yield emptyCrossword;                                // To here.
    },
  );
}

คราวนี้คุณก็จะได้มีเครื่องมือสร้างปริศนาอักษรไขว้ขนาดต่างๆ กัน โดยมี compute ในการไขปริศนาที่เกิดขึ้นในเบื้องหลัง คราวนี้ถ้ามีเพียงโค้ดที่จะมีประสิทธิภาพมากขึ้นเมื่อต้องตัดสินใจว่าจะลองเพิ่มคำใดลงในปริศนาอักษรไขว้

6. จัดการคิวงาน

ส่วนหนึ่งของปัญหาของโค้ดตามที่เห็นคือ ปัญหาที่กำลังแก้ไขคือปัญหาด้านการค้นหาอย่างมีประสิทธิภาพ และวิธีแก้ปัญหาในปัจจุบันคือการค้นหาแบบบอด หากโค้ดมุ่งไปที่การค้นหาคำที่จะแนบไปกับคำปัจจุบัน แทนที่จะพยายามสุ่มวางคำตรงไหนก็ได้ในตาราง ระบบจะหาวิธีแก้ปัญหาได้เร็วขึ้น วิธีการก็คือการแนะนำคิวงานของสถานที่ตั้งเพื่อพยายามค้นหาคำ

ปัจจุบันโค้ดจะสร้างโซลูชันที่รอการพิจารณา ตรวจสอบว่าโซลูชันที่รอการพิจารณานั้นถูกต้องหรือไม่ และขึ้นอยู่กับว่าระบบรวมโซลูชันที่รอการพิจารณาไว้หรือทิ้งไป ทั้งนี้ขึ้นอยู่กับความถูกต้อง นี่คือตัวอย่างการใช้งานจากกลุ่มอัลกอริทึมย้อนหลัง การติดตั้งใช้งานนี้ง่ายขึ้นมากเมื่อ built_value และ built_collection ทําให้สร้างค่าที่เปลี่ยนแปลงไม่ได้ใหม่ซึ่งได้มา ดังนั้นจึงแชร์สถานะทั่วไปกับค่าที่เปลี่ยนแปลงไม่ได้ซึ่งได้มา ทำให้สามารถแสวงหาผลประโยชน์ราคาถูกจากผู้มีโอกาสเป็นผู้สมัครโดยไม่ต้องเปลืองหน่วยความจำสำหรับการคัดลอกแบบละเอียด

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  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. หากยังมีการยึกยือสีแดงอยู่ในไฟล์นี้หลังจากเพิ่มเนื้อหาใหม่นี้นานกว่า 2-3 วินาที ให้ตรวจสอบว่า build_runner ยังทำงานอยู่ หากไม่เห็น ให้เรียกใช้คำสั่ง dart run build_runner watch -d

ในโค้ดที่คุณจะเริ่มใช้การบันทึกเพื่อแสดงระยะเวลาที่ใช้ในการสร้างอักษรไขว้ขนาดต่างๆ ระยะเวลาคงจะดีไม่น้อยหากมีรูปแบบการแสดงผลที่มีการจัดรูปแบบอย่างสวยงาม โชคดีที่เรามีวิธีการขยายเวลาแล้ว เราสามารถเพิ่มวิธีการที่ต้องการได้

  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.

เมธอดส่วนขยายนี้ใช้ประโยชน์จากนิพจน์สวิตช์และการจับคู่รูปแบบจากระเบียน เพื่อเลือกวิธีที่เหมาะสมในการแสดงระยะเวลาที่แตกต่างกันตั้งแต่วินาทีไปจนถึงวัน สำหรับข้อมูลเพิ่มเติมเกี่ยวกับรูปแบบโค้ดนี้ โปรดดู Codelab เกี่ยวกับเจาะลึกรูปแบบและบันทึกของ 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}');
}

การเรียกใช้โค้ดนี้จะทำให้แอปมีหน้าตาเหมือนกันบนพื้นผิว แต่ต่างกันตรงที่ระยะเวลาในการค้นหาปริศนาอักษรไขว้ที่เสร็จสมบูรณ์ นี่คือปริศนาอักษรไขว้ขนาด 80 x 44 ที่สร้างขึ้นใน 1 นาที 29 วินาที

โปรแกรมสร้างอักษรไขว้ที่มีคำตัดกันจำนวนมาก ซูมออก คำมีขนาดเล็กเกินกว่าที่จะอ่านได้

แน่นอนว่าคำถามที่เห็นได้ชัดคือ เราจะไปถึงเร็วขึ้นได้หรือไม่ ใช่ เราทำได้

7. แสดงสถิติ

การทำให้อะไรสักอย่างรวดเร็วช่วยให้เห็นสิ่งที่กำลังเกิดขึ้น สิ่งหนึ่งที่ช่วยเราในเรื่องนี้ได้คือการแสดงข้อมูลเกี่ยวกับกระบวนการที่กำลังดำเนินการอยู่ ดังนั้นในตอนนี้ก็ถึงเวลาเพิ่มการใช้เครื่องมือและแสดงข้อมูลดังกล่าวเป็นแผงข้อมูลแบบวางเมาส์เหนือ

ข้อมูลที่คุณจะแสดงจะต้องดึงออกจาก WorkQueue และแสดงใน UI

ขั้นตอนแรกที่มีประโยชน์คือการกำหนดคลาสโมเดลใหม่ที่มีข้อมูลที่คุณต้องการแสดง

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  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_annotation/riverpod_annotation.dart';

import 'isolates.dart';
import 'model.dart' as model;

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}

@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* { // Modify this provider
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);
  final emptyCrossword =
      model.Crossword.crossword(width: size.width, height: size.height);
  final emptyWorkQueue = model.WorkQueue.from(
    crossword: emptyCrossword,
    candidateWords: BuiltSet<String>(),
    startLocation: model.Location.at(0, 0),
  );

  ref.read(startTimeProvider.notifier).start();
  ref.read(endTimeProvider.notifier).clear();

  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyWorkQueue;
    },
    loading: () async* {
      yield emptyWorkQueue;
    },
  );

  ref.read(endTimeProvider.notifier).end();
}                                                          // To here.

@Riverpod(keepAlive: true)                                 // Add from here to end of file
class StartTime extends _$StartTime {
  @override
  DateTime? build() => _start;

  DateTime? _start;

  void start() {
    _start = DateTime.now();
    ref.invalidateSelf();
  }
}

@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
  @override
  DateTime? build() => _end;

  DateTime? _end;

  void clear() {
    _end = null;
    ref.invalidateSelf();
  }

  void end() {
    _end = DateTime.now();
    ref.invalidateSelf();
  }
}

const _estimatedTotalCoverage = 0.54;

@riverpod
Duration expectedRemainingTime(ExpectedRemainingTimeRef ref) {
  final startTime = ref.watch(startTimeProvider);
  final endTime = ref.watch(endTimeProvider);
  final workQueueAsync = ref.watch(workQueueProvider);

  return workQueueAsync.when(
    data: (workQueue) {
      if (startTime == null || endTime != null || workQueue.isCompleted) {
        return Duration.zero;
      }
      try {
        final soFar = DateTime.now().difference(startTime);
        final completedPercentage = min(
            0.99,
            (workQueue.crossword.characters.length /
                (workQueue.crossword.width * workQueue.crossword.height) /
                _estimatedTotalCoverage));
        final expectedTotal = soFar.inSeconds / completedPercentage;
        final expectedRemaining = expectedTotal - soFar.inSeconds;
        return Duration(seconds: expectedRemaining.toInt());
      } catch (e) {
        return Duration.zero;
      }
    },
    error: (error, stackTrace) => Duration.zero,
    loading: () => Duration.zero,
  );
}

/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
  var _display = true;

  @override
  bool build() => _display;

  void toggle() {
    _display = !_display;
    ref.invalidateSelf();
  }
}

/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
  @override
  model.DisplayInfo build() => ref.watch(workQueueProvider).when(
        data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
        error: (error, stackTrace) => model.DisplayInfo.empty,
        loading: () => model.DisplayInfo.empty,
      );
}

ผู้ให้บริการใหม่เป็นการผสมผสานระหว่างรัฐที่ดำเนินธุรกิจทั่วโลกในรูปแบบที่จะวางการแสดงข้อมูลบนตารางครอสเวิร์ดหรือไม่ และข้อมูลที่ได้ เช่น เวลาที่ใช้ในการสร้างปริศนาอักษรไขว้ ทั้งหมดนี้เป็นเรื่องซับซ้อนเพราะการที่ผู้ฟังสถานะนี้บางส่วนเป็นแบบชั่วคราว จะไม่มีการฟังเวลาเริ่มต้นและเวลาสิ้นสุดของการคำนวณครอสเวิร์ดหากการแสดงข้อมูลถูกซ่อนไว้ แต่การแสดงผลข้อมูลจะต้องอยู่ในหน่วยความจำหากการคำนวณถูกต้องเมื่อการแสดงข้อมูลแสดงขึ้น พารามิเตอร์ keepAlive ของแอตทริบิวต์ Riverpod มีประโยชน์มากในกรณีนี้

ในการแสดงข้อมูล มีรอยย่นเล็กน้อย เราต้องการแสดงเวลาที่ผ่านไปในปัจจุบันได้ แต่ไม่มีสิ่งใดในที่นี้ที่จะบังคับการอัปเดตอย่างต่อเนื่องของเวลาที่ล่วงไปได้โดยง่าย ย้อนกลับไปที่ Codelab เกี่ยวกับการสร้าง UI รุ่นใหม่ใน Flutter วิดเจ็ตนี้ยังมีวิดเจ็ตที่เป็นประโยชน์มากสำหรับข้อกำหนดนี้

  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

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 วิดเจ็ตนี้จะถูกทำเครื่องหมายไว้สำหรับการสร้างใหม่เมื่อผู้ให้บริการใดๆ ใน 5 รายอัปเดต การเปลี่ยนแปลงที่จำเป็นสุดท้ายในขั้นตอนนี้คือการผสานรวมวิดเจ็ตใหม่นี้เข้ากับ UI

  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(
        menu Children: [
          for (final entry in CrosswordSize.values)
            MenuItemButton(
              onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
              leadingIcon: entry == ref.watch(sizeProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              child: Text(entry.label),
            ),
          MenuItemButton(                                  // Add from here
            leadingIcon: ref.watch(showDisplayInfoProvider)
                ? Icon(Icons.check_box_outlined)
                : Icon(Icons.check_box_outline_blank_outlined),
            onPressed: () =>
                ref.read(showDisplayInfoProvider.notifier).toggle(),
            child: Text('Display Info'),
          ),                                               // To here.
        ],
        builder: (context, controller, child) => IconButton(
          onPressed: () => controller.open(),
          icon: Icon(Icons.settings),
        ),
      );
}

การเปลี่ยนแปลง 2 รายการข้างต้นนี้แสดงวิธีการผสานรวมผู้ให้บริการที่ต่างกัน ในเมธอด build ของ CrosswordGeneratorApp คุณได้แนะนำเครื่องมือสร้าง Consumer ใหม่เพื่อให้มีพื้นที่ที่บังคับให้สร้างใหม่เมื่อการแสดงข้อมูลแสดงขึ้นหรือซ่อนไว้ ในอีกแง่หนึ่ง เมนูแบบเลื่อนลงทั้งหมดจะเป็น ConsumerWidget หนึ่งรายการ ซึ่งจะได้รับการสร้างใหม่ ไม่ว่าจะเป็นการปรับขนาดของอักษรไขว้ หรือการแสดงหรือซ่อนการแสดงข้อมูล แนวทางที่ควรใช้ย่อมต้องแลกกับความเรียบง่ายทางวิศวกรรมเสมอเมื่อเทียบกับต้นทุนในการคำนวณเลย์เอาต์ของวิดเจ็ตต้นไม้ที่สร้างใหม่

การเรียกใช้แอปในขณะนี้ช่วยให้ผู้ใช้ได้รับข้อมูลเชิงลึกเกี่ยวกับความคืบหน้าของการสร้างครอสเวิร์ด อย่างไรก็ตาม ในช่วงท้ายของการสร้างปริศนาอักษรไขว้ เราพบว่ามีบางช่วงเวลาที่ตัวเลขมีการเปลี่ยนแปลง แต่ตารางอักขระนั้นมีการเปลี่ยนแปลงน้อยมาก

หน้าต่างแอป Crossword Generator ซึ่งมีคำที่มีขนาดเล็กลงแต่เป็นคำที่รู้จัก และการวางซ้อนแบบลอยในมุมล่างขวาที่มีสถิติเกี่ยวกับการเรียกใช้รุ่นปัจจุบัน

การได้รับข้อมูลเชิงลึกเพิ่มเติมเกี่ยวกับสิ่งที่เกิดขึ้นและสาเหตุจะเป็นประโยชน์มาก

8. โหลดพร้อมกันกับชุดข้อความ

การทำความเข้าใจว่าทำไมสิ่งต่างๆ ถึงล่าช้าในช่วงท้าย การแสดงภาพว่าอัลกอริทึมกำลังทำอะไรจึงมีประโยชน์ ส่วนสำคัญคือ 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),
        ),
      ),
    );
  }
}

เมื่อคุณเรียกใช้โค้ดนี้ คุณจะเห็นภาพของตำแหน่งคงค้างที่อัลกอริทึมยังไม่ได้ตรวจสอบ

โปรแกรมสร้างตารางไขว้ที่แสดงการสร้างผลงานบางส่วน ตัวอักษรบางตัวมีข้อความสีขาวบนพื้นหลังสีน้ำเงินเข้ม ขณะที่ตัวอื่นๆ เป็นข้อความสีน้ำเงินบนพื้นหลังสีขาว

สิ่งที่น่าสนใจในการรับชมเรื่องนี้ขณะที่รูปแบบอักษรไขว้ดำเนินการจนเสร็จสิ้นคือมีคะแนนที่ต้องตรวจสอบเพิ่มเติมซึ่งจะไม่ทำให้ข้อมูลมีประโยชน์ใดๆ ซึ่งมี 2 ตัวเลือกดังต่อไปนี้ หนึ่ง คือการจำกัดการตรวจสอบเมื่อมีการเติมข้อมูลในเซลล์ไขว้ตามที่ระบุ และที่สองคือตรวจสอบจุดที่น่าสนใจหลายจุดในคราวเดียว เส้นทางที่สองฟังดูสนุกกว่า งั้นมาเล่นกันเลย

  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 2 เลเยอร์ เลเยอร์แรกมีหน้าที่ทำฟาร์มแต่ละตำแหน่งเพื่อค้นหาให้คนทำงาน N แยกออกมา จากนั้นจึงรวมผลลัพธ์อีกครั้งเมื่อผู้ปฏิบัติงาน N คนแยกครบถ้วนเสร็จแล้ว เลเยอร์ที่สองประกอบด้วยการแยกผู้ปฏิบัติงาน N การปรับ N เพื่อให้ได้รับประสิทธิภาพที่ดีที่สุดขึ้นอยู่กับทั้งคอมพิวเตอร์ของคุณและข้อมูลที่เป็นปัญหา ยิ่งตารางกริดมีขนาดใหญ่เท่าใด ผู้ปฏิบัติงานก็จะทำงานร่วมกันได้ดีขึ้นโดยไม่ขัดขวางกันและกัน

รอยย่นที่น่าสนใจอย่างหนึ่งคือการบันทึกว่าโค้ดนี้จะจัดการกับปัญหาการปิดซึ่งจับภาพสิ่งที่ไม่ควรจับภาพอย่างไร ปัจจุบันไม่มีการปิดถนน ฟังก์ชัน _generate และ _generateWorker กำหนดเป็นฟังก์ชันระดับบนสุดที่ไม่มีสภาพแวดล้อมโดยรอบให้ดึงข้อมูล อาร์กิวเมนต์และผลลัพธ์ของฟังก์ชันทั้งสองนี้อยู่ในรูปแบบของระเบียน Dart นี่เป็นวิธีง่ายๆ ในการจัดการกับค่า 1 ค่าใน 1 ความหมายของการเรียก compute

ตอนนี้คุณสามารถสร้างกลุ่มผู้ปฏิบัติงานที่ทำงานอยู่เบื้องหลังเพื่อค้นหาคำที่เชื่อมกันอยู่ในตารางกริดเพื่อไขปริศนาอักษรไขว้ได้ ก็ถึงเวลาเปิดใช้ความสามารถดังกล่าวในเครื่องมือสร้างครอสเวิร์ดอื่นๆ แล้ว

  1. แก้ไขไฟล์ providers.dart โดยแก้ไขผู้ให้บริการ WorkQueue ดังนี้

lib/providers.dart

@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
  final workers = ref.watch(workerCountProvider);          // Add this line
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);
  final emptyCrossword =
      model.Crossword.crossword(width: size.width, height: size.height);
  final emptyWorkQueue = model.WorkQueue.from(
    crossword: emptyCrossword,
    candidateWords: BuiltSet<String>(),
    startLocation: model.Location.at(0, 0),
  );

  ref.read(startTimeProvider.notifier).start();
  ref.read(endTimeProvider.notifier).clear();

  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
      maxWorkerCount: workers.count,                       // Add this line
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyWorkQueue;
    },
    loading: () async* {
      yield emptyWorkQueue;
    },
  );

  ref.read(endTimeProvider.notifier).end();
}
  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.

เมื่อมีการเปลี่ยนแปลงทั้ง 2 อย่างนี้ ตอนนี้เลเยอร์ผู้ให้บริการจะแสดงวิธีกำหนดจำนวนผู้ปฏิบัติงานสูงสุดสำหรับ Isolated Pool เบื้องหลังในลักษณะที่กำหนดค่าฟังก์ชัน Isolated ได้อย่างถูกต้อง

  1. อัปเดตไฟล์ crossword_info_widget.dart โดยแก้ไข CrosswordInfoWidget ดังนี้

lib/widgets/crossword_info_widget.dart

class CrosswordInfoWidget extends ConsumerWidget {
  const CrosswordInfoWidget({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final size = ref.watch(sizeProvider);
    final displayInfo = ref.watch(displayInfoProvider);
    final workerCount = ref.watch(workerCountProvider).label;  // Add this line
    final startTime = ref.watch(startTimeProvider);
    final endTime = ref.watch(endTimeProvider);
    final remaining = ref.watch(expectedRemainingTimeProvider);

    return Align(
      alignment: Alignment.bottomRight,
      child: Padding(
        padding: const EdgeInsets.only(
          right: 32.0,
          bottom: 32.0,
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: ColoredBox(
            color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
            child: Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: 12,
                vertical: 8,
              ),
              child: DefaultTextStyle(
                style: TextStyle(
                    fontSize: 16, color: Theme.of(context).colorScheme.primary),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _CrosswordInfoRichText(
                        label: 'Grid Size',
                        value: '${size.width} x ${size.height}'),
                    _CrosswordInfoRichText(
                        label: 'Words in grid',
                        value: displayInfo.wordsInGridCount),
                    _CrosswordInfoRichText(
                        label: 'Candidate words',
                        value: displayInfo.candidateWordsCount),
                    _CrosswordInfoRichText(
                        label: 'Locations to explore',
                        value: displayInfo.locationsToExploreCount),
                    _CrosswordInfoRichText(
                        label: 'Known bad locations',
                        value: displayInfo.knownBadLocationsCount),
                    _CrosswordInfoRichText(
                        label: 'Grid filled',
                        value: displayInfo.gridFilledPercentage),
                    _CrosswordInfoRichText(               // Add these two lines
                        label: 'Max worker count', value: workerCount),
                    switch ((startTime, endTime)) {
                      (null, _) => _CrosswordInfoRichText(
                          label: 'Time elapsed',
                          value: 'Not started yet',
                        ),
                      (DateTime start, null) => TickerBuilder(
                          builder: (context) => _CrosswordInfoRichText(
                            label: 'Time elapsed',
                            value: DateTime.now().difference(start).formatted,
                          ),
                        ),
                      (DateTime start, DateTime end) => _CrosswordInfoRichText(
                          label: 'Completed in',
                          value: end.difference(start).formatted),
                    },
                    if (startTime != null && endTime == null)
                      _CrosswordInfoRichText(
                          label: 'Est. remaining', value: remaining.formatted),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
  1. แก้ไขไฟล์ crossword_generator_app.dart โดยเพิ่มส่วนต่อไปนี้ลงในวิดเจ็ต _CrosswordGeneratorMenu

lib/widgets/crossword_generator_app.dart

class _CrosswordGeneratorMenu extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
        menuChildren: [
          for (final entry in CrosswordSize.values)
            MenuItemButton(
              onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
              leadingIcon: entry == ref.watch(sizeProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              child: Text(entry.label),
            ),
          MenuItemButton(
            leadingIcon: ref.watch(showDisplayInfoProvider)
                ? Icon(Icons.check_box_outlined)
                : Icon(Icons.check_box_outline_blank_outlined),
            onPressed: () =>
                ref.read(showDisplayInfoProvider.notifier).toggle(),
            child: Text('Display Info'),
          ),
          for (final count in BackgroundWorkers.values)    // Add from here
            MenuItemButton(
              leadingIcon: count == ref.watch(workerCountProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              onPressed: () =>
                  ref.read(workerCountProvider.notifier).setCount(count),
              child: Text(count.label),                    // To here.
            ),
        ],
        builder: (context, controller, child) => IconButton(
          onPressed: () => controller.open(),
          icon: Icon(Icons.settings),
        ),
      );
}

ถ้าคุณเรียกใช้แอปตอนนี้ คุณจะสามารถแก้ไขจำนวนการแยกพื้นหลังที่สร้างเป็นอินสแตนซ์เพื่อค้นหาคำที่จะใส่ไว้ในครอสเวิร์ดได้

  1. คลิกที่ไอคอนรูปเฟืองในเพื่อเปิดเมนูตามบริบทที่มีการปรับขนาดของอักษรไขว้ เลือกว่าจะแสดงสถิติเกี่ยวกับอักษรไขว้ที่สร้างขึ้นในปัจจุบันหรือไม่ และตอนนี้แสดงจำนวน Is ที่จะต้องใช้

หน้าต่างโปรแกรมสร้างปริศนาอักษรไขว้ที่มีคำและสถิติ

การเรียกใช้โปรแกรมสร้างปริศนาอักษรไขว้ช่วยลดเวลาประมวลผลสำหรับอักษรไขว้ขนาด 80x44 ลงอย่างมากโดยการใช้หลายแกนพร้อมกัน

9. เปลี่ยนให้เป็นเกม

ส่วนสุดท้ายนี้เป็นรอบพิเศษจริงๆ คุณจะได้นำเทคนิคทั้งหมดที่ได้เรียนรู้ขณะสร้างโปรแกรมสร้างปริศนาอักษรไขว้และใช้เทคนิคเหล่านี้ในการสร้างเกม คุณสามารถใช้โปรแกรมสร้างปริศนาอักษรไขว้เพื่อสร้างปริศนาอักษรไขว้ คุณจะใช้สำนวนเมนูตามบริบทซ้ำเพื่อให้ผู้ใช้เลือกและยกเลิกการเลือกคำที่จะใส่ลงในช่องรูปคำต่างๆ ในตารางได้ โดยมีจุดประสงค์เพื่อไขปริศนาให้สำเร็จ

ฉันไม่ได้จะบอกว่าเกมนี้ดีเลยหรือเล่นจบแล้ว แต่มันยังไม่ใช่เรื่องจริงเลย ปัญหาความสมดุลและความยากที่สามารถแก้ไขได้ด้วยการปรับปรุงการใช้คำทางเลือก ไม่มีบทแนะนําเพื่อดึงดูดผู้ใช้ และภาพเคลื่อนไหวที่ชวนให้คิดอะไรก็ให้อะไรหลายๆ อย่างเป็นที่ต้องการ ฉันจะไม่พูดถึงสิ่งที่ทำได้ง่ายๆ ว่า "คุณชนะ" บนหน้าจอ

ข้อดีข้อเสียก็คือ การขัดเกลาเกมโปรโตนี้ให้เป็นเกมเวอร์ชันเต็มอย่างสมบูรณ์นั้นจะต้องใช้โค้ดมากกว่า มีโค้ดมากกว่าที่ควรจะอยู่ใน Codelab เดียว ดังนั้น จึงเป็นขั้นตอนการทดสอบความเร็วที่ออกแบบมาเพื่อเสริมเทคนิคที่ได้เรียนรู้ใน Codelab นี้โดยการเปลี่ยนตำแหน่งและวิธีการใช้งาน เราหวังว่าข้อมูลนี้จะช่วยเน้นย้ำบทเรียนที่ได้มาก่อนหน้านี้ใน Codelab นี้ หรือคุณจะสร้างประสบการณ์ของคุณเองโดยใช้โค้ดนี้เลยก็ได้ เราอยากเห็นสิ่งที่คุณสร้าง

ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:

  1. ลบทุกอย่างในไดเรกทอรี lib/widgets คุณจะได้สร้างวิดเจ็ตใหม่เอี่ยมสำหรับเกมของคุณ นี่เป็นการยืมข้อมูลจำนวนมากจากวิดเจ็ตเก่า
  2. แก้ไขไฟล์ model.dart เพื่ออัปเดตเมธอด addWord ของ Crossword ดังนี้

lib/model.dart

  /// Add a word to the crossword at the given location and direction.
  Crossword? addWord({
    required Location location,
    required String word,
    required Direction direction,
    bool requireOverlap = true,                            // Add this parameter
  }) {
    // Require that the word is not already in the crossword.
    if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
      return null;
    }

    final wordCharacters = word.characters;
    bool overlap = false;

    // Check that the word fits in the crossword.
    for (final (index, character) in wordCharacters.indexed) {
      final characterLocation = switch (direction) {
        Direction.across => location.rightOffset(index),
        Direction.down => location.downOffset(index),
      };

      final target = characters[characterLocation];
      if (target != null) {
        overlap = true;
        if (target.character != character) {
          return null;
        }
        if (direction == Direction.across && target.acrossWord != null ||
            direction == Direction.down && target.downWord != null) {
          return null;
        }
      }
    }
                                                           // Edit from here
    // If overlap is required, make sure that the word overlaps with an existing
    // word. Skip this test if the crossword is empty.
    if (words.isNotEmpty && !overlap && requireOverlap) {  // To here.
      return null;
    }

    final candidate = rebuild(
      (b) => b
        ..words.add(
          CrosswordWord.word(
            word: word,
            direction: direction,
            location: location,
          ),
        ),
    );

    if (candidate.valid) {
      return candidate;
    } else {
      return null;
    }
  }

การแก้ไขรูปแบบครอสเวิร์ดเล็กน้อยนี้จะทำให้สามารถเพิ่มคำที่ไม่ทับซ้อนกัน การอนุญาตให้ผู้เล่นเล่นที่ใดก็ได้บนกระดานและยังคงใช้ Crossword เป็นโมเดลฐานในการจัดเก็บการเคลื่อนไหวของผู้เล่นได้นั้นจะมีประโยชน์มาก แต่เป็นเพียงรายการคำในตำแหน่งที่เฉพาะเจาะจงซึ่งวางไว้ในทิศทางเฉพาะ

  1. เพิ่มคลาสโมเดล CrosswordPuzzleGame ต่อท้ายไฟล์ model.dart

lib/model.dart

/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
    implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
  static Serializer<CrosswordPuzzleGame> get serializer =>
      _$crosswordPuzzleGameSerializer;

  /// The [Crossword] that this puzzle is based on.
  Crossword get crossword;

  /// The alternate words for each [CrosswordWord] in the crossword.
  BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;

  /// The player's selected words.
  BuiltList<CrosswordWord> get selectedWords;

  bool canSelectWord({
    required Location location,
    required String word,
    required Direction direction,
  }) {
    final crosswordWord = CrosswordWord.word(
      word: word,
      location: location,
      direction: direction,
    );

    if (selectedWords.contains(crosswordWord)) {
      return true;
    }

    var puzzle = this;

    if (puzzle.selectedWords
        .where((b) => b.direction == direction && b.location == location)
        .isNotEmpty) {
      puzzle = puzzle.rebuild((b) => b
        ..selectedWords.removeWhere(
          (selectedWord) =>
              selectedWord.location == location &&
              selectedWord.direction == direction,
        ));
    }

    return null !=
        puzzle.crosswordFromSelectedWords.addWord(
            location: location,
            word: word,
            direction: direction,
            requireOverlap: false);
  }

  CrosswordPuzzleGame? selectWord({
    required Location location,
    required String word,
    required Direction direction,
  }) {
    final crosswordWord = CrosswordWord.word(
      word: word,
      location: location,
      direction: direction,
    );

    if (selectedWords.contains(crosswordWord)) {
      return rebuild((b) => b.selectedWords.remove(crosswordWord));
    }

    var puzzle = this;

    if (puzzle.selectedWords
        .where((b) => b.direction == direction && b.location == location)
        .isNotEmpty) {
      puzzle = puzzle.rebuild((b) => b
        ..selectedWords.removeWhere(
          (selectedWord) =>
              selectedWord.location == location &&
              selectedWord.direction == direction,
        ));
    }

    // Check if the selected word meshes with the already selected words.
    // Note this version of the crossword does not enforce overlap to
    // allow the player to select words anywhere on the grid. Enforcing words
    // to be solved in order is a possible alternative.
    final updatedSelectedWordsCrossword =
        puzzle.crosswordFromSelectedWords.addWord(
      location: location,
      word: word,
      direction: direction,
      requireOverlap: false,
    );

    // Make sure the selected word is in the crossword or is an alternate word.
    if (updatedSelectedWordsCrossword != null) {
      if (puzzle.crossword.words.contains(crosswordWord) ||
          puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
        return puzzle.rebuild((b) => b
          ..selectedWords.add(CrosswordWord.word(
              word: word, location: location, direction: direction)));
      }
    }
    return null;
  }

  /// The crossword from the selected words.
  Crossword get crosswordFromSelectedWords => Crossword.crossword(
      width: crossword.width, height: crossword.height, words: selectedWords);

  /// Test if the puzzle is solved. Note, this allows for the possibility of
  /// multiple solutions.
  bool get solved =>
      crosswordFromSelectedWords.valid &&
      crosswordFromSelectedWords.words.length == crossword.words.length &&
      crossword.words.isNotEmpty;

  /// Create a crossword puzzle game from a crossword and a set of candidate
  /// words.
  factory CrosswordPuzzleGame.from({
    required Crossword crossword,
    required BuiltSet<String> candidateWords,
  }) {
    // Remove all of the currently used words from the list of candidates
    candidateWords = candidateWords
        .rebuild((p0) => p0.removeAll(crossword.words.map((p1) => p1.word)));

    // This is the list of alternate words for each word in the crossword
    var alternates =
        BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();

    // Build the alternate words for each word in the crossword
    for (final crosswordWord in crossword.words) {
      final alternateWords = candidateWords.toBuiltList().rebuild((b) => b
        ..where((b) => b.length == crosswordWord.word.length)
        ..shuffle()
        ..take(4)
        ..sort());

      candidateWords =
          candidateWords.rebuild((b) => b.removeAll(alternateWords));

      alternates = alternates.rebuild(
        (b) => b.updateValue(
          crosswordWord.location,
          (b) => b.rebuild(
            (b) => b.updateValue(
              crosswordWord.direction,
              (b) => b.rebuild((b) => b.replace(alternateWords)),
              ifAbsent: () => alternateWords,
            ),
          ),
          ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
        ),
      );
    }

    return CrosswordPuzzleGame((b) {
      b
        ..crossword.replace(crossword)
        ..alternateWords.replace(alternates);
    });
  }

  factory CrosswordPuzzleGame(
          [void Function(CrosswordPuzzleGameBuilder)? updates]) =
      _$CrosswordPuzzleGame;
  CrosswordPuzzleGame._();
}

/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
  Location,
  Crossword,
  CrosswordWord,
  CrosswordCharacter,
  WorkQueue,
  DisplayInfo,
  CrosswordPuzzleGame,                                     // Add this line
])
final Serializers serializers = _$serializers;

การอัปเดตไฟล์ providers.dart เป็นการเปลี่ยนแปลงที่น่าสนใจ ผู้ให้บริการส่วนใหญ่ที่เคยสนับสนุนการรวบรวมสถิติถูกนําออกแล้ว ระบบได้นำความสามารถในการเปลี่ยนจำนวนการแยกพื้นหลังออกและแทนที่ด้วยค่าคงที่ นอกจากนี้ยังมีผู้ให้บริการรายใหม่ที่ให้สิทธิ์เข้าถึงโมเดล CrosswordPuzzleGame ใหม่ที่คุณเพิ่งเพิ่มไว้ข้างต้นด้วย

lib/providers.dart

import 'dart:convert';
                                                           // Drop the dart:math import

import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'isolates.dart';
import 'model.dart' as model;

part 'providers.g.dart';

const backgroundWorkerCount = 4;                           // Add this line

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}

@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
  final size = ref.watch(sizeProvider);                   // Drop the ref.watch(workerCountProvider)
  final wordListAsync = ref.watch(wordListProvider);
  final emptyCrossword =
      model.Crossword.crossword(width: size.width, height: size.height);
  final emptyWorkQueue = model.WorkQueue.from(
    crossword: emptyCrossword,
    candidateWords: BuiltSet<String>(),
    startLocation: model.Location.at(0, 0),
  );
                                                          // Drop the startTimeProvider and endTimeProvider refs
  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
      maxWorkerCount: backgroundWorkerCount,              // Edit this line
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyWorkQueue;
    },
    loading: () async* {
      yield emptyWorkQueue;
    },
  );
}                                                         // Drop the endTimeProvider ref

@riverpod                                                 // Add from here to end of file
class Puzzle extends _$Puzzle {
  model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
    crossword: model.Crossword.crossword(width: 0, height: 0),
    candidateWords: BuiltSet<String>(),
  );

  @override
  model.CrosswordPuzzleGame build() {
    final size = ref.watch(sizeProvider);
    final wordList = ref.watch(wordListProvider).value;
    final workQueue = ref.watch(workQueueProvider).value;

    if (wordList != null &&
        workQueue != null &&
        workQueue.isCompleted &&
        (_puzzle.crossword.height != size.height ||
            _puzzle.crossword.width != size.width ||
            _puzzle.crossword != workQueue.crossword)) {
      compute(_puzzleFromCrosswordTrampoline, (workQueue.crossword, wordList))
          .then((puzzle) {
        _puzzle = puzzle;
        ref.invalidateSelf();
      });
    }

    return _puzzle;
  }

  Future<void> selectWord({
    required model.Location location,
    required String word,
    required model.Direction direction,
  }) async {
    final candidate = await compute(
        _puzzleSelectWordTrampoline, (_puzzle, location, word, direction));

    if (candidate != null) {
      _puzzle = candidate;
      ref.invalidateSelf();
    } else {
      debugPrint('Invalid word selection: $word');
    }
  }

  bool canSelectWord({
    required model.Location location,
    required String word,
    required model.Direction direction,
  }) {
    return _puzzle.canSelectWord(
      location: location,
      word: word,
      direction: direction,
    );
  }
}

// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.

Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
        (model.Crossword, BuiltSet<String>) args) async =>
    model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);

model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
        (
          model.CrosswordPuzzleGame,
          model.Location,
          String,
          model.Direction
        ) args) =>
    args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);

ส่วนที่น่าสนใจที่สุดของผู้ให้บริการ Puzzle คือการวางกลยุทธ์เพื่อให้ครอบคลุมค่าใช้จ่ายในการสร้าง CrosswordPuzzleGame จาก Crossword และ wordList รวมถึงค่าใช้จ่ายในการเลือกคำ การดำเนินการทั้ง 2 อย่างนี้เมื่อดำเนินการโดยไม่ใช้ตัวช่วยของ Isolate เบื้องหลังจะทำให้การโต้ตอบกับ UI ล่าช้า การใช้มือเอื้อมมือดันผลลัพธ์ขั้นกลางออกมาขณะประมวลผลผลลัพธ์สุดท้ายในเบื้องหลังจะช่วยให้คุณเตรียมตัวโดยมี UI ที่ปรับเปลี่ยนตามอุปกรณ์ในขณะที่ระบบกำลังคำนวณที่จำเป็นอยู่ในเบื้องหลัง

  1. ในไดเรกทอรี lib/widgets ซึ่งว่างเปล่าอยู่ ให้สร้างไฟล์ crossword_puzzle_app.dart ด้วยเนื้อหาต่อไปนี้

lib/widgets/crossword_puzzle_app.dart

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

import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';

class CrosswordPuzzleApp extends StatelessWidget {
  const CrosswordPuzzleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return _EagerInitialization(
      child: Scaffold(
        appBar: AppBar(
          actions: [_CrosswordPuzzleAppMenu()],
          titleTextStyle: TextStyle(
            color: Theme.of(context).colorScheme.primary,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
          title: Text('Crossword Puzzle'),
        ),
        body: SafeArea(
          child: Consumer(builder: (context, ref, _) {
            final workQueueAsync = ref.watch(workQueueProvider);
            final puzzleSolved =
                ref.watch(puzzleProvider.select((puzzle) => puzzle.solved));

            return workQueueAsync.when(
              data: (workQueue) {
                if (puzzleSolved) {
                  return PuzzleCompletedWidget();
                }
                if (workQueue.isCompleted &&
                    workQueue.crossword.characters.isNotEmpty) {
                  return CrosswordPuzzleWidget();
                }
                return CrosswordGeneratorWidget();
              },
              loading: () => Center(child: CircularProgressIndicator()),
              error: (error, stackTrace) => Center(child: Text('$error')),
            );
          }),
        ),
      ),
    );
  }
}

class _EagerInitialization extends ConsumerWidget {
  const _EagerInitialization({required this.child});
  final Widget child;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(wordListProvider);
    return child;
  }
}

class _CrosswordPuzzleAppMenu extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
        menuChildren: [
          for (final entry in CrosswordSize.values)
            MenuItemButton(
              onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
              leadingIcon: entry == ref.watch(sizeProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              child: Text(entry.label),
            ),
        ],
        builder: (context, controller, child) => IconButton(
          onPressed: () => controller.open(),
          icon: Icon(Icons.settings),
        ),
      );
}

ไฟล์นี้ส่วนใหญ่น่าจะคุ้นเคยดีแล้ว ใช่ จะมีวิดเจ็ตที่ไม่ได้กำหนด ซึ่งคุณจะเริ่มแก้ไขได้ทันที

  1. สร้างไฟล์ crossword_generator_widget.dart และเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์

lib/widgets/crossword_generator_widget.dart

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

import '../model.dart';
import '../providers.dart';

class CrosswordGeneratorWidget extends ConsumerWidget {
  const CrosswordGeneratorWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final size = ref.watch(sizeProvider);
    return TableView.builder(
      diagonalDragBehavior: DiagonalDragBehavior.free,
      cellBuilder: _buildCell,
      columnCount: size.width,
      columnBuilder: (index) => _buildSpan(context, index),
      rowCount: size.height,
      rowBuilder: (index) => _buildSpan(context, index),
    );
  }

  TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
    final location = Location.at(vicinity.column, vicinity.row);

    return TableViewCell(
      child: Consumer(
        builder: (context, ref, _) {
          final character = ref.watch(
            workQueueProvider.select(
              (workQueueAsync) => workQueueAsync.when(
                data: (workQueue) => workQueue.crossword.characters[location],
                error: (error, stackTrace) => null,
                loading: () => null,
              ),
            ),
          );

          final explorationCell = ref.watch(
            workQueueProvider.select(
              (workQueueAsync) => workQueueAsync.when(
                data: (workQueue) =>
                    workQueue.locationsToTry.keys.contains(location),
                error: (error, stackTrace) => false,
                loading: () => false,
              ),
            ),
          );

          if (character != null) {
            return AnimatedContainer(
              duration: Durations.extralong1,
              curve: Curves.easeInOut,
              color: explorationCell
                  ? Theme.of(context).colorScheme.primary
                  : Theme.of(context).colorScheme.onPrimary,
              child: Center(
                child: AnimatedDefaultTextStyle(
                  duration: Durations.extralong1,
                  curve: Curves.easeInOut,
                  style: TextStyle(
                    fontSize: 24,
                    color: explorationCell
                        ? Theme.of(context).colorScheme.onPrimary
                        : Theme.of(context).colorScheme.primary,
                  ),
                  child: Text('•'), // https://www.compart.com/en/unicode/U+2022
                ),
              ),
            );
          }

          return ColoredBox(
            color: Theme.of(context).colorScheme.primaryContainer,
          );
        },
      ),
    );
  }

  TableSpan _buildSpan(BuildContext context, int index) {
    return TableSpan(
      extent: FixedTableSpanExtent(32),
      foregroundDecoration: TableSpanDecoration(
        border: TableSpanBorder(
          leading: BorderSide(
              color: Theme.of(context).colorScheme.onPrimaryContainer),
          trailing: BorderSide(
              color: Theme.of(context).colorScheme.onPrimaryContainer),
        ),
      ),
    );
  }
}

ซึ่งควรจะมีความคุ้นเคยพอสมควร ความแตกต่างหลักๆ ก็คือ ตอนนี้คุณจะแสดงอักขระ Unicode แทนการแสดงอักขระที่ไม่ทราบตัวอักขระที่สร้างขึ้น ซึ่งอาจช่วยปรับปรุงความสวยงามได้จริง

  1. สร้างไฟล์ crossword_puzzle_widget.dart และเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์

lib/widgets/crossword_puzzle_widget.dart

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

import '../model.dart';
import '../providers.dart';

class CrosswordPuzzleWidget extends ConsumerWidget {
  const CrosswordPuzzleWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final size = ref.watch(sizeProvider);
    return TableView.builder(
      diagonalDragBehavior: DiagonalDragBehavior.free,
      cellBuilder: _buildCell,
      columnCount: size.width,
      columnBuilder: (index) => _buildSpan(context, index),
      rowCount: size.height,
      rowBuilder: (index) => _buildSpan(context, index),
    );
  }

  TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
    final location = Location.at(vicinity.column, vicinity.row);

    return TableViewCell(
      child: Consumer(
        builder: (context, ref, _) {
          final character = ref.watch(puzzleProvider
              .select((puzzle) => puzzle.crossword.characters[location]));
          final selectedCharacter = ref.watch(puzzleProvider.select((puzzle) =>
              puzzle.crosswordFromSelectedWords.characters[location]));
          final alternateWords = ref
              .watch(puzzleProvider.select((puzzle) => puzzle.alternateWords));

          if (character != null) {
            final acrossWord = character.acrossWord;
            var acrossWords = BuiltList<String>();
            if (acrossWord != null) {
              acrossWords = acrossWords.rebuild((b) => b
                ..add(acrossWord.word)
                ..addAll(alternateWords[acrossWord.location]
                        ?[acrossWord.direction] ??
                    [])
                ..sort());
            }

            final downWord = character.downWord;
            var downWords = BuiltList<String>();
            if (downWord != null) {
              downWords = downWords.rebuild((b) => b
                ..add(downWord.word)
                ..addAll(alternateWords[downWord.location]
                        ?[downWord.direction] ??
                    [])
                ..sort());
            }

            return MenuAnchor(
              builder: (context, controller, _) {
                return GestureDetector(
                  onTapDown: (details) =>
                      controller.open(position: details.localPosition),
                  child: AnimatedContainer(
                    duration: Durations.extralong1,
                    curve: Curves.easeInOut,
                    color: Theme.of(context).colorScheme.onPrimary,
                    child: Center(
                      child: AnimatedDefaultTextStyle(
                        duration: Durations.extralong1,
                        curve: Curves.easeInOut,
                        style: TextStyle(
                          fontSize: 24,
                          color: Theme.of(context).colorScheme.primary,
                        ),
                        child: Text(selectedCharacter?.character ?? ''),
                      ),
                    ),
                  ),
                );
              },
              menuChildren: [
                if (acrossWords.isNotEmpty && downWords.isNotEmpty)
                  Padding(
                    padding: const EdgeInsets.all(4),
                    child: Text('Across'),
                  ),
                for (final word in acrossWords)
                  _WordSelectMenuItem(
                    location: acrossWord!.location,
                    word: word,
                    selectedCharacter: selectedCharacter,
                    direction: Direction.across,
                  ),
                if (acrossWords.isNotEmpty && downWords.isNotEmpty)
                  Padding(
                    padding: const EdgeInsets.all(4),
                    child: Text('Down'),
                  ),
                for (final word in downWords)
                  _WordSelectMenuItem(
                    location: downWord!.location,
                    word: word,
                    selectedCharacter: selectedCharacter,
                    direction: Direction.down,
                  ),
              ],
            );
          }

          return ColoredBox(
            color: Theme.of(context).colorScheme.primaryContainer,
          );
        },
      ),
    );
  }

  TableSpan _buildSpan(BuildContext context, int index) {
    return TableSpan(
      extent: FixedTableSpanExtent(32),
      foregroundDecoration: TableSpanDecoration(
        border: TableSpanBorder(
          leading: BorderSide(
              color: Theme.of(context).colorScheme.onPrimaryContainer),
          trailing: BorderSide(
              color: Theme.of(context).colorScheme.onPrimaryContainer),
        ),
      ),
    );
  }
}

class _WordSelectMenuItem extends ConsumerWidget {
  const _WordSelectMenuItem({
    required this.location,
    required this.word,
    required this.selectedCharacter,
    required this.direction,
  });

  final Location location;
  final String word;
  final CrosswordCharacter? selectedCharacter;
  final Direction direction;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.read(puzzleProvider.notifier);
    return MenuItemButton(
      onPressed: ref.watch(puzzleProvider.select((puzzle) =>
              puzzle.canSelectWord(
                  location: location, word: word, direction: direction)))
          ? () => notifier.selectWord(
              location: location, word: word, direction: direction)
          : null,
      leadingIcon: switch (direction) {
        Direction.across => selectedCharacter?.acrossWord?.word == word,
        Direction.down => selectedCharacter?.downWord?.word == word,
      }
          ? Icon(Icons.radio_button_checked_outlined)
          : Icon(Icons.radio_button_unchecked_outlined),
      child: Text(word),
    );
  }
}

วิดเจ็ตนี้มีความหนักแน่นกว่าวิดเจ็ตล่าสุดเล็กน้อย แม้ว่าจะสร้างขึ้นจากชิ้นงานที่คุณเคยเห็นจากที่อื่นๆ ในอดีตก็ตาม ตอนนี้เซลล์ที่มีการป้อนข้อมูลแต่ละเซลล์จะสร้างเมนูตามบริบทเมื่อคลิก ซึ่งจะแสดงคำที่ผู้ใช้เลือกได้ หากมีการเลือกคำแล้ว จะไม่สามารถเลือกคำที่ขัดแย้งได้ หากต้องการยกเลิกการเลือกคํา ผู้ใช้แตะรายการในเมนูสําหรับคํานั้น

สมมติว่าผู้เล่นสามารถเลือกคำเพื่อเติมคำในอักษรไขว้ทั้งหมดได้ คุณจะต้องมีข้อความ "คุณชนะแล้ว!" บนหน้าจอ

  1. สร้างไฟล์ puzzle_completed_widget.dart แล้วเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์

lib/widgets/puzzle_completed_widget.dart

import 'package:flutter/material.dart';

class PuzzleCompletedWidget extends StatelessWidget {
  const PuzzleCompletedWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        'Puzzle Completed!',
        style: TextStyle(
          fontSize: 36,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

ฉันแน่ใจว่าคุณเอาอันนี้ และทำให้น่าสนใจมากขึ้นได้ ดูข้อมูลเพิ่มเติมเกี่ยวกับเครื่องมือสร้างภาพเคลื่อนไหวได้ที่ Codelab ของการสร้าง UI รุ่นใหม่ใน Flutter

  1. แก้ไขไฟล์ lib/main.dart ดังนี้

lib/main.dart

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

import 'widgets/crossword_puzzle_app.dart';                 // Update this line

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Puzzle',                          // Update this line
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          useMaterial3: true,
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordPuzzleApp(),                         // Update this line
      ),
    ),
  );
}

เมื่อคุณเรียกใช้แอปนี้ คุณจะเห็นภาพเคลื่อนไหวขณะที่โปรแกรมสร้างปริศนาอักษรไขว้สร้างปริศนาของคุณ จากนั้นคุณจะเห็นปริศนาเปล่าให้ไขปริศนา สมมติว่าคุณแก้โจทย์ได้ คุณจะเห็นหน้าจอที่มีลักษณะดังนี้

หน้าต่างแอปปริศนาอักษรไขว้ที่แสดงข้อความ &#39;ปริศนาจบแล้ว!&#39;

10. ขอแสดงความยินดี

ยินดีด้วย คุณประสบความสำเร็จในการสร้างเกมไขปัญหากับ Flutter!

คุณได้สร้างโปรแกรมสร้างปริศนาอักษรไขว้ที่กลายเป็นเกมไขปัญหา คุณเชี่ยวชาญการคำนวณพื้นหลังในกลุ่มไฟล์ Isolation คุณใช้โครงสร้างข้อมูลที่เปลี่ยนแปลงไม่ได้เพื่อให้ติดตั้งใช้งานอัลกอริทึมการย้อนกลับได้ง่ายขึ้น และคุณใช้เวลาอันมีค่ากับ TableView ซึ่งจะเป็นประโยชน์สำหรับครั้งต่อไปที่คุณต้องแสดงข้อมูลแบบตาราง

ดูข้อมูลเพิ่มเติม