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

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

เกี่ยวกับ Codelab นี้

subjectอัปเดตล่าสุดเมื่อ เม.ย. 29, 2024
account_circleเขียนโดย Brett Morgan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

pubspec.yaml

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

environment
:
  sdk
: '>=3.3.3 <4.0.0'

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

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

flutter
:
  uses
-material-design: true

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

  1. เปิดไฟล์ main.dart ในไดเรกทอรี lib/

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

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

lib/main.dart

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

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

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

3 เพิ่มคำ

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

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

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

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

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

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

pubspec.yaml

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

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

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

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

โหลดคำ

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

  1. สร้างไฟล์ providers.dart ในไดเรกทอรี lib
  2. เพิ่มข้อมูลต่อไปนี้ลงในไฟล์

lib/providers.dart

import 'dart:convert';

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

part
'providers.g.dart';

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

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

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

  1. ในการเริ่มสร้างโค้ด ให้เรียกใช้คำสั่งต่อไปนี้
$ dart run build_runner watch -d
[INFO] Generating build script completed, took 174ms
[INFO] Setting up file watchers completed, took 5ms
[INFO] Waiting for all file watchers to be ready completed, took 202ms
[INFO] Reading cached asset graph completed, took 65ms
[INFO] Checking for updates since last build completed, took 680ms
[INFO] Running build completed, took 2.3s
[INFO] Caching finalized dependency graph completed, took 42ms
[INFO] Succeeded after 2.3s with 122 outputs (243 actions)

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

ใน Riverpod โดยทั่วไปแล้วจะสร้างอินสแตนซ์ผู้ให้บริการอย่างเช่นฟังก์ชัน wordList ที่คุณกำหนดไว้ข้างต้นแบบ Lazy Loading แต่สำหรับจุดประสงค์ของแอปนี้ คุณจะต้องโหลดรายการคำให้ตั้งใจ เอกสารประกอบของ Riverpod แนะนำแนวทางต่อไปนี้ในการจัดการกับผู้ให้บริการที่คุณจำเป็นต้องใช้อย่างจริงจัง ซึ่งคุณจะนำไปใช้ในตอนนี้

  1. สร้างไฟล์ crossword_generator_app.dart ในไดเรกทอรี lib/widgets
  2. เพิ่มข้อมูลต่อไปนี้ลงในไฟล์

lib/widgets/crossword_generator_app.dart

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

import '../providers.dart';

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

 
@override
 
Widget build(BuildContext context) {
   
return _EagerInitialization(
      child
: Scaffold(
        appBar
: AppBar(
          titleTextStyle
: TextStyle(
            color
: Theme.of(context).colorScheme.primary,
            fontSize
: 16,
            fontWeight
: FontWeight.bold,
         
),
          title
: Text('Crossword Generator'),
       
),
        body
: SafeArea(
          child
: Consumer(
            builder
: (context, ref, _) {
             
final wordListAsync = ref.watch(wordListProvider);
             
return wordListAsync.when(
                data
: (wordList) => ListView.builder(
                  itemCount
: wordList.length,
                  itemBuilder
: (context, index) {
                   
return ListTile(
                      title
: Text(wordList.elementAt(index)),
                   
);
                 
},
               
),
                error
: (error, stackTrace) => Center(
                  child
: Text('$error'),
               
),
                loading
: () => Center(
                  child
: CircularProgressIndicator(),
               
),
             
);
           
},
         
),
       
),
     
),
   
);
 
}
}

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

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

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

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

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

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

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

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

lib/main.dart

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

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

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

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

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

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

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

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

lib/model.dart

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

part
'model.g.dart';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 
@override
 
String toString() => name;
}

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

 
/// The word itself.
 
String get word;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

   
return buffer.toString();
 
}

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

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

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

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

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

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

lib/utils.dart

import 'dart:math';

import 'package:built_collection/built_collection.dart';

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

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

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

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

lib/providers.dart

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

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

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

part
'providers.g.dart';

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

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

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

lib/providers.dart

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

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

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

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

 
@override
 
CrosswordSize build() => _size;

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

final _random = Random();

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

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

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

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

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

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

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

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

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

