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

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

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

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

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

เมื่อมีเครื่องมือนี้เป็นพื้นฐานแล้ว คุณก็สร้างปริศนาอักษรไขว้โดยใช้เครื่องมือสร้างปริศนาอักษรไขว้เพื่อสร้างปริศนาให้ผู้ใช้แก้ ปริศนานี้ใช้ได้ใน Android, iOS, Windows, macOS และ Linux วิธีทำใน Android

ภาพหน้าจอของเกมปริศนาอักษรไขว้ที่กำลังไขในโปรแกรมจำลอง Pixel Fold

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

สิ่งที่คุณจะได้เรียนรู้

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

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

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

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

  1. เปิด VS Code
  2. เปิด Command Palette (Ctrl+Shift+P ใน Windows/Linux, Cmd+Shift+P ใน macOS) พิมพ์ "flutter new" แล้วเลือก Flutter: New Project ในเมนู

VS Code พร้อม Flutter: โปรเจ็กต์ใหม่ที่แสดงในพาเล็ตคำสั่งเปิด

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

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

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

VS Code ที่มี generate_crossword แสดงเป็นชื่อของโปรเจ็กต์ใหม่ที่กำลังสร้าง

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

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

  1. ในแผงด้านซ้ายของ VS Code ให้คลิกExplorer แล้วเปิดไฟล์ pubspec.yaml

ภาพหน้าจอบางส่วนของ VS Code พร้อมลูกศรที่ไฮไลต์ตำแหน่งของไฟล์ pubspec.yaml

  1. แทนที่เนื้อหาของไฟล์นี้ด้วยทรัพยากร Dependency ต่อไปนี้ที่จำเป็นสำหรับการสร้างปริศนาอักษรไขว้

pubspec.yaml

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

environment:
  sdk: ^3.9.0

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

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

flutter:
  uses-material-design: true

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

ทำความเข้าใจทรัพยากร Dependency

ก่อนจะไปดูโค้ด มาดูกันก่อนว่าทำไมเราจึงเลือกใช้แพ็กเกจเหล่านี้

  • built_value: สร้างออบเจ็กต์ที่ไม่เปลี่ยนแปลงซึ่งแชร์หน่วยความจำได้อย่างมีประสิทธิภาพ ซึ่งมีความสำคัญต่ออัลกอริทึมการย้อนรอยของเรา
  • Riverpod: จัดการสถานะแบบละเอียดด้วย select() เพื่อลดการสร้างใหม่
  • two_dimensional_scrollables: จัดการตารางขนาดใหญ่ได้โดยไม่ส่งผลต่อประสิทธิภาพ
  1. เปิดไฟล์ main.dart ในไดเรกทอรี lib/

ภาพหน้าจอบางส่วนของ VS Code พร้อมลูกศรที่แสดงตำแหน่งของไฟล์ main.dart

  1. แทนที่เนื้อหาของไฟล์นี้ด้วยเนื้อหาต่อไปนี้

lib/main.dart

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

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Builder',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: Scaffold(
          body: Center(
            child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
          ),
        ),
      ),
    ),
  );
}
  1. เรียกใช้โค้ดนี้เพื่อตรวจสอบว่าทุกอย่างทำงานได้ โดยควรแสดงหน้าต่างใหม่พร้อมวลีเริ่มต้นที่จำเป็นของทุกโปรเจ็กต์ใหม่ในทุกที่ มี ProviderScope ที่ระบุว่าแอปนี้จะใช้ riverpod สำหรับการจัดการสถานะ

หน้าต่างแอปที่มีคำว่า "Hello, World!" อยู่ตรงกลาง

จุดตรวจ: การเรียกใช้แอปพื้นฐาน

ตอนนี้คุณควรเห็นหน้าต่าง "Hello, World!" หากไม่เป็นเช่นนั้น ให้ทำดังนี้

  • ตรวจสอบว่าติดตั้ง Flutter อย่างถูกต้อง
  • ยืนยันว่าแอปทำงานด้วย flutter run
  • ตรวจสอบว่าไม่มีข้อผิดพลาดในการคอมไพล์ในเทอร์มินัล

3. เพิ่มคำ

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

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

แหล่งข้อมูลที่ดีสำหรับคำเหล่านี้คือหน้าข้อมูลคลังข้อความภาษาธรรมชาติของ Peter Norvig รายการ SOWPODS เป็นจุดเริ่มต้นที่มีประโยชน์ โดยมีคำ 267,750 คำ

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

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

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

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/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'providers.g.dart';

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

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

