From c7e80406b1c5cce021f77171d41be12a3814d658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Thu, 4 Jun 2026 17:59:48 +0200 Subject: [PATCH] Do Widgets tutorial (#1) --- AGENTS.md | 10 ++ abc/lib/game.dart | 1 + abc/lib/main.dart | 21 +--- abc/lib/tutFun.dart | 1 + src/game.dart | 287 ++++++++++++++++++++++++++++++++++++++++++++ src/main.dart | 138 +++++++++++++++++++++ src/tutFun.dart | 17 +++ util/run-macos | 5 + 8 files changed, 460 insertions(+), 20 deletions(-) create mode 100644 AGENTS.md create mode 120000 abc/lib/game.dart mode change 100644 => 120000 abc/lib/main.dart create mode 120000 abc/lib/tutFun.dart create mode 100644 src/game.dart create mode 100644 src/main.dart create mode 100644 src/tutFun.dart create mode 100755 util/run-macos diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..18df25e --- /dev/null +++ b/AGENTS.md @@ -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/`. diff --git a/abc/lib/game.dart b/abc/lib/game.dart new file mode 120000 index 0000000..27b2fe2 --- /dev/null +++ b/abc/lib/game.dart @@ -0,0 +1 @@ +../../src/game.dart \ No newline at end of file diff --git a/abc/lib/main.dart b/abc/lib/main.dart deleted file mode 100644 index 0ac50be..0000000 --- a/abc/lib/main.dart +++ /dev/null @@ -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!'), - ), - ), - ); - } -} diff --git a/abc/lib/main.dart b/abc/lib/main.dart new file mode 120000 index 0000000..832c80a --- /dev/null +++ b/abc/lib/main.dart @@ -0,0 +1 @@ +../../src/main.dart \ No newline at end of file diff --git a/abc/lib/tutFun.dart b/abc/lib/tutFun.dart new file mode 120000 index 0000000..aa9d744 --- /dev/null +++ b/abc/lib/tutFun.dart @@ -0,0 +1 @@ +../../src/tutFun.dart \ No newline at end of file diff --git a/src/game.dart b/src/game.dart new file mode 100644 index 0000000..c1d7a01 --- /dev/null +++ b/src/game.dart @@ -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 allLegalGuesses = [...legalWords, ...legalGuesses]; + +/// Words that can be chosen as the hidden word. +const List legalWords = ['aback', 'abase', 'abate', 'abbey', 'abbot']; + +/// Additional words accepted as guesses beyond those in [legalWords]. +const List 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.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 _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 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.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 { + /// 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.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 _letters; + + @override + Iterator 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.filled(length, (char: '', type: HitType.none)); + // Counts hidden-word letters that can still be claimed as partial matches. + final unmatchedHiddenLetterCounts = {}; + + // 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); + } +} diff --git a/src/main.dart b/src/main.dart new file mode 100644 index 0000000..775a6eb --- /dev/null +++ b/src/main.dart @@ -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 createState() => GamePageState(); +} + +class GamePageState extends State { + 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, + ); + } +} diff --git a/src/tutFun.dart b/src/tutFun.dart new file mode 100644 index 0000000..0f90d09 --- /dev/null +++ b/src/tutFun.dart @@ -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; +} diff --git a/util/run-macos b/util/run-macos new file mode 100755 index 0000000..d1a46cc --- /dev/null +++ b/util/run-macos @@ -0,0 +1,5 @@ +#!/bin/bash +SDIR=$(cd "$(dirname "$0")" ; pwd -P) + +cd $SDIR/../abc +flutter run --enable-experiment=dot-shorthands -d macos