lib/widgets/crossword_widget.dart

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

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

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

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

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

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

         
if (character != null) {
           
return Container(
              color
: Theme.of(context).colorScheme.onPrimary,
              child
: Center(
                child
: Text(
                  character
.character,
                  style
: TextStyle(
                    fontSize
: 24,
                    color
: Theme.of(context).colorScheme.primary,
                 
),
               
),
             
),
           
);
         
}

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

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

วิดเจ็ตนี้ในฐานะ ConsumerWidget สามารถพึ่งพาผู้ให้บริการ Size โดยตรงในการกำหนดขนาดของตารางกริดแสดงอักขระของ Crossword การแสดงตารางกริดนี้ดำเนินการได้ด้วยวิดเจ็ต TableView จากแพ็กเกจ two_dimensional_scrollables

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

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

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

lib/widgets/crossword_generator_app.dart

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. เปิดไฟล์ model.dart และแทนที่โมเดล Crossword ด้วยข้อมูลต่อไปนี้

lib/model.dart

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

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

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

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

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

 
/// Checks if this crossword is valid.
 
bool get valid {
   
// Check that there are no duplicate words.
   
final wordSet = words.map((word) => word.word).toBuiltSet();
   
if (wordSet.length != words.length) {
     
return false;
   
}

   
for (final MapEntry(key: location, value: character)
       
in characters.entries) {
     
// All characters must be a part of an across or down word.
     
if (character.acrossWord == null && character.downWord == null) {
       
return false;
     
}

     
// All characters must be within the crossword puzzle.
     
// No drawing outside the lines.
     
if (location.x < 0 ||
          location
.y < 0 ||
          location
.x >= width ||
          location
.y >= height) {
       
return false;
     
}

     
// Characters above and below this character must be related
     
// by a vertical word
     
if (characters[location.up] case final up?) {
       
if (character.downWord == null) {
         
return false;
       
}
       
if (up.downWord != character.downWord) {
         
return false;
       
}
     
}

     
if (characters[location.down] case final down?) {
       
if (character.downWord == null) {
         
return false;
       
}
       
if (down.downWord != character.downWord) {
         
return false;
       
}
     
}

     
// Characters to the left and right of this character must be
     
// related by a horizontal word
     
final left = characters[location.left];
     
if (left != null) {
       
if (character.acrossWord == null) {
         
return false;
       
}
       
if (left.acrossWord != character.acrossWord) {
         
return false;
       
}
     
}

     
final right = characters[location.right];
     
if (right != null) {
       
if (character.acrossWord == null) {
         
return false;
       
}
       
if (right.acrossWord != character.acrossWord) {
         
return false;
       
}
     
}
   
}

   
return true;
 
}

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

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

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

     
final target = characters[characterLocation];
     
if (target != null) {
        overlap
= true;
       
if (target.character != character) {
         
return null;
       
}
       
if (direction == Direction.across && target.acrossWord != null ||
            direction
== Direction.down && target.downWord != null) {
         
return null;
       
}
     
}
   
}
   
if (words.isNotEmpty && !overlap) {
     
return null;
   
}

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

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

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

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

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

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

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

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

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

   
return buffer.toString();
 
}

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

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

โปรดอย่าลืมว่าการเปลี่ยนแปลงที่คุณทำในไฟล์ model.dart และ providers.dart จะต้องเรียกใช้ build_runner เพื่ออัปเดตไฟล์ model.g.dart และ providers.g.dart ที่เกี่ยวข้อง หากไฟล์เหล่านี้ไม่ได้อัปเดตตัวเองโดยอัตโนมัติ ตอนนี้ก็เป็นโอกาสดีที่จะเริ่มต้น build_runner อีกครั้งด้วย dart run build_runner watch -d

หากต้องการใช้ประโยชน์จากความสามารถใหม่นี้ในเลเยอร์โมเดล คุณจะต้องอัปเดตเลเยอร์ผู้ให้บริการให้สอดคล้องกัน

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

lib/providers.dart

import 'dart:convert';
import 'dart:math';

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

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

part
'providers.g.dart';

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

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

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

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

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

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

 
@override
 
CrosswordSize build() => _size;

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