นี่คือผู้ให้บริการ Riverpod รายการแรกสำหรับโค้ดเบสนี้

วิธีการทำงานของผู้ให้บริการรายนี้

  1. โหลดรายการคำจากเนื้อหาแบบอะซิงโครนัส
  2. กรองคำให้มีเฉพาะอักขระ a-z ที่ยาวกว่า 2 ตัวอักษร
  3. แสดงผล BuiltSet ที่เปลี่ยนแปลงไม่ได้สำหรับการเข้าถึงแบบสุ่มที่มีประสิทธิภาพ

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

  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 ในหัวข้อการเริ่มต้นใช้งาน Provider อย่างรวดเร็ว

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

เมธอด when ของ AsyncValue จะจัดการสถานะที่เป็นไปได้ 3 สถานะที่มูลค่าในอนาคตอาจอยู่ อนาคตอาจได้รับการแก้ไขเรียบร้อยแล้ว ในกรณีนี้จะมีการเรียกใช้การเรียกกลับ data หรืออาจอยู่ในสถานะข้อผิดพลาด ในกรณีนี้จะมีการเรียกใช้การเรียกกลับ error หรือสุดท้ายอาจยังคงโหลดอยู่ ประเภทการคืนค่าของ Callback ทั้ง 3 รายการต้องมีประเภทการคืนค่าที่เข้ากันได้ เนื่องจากเมธอด when จะคืนค่าการคืนค่าของ Callback ที่เรียกใช้ ในกรณีนี้ ผลลัพธ์ของเมธอด when จะแสดงเป็น body ของวิดเจ็ต Scaffold

สร้างแอปรายการที่แทบจะไม่มีที่สิ้นสุด

หากต้องการผสานรวมวิดเจ็ต CrosswordGeneratorApp เข้ากับแอป ให้ทำตามขั้นตอนต่อไปนี้

  1. อัปเดตไฟล์ lib/main.dart โดยเพิ่มโค้ดต่อไปนี้

lib/main.dart

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

import 'widgets/crossword_generator_app.dart';             // Add this import

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Builder',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordGeneratorApp(),                     // Remove what was here and replace
      ),
    ),
  );
}
  1. รีสตาร์ทแอป คุณควรเห็นรายการที่เลื่อนได้ซึ่งจะแสดงคำทั้งหมด 267,750 คำขึ้นไปในพจนานุกรม

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

สิ่งที่คุณจะสร้างต่อไป

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

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

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

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

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

lib/model.dart

import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';

part 'model.g.dart';

/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
  static Serializer<Location> get serializer => _$locationSerializer;

  /// The horizontal part of the location. The location is 0 based.
  int get x;

  /// The vertical part of the location. The location is 0 based.
  int get y;

  /// Returns a new location that is one step to the left of this location.
  Location get left => rebuild((b) => b.x = x - 1);

  /// Returns a new location that is one step to the right of this location.
  Location get right => rebuild((b) => b.x = x + 1);

  /// Returns a new location that is one step up from this location.
  Location get up => rebuild((b) => b.y = y - 1);

  /// Returns a new location that is one step down from this location.
  Location get down => rebuild((b) => b.y = y + 1);

  /// Returns a new location that is [offset] steps to the left of this location.
  Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);

  /// Returns a new location that is [offset] steps to the right of this location.
  Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);

  /// Returns a new location that is [offset] steps up from this location.
  Location upOffset(int offset) => rebuild((b) => b.y = y - offset);

  /// Returns a new location that is [offset] steps down from this location.
  Location downOffset(int offset) => rebuild((b) => b.y = y + offset);

  /// Pretty print a location as a (x,y) coordinate.
  String prettyPrint() => '($x,$y)';

  /// Returns a new location built from [updates]. Both [x] and [y] are
  /// required to be non-null.
  factory Location([void Function(LocationBuilder)? updates]) = _$Location;
  Location._();

  /// Returns a location at the given coordinates.
  factory Location.at(int x, int y) {
    return Location((b) {
      b
        ..x = x
        ..y = y;
    });
  }
}

/// The direction of a word in a crossword.
enum Direction {
  across,
  down;

  @override
  String toString() => name;
}

