1. Sebelum memulai
Bayangkan Anda ditanya apakah mungkin membuat teka-teki silang terbesar di dunia. Anda mengingat beberapa teknik AI yang Anda pelajari di sekolah dan bertanya-tanya apakah Anda dapat menggunakan Flutter untuk mempelajari opsi algoritma guna membuat solusi untuk masalah yang intensif secara komputasi.
Dalam codelab ini, Anda akan melakukannya. Pada akhirnya, Anda akan membuat alat untuk bermain di ruang algoritma dalam menyusun teka-teki petak kata. Ada banyak definisi berbeda tentang teka-teki silang yang valid dan teknik ini membantu Anda membuat teka-teki yang sesuai dengan definisi Anda.
Dengan alat ini sebagai dasar, Anda kemudian membuat teka-teki silang yang menggunakan generator teka-teki silang untuk membuat teka-teki yang harus dipecahkan pengguna. Teka-teki ini dapat digunakan di Android, iOS, Windows, macOS, dan Linux. Berikut cara melakukannya di Android:
Prasyarat
- Menyelesaikan codelab Aplikasi Flutter pertama Anda
Yang Anda pelajari
- Cara menggunakan isolate untuk melakukan pekerjaan yang mahal secara komputasi tanpa menghambat loop render Flutter dengan kombinasi fungsi
compute
Flutter dan kemampuan penampungan nilai filter pembangunan ulang Riverpodselect
. - Cara memanfaatkan struktur data yang tidak dapat diubah dengan
built_value
danbuilt_collection
untuk menerapkan teknik Good Old Fashioned AI (GOFAI) berbasis penelusuran seperti penelusuran depth-first dan backtracking. - Cara menggunakan kemampuan paket
two_dimensional_scrollables
untuk menampilkan data petak dengan cepat dan intuitif.
Yang Anda perlukan
- Flutter SDK.
- Visual Studio Code (VS Code) dengan plugin Flutter dan Dart.
- Software compiler untuk target pengembangan pilihan Anda. Codelab ini berfungsi untuk semua platform desktop, Android, dan iOS. Anda memerlukan VS Code untuk menargetkan Windows, Xcode untuk menargetkan macOS atau iOS, dan Android Studio untuk menargetkan Android.
2. Membuat project
Membuat proyek Flutter pertama Anda
- Luncurkan VS Code.
- Buka Palet Perintah (Ctrl+Shift+P di Windows/Linux, Cmd+Shift+P di macOS), ketik "flutter new", lalu pilih Flutter: New Project di menu.
- Pilih Empty application, lalu pilih direktori tempat project akan dibuat. Ini harus berupa direktori yang tidak memerlukan hak istimewa yang ditingkatkan atau memiliki spasi di jalur direktori. Contohnya termasuk direktori utama Anda atau
C:\src\
.
- Beri nama project Anda
generate_crossword
. Sisa codelab ini mengasumsikan Anda menamai aplikasi Andagenerate_crossword
.
Flutter kini membuat folder proyek Anda dan VS Code membuka folder tersebut. Anda sekarang akan menimpa isi dua file dengan scaffold dasar aplikasi.
Menyalin dan menempelkan aplikasi awal
- Di panel sebelah kiri VS Code, klik Explorer dan buka file
pubspec.yaml
.
- Ganti konten file ini dengan dependensi berikut yang diperlukan untuk pembuatan teka-teki silang:
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter
built_collection: ^5.1.1
built_value: ^8.10.1
characters: ^1.4.0
flutter_riverpod: ^2.6.1
intl: ^0.20.2
riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
two_dimensional_scrollables: ^0.3.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.5.4
built_value_generator: ^8.10.1
custom_lint: ^0.7.6
riverpod_generator: ^2.6.5
riverpod_lint: ^2.6.5
flutter:
uses-material-design: true
File pubspec.yaml
menentukan informasi dasar tentang aplikasi Anda, seperti versi aplikasi saat ini, dan dependensinya. Anda akan melihat kumpulan dependensi yang bukan bagian dari aplikasi Flutter kosong normal. Anda akan mendapatkan manfaat dari semua paket ini pada langkah-langkah berikutnya.
Memahami Dependensi
Sebelum membahas kode, mari kita pahami alasan pemilihan paket tertentu ini:
- built_value: Membuat objek tetap yang berbagi memori secara efisien, yang sangat penting untuk algoritma backtracking kami
- Riverpod: Menyediakan pengelolaan status terperinci dengan
select()
untuk meminimalkan pembangunan ulang - two_dimensional_scrollables: Menangani petak besar tanpa mengganggu performa
- Buka file
main.dart
di direktorilib/
.
- Ganti konten file ini dengan kode berikut:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
),
),
),
),
);
}
- Jalankan kode ini untuk memeriksa apakah semuanya berfungsi. Jendela baru dengan frasa awal wajib dari setiap project baru di mana pun akan ditampilkan. Ada
ProviderScope
yang menunjukkan bahwa aplikasi ini akan menggunakanriverpod
untuk pengelolaan status.
Checkpoint: Aplikasi Dasar Berjalan
Pada tahap ini, Anda akan melihat jendela "Hello, World!". Jika tidak:
- Pastikan Flutter telah diinstal dengan benar
- Verifikasi aplikasi berjalan dengan
flutter run
- Pastikan tidak ada error kompilasi di terminal
3. Menambahkan kata
Elemen penyusun teka-teki silang
Inti dari teka-teki silang adalah daftar kata. Kata-kata disusun dalam petak, beberapa melintang, beberapa menurun, sehingga kata-kata tersebut saling terkait. Memecahkan satu kata akan memberikan petunjuk tentang kata-kata yang berpotongan dengan kata pertama tersebut. Jadi, elemen penyusun pertama yang baik adalah daftar kata.
Sumber yang baik untuk kata-kata ini adalah halaman Natural Language Corpus Data Peter Norvig. Daftar SOWPODS adalah titik awal yang berguna, dengan 267.750 kata.
Pada langkah ini, Anda akan mendownload daftar kata, menambahkannya sebagai aset ke aplikasi Flutter, dan mengatur penyedia Riverpod untuk memuat daftar ke aplikasi saat startup.
Untuk memulai, ikuti langkah-langkah berikut:
- Ubah file
pubspec.yaml
project Anda untuk menambahkan deklarasi aset berikut untuk daftar kata yang dipilih. Listingan ini hanya menampilkan stanza flutter konfigurasi aplikasi Anda, karena stanza lainnya tetap sama.
pubspec.yaml
flutter:
uses-material-design: true
assets: # Add this line
- assets/words.txt # And this one.
Editor Anda mungkin akan menandai baris terakhir ini dengan peringatan karena Anda belum membuat file ini.
- Dengan menggunakan browser dan editor, buat direktori
assets
di tingkat teratas project Anda dan buat filewords.txt
di dalamnya dengan salah satu daftar kata yang ditautkan sebelumnya.
Kode ini dirancang dengan daftar SOWPODS yang disebutkan sebelumnya, tetapi akan berfungsi dengan daftar kata apa pun yang hanya terdiri dari karakter A-Z. Memperluas codebase ini agar dapat berfungsi dengan set karakter yang berbeda diserahkan sebagai latihan kepada pembaca.
Memuat kata
Untuk menulis kode yang bertanggung jawab memuat daftar kata saat aplikasi dimulai, ikuti langkah-langkah berikut:
- Buat file
providers.dart
di direktorilib
. - Tambahkan kode berikut ke file:
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
Ini adalah penyedia Riverpod pertama Anda untuk codebase ini.
Cara kerja penyedia ini:
- Memuat daftar kata dari aset secara asinkron
- Memfilter kata hanya untuk menyertakan karakter a-z yang lebih panjang dari 2 huruf
- Menampilkan
BuiltSet
yang tidak dapat diubah untuk akses acak yang efisien
Project ini menggunakan pembuatan kode untuk beberapa dependensi, termasuk Riverpod.
- Untuk mulai membuat kode, jalankan perintah berikut:
$ 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)
Proses ini akan terus berjalan di latar belakang, memperbarui file yang dihasilkan saat Anda membuat perubahan pada project. Setelah perintah ini menghasilkan kode di providers.g.dart
, editor Anda akan menerima kode yang Anda tambahkan ke providers.dart
.
Di Riverpod, penyedia seperti fungsi wordList
yang Anda tentukan sebelumnya umumnya di-instansiasi secara lambat. Namun, untuk tujuan aplikasi ini, Anda perlu memuat daftar kata secara langsung. Dokumentasi Riverpod menyarankan pendekatan berikut untuk menangani penyedia yang perlu Anda muat dengan segera. Anda akan menerapkannya sekarang.
- Buat file
crossword_generator_app.dart
di direktorilib/widgets
. - Tambahkan kode berikut ke file:
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;
}
}
File ini menarik dari dua arah yang berbeda. Yang pertama adalah widget _EagerInitialization
, yang memiliki satu-satunya misi untuk mewajibkan penyedia wordList
yang Anda buat sebelumnya untuk memuat daftar kata. Widget ini mencapai tujuan ini dengan memproses penyedia menggunakan panggilan ref.watch()
. Anda dapat membaca lebih lanjut teknik ini di dokumentasi Riverpod tentang Inisialisasi provider yang bersemangat.
Poin menarik kedua yang perlu diperhatikan dalam file ini adalah cara Riverpod menangani konten asinkron. Seperti yang mungkin Anda ingat, penyedia wordList
ditentukan sebagai fungsi asinkron, karena memuat konten dari disk berjalan lambat. Saat mengamati penyedia daftar kata dalam kode ini, Anda akan menerima AsyncValue<BuiltSet<String>>
. Bagian AsyncValue
dari jenis tersebut adalah adaptor antara dunia penyedia asinkron dan dunia sinkron metode build
Widget.
Metode when
AsyncValue
menangani tiga kemungkinan status yang mungkin dimiliki nilai mendatang. Future mungkin telah berhasil diselesaikan, dalam hal ini callback data
dipanggil, mungkin dalam status error, dalam hal ini callback error
dipanggil, atau akhirnya mungkin masih memuat. Jenis nilai yang ditampilkan dari ketiga callback harus memiliki jenis nilai yang kompatibel, karena nilai yang ditampilkan dari callback yang dipanggil ditampilkan oleh metode when
. Dalam contoh ini, hasil metode when ditampilkan sebagai body
widget Scaffold
.
Membuat aplikasi daftar yang hampir tak terbatas
Untuk mengintegrasikan widget CrosswordGeneratorApp
ke dalam aplikasi Anda, ikuti langkah-langkah berikut:
- Perbarui file
lib/main.dart
dengan menambahkan kode berikut:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- Mulai ulang aplikasi. Anda akan melihat daftar yang di-scroll yang akan memuat lebih dari 267.750 kata dalam kamus.
Yang akan Anda bangun selanjutnya
Sekarang Anda akan membuat struktur data inti untuk teka-teki silang menggunakan objek yang tidak dapat diubah. Dasar ini akan memungkinkan algoritma yang efisien dan update UI yang lancar.
4. Menampilkan kata-kata dalam petak
Pada langkah ini, Anda akan membuat struktur data untuk membuat teka-teki silang menggunakan paket built_value
dan built_collection
. Kedua paket ini memungkinkan pembuatan struktur data sebagai nilai yang tidak dapat diubah, yang akan berguna untuk meneruskan data antar-Isolate, dan mempermudah penerapan penelusuran depth first dan backtracking.
Untuk memulai, ikuti langkah-langkah berikut:
- Buat file
model.dart
di direktorilib
, lalu tambahkan konten berikut ke file tersebut:
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;
File ini menjelaskan awal struktur data yang akan Anda gunakan untuk membuat teka-teki silang. Pada dasarnya, teka-teki silang adalah daftar kata horizontal dan vertikal yang saling terkait dalam petak. Untuk menggunakan struktur data ini, Anda membuat Crossword
dengan ukuran yang sesuai menggunakan konstruktor bernama Crossword.crossword
, lalu menambahkan kata-kata menggunakan metode addWord
. Sebagai bagian dari pembuatan nilai akhir, petak CrosswordCharacter
dibuat oleh metode _fillCharacters
.
Untuk menggunakan struktur data ini, ikuti langkah-langkah berikut:
- Buat file
utils
di direktorilib
, lalu tambahkan konten berikut ke file tersebut:
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));
}
}
Ini adalah ekstensi pada BuiltSet
yang memudahkan pengambilan elemen acak dari set. Metode ekstensi adalah cara yang baik untuk memperluas class dengan fungsi tambahan. Penamaan ekstensi diperlukan agar ekstensi tersedia di luar file utils.dart
.
- Di file
lib/providers.dart
, tambahkan impor berikut:
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 {
Impor ini mengekspos model yang ditentukan sebelumnya ke penyedia yang akan Anda buat. Impor dart:math
disertakan untuk Random
, impor flutter/foundation.dart
disertakan untuk debugPrint
, model.dart
untuk model, dan utils.dart
untuk ekstensi BuiltSet
.
- Di akhir file yang sama, tambahkan penyedia berikut:
lib/providers.dart
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
crossword = crossword.addWord(
word: word,
direction: direction,
location: location,
);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
Perubahan ini menambahkan dua penyedia ke aplikasi Anda. Yang pertama adalah Size
, yang secara efektif merupakan variabel global yang berisi nilai enumerasi CrosswordSize
yang dipilih. Hal ini akan memungkinkan UI menampilkan dan menyetel ukuran teka-teki silang yang sedang dibuat. Penyedia kedua, crossword
, adalah kreasi yang lebih menarik. Ini adalah fungsi yang menampilkan serangkaian Crossword
. Stream ini dibuat menggunakan dukungan Dart untuk generator, seperti yang ditandai dengan async*
pada fungsi. Artinya, alih-alih berakhir pada pengembalian, fungsi ini menghasilkan serangkaian Crossword
, cara yang jauh lebih mudah untuk menulis komputasi yang menampilkan hasil sementara.
Karena adanya sepasang panggilan ref.watch
di awal fungsi penyedia crossword
, aliran Crossword
akan dimulai ulang oleh sistem Riverpod setiap kali ukuran teka-teki silang yang dipilih berubah dan saat daftar kata selesai dimuat.
Sekarang setelah Anda memiliki kode untuk membuat Teka-Teki Silang, meskipun penuh dengan kata-kata acak, akan lebih baik jika menampilkannya kepada pengguna alat.
- Dalam direktori
lib/widgets
, buat filecrossword_widget.dart
yang berisi kode berikut:
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,
),
),
),
);
}
}
Widget ini, sebagai ConsumerWidget
, dapat langsung mengandalkan penyedia Size
untuk menentukan ukuran petak guna menampilkan karakter Crossword
. Tampilan petak ini dilakukan dengan widget TableView
dari paket two_dimensional_scrollables
.
Perlu diperhatikan bahwa setiap sel yang dirender oleh fungsi helper _buildCell
berisi widget Consumer
dalam hierarki Widget
yang ditampilkan. Hal ini berfungsi sebagai batas refresh. Semua yang ada di dalam widget Consumer
dibuat ulang saat nilai yang ditampilkan ref.watch
berubah. Mungkin Anda tergoda untuk membuat ulang seluruh hierarki setiap kali Crossword
berubah, tetapi hal ini menyebabkan banyak komputasi yang dapat dilewati dengan menggunakan penyiapan ini.
Jika melihat parameter ref.watch
, Anda akan melihat ada lapisan lain untuk menghindari penghitungan ulang tata letak, dengan menggunakan crosswordProvider.select
. Artinya, ref.watch
hanya akan memicu pembangunan ulang konten TableViewCell
saat karakter yang dirender oleh sel berubah. Pengurangan rendering ulang ini adalah bagian penting untuk menjaga responsivitas UI.
Untuk mengekspos penyedia CrosswordWidget
dan Size
kepada pengguna, ubah file crossword_generator_app.dart
sebagai berikut:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(child: CrosswordWidget()), // Replace what was here before
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
Ada beberapa hal yang telah berubah di sini. Pertama, kode yang bertanggung jawab untuk merender wordList
sebagai ListView
telah diganti dengan panggilan ke CrosswordWidget
yang ditentukan dalam file lib/widgets/crossword_widget.dart
. Perubahan besar lainnya adalah dimulainya menu untuk mengubah perilaku aplikasi, dimulai dengan mengubah ukuran teka-teki silang. Akan ada lebih banyak MenuItemButton
yang ditambahkan pada langkah-langkah berikutnya. Jalankan aplikasi Anda, Anda akan melihat tampilan seperti ini:
Ada karakter yang ditampilkan dalam petak dan menu yang memungkinkan pengguna mengubah ukuran petak. Namun, kata-katanya tidak disusun seperti teka-teki silang. Hal ini merupakan akibat dari tidak menerapkan batasan apa pun tentang cara kata ditambahkan ke teka-teki silang. Singkatnya, ini berantakan. Sesuatu yang akan mulai Anda kendalikan di langkah berikutnya.
5. Menerapkan batasan
Apa yang kami ubah dan alasannya
Saat ini, teka-teki silang Anda memungkinkan kata-kata yang tumpang-tindih tanpa validasi. Anda akan menambahkan pemeriksaan batasan untuk memastikan kata-kata saling terkait dengan benar seperti teka-teki silang sungguhan.
Tujuan langkah ini adalah menambahkan kode ke model untuk menerapkan batasan teka-teki silang. Ada banyak jenis teka-teki silang, dan gaya yang akan diterapkan codelab ini mengikuti tradisi teka-teki silang dalam bahasa Inggris. Mengubah kode ini untuk membuat gaya teka-teki silang lainnya, seperti biasa, diserahkan sebagai latihan bagi pembaca.
Untuk memulai, ikuti langkah-langkah berikut:
- Buka file
model.dart
dan ganti hanya modelCrossword
dengan yang berikut:
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._();
}
Sebagai pengingat, perubahan yang Anda lakukan pada file model.dart
dan providers.dart
memerlukan build_runner
agar dapat berjalan untuk memperbarui file model.g.dart
dan providers.g.dart
masing-masing. Jika file ini belum diperbarui secara otomatis, sekarang adalah saat yang tepat untuk memulai build_runner
lagi dengan dart run build_runner watch -d
.
Untuk memanfaatkan kemampuan baru ini di lapisan model, Anda harus memperbarui lapisan penyedia agar cocok.
- Edit file
providers.dart
Anda sebagai berikut:
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;
},
);
}
- Jalankan aplikasi Anda. Tidak banyak yang terjadi di UI, tetapi banyak yang terjadi jika Anda melihat log.
Jika Anda memikirkan apa yang terjadi di sini, kita melihat teka-teki silang muncul secara acak. Metode addWord
dalam model Crossword
menolak setiap kata yang diusulkan yang tidak sesuai dengan teka-teki silang saat ini, jadi sangat luar biasa bahwa kita melihat sesuatu muncul.
Mengapa Harus Beralih ke Pemrosesan di Latar Belakang?
Anda mungkin melihat UI menjadi tidak responsif selama pembuatan teka-teki silang. Hal ini terjadi karena pembuatan teka-teki silang melibatkan ribuan pemeriksaan validasi. Penghitungan ini memblokir loop rendering 60 fps Flutter, jadi Anda akan memindahkan penghitungan berat ke isolate latar belakang. Hal ini memberikan manfaat bahwa UI tetap lancar saat teka-teki dibuat di latar belakang
Sebagai persiapan untuk lebih metodis dalam memilih kata yang akan dicoba di mana, akan sangat membantu jika memindahkan komputasi ini dari thread UI ke isolate latar belakang. Flutter memiliki wrapper yang sangat berguna untuk mengambil sebagian pekerjaan dan menjalankannya di isolate latar belakang - fungsi compute
.
- Di file
providers.dart
, ubah penyedia teka-teki silang sebagai berikut:
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
try { // Edit from here
var candidate = await compute((
(String, model.Direction, model.Location) wordToAdd,
) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
Memahami Pembatasan Isolasi
Kode ini berfungsi, tetapi memiliki masalah tersembunyi. Isolat memiliki aturan ketat tentang data apa yang dapat diteruskan di antara keduanya, dengan masalahnya adalah penutupan "mencatat" referensi penyedia, yang tidak dapat diserialisasi dan dikirim ke isolat lain.
Anda akan melihat ini saat sistem mencoba mengirim data yang tidak dapat diserialisasi:
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)
Ini adalah hasil penutupan yang diserahkan compute
ke penutupan isolate latar belakang melalui penyedia, yang tidak dapat dikirim melalui SendPort.send()
. Salah satu perbaikan untuk masalah ini adalah memastikan tidak ada yang perlu ditutup oleh penutupan yang tidak dapat dikirim.
Langkah pertama adalah memisahkan penyedia dari kode Isolate.
- Buat file
isolates.dart
di direktorilib
, lalu tambahkan konten berikut ke dalamnya:
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');
}
}
}
Kode ini seharusnya terlihat cukup familier. Ini adalah inti dari penyedia crossword
, tetapi sekarang sebagai fungsi generator mandiri. Sekarang Anda dapat memperbarui file providers.dart
untuk menggunakan fungsi baru ini guna membuat instance isolate latar belakang.
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword( // Edit from here
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
Dengan ini, Anda sekarang memiliki alat yang membuat teka-teki silang dengan berbagai ukuran, dengan compute
memecahkan teka-teki yang terjadi di isolasi latar belakang. Sekarang, jika saja kode dapat lebih efisien saat memutuskan kata-kata yang akan dicoba ditambahkan ke teka-teki silang.
6. Mengelola antrean tugas
Memahami Strategi Penelusuran
Pembuatan teka-teki silang Anda menggunakan backtracking, pendekatan coba-coba yang sistematis. Pertama, aplikasi Anda mencoba menempatkan kata di suatu lokasi, lalu memeriksa apakah kata tersebut cocok dengan kata-kata yang ada. Jika ya, simpan kata tersebut dan coba kata berikutnya. Jika tidak, cabut dan coba di tempat lain.
Backtracking berfungsi untuk teka-teki silang karena setiap penempatan kata menciptakan batasan untuk kata-kata berikutnya, yang mana penempatan yang tidak valid akan cepat terdeteksi dan ditinggalkan. Struktur data yang tidak dapat diubah membuat "membatalkan" perubahan menjadi efisien.
Sebagian masalah pada kode saat ini adalah bahwa masalah yang diselesaikan secara efektif merupakan masalah penelusuran, dan solusi saat ini melakukan penelusuran tanpa mengetahui apa yang dicari. Jika kode berfokus pada pencarian kata yang akan dilampirkan ke kata saat ini, bukan mencoba menempatkan kata secara acak di mana saja pada petak, sistem akan menemukan solusi lebih cepat. Salah satu cara untuk melakukannya adalah dengan memperkenalkan antrean kerja lokasi untuk mencoba menemukan kata-kata.
Kode ini membuat solusi kandidat, memeriksa apakah solusi kandidat valid, dan bergantung pada validitasnya, kode ini akan menggabungkan kandidat atau menghapusnya. Ini adalah contoh penerapan dari algoritma keluarga backtracking. Penerapan ini sangat dipermudah oleh built_value
dan built_collection
, yang memungkinkan pembuatan nilai tidak dapat diubah baru yang berasal dari, dan dengan demikian berbagi, status umum dengan nilai tidak dapat diubah yang menjadi asalnya. Hal ini memungkinkan eksploitasi murah calon kandidat tanpa biaya memori yang diperlukan untuk penyalinan mendalam.
Untuk memulai, ikuti langkah-langkah berikut:
- Buka file
model.dart
dan tambahkan definisiWorkQueue
berikut ke dalamnya:
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;
- Jika Anda masih melihat garis merah bergelombang dalam file ini setelah menambahkan konten baru selama lebih dari beberapa detik, pastikan
build_runner
Anda masih berjalan. Jika tidak, jalankan perintahdart run build_runner watch -d
.
Dalam kode yang akan Anda perkenalkan, pencatatan log akan menunjukkan berapa lama waktu yang dibutuhkan untuk membuat teka-teki silang dalam berbagai ukuran. Akan lebih baik jika Durasi memiliki beberapa bentuk tampilan yang diformat dengan baik. Untungnya, dengan metode ekstensi, kita dapat menambahkan metode persis yang kita butuhkan.
- Edit file
utils.dart
sebagai berikut:
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.
Metode ekstensi ini memanfaatkan ekspresi switch dan pencocokan pola pada rekaman untuk memilih cara yang tepat untuk menampilkan durasi yang berbeda-beda mulai dari detik hingga hari. Untuk mengetahui informasi selengkapnya tentang gaya kode ini, lihat codelab Pelajari pola dan rekaman Dart.
- Untuk mengintegrasikan fungsi baru ini, ganti file
isolates.dart
untuk mendefinisikan ulang cara fungsiexploreCrosswordSolutions
ditentukan sebagai berikut:
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}',
);
}
Menjalankan kode ini akan menghasilkan aplikasi yang terlihat identik di permukaannya, tetapi perbedaannya terletak pada berapa lama waktu yang dibutuhkan untuk menemukan teka-teki silang yang sudah selesai. Berikut adalah teka-teki silang 80 x 44 yang dibuat dalam 1 menit 29 detik.
Checkpoint: Efficient Algorithm Working
Pembuatan teka-teki silang Anda kini akan jauh lebih cepat berkat:
- Titik persimpangan penargetan penempatan kata yang cerdas
- Pencarian mundur yang efisien saat penempatan gagal
- Pengelolaan antrean tugas untuk menghindari penelusuran yang berlebihan
Pertanyaan yang jelas adalah, tentu saja, bisakah kita bergerak lebih cepat? Oh ya, tentu saja.
7. Statistik permukaan
Mengapa Menambahkan Statistik?
Untuk membuat sesuatu menjadi cepat, Anda perlu melihat apa yang sedang terjadi. Statistik membantu Anda memantau progres, melihat performa algoritma secara real time. Hal ini memungkinkan Anda mengidentifikasi hambatan dengan memahami tempat algoritma menghabiskan waktunya. Hal ini memungkinkan Anda menyesuaikan performa dengan membuat keputusan yang tepat tentang pendekatan pengoptimalan.
Informasi yang akan Anda tampilkan perlu diekstrak dari WorkQueue dan ditampilkan di UI. Langkah pertama yang berguna adalah menentukan class model baru yang berisi informasi yang ingin Anda tampilkan.
Untuk memulai, ikuti langkah-langkah berikut:
- Edit file
model.dart
sebagai berikut untuk menambahkan classDisplayInfo
:
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> {
- Di akhir file, lakukan perubahan berikut untuk menambahkan class
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;
- Ubah file
isolates.dart
untuk mengekspos modelWorkQueue
sebagai berikut:
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}',
);
}
Setelah isolasi latar belakang mengekspos antrean tugas, kini muncul pertanyaan tentang cara dan tempat untuk mendapatkan statistik dari sumber data ini.
- Ganti penyedia teka-teki silang lama dengan penyedia antrean tugas, lalu tambahkan penyedia lain yang mendapatkan informasi dari aliran penyedia antrean tugas:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(Ref ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage),
);
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
Penyedia baru adalah campuran status global, dalam bentuk apakah tampilan informasi harus ditumpangkan di atas petak teka-teki silang, dan data turunan seperti waktu berjalan pembuatan teka-teki silang. Semua ini dipersulit oleh fakta bahwa pemroses ke beberapa status ini bersifat sementara. Tidak ada yang memantau waktu mulai dan berakhirnya komputasi teka-teki silang jika tampilan info disembunyikan, tetapi waktu tersebut harus tetap ada dalam memori agar perhitungan akurat saat tampilan info ditampilkan. Parameter keepAlive
atribut Riverpod
sangat berguna dalam hal ini.
Dalam menampilkan tampilan info, ada sedikit masalah. Kita ingin dapat menampilkan waktu berjalan yang telah berlalu, tetapi tidak ada apa pun di sini untuk memaksa pembaruan waktu yang telah berlalu secara konstan. Kembali ke codelab Membangun UI generasi berikutnya di Flutter, berikut adalah widget berguna untuk persyaratan ini.
- Buat file
ticker_builder.dart
di direktorilib/widgets
, lalu tambahkan konten berikut ke dalamnya:
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);
}
Widget ini adalah palu godam. Objek ini membangun kembali kontennya di setiap frame. Hal ini umumnya tidak disarankan, tetapi dibandingkan dengan beban komputasi untuk menelusuri teka-teki silang, beban komputasi untuk mengecat ulang waktu yang telah berlalu setiap frame mungkin tidak akan terlihat. Untuk memanfaatkan informasi baru yang diperoleh ini, saatnya membuat widget baru.
- Buat file
crossword_info_widget.dart
di direktorilib/widgets
, lalu tambahkan konten berikut ke dalamnya:
lib/widgets/crossword_info_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.dart';
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(text: '$label ', style: DefaultTextStyle.of(context).style),
TextSpan(
text: value,
style: DefaultTextStyle.of(
context,
).style.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
Widget ini adalah contoh utama dari kemampuan penyedia Riverpod. Widget ini akan ditandai untuk dibangun ulang saat salah satu dari lima penyedia diperbarui. Perubahan terakhir yang diperlukan dalam langkah ini adalah mengintegrasikan widget baru ini ke dalam UI.
- Edit file
crossword_generator_app.dart
Anda sebagai berikut:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(child: CrosswordWidget()),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
Kedua perubahan di sini menunjukkan berbagai pendekatan untuk mengintegrasikan penyedia. Dalam metode build
CrosswordGeneratorApp
, Anda memperkenalkan builder Consumer
baru untuk memuat area yang dipaksa dibangun kembali saat tampilan info ditampilkan atau disembunyikan. Di sisi lain, seluruh menu drop-down adalah satu ConsumerWidget
, yang akan dibangun ulang baik saat mengubah ukuran teka-teki silang maupun saat menampilkan atau menyembunyikan tampilan info. Pendekatan yang harus diambil selalu merupakan pertimbangan engineering antara kesederhanaan dan biaya penghitungan ulang tata letak pohon widget yang dibangun ulang.
Menjalankan aplikasi sekarang memberi pengguna lebih banyak insight tentang progres pembuatan teka-teki silang. Namun, menjelang akhir pembuatan teka-teki silang, kita melihat ada periode saat angka berubah, tetapi hanya ada sedikit perubahan pada petak karakter.
Akan sangat berguna untuk mendapatkan insight tambahan tentang apa yang terjadi dan alasannya.
8. Memparalelkan dengan thread
Penyebab Penurunan Performa
Saat teka-teki silang hampir selesai, algoritma akan melambat karena hanya ada sedikit opsi penempatan kata yang valid. Algoritma mencoba banyak kombinasi yang tidak akan berhasil. Pemrosesan ber-thread tunggal tidak dapat menjelajahi beberapa opsi secara efisien
Memvisualisasikan Algoritma
Untuk memahami mengapa hal-hal menjadi lambat di akhir, akan berguna jika kita dapat memvisualisasikan apa yang dilakukan algoritma. Bagian pentingnya adalah locationsToTry
yang luar biasa di WorkQueue
. TableView memberi kita cara yang berguna untuk menyelidiki hal ini. Kita dapat mengubah warna sel berdasarkan apakah sel tersebut berada di locationsToTry
.
Untuk memulai, ikuti langkah-langkah berikut:
- Ubah file
crossword_widget.dart
sebagai berikut:
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,
),
),
),
);
}
}
Saat menjalankan kode ini, Anda akan melihat visualisasi lokasi yang belum diselidiki oleh algoritma.
Hal menarik saat menyaksikan teka-teki silang ini diselesaikan adalah ada banyak titik yang harus diselidiki, tetapi tidak akan menghasilkan apa pun yang berguna. Ada beberapa opsi di sini; salah satunya adalah menghentikan penyelidikan setelah persentase tertentu dari sel teka-teki silang terisi dan yang kedua adalah menyelidiki beberapa poin menarik sekaligus. Jalur kedua terdengar lebih menyenangkan, jadi saatnya melakukannya.
- Edit file
isolates.dart
. Hal ini hampir merupakan penulisan ulang kode secara keseluruhan untuk membagi apa yang sedang dikomputasi dalam satu isolate latar belakang menjadi kumpulan N isolate latar belakang.
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);
}
Memahami Arsitektur Multi-Isolate
Sebagian besar kode ini sudah tidak asing lagi karena logika bisnis inti tidak berubah. Yang berubah adalah sekarang ada dua lapisan panggilan compute
. Lapisan pertama bertanggung jawab untuk membagi posisi individual ke penelusuran ke N worker isolate, lalu menggabungkan kembali hasilnya setelah semua N worker isolate selesai. Lapisan kedua terdiri dari N isolat pekerja. Menyesuaikan N untuk mendapatkan performa terbaik bergantung pada komputer Anda dan data yang dimaksud. Makin besar petak, makin banyak pekerja yang dapat bekerja sama tanpa saling mengganggu.
Salah satu hal menarik yang perlu diperhatikan adalah cara kode ini menangani masalah penutupan yang menangkap hal-hal yang seharusnya tidak ditangkap. Sekarang tidak ada penutupan. Fungsi _generate
dan _generateWorker
ditentukan sebagai fungsi tingkat teratas, yang tidak memiliki lingkungan di sekitarnya untuk diambil. Argumen ke dalam, dan hasil dari kedua fungsi ini berbentuk rekaman Dart. Ini adalah cara untuk mengatasi semantik satu nilai masuk, satu nilai keluar dari panggilan compute
.
Sekarang setelah Anda dapat membuat kumpulan pekerja latar belakang untuk menelusuri kata-kata yang saling terkait dalam petak untuk membentuk teka-teki silang, saatnya untuk mengekspos kemampuan tersebut ke alat pembuat teka-teki silang lainnya.
- Edit file
providers.dart
dengan mengedit penyedia workQueue sebagai berikut:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- Tambahkan penyedia
WorkerCount
ke akhir file sebagai berikut:
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.
Dengan dua perubahan ini, lapisan penyedia kini mengekspos cara untuk menetapkan jumlah pekerja maksimum untuk pool isolate latar belakang sehingga fungsi isolate dikonfigurasi dengan benar.
- Perbarui file
crossword_info_widget.dart
dengan mengubahCrosswordInfoWidget
sebagai berikut:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final workerCount = ref.watch(workerCountProvider).label; // Add this line
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
_CrosswordInfoRichText( // Add from here
label: 'Max worker count',
value: workerCount,
), // To here.
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
- Ubah file
crossword_generator_app.dart
dengan menambahkan bagian berikut ke widget_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),
),
);
}
Jika menjalankan aplikasi sekarang, Anda akan dapat mengubah jumlah isolat latar belakang yang di-instansiasi untuk menelusuri kata yang akan dimasukkan ke dalam teka-teki silang.
- Klik ikon roda gigi di untuk membuka menu kontekstual yang berisi ukuran teka-teki silang, apakah akan menampilkan statistik pada teka-teki silang yang dibuat, dan sekarang, jumlah isolat yang akan digunakan.
Checkpoint: Performa Multi-thread
Menjalankan generator teka-teki silang telah mengurangi waktu komputasi untuk teka-teki silang 80x44 secara signifikan dengan menggunakan beberapa core secara bersamaan. Anda akan melihat:
- Pembuatan teka-teki silang yang lebih cepat dengan jumlah pekerja yang lebih tinggi
- Daya respons UI yang lancar selama pembuatan
- Statistik real-time yang menunjukkan progres pembuatan
- Masukan visual area eksplorasi algoritma
9. Ubah menjadi game
Yang akan kita bangun: Game Teka-Teki Silang yang Dapat Dimainkan
Bagian terakhir ini benar-benar babak bonus. Anda akan menggunakan semua teknik yang telah Anda pelajari saat membuat generator teka-teki silang dan menggunakan teknik ini untuk membuat game. Anda akan:
- Membuat puzzle: Gunakan pembuat teka-teki silang untuk membuat puzzle yang dapat dipecahkan
- Buat pilihan kata: Berikan beberapa opsi kata untuk setiap posisi
- Aktifkan interaksi: Izinkan pengguna memilih dan menempatkan kata
- Validasi solusi: Periksa apakah teka-teki silang yang telah diselesaikan sudah benar
Anda akan menggunakan pembuat teka-teki silang untuk membuat teka-teki silang. Anda akan menggunakan kembali idiom menu kontekstual untuk memungkinkan pengguna memilih dan membatalkan pilihan kata untuk dimasukkan ke dalam berbagai lubang berbentuk kata dalam petak. Semua dengan tujuan menyelesaikan teka-teki silang.
Saya tidak akan mengatakan bahwa game ini sudah sempurna atau selesai, karena kenyataannya masih jauh dari itu. Ada masalah keseimbangan dan kesulitan yang dapat diselesaikan dengan meningkatkan pilihan kata alternatif. Tidak ada tutorial untuk memandu pengguna dalam memecahkan teka-teki. Saya bahkan tidak akan menyebutkan layar "Anda telah menang!" yang sangat sederhana.
Sebagai gantinya, untuk memoles proto-game ini menjadi game lengkap dengan benar akan memerlukan lebih banyak kode. Lebih banyak kode daripada yang seharusnya ada dalam satu codelab. Jadi, langkah ini adalah langkah yang dirancang untuk memperkuat teknik yang telah dipelajari sejauh ini dalam codelab ini dengan mengubah tempat dan cara penggunaannya. Semoga hal ini memperkuat pelajaran yang dipelajari sebelumnya dalam codelab ini. Atau, Anda dapat melanjutkan dan membuat pengalaman Anda sendiri berdasarkan kode ini. Kami ingin sekali melihat hasil kreasi Anda.
Untuk memulai, ikuti langkah-langkah berikut:
- Hapus semua yang ada di direktori
lib/widgets
. Anda akan membuat widget baru yang menarik untuk game Anda. Widget ini kebetulan meminjam banyak elemen dari widget lama.
- Edit file
model.dart
Anda untuk memperbarui metodeaddWord
Crossword
sebagai berikut:
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;
}
}
Modifikasi kecil pada model Teka-Teki Silang ini memungkinkan penambahan kata yang tidak tumpang-tindih. Hal ini berguna untuk memungkinkan pemain bermain di mana saja di papan dan tetap dapat menggunakan Crossword
sebagai model dasar untuk menyimpan langkah pemain. Ini hanyalah daftar kata di lokasi tertentu yang ditempatkan dalam arah tertentu.
- Tambahkan class model
CrosswordPuzzleGame
di akhir filemodel.dart
Anda.
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;
Perubahan pada file providers.dart
adalah kumpulan perubahan yang menarik. Sebagian besar penyedia yang ada untuk mendukung pengumpulan statistik telah dihapus. Kemampuan untuk mengubah jumlah isolat latar belakang telah dihapus dan diganti dengan konstanta. Ada juga penyedia baru yang memberikan akses ke model CrosswordPuzzleGame
baru yang Anda tambahkan sebelumnya.
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (
workQueue.crossword,
wordList,
)).then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(_puzzleSelectWordTrampoline, (
_puzzle,
location,
word,
direction,
));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args,
) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(model.CrosswordPuzzleGame, model.Location, String, model.Direction) args,
) => args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
Bagian paling menarik dari penyedia Puzzle
adalah strategi yang dilakukan untuk menutupi biaya pembuatan CrosswordPuzzleGame
dari Crossword
dan wordList
, serta biaya pemilihan kata. Kedua tindakan ini, jika dilakukan tanpa bantuan Isolasi latar belakang, akan menyebabkan interaksi UI yang lambat. Dengan menggunakan beberapa trik sulap untuk mengeluarkan hasil sementara saat menghitung hasil akhir di latar belakang, Anda akan mendapatkan UI yang responsif saat komputasi yang diperlukan berlangsung di latar belakang.
- Di direktori
lib/widgets
yang sekarang kosong, buat filecrossword_puzzle_app.dart
dengan konten berikut:
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),
),
);
}
Sebagian besar file ini seharusnya sudah cukup familier sekarang. Ya, akan ada widget yang tidak ditentukan, yang sekarang akan Anda mulai perbaiki.
- Buat file
crossword_generator_widget.dart
dan tambahkan konten berikut ke dalamnya:
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,
),
),
),
);
}
}
Hal ini juga seharusnya cukup familier. Perbedaan utamanya adalah, alih-alih menampilkan karakter kata yang dibuat, Anda kini menampilkan karakter unicode untuk menunjukkan keberadaan karakter yang tidak diketahui. Area ini benar-benar perlu diperbaiki untuk meningkatkan estetika.
- Buat file
crossword_puzzle_widget.dart
dan tambahkan konten berikut ke dalamnya:
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),
);
}
}
Widget ini sedikit lebih intens daripada widget terakhir, meskipun telah dibuat dari potongan yang pernah Anda lihat digunakan di tempat lain pada masa lalu. Sekarang, setiap sel yang terisi akan menghasilkan menu konteks saat diklik, yang mencantumkan kata-kata yang dapat dipilih pengguna. Jika kata telah dipilih, kata yang bertentangan tidak dapat dipilih. Untuk membatalkan pilihan kata, pengguna mengetuk item menu untuk kata tersebut.
Dengan asumsi pemain dapat memilih kata untuk mengisi seluruh teka-teki silang, Anda memerlukan layar "Anda menang!".
- Buat file
puzzle_completed_widget.dart
, lalu tambahkan konten berikut ke dalamnya:
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),
),
);
}
}
Saya yakin Anda dapat mengambilnya dan membuatnya lebih menarik. Untuk mempelajari lebih lanjut alat animasi, lihat codelab Membangun UI generasi berikutnya di Flutter.
- Edit file
lib/main.dart
Anda sebagai berikut:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
Saat menjalankan aplikasi ini, Anda akan melihat animasi saat pembuat teka-teki silang membuat teka-teki Anda. Kemudian, Anda akan melihat teka-teki kosong yang harus dipecahkan. Jika Anda berhasil menyelesaikannya, Anda akan melihat layar seperti ini:
10. Selamat
Selamat! Anda berhasil membuat game teka-teki dengan Flutter.
Anda membuat generator teka-teki silang yang menjadi game teka-teki. Anda telah menguasai cara menjalankan komputasi latar belakang di kumpulan isolate. Anda menggunakan struktur data yang tidak dapat diubah untuk mempermudah penerapan algoritma backtracking. Selain itu, Anda telah menghabiskan waktu berkualitas dengan TableView
, yang akan berguna saat Anda perlu menampilkan data tabel di lain waktu.