final _random = Random();

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

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

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

       
var candidate = crossword.addWord(                 // Edit from here
            word
: word, direction: direction, location: location);
        await
Future.delayed(Duration(milliseconds: 10));
       
if (candidate != null) {
          debugPrint
('Added word: $word');
          crossword
= candidate;
         
yield crossword;
       
} else {
          debugPrint
('Failed to add word: $word');
       
}                                                  // To here.
     
}

     
yield crossword;
   
},
    error
: (error, stackTrace) async* {
      debugPrint
('Error loading word list: $error');
     
yield crossword;
   
},
    loading
: () async* {
     
yield crossword;
   
},
 
);
}
  1. เรียกใช้แอป แทบไม่มีสิ่งใดเกิดขึ้นใน UI แต่มีอะไรเกิดขึ้นมากมายถ้าคุณดูบันทึก

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

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

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

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

lib/providers.dart

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

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

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

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

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

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

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

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

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

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

lib/isolates.dart

import 'dart:math';

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

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

final _random = Random();

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

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

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

lib/providers.dart

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

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

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

part
'providers.g.dart';

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

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

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

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

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

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

 
@override
 
CrosswordSize build() => _size;

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

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

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

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

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

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

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

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

  1. เปิดไฟล์ model.dart และเพิ่มคำจำกัดความ WorkQueue ต่อไปนี้ลงในไฟล์

lib/model.dart

  /// Constructor for [Crossword].
 
/// Use [Crossword.crossword] instead.
  factory
Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
 
Crossword._();
}
                                                           
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
 
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;

 
/// The crossword the worker is working on.
 
Crossword get crossword;

 
/// The outstanding queue of locations to try.
 
BuiltMap<Location, Direction> get locationsToTry;

 
/// Known bad locations.
 
BuiltSet<Location> get badLocations;

 
/// The list of unused candidate words that can be added to this crossword.
 
BuiltSet<String> get candidateWords;

 
/// Returns true if the work queue is complete.
 
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;

 
/// Create a work queue from a crossword.
 
static WorkQueue from({
    required
Crossword crossword,
    required
Iterable<String> candidateWords,
    required
Location startLocation,
 
}) =>
     
WorkQueue((b) {
       
if (crossword.words.isEmpty) {
         
// Strip candidate words too long to fit in the crossword
          b
.candidateWords.addAll(candidateWords
             
.where((word) => word.characters.length <= crossword.width));

          b
.crossword.replace(crossword);

          b
.locationsToTry.addAll({startLocation: Direction.across});
       
} else {
         
// Assuming words have already been stripped to length
          b
.candidateWords.addAll(
            candidateWords
.toBuiltSet().rebuild(
               
(b) => b.removeAll(crossword.words.map((word) => word.word))),
         
);
          b
.crossword.replace(crossword);
          crossword
.characters
             
.rebuild((b) => b.removeWhere((location, character) {
                   
if (character.acrossWord != null &&
                        character
.downWord != null) {
                     
return true;
                   
}
                   
final left = crossword.characters[location.left];
                   
if (left != null && left.downWord != null) return true;
                   
final right = crossword.characters[location.right];
                   
if (right != null && right.downWord != null) return true;
                   
final up = crossword.characters[location.up];
                   
if (up != null && up.acrossWord != null) return true;
                   
final down = crossword.characters[location.down];
                   
if (down != null && down.acrossWord != null) return true;
                   
return false;
                 
}))
             
.forEach((location, character) {
            b
.locationsToTry.addAll({
              location
: switch ((character.acrossWord, character.downWord)) {
               
(null, null) =>
                 
throw StateError('Character is not part of a word'),
               
(null, _) => Direction.across,
               
(_, null) => Direction.down,
               
(_, _) => throw StateError('Character is part of two words'),
             
}
           
});
         
});
       
}
     
});

 
WorkQueue remove(Location location) => rebuild((b) => b
   
..locationsToTry.remove(location)
   
..badLocations.add(location));

 
/// Update the work queue from a crossword derived from the current crossword
 
/// that this work queue is built from.
 