/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
    implements Built<CrosswordWord, CrosswordWordBuilder> {
  static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;

  /// The word itself.
  String get word;

  /// The location of this word in the crossword.
  Location get location;

  /// The direction of this word in the crossword.
  Direction get direction;

  /// Compare two CrosswordWord by coordinates, x then y.
  static int locationComparator(CrosswordWord a, CrosswordWord b) {
    final compareRows = a.location.y.compareTo(b.location.y);
    final compareColumns = a.location.x.compareTo(b.location.x);
    return switch (compareColumns) {
      0 => compareRows,
      _ => compareColumns,
    };
  }

  /// Constructor for [CrosswordWord].
  factory CrosswordWord.word({
    required String word,
    required Location location,
    required Direction direction,
  }) {
    return CrosswordWord(
      (b) => b
        ..word = word
        ..direction = direction
        ..location.replace(location),
    );
  }

  /// Constructor for [CrosswordWord].
  /// Use [CrosswordWord.word] instead.
  factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
      _$CrosswordWord;
  CrosswordWord._();
}

/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
    implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
  static Serializer<CrosswordCharacter> get serializer =>
      _$crosswordCharacterSerializer;

  /// The character at this location.
  String get character;

  /// The across word that this character is a part of.
  CrosswordWord? get acrossWord;

  /// The down word that this character is a part of.
  CrosswordWord? get downWord;

  /// Constructor for [CrosswordCharacter].
  /// [acrossWord] and [downWord] are optional.
  factory CrosswordCharacter.character({
    required String character,
    CrosswordWord? acrossWord,
    CrosswordWord? downWord,
  }) {
    return CrosswordCharacter((b) {
      b.character = character;
      if (acrossWord != null) {
        b.acrossWord.replace(acrossWord);
      }
      if (downWord != null) {
        b.downWord.replace(downWord);
      }
    });
  }

  /// Constructor for [CrosswordCharacter].
  /// Use [CrosswordCharacter.character] instead.
  factory CrosswordCharacter([
    void Function(CrosswordCharacterBuilder)? updates,
  ]) = _$CrosswordCharacter;
  CrosswordCharacter._();
}

/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
  /// Serializes and deserializes the [Crossword] class.
  static Serializer<Crossword> get serializer => _$crosswordSerializer;

  /// Width across the [Crossword] puzzle.
  int get width;

  /// Height down the [Crossword] puzzle.
  int get height;

  /// The words in the crossword.
  BuiltList<CrosswordWord> get words;

  /// The characters by location. Useful for displaying the crossword.
  BuiltMap<Location, CrosswordCharacter> get characters;

  /// Add a word to the crossword at the given location and direction.
  Crossword addWord({
    required Location location,
    required String word,
    required Direction direction,
  }) {
    return rebuild(
      (b) => b
        ..words.add(
          CrosswordWord.word(
            word: word,
            direction: direction,
            location: location,
          ),
        ),
    );
  }

  /// As a finalize step, fill in the characters map.
  @BuiltValueHook(finalizeBuilder: true)
  static void _fillCharacters(CrosswordBuilder b) {
    b.characters.clear();

    for (final word in b.words.build()) {
      for (final (idx, character) in word.word.characters.indexed) {
        switch (word.direction) {
          case Direction.across:
            b.characters.updateValue(
              word.location.rightOffset(idx),
              (b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
              ifAbsent: () => CrosswordCharacter.character(
                acrossWord: word,
                character: character,
              ),
            );
          case Direction.down:
            b.characters.updateValue(
              word.location.downOffset(idx),
              (b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
              ifAbsent: () => CrosswordCharacter.character(
                downWord: word,
                character: character,
              ),
            );
        }
      }
    }
  }

  /// Pretty print a crossword. Generates the character grid, and lists
  /// the down words and across words sorted by location.
  String prettyPrintCrossword() {
    final buffer = StringBuffer();
    final grid = List.generate(
      height,
      (_) => List.generate(
        width,
        (_) => '░', // https://www.compart.com/en/unicode/U+2591
      ),
    );

    for (final MapEntry(key: Location(:x, :y), value: character)
        in characters.entries) {
      grid[y][x] = character.character;
    }

    for (final row in grid) {
      buffer.writeln(row.join());
    }

    buffer.writeln();
    buffer.writeln('Across:');
    for (final word
        in words.where((word) => word.direction == Direction.across).toList()
          ..sort(CrosswordWord.locationComparator)) {
      buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
    }

    buffer.writeln();
    buffer.writeln('Down:');
    for (final word
        in words.where((word) => word.direction == Direction.down).toList()
          ..sort(CrosswordWord.locationComparator)) {
      buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
    }

    return buffer.toString();
  }

  /// Constructor for [Crossword].
  factory Crossword.crossword({
    required int width,
    required int height,
    Iterable<CrosswordWord>? words,
  }) {
    return Crossword((b) {
      b
        ..width = width
        ..height = height;
      if (words != null) {
        b.words.addAll(words);
      }
    });
  }

  /// Constructor for [Crossword].
  /// Use [Crossword.crossword] instead.
  factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
  Crossword._();
}

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

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

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

  1. สร้างไฟล์ utils ในไดเรกทอรี lib แล้วเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์

lib/utils.dart

import 'dart:math';

import 'package:built_collection/built_collection.dart';

/// A [Random] instance for generating random numbers.
final _random = Random();

/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
  E randomElement() {
    return elementAt(_random.nextInt(length));
  }
}

