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

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