WorkQueue updateFrom(final Crossword crossword) => WorkQueue.from(
        crossword
: crossword,
        candidateWords
: candidateWords,
        startLocation
: locationsToTry.isNotEmpty
           
? locationsToTry.keys.first
           
: Location.at(0, 0),
     
).rebuild((b) => b
       
..badLocations.addAll(badLocations)
       
..locationsToTry
           
.removeWhere((location, _) => badLocations.contains(location)));

 
/// Factory constructor for [WorkQueue]
  factory
WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;

 
WorkQueue._();
}                                                          // To here.

/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
 
Location,
 
Crossword,
 
CrosswordWord,
 
CrosswordCharacter,
 
WorkQueue,                                               // Add this line
])
final Serializers serializers = _$serializers;
  1. หากยังมีการยึกยือสีแดงอยู่ในไฟล์นี้หลังจากเพิ่มเนื้อหาใหม่นี้นานกว่า 2-3 วินาที ให้ตรวจสอบว่า build_runner ยังทำงานอยู่ หากไม่เห็น ให้เรียกใช้คำสั่ง dart run build_runner watch -d

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

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

lib/utils.dart

import 'dart:math';

import 'package:built_collection/built_collection.dart';

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

/// An extension on [BuiltSet] that adds a method to get a random element.
extension
RandomElements<E> on BuiltSet<E> {
  E randomElement
() {
   
return elementAt(_random.nextInt(length));
 
}
}
                                                             
// Add from here
/// An extension on [Duration] that adds a method to format the duration.
extension
DurationFormat on Duration {
 
/// A human-readable string representation of the duration.
 
/// This format is tuned for durations in the seconds to days range.
 
String get formatted {
   
final hours = inHours.remainder(24).toString().padLeft(2, '0');
   
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
   
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
   
return switch ((inDays, inHours, inMinutes, inSeconds)) {
     
(0, 0, 0, _) => '${inSeconds}s',
     
(0, 0, _, _) => '$inMinutes:$seconds',
     
(0, _, _, _) => '$inHours:$minutes:$seconds',
      _
=> '$inDays days, $hours:$minutes:$seconds',
   
};
 
}
}                                                             // To here.

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

  1. หากต้องการผสานรวมฟังก์ชันใหม่นี้ ให้แทนที่ไฟล์ isolates.dart เพื่อกำหนดวิธีกำหนดฟังก์ชัน exploreCrosswordSolutions ใหม่ดังนี้

lib/isolates.dart

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

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

Stream<Crossword> exploreCrosswordSolutions({
  required
Crossword crossword,
  required
BuiltSet<String> wordList,
}) async* {
 
final start = DateTime.now();
 
var workQueue = WorkQueue.from(
    crossword
: crossword,
    candidateWords
: wordList,
    startLocation
: Location.at(0, 0),
 
);
 
while (!workQueue.isCompleted) {
   
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
   
try {
     
final crossword = await compute(((WorkQueue, Location) workMessage) {
       
final (workQueue, location) = workMessage;
       
final direction = workQueue.locationsToTry[location]!;
       
final target = workQueue.crossword.characters[location];
       
if (target == null) {
         
return workQueue.crossword.addWord(
            direction
: direction,
            location
: location,
            word
: workQueue.candidateWords.randomElement(),
         
);
       
}
       
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
         
..where((b) => b.characters.contains(target.character))
         
..shuffle());
       
int tryCount = 0;
       
for (final word in words) {
          tryCount
++;
         
for (final (index, character) in word.characters.indexed) {
           
if (character != target.character) continue;

           
final candidate = workQueue.crossword.addWord(
              location
: switch (direction) {
               
Direction.across => location.leftOffset(index),
               
Direction.down => location.upOffset(index),
             
},
              word
: word,
              direction
: direction,
           
);
           
if (candidate != null) {
             
return candidate;
           
}
         
}
         
if (tryCount > 1000) {
           
break;
         
}
       
}
     
}, (workQueue, location));
     
if (crossword != null) {
        workQueue
= workQueue.updateFrom(crossword);
       
yield crossword;
     
} else {
        workQueue
= workQueue.remove(location);
     
}
   
} catch (e) {
      debugPrint
('Error running isolate: $e');
   
}
 
}
  debugPrint