ซึ่งเป็นส่วนขยายของ BuiltSet ที่ช่วยให้ดึงองค์ประกอบแบบสุ่มของชุดข้อมูลได้อย่างง่ายดาย วิธีการขยายเป็นวิธีที่ดีในการขยายคลาสด้วยฟังก์ชันเพิ่มเติม คุณต้องตั้งชื่อส่วนขยายเพื่อให้ส่วนขยายพร้อมใช้งานนอกไฟล์ utils.dart

  1. เพิ่มการนำเข้าต่อไปนี้ในไฟล์ lib/providers.dart

lib/providers.dart

import 'dart:convert';
import 'dart:math';                                        // Add this import

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

import 'model.dart' as model;                              // And this import
import 'utils.dart';                                       // And this one

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {

การนำเข้าเหล่านี้จะแสดงโมเดลที่กำหนดไว้ก่อนหน้านี้ต่อผู้ให้บริการที่คุณกำลังจะสร้าง การนำเข้า dart:math จะรวมอยู่ใน Random การนำเข้า flutter/foundation.dart จะรวมอยู่ใน debugPrint model.dart สำหรับโมเดล และ utils.dart สำหรับส่วนขยาย BuiltSet

  1. เพิ่มผู้ให้บริการต่อไปนี้ที่ส่วนท้ายของไฟล์เดียวกัน

lib/providers.dart

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

final _random = Random();

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

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

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

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

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

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

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

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

  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()),             // Replace what was here before
      ),
    );
  }
}

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

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

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

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

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

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

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 ไม่มีการเปลี่ยนแปลงมากนัก แต่หากดูที่บันทึก คุณจะเห็นการเปลี่ยนแปลงมากมาย

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

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

เหตุผลที่ควรเปลี่ยนไปใช้การประมวลผลในเบื้องหลัง

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

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

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

lib/providers.dart

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

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

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

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

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

ทำความเข้าใจข้อจำกัดของฟีเจอร์แยก

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

คุณจะเห็นข้อความนี้เมื่อระบบพยายามส่งข้อมูลที่แปลงเป็นอนุกรมไม่ได้

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

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

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

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

lib/isolates.dart

import 'dart:math';

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

import 'model.dart';
import 'utils.dart';

final _random = Random();

Stream<Crossword> exploreCrosswordSolutions({
  required Crossword crossword,
  required BuiltSet<String> wordList,
}) async* {
  while (crossword.characters.length <
      crossword.width * crossword.height * 0.8) {
    final word = wordList.randomElement();
    final direction = _random.nextBool() ? Direction.across : Direction.down;
    final location = Location.at(
      _random.nextInt(crossword.width),
      _random.nextInt(crossword.height),
    );
    try {
      var candidate = await compute(((String, Direction, Location) wordToAdd) {
        final (word, direction, location) = wordToAdd;
        return crossword.addWord(
          word: word,
          direction: direction,
          location: location,
        );
      }, (word, direction, location));

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

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

lib/providers.dart

// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';

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

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

part 'providers.g.dart';

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

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

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

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

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

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

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. สถิติของ Surface

เหตุผลที่ควรเพิ่มสถิติ

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

คุณต้องดึงข้อมูลที่จะแสดงจาก 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}',
  );
}

