Do Widgets tutorial #1
10
AGENTS.md
Normal file
10
AGENTS.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
This is a Flutter project.
|
||||||
|
|
||||||
|
The structure is as follows:
|
||||||
|
|
||||||
|
* `abc/` directory contains usual Flutter application
|
||||||
|
* `util/` directory contains scripts to run the application
|
||||||
|
* `src/` directory contains source code files that are symlinked into `abc/lib/` directory so that Flutter builder can find them
|
||||||
|
|
||||||
|
|
||||||
|
All new source code files should go into `src/` directory and be symlinked to `abc/lib/`.
|
||||||
1
abc/lib/game.dart
Symbolic link
1
abc/lib/game.dart
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../src/game.dart
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
runApp(const MainApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
class MainApp extends StatelessWidget {
|
|
||||||
const MainApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const MaterialApp(
|
|
||||||
home: Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Text('Wow, nice, Hello World!'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
abc/lib/main.dart
Symbolic link
1
abc/lib/main.dart
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../src/main.dart
|
||||||
1
abc/lib/tutFun.dart
Symbolic link
1
abc/lib/tutFun.dart
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../src/tutFun.dart
|
||||||
287
src/game.dart
Normal file
287
src/game.dart
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/// Game logic and supporting types for Birdle,
|
||||||
|
/// a five-letter word-guessing game similar to Wordle.
|
||||||
|
///
|
||||||
|
/// Defines the [Game] state machine and the
|
||||||
|
/// [Word], [Letter], and [HitType] data model used to
|
||||||
|
/// represent guesses and their evaluation against a hidden word.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// The result of evaluating a [Letter] of a guess against the hidden word.
|
||||||
|
enum HitType {
|
||||||
|
/// The letter hasn't yet been evaluated.
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// The letter matches the hidden word's letter at the same position.
|
||||||
|
hit,
|
||||||
|
|
||||||
|
/// The letter is in the hidden word, but at a different position.
|
||||||
|
partial,
|
||||||
|
|
||||||
|
/// The letter doesn't appear in the hidden word.
|
||||||
|
miss,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single character paired with its [HitType] against the hidden word.
|
||||||
|
typedef Letter = ({String char, HitType type});
|
||||||
|
|
||||||
|
/// Every word that can be legally entered as a guess.
|
||||||
|
const List<String> allLegalGuesses = [...legalWords, ...legalGuesses];
|
||||||
|
|
||||||
|
/// Words that can be chosen as the hidden word.
|
||||||
|
const List<String> legalWords = ['aback', 'abase', 'abate', 'abbey', 'abbot'];
|
||||||
|
|
||||||
|
/// Additional words accepted as guesses beyond those in [legalWords].
|
||||||
|
const List<String> legalGuesses = [
|
||||||
|
'aback',
|
||||||
|
'abase',
|
||||||
|
'abate',
|
||||||
|
'abbey',
|
||||||
|
'abbot',
|
||||||
|
'abhor',
|
||||||
|
'abide',
|
||||||
|
'abled',
|
||||||
|
'abode',
|
||||||
|
'abort',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Game state of a single round of Birdle,
|
||||||
|
/// a five-letter word-guessing game similar to Wordle.
|
||||||
|
///
|
||||||
|
/// Exposes the state and methods a UI needs to
|
||||||
|
/// evaluate guesses and track progress,
|
||||||
|
/// but doesn't advance play on its own.
|
||||||
|
///
|
||||||
|
/// Clients drive each round by calling [guess] to submit an attempt and
|
||||||
|
/// [resetGame] to start over.
|
||||||
|
class Game {
|
||||||
|
/// The default maximum number of guesses allowed in a [Game].
|
||||||
|
static const int defaultMaxGuesses = 5;
|
||||||
|
|
||||||
|
/// Creates a new game with [maxGuesses] guesses allowed.
|
||||||
|
///
|
||||||
|
/// If [seed] is provided, the hidden word is
|
||||||
|
/// chosen deterministically from [legalWords],
|
||||||
|
/// otherwise it is selected at random.
|
||||||
|
Game({this.maxGuesses = defaultMaxGuesses, this.seed})
|
||||||
|
: _wordToGuess = _generateInitialWord(seed),
|
||||||
|
_guesses = List<Word>.filled(maxGuesses, Word.empty());
|
||||||
|
|
||||||
|
/// The maximum number of guesses allowed in this game.
|
||||||
|
final int maxGuesses;
|
||||||
|
|
||||||
|
/// The seed used to choose the hidden word,
|
||||||
|
/// or `null` if it was selected at random.
|
||||||
|
final int? seed;
|
||||||
|
|
||||||
|
/// The current hidden word, exposed publicly through [hiddenWord].
|
||||||
|
Word _wordToGuess;
|
||||||
|
|
||||||
|
/// Backing storage for [guesses].
|
||||||
|
///
|
||||||
|
/// Holds every guess slot in order,
|
||||||
|
/// with unfilled slots represented by empty [Word]s.
|
||||||
|
List<Word> _guesses;
|
||||||
|
|
||||||
|
/// The word the player is trying to guess.
|
||||||
|
Word get hiddenWord => _wordToGuess;
|
||||||
|
|
||||||
|
/// An unmodifiable view of every guess slot, including those still empty.
|
||||||
|
UnmodifiableListView<Word> get guesses => UnmodifiableListView(_guesses);
|
||||||
|
|
||||||
|
/// The most recently submitted guess,
|
||||||
|
/// or an empty [Word] if no guesses have been made.
|
||||||
|
Word get previousGuess {
|
||||||
|
final index = _guesses.lastIndexWhere((word) => word.isNotEmpty);
|
||||||
|
return index == -1 ? Word.empty() : _guesses[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index of the next empty guess slot, or `-1` if every slot is full.
|
||||||
|
int get activeIndex => _guesses.indexWhere((word) => word.isEmpty);
|
||||||
|
|
||||||
|
/// The number of guesses still available to the player.
|
||||||
|
int get guessesRemaining {
|
||||||
|
if (activeIndex == -1) return 0;
|
||||||
|
return maxGuesses - activeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the most recent guess matches the hidden word.
|
||||||
|
bool get didWin {
|
||||||
|
if (_guesses.first.isEmpty) return false;
|
||||||
|
|
||||||
|
for (final letter in previousGuess) {
|
||||||
|
if (letter.type != HitType.hit) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether all allowed guesses have been used without winning.
|
||||||
|
bool get didLose => guessesRemaining == 0 && !didWin;
|
||||||
|
|
||||||
|
/// Picks a new hidden word and clears every submitted guess.
|
||||||
|
void resetGame() {
|
||||||
|
_wordToGuess = _generateInitialWord(seed);
|
||||||
|
_guesses = List<Word>.filled(maxGuesses, Word.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates [guess] against the hidden word,
|
||||||
|
/// records the result in [guesses], and returns it.
|
||||||
|
///
|
||||||
|
/// For finer control, use [isLegalGuess] to validate input or
|
||||||
|
/// [matchGuessOnly] to evaluate without recording the result.
|
||||||
|
Word guess(String guess) {
|
||||||
|
final result = matchGuessOnly(guess);
|
||||||
|
addGuessToList(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [guess] is a legal word to guess.
|
||||||
|
///
|
||||||
|
/// UIs can call this method before [guess] to
|
||||||
|
/// show players a message when they enter an invalid word.
|
||||||
|
bool isLegalGuess(String guess) => Word.fromString(guess).isLegalGuess;
|
||||||
|
|
||||||
|
/// Evaluates [guess] against the hidden word without advancing the game.
|
||||||
|
Word matchGuessOnly(String guess) =>
|
||||||
|
Word.fromString(guess).evaluateGuess(_wordToGuess);
|
||||||
|
|
||||||
|
/// Stores [guess] in the next empty slot of [guesses].
|
||||||
|
void addGuessToList(Word guess) {
|
||||||
|
final guessIndex = activeIndex;
|
||||||
|
if (guessIndex == -1) {
|
||||||
|
throw StateError('No guesses remaining.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_guesses[guessIndex] = guess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the starting hidden word for a new round.
|
||||||
|
///
|
||||||
|
/// Picks a deterministic word from [legalWords] when [seed] is provided,
|
||||||
|
/// or one at random otherwise.
|
||||||
|
static Word _generateInitialWord(int? seed) =>
|
||||||
|
seed == null ? Word.random() : Word.fromSeed(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A five-letter word made up of [Letter]s, each tracking its [HitType].
|
||||||
|
class Word with IterableMixin<Letter> {
|
||||||
|
/// Creates a word backed by the specified list of [Letter]s.
|
||||||
|
Word(this._letters);
|
||||||
|
|
||||||
|
/// Creates a word with five blank letters of [HitType.none].
|
||||||
|
factory Word.empty() =>
|
||||||
|
Word(List<Letter>.filled(5, (char: '', type: HitType.none)));
|
||||||
|
|
||||||
|
/// Creates a [Word] from [guess].
|
||||||
|
///
|
||||||
|
/// Each character is lowercased,
|
||||||
|
/// every [Letter] starts as [HitType.none].
|
||||||
|
factory Word.fromString(String guess) {
|
||||||
|
if (guess.length != 5) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
guess,
|
||||||
|
'guess',
|
||||||
|
'Must be exactly 5 characters long.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final letters = guess
|
||||||
|
.toLowerCase()
|
||||||
|
.split('')
|
||||||
|
.map((char) => (char: char, type: HitType.none))
|
||||||
|
.toList();
|
||||||
|
return Word(letters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a word chosen at random from [legalWords].
|
||||||
|
factory Word.random() {
|
||||||
|
final random = Random();
|
||||||
|
final nextWord = legalWords[random.nextInt(legalWords.length)];
|
||||||
|
return Word.fromString(nextWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a word chosen from [legalWords] using [seed] as an index.
|
||||||
|
factory Word.fromSeed(int seed) =>
|
||||||
|
Word.fromString(legalWords[seed % legalWords.length]);
|
||||||
|
|
||||||
|
/// An unmodifiable list of [Letter]s that make up this word.
|
||||||
|
final List<Letter> _letters;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterator<Letter> get iterator => _letters.iterator;
|
||||||
|
|
||||||
|
/// Whether every [Letter] in this word has no character.
|
||||||
|
@override
|
||||||
|
bool get isEmpty => every((letter) => letter.char.isEmpty);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get length => _letters.length;
|
||||||
|
|
||||||
|
/// The [Letter] at index [i] in word.
|
||||||
|
Letter operator [](int i) => _letters[i];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _letters.map((letter) => letter.char).join().trim();
|
||||||
|
|
||||||
|
/// Returns a multi-line string showing each [Letter] alongside its [HitType].
|
||||||
|
///
|
||||||
|
/// Used to play the game from the command line.
|
||||||
|
String toStringVerbose() => _letters
|
||||||
|
.map((letter) => '${letter.char} - ${letter.type.name}')
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation and guess-evaluation logic on [Word].
|
||||||
|
extension WordUtils on Word {
|
||||||
|
/// Whether this word appears in [allLegalGuesses].
|
||||||
|
bool get isLegalGuess => allLegalGuesses.contains(toString());
|
||||||
|
|
||||||
|
/// Compares this [Word] against the specified [hiddenWord]
|
||||||
|
/// and returns a new [Word] with the same letters,
|
||||||
|
/// but where each [Letter] has new a [HitType] of
|
||||||
|
/// [HitType.hit], [HitType.partial], or [HitType.miss].
|
||||||
|
Word evaluateGuess(Word hiddenWord) {
|
||||||
|
//assert(isLegalGuess);
|
||||||
|
|
||||||
|
final result = List<Letter>.filled(length, (char: '', type: HitType.none));
|
||||||
|
// Counts hidden-word letters that can still be claimed as partial matches.
|
||||||
|
final unmatchedHiddenLetterCounts = <String, int>{};
|
||||||
|
|
||||||
|
// Reserve exact matches before scoring partial matches.
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final guessChar = this[i].char;
|
||||||
|
final hiddenChar = hiddenWord[i].char;
|
||||||
|
|
||||||
|
if (guessChar == hiddenChar) {
|
||||||
|
result[i] = (char: guessChar, type: HitType.hit);
|
||||||
|
} else {
|
||||||
|
// Track non-hit hidden letters for the partial-match pass.
|
||||||
|
final unmatchedCount = unmatchedHiddenLetterCounts[hiddenChar] ?? 0;
|
||||||
|
unmatchedHiddenLetterCounts[hiddenChar] = unmatchedCount + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spend each remaining hidden letter only once for partial matches.
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
if (result[i].type == HitType.hit) continue;
|
||||||
|
|
||||||
|
final guessChar = this[i].char;
|
||||||
|
final unmatchedCount = unmatchedHiddenLetterCounts[guessChar] ?? 0;
|
||||||
|
final isPartial = unmatchedCount > 0;
|
||||||
|
if (isPartial) {
|
||||||
|
// Use one available hidden letter for this partial match.
|
||||||
|
unmatchedHiddenLetterCounts[guessChar] = unmatchedCount - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i] = (
|
||||||
|
char: guessChar,
|
||||||
|
type: isPartial ? HitType.partial : HitType.miss,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Word(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/main.dart
Normal file
138
src/main.dart
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'game.dart';
|
||||||
|
import 'tutFun.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const MainApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class GamePage extends StatefulWidget {
|
||||||
|
GamePage({super.key});
|
||||||
|
|
||||||
|
@override State<GamePage> createState() => GamePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class GamePageState extends State<GamePage> {
|
||||||
|
final Game _game = Game();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
spacing: 5.0,
|
||||||
|
children: [
|
||||||
|
for (final guess in _game.guesses)
|
||||||
|
Row(
|
||||||
|
spacing: 5.0,
|
||||||
|
children: [
|
||||||
|
for (final letter in guess)
|
||||||
|
Tile(letter.char, letter.type)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GuessInput(
|
||||||
|
onSubmitGuess: (guess) {
|
||||||
|
/**/print(guess);
|
||||||
|
setState(() {
|
||||||
|
_game.guess(guess);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GuessInput extends StatelessWidget {
|
||||||
|
GuessInput({super.key, required this.onSubmitGuess});
|
||||||
|
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
final void Function(String) onSubmitGuess;
|
||||||
|
final TextEditingController _textEditingController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: _textEditingController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(35)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusNode: _focusNode,
|
||||||
|
maxLength: 5,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
processSubmit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_circle_up),
|
||||||
|
onPressed: () {
|
||||||
|
processSubmit();
|
||||||
|
},
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void processSubmit() {
|
||||||
|
onSubmitGuess(_textEditingController.text.trim());
|
||||||
|
_textEditingController.clear();
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainApp extends StatelessWidget {
|
||||||
|
const MainApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text("Birdle"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Center(child: GamePage()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Tile extends StatelessWidget {
|
||||||
|
const Tile(this.letter, this.hitType, {super.key});
|
||||||
|
|
||||||
|
final String letter;
|
||||||
|
final HitType hitType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
letter.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
color: tutTileColor(hitType),
|
||||||
|
),
|
||||||
|
duration: Duration(milliseconds: 300),
|
||||||
|
height: 60,
|
||||||
|
width: 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/tutFun.dart
Normal file
17
src/tutFun.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'game.dart';
|
||||||
|
|
||||||
|
Color tutTileColor(HitType t) {
|
||||||
|
if (t == HitType.hit) {
|
||||||
|
return Colors.green;
|
||||||
|
}
|
||||||
|
if (t == HitType.partial) {
|
||||||
|
return Colors.yellow;
|
||||||
|
}
|
||||||
|
if (t == HitType.miss) {
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
return Colors.white;
|
||||||
|
}
|
||||||
5
util/run-macos
Executable file
5
util/run-macos
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
SDIR=$(cd "$(dirname "$0")" ; pwd -P)
|
||||||
|
|
||||||
|
cd $SDIR/../abc
|
||||||
|
flutter run --enable-experiment=dot-shorthands -d macos
|
||||||
Reference in New Issue
Block a user