('${crossword.width} x ${crossword.height} Crossword generated in '
     
'${DateTime.now().difference(start).formatted}');
}

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

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

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

7 แสดงสถิติ

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

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

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

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

  1. แก้ไขไฟล์ model.dart ดังต่อไปนี้เพื่อเพิ่มคลาส DisplayInfo

lib/model.dart

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

part
'model.g.dart';

/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
  1. ในตอนท้ายของไฟล์ ให้ทำการเปลี่ยนแปลงต่อไปนี้เพื่อเพิ่มชั้นเรียน DisplayInfo

lib/model.dart

  /// Factory constructor for [WorkQueue]
  factory
WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;

 
WorkQueue._();
}
                                                           
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
 
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;

 
/// The number of words in the grid.
 
String get wordsInGridCount;

 
/// The number of candidate words.
 
String get candidateWordsCount;

 
/// The number of locations to explore.
 
String get locationsToExploreCount;

 
/// The number of known bad locations.
 
String get knownBadLocationsCount;

 
/// The percentage of the grid filled.
 
String get gridFilledPercentage;

 
/// Construct a [DisplayInfo] instance from a [WorkQueue].
  factory
DisplayInfo.from({required WorkQueue workQueue}) {
   
final gridFilled = (workQueue.crossword.characters.length /
       
(workQueue.crossword.width * workQueue.crossword.height));
   
final fmt = NumberFormat.decimalPattern();

   
return DisplayInfo((b) => b
     
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
     
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
     
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
     
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
     
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%');
 
}

 
/// An empty [DisplayInfo] instance.
 
static DisplayInfo get empty => DisplayInfo((b) => b
   
..wordsInGridCount = '0'
   
..candidateWordsCount = '0'
   
..locationsToExploreCount = '0'
   
..knownBadLocationsCount = '0'
   
..gridFilledPercentage = '0%');

  factory
DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
      _$DisplayInfo
;
 
DisplayInfo._();
}                                                          // To here.

/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
 
Location,
 
Crossword,
 
CrosswordWord,
 
CrosswordCharacter,
 
WorkQueue,
 
DisplayInfo,                                             // Add this line.
])
final Serializers serializers = _$serializers;
  1. แก้ไขไฟล์ isolates.dart เพื่อแสดงโมเดล WorkQueue ดังนี้

lib/isolates.dart

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

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

Stream<WorkQueue> exploreCrosswordSolutions({              // Modify this line
  required
Crossword crossword,
  required
BuiltSet<String> wordList,
}) async* {
 
final start = DateTime.now();
 
var workQueue = WorkQueue.from(
    crossword
: crossword,
    candidateWords
: wordList,
    startLocation
: Location.at(0, 0),
 
);
 
while (!workQueue.isCompleted) {
   
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
   
try {
     
final crossword = await compute(((WorkQueue, Location) workMessage) {
       
final (workQueue, location) = workMessage;
       
final direction = workQueue.locationsToTry[location]!;
       
final target = workQueue.crossword.characters[location];
       
if (target == null) {
         
return workQueue.crossword.addWord(
            direction
: direction,
            location
: location,
            word
: workQueue.candidateWords.randomElement(),
         
);
       
}
       
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
         
..where((b) => b.characters.contains(target.character))
         
..shuffle());
       
int tryCount = 0;
       
for (final word in words) {
          tryCount
++;
         
for (final (index, character) in word.characters.indexed) {
           
if (character != target.character) continue;

           
final candidate = workQueue.crossword.addWord(
              location
: switch (direction) {
               
Direction.across => location.leftOffset(index),
               
Direction.down => location.upOffset(index),
             
},
              word
: word,
              direction
: direction,
           
);
           
if (candidate != null) {
             
return candidate;
           
}
         
}
         
if (tryCount > 1000) {
           
break;
         
}
       
}
     
}, (workQueue, location));
     
if (crossword != null) {
        workQueue
= workQueue.updateFrom(crossword);       // Drop the yield crossword;
     
} else {
        workQueue
= workQueue.remove(location);
     
}
     
yield workQueue;                                     // Add this line.
   
} catch (e) {
      debugPrint
('Error running isolate: $e');
   
}
 
}
  debugPrint