ตอนนี้ Background Isolate ได้เปิดเผยคิวงานแล้ว จึงเป็นคำถามว่าเราจะดึงสถิติจากแหล่งข้อมูลนี้ได้อย่างไรและที่ไหน

  1. แทนที่ผู้ให้บริการปริศนาอักษรไขว้รายเก่าด้วยผู้ให้บริการคิวงาน แล้วเพิ่มผู้ให้บริการอื่นๆ ที่ดึงข้อมูลจากสตรีมของผู้ให้บริการคิวงาน

lib/providers.dart

import 'dart:convert';
import 'dart:math';                                        // Add this import

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

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

part 'providers.g.dart';

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

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

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

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

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

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

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

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

  DateTime? _start;

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

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

  DateTime? _end;

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

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

const _estimatedTotalCoverage = 0.54;

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

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

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

  @override
  bool build() => _display;

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

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

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

แต่การแสดงข้อมูลก็มีข้อควรทราบเล็กน้อย เราต้องการให้แสดงเวลาที่ผ่านไป แต่ไม่มีอะไรที่นี่ที่จะบังคับให้อัปเดตเวลาที่ผ่านไปอย่างต่อเนื่อง เมื่อย้อนกลับไปที่ Codelab Building next generation UIs in 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

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

import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final size = ref.watch(sizeProvider);
    final displayInfo = ref.watch(displayInfoProvider);
    final startTime = ref.watch(startTimeProvider);
    final endTime = ref.watch(endTimeProvider);
    final remaining = ref.watch(expectedRemainingTimeProvider);

    return Align(
      alignment: Alignment.bottomRight,
      child: Padding(
        padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: ColoredBox(
            color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              child: DefaultTextStyle(
                style: TextStyle(
                  fontSize: 16,
                  color: Theme.of(context).colorScheme.primary,
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _CrosswordInfoRichText(
                      label: 'Grid Size',
                      value: '${size.width} x ${size.height}',
                    ),
                    _CrosswordInfoRichText(
                      label: 'Words in grid',
                      value: displayInfo.wordsInGridCount,
                    ),
                    _CrosswordInfoRichText(
                      label: 'Candidate words',
                      value: displayInfo.candidateWordsCount,
                    ),
                    _CrosswordInfoRichText(
                      label: 'Locations to explore',
                      value: displayInfo.locationsToExploreCount,
                    ),
                    _CrosswordInfoRichText(
                      label: 'Known bad locations',
                      value: displayInfo.knownBadLocationsCount,
                    ),
                    _CrosswordInfoRichText(
                      label: 'Grid filled',
                      value: displayInfo.gridFilledPercentage,
                    ),
                    switch ((startTime, endTime)) {
                      (null, _) => _CrosswordInfoRichText(
                        label: 'Time elapsed',
                        value: 'Not started yet',
                      ),
                      (DateTime start, null) => TickerBuilder(
                        builder: (context) => _CrosswordInfoRichText(
                          label: 'Time elapsed',
                          value: DateTime.now().difference(start).formatted,
                        ),
                      ),
                      (DateTime start, DateTime end) => _CrosswordInfoRichText(
                        label: 'Completed in',
                        value: end.difference(start).formatted,
                      ),
                    },
                    if (startTime != null && endTime == null)
                      _CrosswordInfoRichText(
                        label: 'Est. remaining',
                        value: remaining.formatted,
                      ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _CrosswordInfoRichText extends StatelessWidget {
  final String label;
  final String value;

  const _CrosswordInfoRichText({required this.label, required this.value});

  @override
  Widget build(BuildContext context) => RichText(
    text: TextSpan(
      children: [
        TextSpan(text: '$label ', style: DefaultTextStyle.of(context).style),
        TextSpan(
          text: value,
          style: DefaultTextStyle.of(
            context,
          ).style.copyWith(fontWeight: FontWeight.bold),
        ),
      ],
    ),
  );
}

วิดเจ็ตนี้เป็นตัวอย่างที่ยอดเยี่ยมของความสามารถของผู้ให้บริการของ Riverpod ระบบจะทำเครื่องหมายวิดเจ็ตนี้เพื่อสร้างใหม่เมื่อผู้ให้บริการรายใดรายหนึ่งใน 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(
    menuChildren: [
      for (final entry in CrosswordSize.values)
        MenuItemButton(
          onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
          leadingIcon: entry == ref.watch(sizeProvider)
              ? Icon(Icons.radio_button_checked_outlined)
              : Icon(Icons.radio_button_unchecked_outlined),
          child: Text(entry.label),
        ),
      MenuItemButton(                                      // Add from here
        leadingIcon: ref.watch(showDisplayInfoProvider)
            ? Icon(Icons.check_box_outlined)
            : Icon(Icons.check_box_outline_blank_outlined),
        onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
        child: Text('Display Info'),
      ),                                                   // To here.
    ],
    builder: (context, controller, child) => IconButton(
      onPressed: () => controller.open(),
      icon: Icon(Icons.settings),
    ),
  );
}

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

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

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

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

8. ทำงานแบบขนานด้วยเธรด

สาเหตุที่ประสิทธิภาพลดลง

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

การแสดงภาพอัลกอริทึม

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

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

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

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

lib/providers.dart

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

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

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

  ref.read(endTimeProvider.notifier).end();
}
  1. เพิ่มผู้ให้บริการ WorkerCount ที่ส่วนท้ายของไฟล์ดังนี้

lib/providers.dart

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

enum BackgroundWorkers {                                   // Add from here
  one(1),
  two(2),
  four(4),
  eight(8),
  sixteen(16),
  thirtyTwo(32),
  sixtyFour(64),
  oneTwentyEight(128);

  const BackgroundWorkers(this.count);

  final int count;
  String get label => count.toString();
}

/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
  var _count = BackgroundWorkers.four;

  @override
  BackgroundWorkers build() => _count;

  void setCount(BackgroundWorkers count) {
    _count = count;
    ref.invalidateSelf();
  }
}                                                          // To here.

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

  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 from here
                      label: 'Max worker count',
                      value: workerCount,
                    ),                                    // To here.
                    switch ((startTime, endTime)) {
                      (null, _) => _CrosswordInfoRichText(
                        label: 'Time elapsed',
                        value: 'Not started yet',
                      ),
                      (DateTime start, null) => TickerBuilder(
                        builder: (context) => _CrosswordInfoRichText(
                          label: 'Time elapsed',
                          value: DateTime.now().difference(start).formatted,
                        ),
                      ),
                      (DateTime start, DateTime end) => _CrosswordInfoRichText(
                        label: 'Completed in',
                        value: end.difference(start).formatted,
                      ),
                    },
                    if (startTime != null && endTime == null)
                      _CrosswordInfoRichText(
                        label: 'Est. remaining',
                        value: remaining.formatted,
                      ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
  1. แก้ไขไฟล์ 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. คลิกไอคอนรูปเฟืองในเพื่อเปิดเมนูตามบริบทที่มีการปรับขนาดสำหรับปริศนาอักษรไขว้ เลือกว่าจะแสดงสถิติในปริศนาอักษรไขว้ที่สร้างขึ้นหรือไม่ และตอนนี้ก็คือจำนวนคำที่ใช้

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

จุดตรวจสอบ: ประสิทธิภาพแบบหลายเธรด

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

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

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

สิ่งที่เรากำลังสร้าง: เกมปริศนาอักษรไขว้ที่เล่นได้

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

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

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

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

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

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

  1. ลบทุกอย่างในไดเรกทอรี lib/widgets คุณจะได้สร้างวิดเจ็ตใหม่ที่สวยงามสำหรับเกม ซึ่งมีลักษณะคล้ายกับวิดเจ็ตเก่าๆ
  1. แก้ไขไฟล์ 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/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

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

part 'providers.g.dart';

const backgroundWorkerCount = 4;                           // Add this line

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

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

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

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

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

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

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

    return _puzzle;
  }

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

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

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

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

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

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

ส่วนที่น่าสนใจที่สุดของPuzzleคือกลยุทธ์ที่ใช้ในการปกปิดค่าใช้จ่ายในการสร้างCrosswordPuzzleGameจากCrosswordและwordList รวมถึงค่าใช้จ่ายในการเลือกคำ การดำเนินการทั้ง 2 อย่างนี้เมื่อดำเนินการโดยไม่มีการแยกพื้นหลังจะทำให้การโต้ตอบ 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(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordPuzzleApp(),                         // Update this line
      ),
    ),
  );
}

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

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

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

ยินดีด้วย คุณสร้างเกมปริศนาด้วย Flutter ได้สำเร็จแล้ว

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

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