('${crossword.width} x ${crossword.height} Crossword generated in '
     
'${DateTime.now().difference(start).formatted}');
}

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

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

lib/providers.dart

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

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

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

part
'providers.g.dart';

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

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

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

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

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

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

 
@override
 
CrosswordSize build() => _size;

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

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

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

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

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

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

 
DateTime? _start;

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

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

 
DateTime? _end;

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

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

const _estimatedTotalCoverage = 0.54;

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

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

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

 
@override
 
bool build() => _display;

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

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

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

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

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

lib/widgets/ticker_builder.dart

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

/// A Builder widget that invokes its [builder] function on every animation frame.
class TickerBuilder extends StatefulWidget {
 
const TickerBuilder({super.key, required this.builder});
 
final Widget Function(BuildContext context) builder;
 
@override
 
State<TickerBuilder> createState() => _TickerBuilderState();
}

class _TickerBuilderState extends State<TickerBuilder>
   
with SingleTickerProviderStateMixin {
  late
final Ticker _ticker;

 
@override
 
void initState() {
   
super.initState();
    _ticker
= createTicker(_handleTick)..start();
 
}

 
@override
 
void dispose() {
    _ticker
.dispose();
   
super.dispose();
 
}

 
void _handleTick(Duration elapsed) {
    setState
(() {
     
// Force a rebuild without changing the widget tree.
   
});
 
}

 
@override
 
Widget build(BuildContext context) => widget.builder.call(context);
}

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

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

lib/widgets/crossword_info_widget.dart

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

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

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

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

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

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

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

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

lib/widgets/crossword_generator_app.dart

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

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

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

 
@override
 
Widget build(BuildContext context) {
   
return _EagerInitialization(
      child
: Scaffold(
        appBar
: AppBar(
          actions
: [_CrosswordGeneratorMenu()],
          titleTextStyle
: TextStyle(
            color
: Theme.of(context).colorScheme.primary,
            fontSize
: 16,
            fontWeight
: FontWeight.bold,
         
),
          title
: Text('Crossword Generator'),
       
),
        body
: SafeArea(
          child
: Consumer(                                 // Modify from here
            builder
: (context, ref, child) {
             
return Stack(
                children
: [
                 
Positioned.fill(
                    child
: CrosswordWidget(),
                 
),
                 
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
               
],
             
);
           
},
         
),                                               // To here.
       
),
     
),
   
);
 
}
}

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

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

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

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

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

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

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

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

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

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

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

lib/widgets/crossword_widget.dart

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

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

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

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

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

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

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

         
if (character != null) {                         // Modify from here
           
return AnimatedContainer(
              duration
: Durations.extralong1,
              curve
: Curves.easeInOut,
              color
: explorationCell
                 
? Theme.of(context).colorScheme.primary
                 
: Theme.of(context).colorScheme.onPrimary,
              child
: Center(
                child
: AnimatedDefaultTextStyle(
                  duration
: Durations.extralong1,
                  curve
: Curves.easeInOut,
                  style
: TextStyle(
                    fontSize
: 24,
                    color
: explorationCell
                       
? Theme.of(context).colorScheme.onPrimary
                       
: Theme.of(context).colorScheme.primary,
                 
),
                  child
: Text(character.character),
               
),                                          // To here.
             
),
           
);
         
}

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

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

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

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

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

  1. แก้ไขไฟล์ isolates.dart นี่เป็นการเขียนโค้ดใหม่เกือบเสร็จสมบูรณ์เพื่อแยกสิ่งที่ประมวลผลอยู่ในพื้นหลังหนึ่งๆ แล้วแยกออกมาเป็นพูลที่แยกออกมาเป็น N ไฟล์

lib/isolates.dart

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

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

Stream<WorkQueue> exploreCrosswordSolutions({
  required
Crossword crossword,
  required
BuiltSet<String> wordList,
  required
int maxWorkerCount,
}) async* {
 
final start = DateTime.now();
 
var workQueue = WorkQueue.from(
    crossword
: crossword,
    candidateWords
: wordList,
    startLocation
: Location.at(0, 0),
 
);
 
while (!workQueue.isCompleted) {
   
try {
      workQueue
= await compute(_generate, (workQueue, maxWorkerCount));
     
yield workQueue;
   
} catch (e) {
      debugPrint
('Error running isolate: $e');
   
}
 
}

  debugPrint
('Generated ${workQueue.crossword.width} x '
     
'${workQueue.crossword.height} crossword in '
     
'${DateTime.now().difference(start).formatted} '
     
'with $maxWorkerCount workers.');
}

Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
 
var (workQueue, maxWorkerCount) = workMessage;
 
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
 
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild((b) => b
   
..shuffle()
   
..take(maxWorkerCount));

 
for (final location in locations) {
   
final direction = workQueue.locationsToTry[location]!;

    candidateGeneratorFutures
.add(compute(_generateCandidate,
       
(workQueue.crossword, workQueue.candidateWords, location, direction)));
 
}

 
try {
   
final results = await candidateGeneratorFutures.wait;
   
var crossword = workQueue.crossword;
   
for (final (location, direction, word) in results) {
     
if (word != null) {
       
final candidate = crossword.addWord(
            location
: location, word: word, direction: direction);
       
if (candidate != null) {
          crossword
= candidate;
       
}
     
} else {
        workQueue
= workQueue.remove(location);
     
}
   
}

    workQueue
= workQueue.updateFrom(crossword);
 
} catch (e) {
    debugPrint
('$e');
 
}

 
return workQueue;
}

(Location, Direction, String?) _generateCandidate(
   
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage) {
 
final (crossword, candidateWords, location, direction) = searchDetailMessage;

 
final target = crossword.characters[location];
 
if (target == null) {
   
return (location, direction, candidateWords.randomElement());
 
}

 
// Filter down the candidate word list to those that contain the letter
 
// at the current location
 
final words = candidateWords.toBuiltList().rebuild((b) => b
   
..where((b) => b.characters.contains(target.character))
   
..shuffle());
 
int tryCount = 0;
 
final start = DateTime.now();
 
for (final word in words) {
    tryCount
++;
   
for (final (index, character) in word.characters.indexed) {
     
if (character != target.character) continue;

     
final candidate = crossword.addWord(
        location
: switch (direction) {
         
Direction.across => location.leftOffset(index),
         
Direction.down => location.upOffset(index),
       
},
        word
: word,
        direction
: direction,
     
);
     
if (candidate != null) {
       
return switch (direction) {
         
Direction.across => (location.leftOffset(index), direction, word),
         
Direction.down => (location.upOffset(index), direction, word),
       
};
     
}
     
final deltaTime = DateTime.now().difference(start);
     
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
       
return (location, direction, null);
     
}
   
}
 
}

 
return (location, direction, null);
}

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

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

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

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

lib/providers.dart

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

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

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

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

lib/providers.dart

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

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

 
const BackgroundWorkers(this.count);

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

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

 
@override
 
BackgroundWorkers build() => _count;

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

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

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

lib/widgets/crossword_info_widget.dart

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

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

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

lib/widgets/crossword_generator_app.dart

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

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

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

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

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

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

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

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

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

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

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

lib/model.dart

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

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

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

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

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

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

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

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

lib/model.dart

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

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

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

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

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

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

   
var puzzle = this;

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

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

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

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

   
var puzzle = this;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

lib/providers.dart

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

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

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

part
'providers.g.dart';

const backgroundWorkerCount = 4;                           // Add this line

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

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

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

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

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

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

 
@override
 
CrosswordSize build() => _size;

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

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

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

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

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

   
return _puzzle;
 
}

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

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

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

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

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

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

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

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

lib/widgets/crossword_puzzle_app.dart

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

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

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

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

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

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

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

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

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

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

lib/widgets/crossword_generator_widget.dart

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

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

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

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

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

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

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

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

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

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

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

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

lib/widgets/crossword_puzzle_widget.dart

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

lib/widgets/puzzle_completed_widget.dart

import 'package:flutter/material.dart';

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

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

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

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

lib/main.dart

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

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

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

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

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

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

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

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

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