יחידה 2 · מדריך שלב-אחר-שלב (Java)
ווֹרדל בעברית קרדיט: רוני רובן, ויצו נהלל, שנה"ל 2026-7
📂 קוד המקור של האפליקציה

ברוכים הבאים ליחידה 2: ווֹרדל בעברית!

🎯 מטרה: לבנות את האפליקציה האמיתית הראשונה שלכם באנדרואיד – משחק ניחוש מילים שלם – וללמוד דרכה מה זה Activity, איך בונים מסך, איך מגיבים ללחיצות ואיך לא מאבדים את המשחק כשהטלפון מסתובב.

ווֹרדל הוא משחק פשוט להסבר וקשה להפסקה: צריך לנחש מילה בת 5 אותיות ב-6 ניסיונות. אחרי כל ניחוש האותיות נצבעות – ירוק לאות שנמצאת במקום הנכון, צהוב לאות שקיימת במילה אבל במקום אחר, ואפור לאות שלא במילה בכלל. בסוף היחידה יהיה לכם משחק עובד, בעברית, עם מקלדת על המסך, שממשיך בדיוק מאותה נקודה גם אחרי סיבוב של הטלפון.

מסך הפתיחה של המשחק המשחק אחרי ניצחון בשני ניחושים
המשחק הגמור באמולטור: בהתחלה, ואחרי ניחוש שגוי אחד וניחוש נכון. שימו לב שהמקשים נצבעים גם הם.

מה לומדים ביחידה הזאת (לפי תוכנית הלימודים)

נושא בתכ"לאיפה זה מופיע במשחקשלבים
מבוא לאפליקציה: Activity, מבנה הפרויקט, קובץ ההגדרות, מעגל חיי הפעילותהמסך היחיד של המשחק הוא Activity. נראה אותו נולד, נעצר, נהרס ונולד מחדש2, 3
ממשק משתמש: רכיבים ותבניות XML מקוד, סידור בתבניות, שמירת מצב בשינוי תצורהשלד המסך ב-ConstraintLayout, לוח של 30 אריחים שנבנה בלולאה, והלוח שורד סיבוב4, 5, 10
טיפול באירועים: מאזינים ב-XML, פעילות מממשת, מחלקה פנימית, מאזין בשורהארבע דרכים לטפל בלחיצה – כל אחת על כפתור אחר, בכוונה7, 8, 9
משאבי האפליקציה: מבנה תיקיית המשאבים, יצירה וארגוןstrings, colors, dimens, drawables, ורשימת המילים כמשאב6
תכנון וניתוח אפליקציות (יחידה 6)פותח כל יחידה: UML, שרטוט מסך, תרשים זרימה1

איך עובדים עם המדריך

  • כל שלב מתחיל ב-"למה ככה?" – ההחלטה שמאחורי הקוד – ורק אחר כך מגיע הקוד. קראו את ההסבר לפני שאתם מעתיקים.
  • בכל כרטיס קוד יש כפתור הצג קוד והעתק קוד. הקוד במדריך נלקח ישירות מהפרויקט הבנוי, אז הוא תמיד מתקמפל.
  • כל שלב נגמר ב-תחנת אישור מורה: רשימת בדיקה ושאלת הבנה. לא ממשיכים לשלב הבא בלי לעבור אותה.
  • את הפרויקט המלא אפשר להוריד מהקישור "קוד המקור של האפליקציה" למעלה – אבל המטרה היא לכתוב בעצמכם ולהשוות, לא להוריד ולהריץ.

⏱️ כמה זמן זה לוקח?

כ-9.5 שעות עבודה בכיתה ובמעבדה, מחולקות ל-10 שלבים. ליד כל שלב וכל תת-שלב יש הערכת זמן. אם אתם מהירים – הרחבות מופיעות בסוף כל שלב.

שלב 1: תכנון וניתוח ⏱️ סה"כ כ-60 דק'

🎯 מטרה: לסיים את השלב עם דף שבו מצוירים המסך, המחלקות ותרשים הזרימה של "בדיקה" – לפני שפותחים את אנדרואיד סטודיו.

למה ככה? למה לא פשוט להתחיל?

כי בווֹרדל יש שאלה אחת מסובכת באמת, ואם לא פותרים אותה על הנייר היא תחזור כבאג: מה קורה עם אות כפולה? אם המילה הסודית היא תלמיד ואתם מנחשים תתתתת – האם כל ה-ת' צהובות? רק אחת? הראשונה ירוקה והשאר? מי שמתחיל לקודד בלי לענות על זה, כותב קוד שעובד ב-90% מהמקרים ומבלבל ב-10% הנותרים. לכן קודם מתכננים.

מה עושים בשלב הזה

  1. מציירים את המסך ומסמנים אילו חלקים משתנים תוך כדי משחק.
  2. מציירים UML של שלוש מחלקות המודל: המשחק, ניחוש, ומצב-אות.
  3. כותבים תרשים זרימה של מה שקורה כשלוחצים "בדיקה".
  4. ממלאים טבלת ישויות: מה המידע שהמשחק צריך לזכור, ומה אפשר לחשב מחדש.

א. המסך ⏱️ 10 דק'

מסך אחד בלבד (מסכים מרובים ומעבר ביניהם – זו יחידה 3). ארבעה אזורים מלמעלה למטה: כותרת, שורת הודעה, הלוח, והמקלדת. שימו לב מה קבוע ומה משתנה:

אזורמה יש בומשתנה תוך כדי משחק?
כותרת"ווֹרדל בעברית"לא
שורת הודעה"נשארו 4 ניסיונות", "כל הכבוד!", "המילה הייתה..."כן – אחרי כל בדיקה
הלוח6 שורות × 5 אריחיםכן – אותיות וצבעים
המקלדת27 אותיות + מחק + בדיקהכן – צבעי המקשים

ב. UML של המודל ⏱️ 20 דק'

הכלל שילווה אתכם כל השנה: חוקי המשחק לא יודעים שקיים אנדרואיד. מחלקה שמחשבת מה ירוק ומה צהוב לא מכירה TextView ולא Button. למה? כי כך אפשר לבדוק אותה על המחשב בשנייה, בלי אמולטור, וכי ביחידה 5 נרצה להשתמש באותם חוקים עם מסך אחר לגמרי.

+------------------------+ +-----------------------------+ | <> | | WordleGame | | LetterState | +-----------------------------+ +------------------------+ | - secret : String | | EMPTY | | - guesses : List<Guess> | | ABSENT (אפור) | +-----------------------------+ | PRESENT (צהוב) | | + submit(word) : Guess | | CORRECT (ירוק) | | + evaluate(secret, word) | +------------------------+ | : LetterState[] {static}| | + isWon() / isLost() | +------------------------+ | + isOver() | | Guess | 1..6 | + getGuesses() | +------------------------+<-------+-----------------------------+ | - word : String | | - states : LetterState[]| +------------------+ +------------------------+ | MainActivity | | + getState(i) | +------------------+ | + isAllCorrect() | משתמשת ב- | - game : WordleGame| +------------------------+<----------------| - tiles[6][5] | +------------------+

הפעילות (MainActivity) מצביעה על המשחק, אבל המשחק לא מצביע על הפעילות. החץ הולך בכיוון אחד בלבד.

ג. תרשים זרימה: מה קורה כשלוחצים "בדיקה" ⏱️ 15 דק'

לחיצה על "בדיקה"
↓
המשחק כבר נגמר?
← כן →
לא עושים כלום
↓לא
יש 5 אותיות בשורה?
← לא →
הודעה: "צריך 5 אותיות"
↓כן
game.submit(word)
מעבר 1 מסמן ירוקים, מעבר 2 מסמן צהובים ואפורים
↓
צובעים את השורה ואת המקשים
↓
ניצחון → "כל הכבוד"
ניסיון שישי → "המילה הייתה…"
אחרת → "נשארו N ניסיונות"

🤔 ואיך פותרים את האות הכפולה?

בשני מעברים. במעבר הראשון מסמנים רק ירוקים ו"מנצלים" את האות המתאימה במילה הסודית. במעבר השני, לכל אות שעוד לא נצבעה, מחפשים אות שעוד לא נוצלה במילה הסודית: מצאנו – צהוב ומנצלים אותה; לא מצאנו – אפור. כך בדוגמה של תלמיד מול תתתתת: ה-ת' הראשונה ירוקה (ומנצלת את ה-ת' היחידה), וארבע האחרות אפורות. תבדקו את עצמכם: מה יקרה עם ספינה מול ימיים?

ד. טבלת ישויות: מה שומרים ומה מחשבים ⏱️ 15 דק'

השאלה הזאת תהפוך לקוד בשלב 10. כשהטלפון מסתובב, הפעילות נהרסת ונבנית מחדש. מה חייבים לשמור כדי לשחזר את המשחק?

מידעשומרים?למה
המילה הסודיתכןבלעדיה אי אפשר לבדוק כלום
רשימת המילים שנוחשוכןזו ההיסטוריה של המשחק
האותיות שהוקלדו בשורה הנוכחיתכןאחרת הסיבוב מוחק מה שהקלדתם
הצבע של כל אריחלאמחשבים מחדש מהמילה הסודית ומהניחושים
הצבע של כל מקשלאגם זה נובע מהניחושים

העיקרון: שומרים את המקור, לא את התוצאה. כל מה שאפשר לחשב – לא שומרים.

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. יש בידיכם דף עם שרטוט המסך וארבעת האזורים, ולצד כל אזור כתוב אם הוא משתנה.
  2. ציירתם את ה-UML של שלוש מחלקות המודל, והחץ בין MainActivity ל-WordleGame הולך בכיוון אחד.
  3. כתבתם על הנייר מה יחזיר evaluate עבור ספינה מול ימיים, אות אחרי אות.
שאלת הבנה (המורה ישאל):
תלמיד הציע לשמור בסיבוב מסך את צבע כל אריח (30 ערכים) במקום את רשימת המילים שנוחשו (עד 6 מחרוזות).
א. תארו מצב שבו שתי השיטות נותנות אותה תוצאה על המסך.
ב. תארו מה יקרה בשיטה שלו כשהמשתמש ילחץ "בדיקה" מיד אחרי הסיבוב. מה חסר?

שלב 2: הפרויקט הראשון ⏱️ סה"כ כ-60 דק'

🎯 מטרה: ליצור פרויקט חדש באנדרואיד סטודיו, להבין מה כל תיקייה וכל קובץ עושים, ולראות את המסך הראשון רץ באמולטור.

למה ככה?

אנדרואיד סטודיו יוצר בשבילכם עשרות קבצים. רובם לא מעניינים אתכם השנה, אבל חמישה מהם תפתחו כל שיעור. השלב הזה הוא סיור מודרך: לדעת איפה כל דבר יושב ומי קורא אותו. בלי זה, כל שגיאת קומפילציה נראית כמו קסם שחור.

א. יוצרים את הפרויקט ⏱️ 15 דק'

  1. 🆕 File → New → New Project, בוחרים Empty Views Activity (לא "Empty Activity" – זו Compose, שלא נלמד השנה).
  2. ✏️ Name: HebrewWordle. Package name: com.example.hebrewwordle. Language: Java. Minimum SDK: API 24.
  3. ⏳ Finish, וממתינים ל-Gradle Sync (הסרגל התחתון). בפעם הראשונה זה יכול לקחת כמה דקות.
  4. ▶️ לוחצים על החץ הירוק (Run). אם אין אמולטור: Device Manager → Create Device → Pixel 9a, מערכת API 34.

ב. סיור במבנה הפרויקט ⏱️ 25 דק'

עברו לתצוגת Project (התפריט הנפתח מעל עץ הקבצים, לא "Android") כדי לראות את התיקיות האמיתיות:

HebrewWordle/ ├── settings.gradle <- אילו מודולים יש בפרויקט (אצלנו: app בלבד) ├── build.gradle <- אילו תוספים (plugins) ובאילו גרסאות ├── gradle.properties <- הגדרות כלליות ל-Gradle ├── local.properties <- איפה ה-SDK על המחשב שלכם. לא נכנס ל-git! └── app/ ├── build.gradle <- ה-build של האפליקציה: גרסאות SDK, ספריות └── src/ ├── main/ │ ├── AndroidManifest.xml <- "תעודת הזהות": אילו מסכים, אילו הרשאות │ ├── java/com/example/hebrewwordle/ │ │ └── MainActivity.java <- הקוד │ └── res/ <- משאבים (שלב 6) │ ├── layout/activity_main.xml │ ├── values/strings.xml, colors.xml, themes.xml │ ├── drawable/ │ └── mipmap-*/ic_launcher <- האייקון └── test/java/... <- בדיקות JUnit שרצות על המחשב

קובץ ההגדרות: AndroidManifest.xml

המניפסט הוא המקום היחיד שבו מערכת ההפעלה "קוראת עליכם" לפני שהאפליקציה רצה. כאן היא לומדת איזה מסך לפתוח כשלוחצים על האייקון, ואילו הרשאות אתם מבקשים. לנו יש מסך אחד:

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.App">

        <!-- המסך היחיד של המשחק. exported + intent-filter: זו נקודת הכניסה מהלאנצ'ר -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:screenOrientation="unspecified">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>
</manifest>
  • android:label – השם שמופיע מתחת לאייקון. שימו לב שהוא לא כתוב כאן אלא מפנה ל-@string/app_name. למה? שלב 6.
  • android:supportsRtl="true" – בלי זה, המקלדת העברית תסודר משמאל לימין.
  • <intent-filter> עם MAIN ו-LAUNCHER – "אני המסך שפותחים מהאייקון". על Intents נדבר הרבה ביחידה 3.
  • android:exported="true" – חובה על מסך שהלאנצ'ר (אפליקציה אחרת!) פותח. בלי זה הבנייה נכשלת.

קובץ ה-build: app/build.gradle

app/build.gradle
plugins {
    id 'com.android.application'
}

android {
    namespace 'com.example.hebrewwordle'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.hebrewwordle"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_11
        targetCompatibility JavaVersion.VERSION_11
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.7.0'
    implementation 'com.google.android.material:material:1.12.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'

    testImplementation 'junit:junit:4.13.2'
}
שורהפירוש
compileSdk 34מול איזו גרסת אנדרואיד הקוד מתקמפל. קובע אילו מתודות קיימות
minSdk 24הגרסה הישנה ביותר שהאפליקציה מסכימה לרוץ עליה (אנדרואיד 7)
targetSdk 34"בדקתי את עצמי מול 34" – משפיע על התנהגויות של המערכת
applicationIdהמזהה הייחודי בעולם. שתי אפליקציות עם אותו מזהה דורסות זו את זו
dependenciesספריות: appcompat (תאימות לאחור), material (הכפתורים), constraintlayout (שלב 4)

ג. מריצים ומסתכלים על Logcat ⏱️ 20 דק'

הריצו את הפרויקט הריק. פתחו את החלון Logcat (למטה) והקלידו בשורת הסינון package:mine. זה החלון שבו תבלו את השלב הבא. אם אתם רואים "Hello World!" באמולטור וזרם של שורות ב-Logcat – סיימתם.

⚠️ שלוש התקלות הנפוצות בשלב הזה

  • Gradle sync failed – כמעט תמיד אין אינטרנט או שה-SDK לא נמצא. בדקו את local.properties.
  • האמולטור לא עולה – ב-Device Manager מחקו את המכשיר ויצרו חדש; ודאו ש-Virtualization מופעל ב-BIOS.
  • "Installed on … but not launched" – פשוט לחצו על האייקון באמולטור. זו לא שגיאה.
תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. הפרויקט רץ באמולטור ומציג את מסך הפתיחה הריק.
  2. אתם יכולים להצביע בעץ הקבצים על: המניפסט, ה-build של app, התבנית של המסך, ו-strings.xml – ולומר במשפט מה כל אחד עושה.
  3. Logcat פתוח ומסונן ל-package:mine.
שאלת הבנה (המורה ישאל):
שינינו את minSdk מ-24 ל-21.
א. יותר טלפונים או פחות טלפונים יוכלו להתקין את האפליקציה?
ב. מה המחיר? רמז: מה קורה אם נקרא למתודה שנוספה באנדרואיד 7?

שלב 3: מעגל חיי הפעילות ⏱️ סה"כ כ-50 דק'

🎯 מטרה: לראות במו עיניכם ב-Logcat מתי הפעילות נולדת, נעצרת, נהרסת ונולדת מחדש – ולגלות שסיבוב מסך הורג אותה.

למה ככה?

Activity היא לא תוכנית רגילה עם main שרצה מההתחלה לסוף. היא אובייקט שמערכת ההפעלה מנהלת: המערכת יוצרת אותה, מודיעה לה כשהיא עולה למסך, כשמשהו מכסה אותה, וכשהגיע זמנה למות. כל הודעה כזאת היא מתודה (callback) שאתם יכולים לדרוס. אם לא מבינים את הסדר – כותבים קוד שמתחיל מוזיקה ב-onCreate ולא מכבה אותה אף פעם, או מאבדים את הלוח בכל סיבוב.

onCreate
המערכת יוצרת את הפעילות. פעם אחת לכל "חיים" שלה
↓
onStart
המסך נהיה גלוי
↓
onResume
המסך בחזית ומגיב ללחיצות
↓המשתמש לחץ Home / הגיעה שיחה / סיבוב מסך
onPause
מאבד פוקוס (אולי עדיין גלוי)
↓
onStop
לא גלוי בכלל. חזרה לאפליקציה: onRestart → onStart → onResume
↓
onDestroy
סוף. בסיבוב מסך: מיד אחר כך onCreate חדש!

א. מוסיפים Log לכל שלב ⏱️ 20 דק'

ב-MainActivity דורסים את חמש המתודות. בכל אחת קוראים קודם ל-super (חובה – אחרת המערכת זורקת חריגה), ואז כותבים ל-Log עם תגית קבועה:

MainActivity.java – התגית
private static final String TAG = "LIFECYCLE";
MainActivity.java – חמשת ה-callbacks
@Override
protected void onStart() {
    super.onStart();
    Log.d(TAG, "onStart - המסך נהיה גלוי");
}

@Override
protected void onResume() {
    super.onResume();
    Log.d(TAG, "onResume - המסך בחזית ומגיב ללחיצות");
}

@Override
protected void onPause() {
    super.onPause();
    Log.d(TAG, "onPause - משהו כיסה אותי, אני מאבד פוקוס");
}

@Override
protected void onStop() {
    super.onStop();
    Log.d(TAG, "onStop - אני כבר לא גלוי בכלל");
}

@Override
protected void onDestroy() {
    super.onDestroy();
    Log.d(TAG, "onDestroy - הפעילות נהרסת (סיבוב מסך או סגירה)");
}

הגרסה המלאה של onCreate מופיעה בשלב 9, כי היא קוראת למתודות שעוד לא כתבנו. בינתיים השאירו בה רק את setContentView ואת שורת ה-Log.

ב. משחקים עם הטלפון ⏱️ 20 דק'

סננו את Logcat לפי LIFECYCLE ובצעו את הרצף הבא. רשמו ליד כל פעולה אילו שורות הופיעו:

פעולהמה תראו ב-Logcatלמה
הרצה ראשונהonCreate → onStart → onResumeלידה מלאה
כפתור הביתonPause → onStopהאפליקציה חיה ברקע, לא נהרסה
חזרה לאפליקציהonStart → onResume (בלי onCreate!)אותו אובייקט חוזר לחזית
סיבוב מסךonPause → onStop → onDestroy → onCreate → onStart → onResumeהפעילות נהרגת ונולדת מחדש – כל השדות מתאפסים!
כפתור "אחורה"onPause → onStop → onDestroyסוף אמיתי

זה הפלט האמיתי מהאמולטור של המשחק הגמור, אחרי ניחוש אחד וסיבוב מסך:

D LIFECYCLE: onCreate - המסך נבנה. savedInstanceState=null D LIFECYCLE: onStart - המסך נהיה גלוי D LIFECYCLE: onResume - המסך בחזית ומגיב ללחיצות D LIFECYCLE: onPause - משהו כיסה אותי, אני מאבד פוקוס D LIFECYCLE: onStop - אני כבר לא גלוי בכלל D LIFECYCLE: onSaveInstanceState - נשמרו 1 ניחושים D LIFECYCLE: onDestroy - הפעילות נהרסת (סיבוב מסך או סגירה) D LIFECYCLE: onCreate - המסך נבנה. savedInstanceState=יש מצב שמור D LIFECYCLE: restoreGame - שוחזרו 1 ניחושים D LIFECYCLE: onStart - המסך נהיה גלוי D LIFECYCLE: onResume - המסך בחזית ומגיב ללחיצות

🤔 אז למה בכלל להרוג את הפעילות בסיבוב?

כי בסיבוב ייתכן שצריך תבנית אחרת לגמרי (למשל layout-land/), צבעים אחרים, מחרוזות בשפה אחרת. הדרך הפשוטה ביותר להבטיח שהכול נטען מחדש נכון היא להתחיל מאפס. המחיר: אנחנו אחראים לשמור את מה שחשוב. זה בדיוק שלב 10, ואת השורות onSaveInstanceState ו-restoreGame בפלט למעלה נכתוב שם.

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. חמש המתודות דרוסות, כל אחת קוראת ל-super בשורה הראשונה.
  2. מילאתם את הטבלה מהניסוי שלכם, והיא תואמת לטבלה במדריך.
  3. אתם יכולים להסביר למה "חזרה לאפליקציה" לא מפעילה onCreate אבל "סיבוב" כן.
שאלת הבנה (המורה ישאל):
תלמידה שמה את ההגרלה של המילה הסודית בתוך onCreate.
א. מה יקרה למילה הסודית כשהמשתמש יסובב את המסך באמצע משחק?
ב. האם להעביר את ההגרלה ל-onStart פותר את זה? הסבירו.

שלב 4: שלד המסך ב-ConstraintLayout ⏱️ סה"כ כ-60 דק'

🎯 מטרה: לבנות ב-XML את ארבעת האזורים מהתכנון – כותרת, הודעה, לוח ומקלדת – כך שיתאימו לכל גודל מסך.

למה ככה? למה ConstraintLayout?

באנדרואיד אין "מסך בגודל קבוע". אותה אפליקציה רצה על טלפון צר ועל טאבלט רחב. לכן לא כותבים "הכפתור במרחק 300 פיקסלים מלמעלה", אלא יחסים: "הלוח מתחת להודעה ומעל הכפתור, ממורכז". ConstraintLayout הוא תבנית שבה כל רכיב מוגדר על ידי אילוצים (constraints) לרכיבים אחרים או להורה. רכיב בלי אילוצים "קופץ" לפינה השמאלית-עליונה – זו הטעות מספר אחת.

א. התבנית ⏱️ 30 דק'

פתחו את res/layout/activity_main.xml בתצוגת Code (לא Design – נלמד לקרוא XML) והחליפו את התוכן:

res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<!--
    שלד המסך: ConstraintLayout אחד שמחזיק ארבעה אזורים מלמעלה למטה.
    הלוח עצמו (boardContainer) ריק ב-XML: שלושים האריחים נבנים בקוד ב-MainActivity.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="@dimen/screen_padding">

    <TextView
        android:id="@+id/txtTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="@string/title"
        android:textColor="@color/primary"
        android:textSize="28sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/txtMessage"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="@string/msg_start"
        android:textColor="@color/ink"
        android:textSize="16sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/txtTitle" />

    <LinearLayout
        android:id="@+id/boardContainer"
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:gravity="center"
        android:orientation="vertical"
        app:layout_constraintBottom_toTopOf="@id/btnNewGame"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/txtMessage" />

    <Button
        android:id="@+id/btnNewGame"
        style="@style/Widget.Material3.Button.OutlinedButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/new_game"
        app:layout_constraintBottom_toTopOf="@id/keyboard"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <!-- שימו לב: ה-id שניתן ל-include דורס את ה-id של שורש התבנית המוכללת.
         לכן בקוד מחפשים R.id.keyboard - ה-id שכתוב כאן, לא זה שבתוך keyboard.xml -->
    <include
        android:id="@+id/keyboard"
        layout="@layout/keyboard"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

איך קוראים אילוץ

מאפייןפירוש
layout_constraintTop_toBottomOf="@id/txtTitle""הצד העליון שלי צמוד לצד התחתון של הכותרת"
layout_constraintStart_toStartOf="parent" + End_toEndOf="parent"אילוץ משני הצדדים = מרכוז אופקי
layout_width="0dp""match constraints": תמלא את כל המרחב שהאילוצים מרשים
layout_height="0dp" על הלוחהלוח תופס את כל הגובה שנשאר בין ההודעה לכפתור
Start / End ולא Left / Rightב-RTL "התחלה" היא ימין. תמיד Start/End

⚠️ include דורס את ה-id

המקלדת יושבת בקובץ נפרד (keyboard.xml, שלב 7) ומוכללת עם <include>. כש-include מקבל android:id, ה-id הזה מחליף את ה-id של שורש הקובץ המוכלל. אם בקוד תחפשו את ה-id הפנימי תקבלו null וקריסה ב-onCreate. כך בדיוק קרס המשחק הזה בהרצה הראשונה שלו – אל תחזרו על זה.

ב. בודקים בשני גדלים ⏱️ 15 דק'

הלוח עדיין ריק (הוא נבנה בקוד בשלב הבא), אבל כבר אפשר לבדוק את השלד. בתצוגת Design לחצו על אייקון המכשיר ובחרו פעם Pixel 9a ופעם טאבלט. הכותרת וההודעה צריכות להישאר ממורכזות, והמקלדת צמודה לתחתית בשניהם.

הריצו גם על האמולטור. המסך צריך להיראות כך (עוד בלי אריחים):

המסך אחרי שלב 5: כותרת, הודעה, לוח ריק, כפתור ומקלדת
כך ייראה המסך אחרי שלב 5 (בשלב הזה הלוח עדיין ריק). ארבעת האזורים: כותרת, הודעה, לוח, מקלדת.

ג. הרחבה: מסך לרוחב ⏱️ 15 דק'

סובבו את האמולטור. הלוח יחתך, כי בגובה של מסך רוחבי אין מקום ל-6 שורות של 52dp ועוד מקלדת. פתרון: תיקיית res/layout-land/ עם גרסה שנייה של התבנית, שבה הלוח והמקלדת זה לצד זה. המערכת בוחרת לבד לפי הכיוון – זה בדיוק אחד ממנגנוני המשאבים של שלב 6. נסו, זה תרגיל טוב, אבל לא חובה.

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. אין אף רכיב עם אזהרת "not constrained" בעורך.
  2. הכותרת וההודעה נשארות ממורכזות גם על טאבלט.
  3. אתם יכולים להסביר מה ההבדל בין wrap_content, match_parent ו-0dp.
שאלת הבנה (המורה ישאל):
מחקנו מ-txtMessage את האילוץ layout_constraintEnd_toEndOf והשארנו רק Start ו-Top.
א. איפה הטקסט יופיע עכשיו?
ב. מה יקרה ל-layout_width="0dp" שלו? למה זו שגיאה?

שלב 5: הלוח נבנה בקוד ⏱️ סה"כ כ-60 דק'

🎯 מטרה: ליצור 30 אריחים (6 שורות × 5) בלולאה כפולה, לשמור אותם במערך דו-ממדי ולצבוע אותם לפי מצב.

למה ככה? למה לא 30 TextViews ב-XML?

אפשר. זה יעבוד. אבל זה 30 בלוקים זהים של 8 שורות כל אחד, ועוד 30 findViewById בקוד. וכשתרצו לשנות את גודל האריח – 30 מקומות לערוך. בקוד זו לולאה אחת, והמערך tiles[row][col] נותן לנו בדיוק את מה שהמשחק צריך: "האריח בשורה 2, עמודה 3". כלל אצבע: רכיבים ייחודיים ב-XML, רכיבים חוזרים בקוד.

א. השדות ⏱️ 5 דק'

MainActivity.java – שדות הלוח
private TextView[][] tiles;                 // [שורה][עמודה] - 6×5
private LinearLayout boardContainer;
private TextView txtMessage;

ב. בונים את הלוח ⏱️ 30 דק'

MainActivity.java – buildBoard ו-paintTile
/**
 * בונה 6 שורות של 5 אריחים בקוד. כל אריח הוא TextView רגיל עם רקע מ-drawable.
 * למה בקוד ולא ב-XML? כי 30 רכיבים זהים ב-XML הם 30 הזדמנויות לטעות העתקה,
 * ואילו לולאה כפולה היא 8 שורות.
 */
private void buildBoard() {
    Resources res = getResources();
    int size = res.getDimensionPixelSize(R.dimen.tile_size);
    int margin = res.getDimensionPixelSize(R.dimen.tile_margin);

    tiles = new TextView[WordleGame.MAX_GUESSES][WordleGame.WORD_LENGTH];
    boardContainer.removeAllViews();

    for (int row = 0; row < WordleGame.MAX_GUESSES; row++) {
        LinearLayout rowLayout = new LinearLayout(this);
        rowLayout.setOrientation(LinearLayout.HORIZONTAL);
        rowLayout.setGravity(Gravity.CENTER);

        for (int col = 0; col < WordleGame.WORD_LENGTH; col++) {
            TextView tile = new TextView(this);
            LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size, size);
            lp.setMargins(margin, margin, margin, margin);
            tile.setLayoutParams(lp);
            tile.setGravity(Gravity.CENTER);
            tile.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.tile_text));
            tile.setTypeface(null, android.graphics.Typeface.BOLD);
            paintTile(tile, LetterState.EMPTY);

            tiles[row][col] = tile;
            rowLayout.addView(tile);
        }
        boardContainer.addView(rowLayout);
    }
}

/** צובע אריח אחד לפי מצב. הצבעים עצמם יושבים ב-colors.xml וב-drawable. */
private void paintTile(TextView tile, LetterState state) {
    int bg;
    int textColor = R.color.tile_text_light;
    switch (state) {
        case CORRECT: bg = R.drawable.tile_correct; break;
        case PRESENT: bg = R.drawable.tile_present; break;
        case ABSENT:  bg = R.drawable.tile_absent;  break;
        default:      bg = R.drawable.tile_empty;   textColor = R.color.tile_text; break;
    }
    tile.setBackground(ContextCompat.getDrawable(this, bg));
    tile.setTextColor(ContextCompat.getColor(this, textColor));
}
  • LayoutParams – כשיוצרים רכיב בקוד, הגודל והשוליים לא נכתבים עליו אלא על אובייקט "פרמטרים של תבנית" שמתאים להורה שלו. ההורה כאן הוא LinearLayout, ולכן LinearLayout.LayoutParams.
  • dp לעומת px – ב-dimens.xml כתוב 52dp. המסך עובד בפיקסלים, וצפיפות הפיקסלים שונה בכל טלפון. getDimensionPixelSize עושה את ההמרה בשבילנו.
  • paintTile – מתודה אחת שמקבלת מצב ומחליפה רקע וצבע טקסט. היא תשרת אותנו גם בשלב 9 (צביעה אחרי ניחוש) וגם בשלב 10 (שחזור). כתבו אותה פעם אחת.
  • ב-onCreate קוראים buildBoard() מיד אחרי findViewById של המיכל.

ג. בדיקה ⏱️ 10 דק'

הריצו. אמורים לראות 30 ריבועים לבנים עם מסגרת אפורה. כדי לוודא שהמערך נכון, הוסיפו זמנית ב-onCreate את השורה tiles[2][4].setText("א"); – האות צריכה להופיע בשורה השלישית, באריח הימני (עמודה 0 היא ימין, כי RTL). מחקו אחר כך.

ד. הרחבה ⏱️ 15 דק'

החליפו את LinearLayout המקונן ב-GridLayout אחד עם columnCount=5. מה יותר קצר? מה קורה לשוליים?

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. 30 אריחים על המסך, ממורכזים, בגודל אחיד.
  2. הניסוי עם tiles[2][4] הציג את האות במקום הנכון.
  3. אתם יכולים להסביר למה האריח צריך LayoutParams ולמה לא כותבים setWidth(52).
שאלת הבנה (המורה ישאל):
תלמיד שינה את tile_size ב-dimens.xml ל-80dp, ועל הטלפון שלו שתי השורות התחתונות נעלמו.
א. למה הן נעלמו ולא "נדחסו"?
ב. הציעו שני פתרונות: אחד ב-XML ואחד בקוד.

שלב 6: משאבי האפליקציה ⏱️ סה"כ כ-50 דק'

🎯 מטרה: להוציא מהקוד כל טקסט, צבע, מידה וצורה אל תיקיית res/, ולהבין איך אנדרואיד בוחר משאב לפי המכשיר.

למה ככה?

"כל הכבוד! ניחשתם ב-2 ניסיונות" יכול להיכתב ישירות בקוד. אבל אז: תרגום לערבית = לחפש בכל הקוד; שינוי הירוק = לחפש בכל הקוד; אריח גדול יותר לטאבלט = לכתוב תנאים. ב-res/ כל דבר כזה יושב במקום אחד, עם שם, והקוד מתייחס לשם. ויש בונוס: אנדרואיד יודע לבחור לבד בין values/ ל-values-ar/ לפי שפת המכשיר, ובין layout/ ל-layout-land/ לפי הכיוון.

res/ ├── values/ │ ├── strings.xml <- כל טקסט שהמשתמש רואה │ ├── colors.xml <- כל צבע, בשם שאומר מה הוא (tile_correct, לא green) │ ├── dimens.xml <- מידות: tile_size, key_height │ ├── themes.xml <- ערכת הנושא + הסגנון KeyButton │ └── words.xml <- רשימת המילים כ-string-array ├── drawable/ │ └── tile_*.xml <- ארבע צורות אריח (shape) └── layout/ ├── activity_main.xml └── keyboard.xml

א. מחרוזות ⏱️ 10 דק'

res/values/strings.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">ווֹרדל בעברית</string>
    <string name="title">ווֹרדל בעברית</string>
    <string name="msg_start">נחשו מילה בת 5 אותיות. יש לכם 6 ניסיונות.</string>
    <string name="msg_too_short">צריך 5 אותיות לפני הבדיקה</string>
    <string name="msg_win">כל הכבוד! ניחשתם ב-%1$d ניסיונות</string>
    <string name="msg_lose">נגמרו הניסיונות. המילה הייתה: %1$s</string>
    <string name="msg_guesses_left">נשארו %1$d ניסיונות</string>
    <string name="key_delete">מחק</string>
    <string name="key_check">בדיקה</string>
    <string name="new_game">משחק חדש</string>
</resources>

שימו לב ל-%1$d ו-%1$s: מחרוזת עם "חור" למספר או לטקסט. בקוד: getString(R.string.msg_win, 2). לעולם לא "ניחשתם ב-" + n + " ניסיונות" בקוד – בשפות אחרות סדר המילים שונה.

ב. צבעים ומידות ⏱️ 10 דק'

res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="primary">#538D4E</color>
    <color name="on_primary">#FFFFFF</color>
    <color name="secondary">#B59F3B</color>
    <color name="on_secondary">#FFFFFF</color>
    <color name="white">#FFFFFF</color>
    <color name="ink">#1A1A2E</color>

    <!-- ארבעת מצבי האות -->
    <color name="tile_empty">#FFFFFF</color>
    <color name="tile_border">#9AA0A6</color>
    <color name="tile_absent">#787C7E</color>
    <color name="tile_present">#C9B458</color>
    <color name="tile_correct">#6AAA64</color>
    <color name="tile_text">#1A1A2E</color>
    <color name="tile_text_light">#FFFFFF</color>

    <color name="key_bg">#D3D6DA</color>
    <color name="screen_bg">#F7F7F7</color>
</resources>
res/values/dimens.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="tile_size">52dp</dimen>
    <dimen name="tile_margin">3dp</dimen>
    <dimen name="tile_text">26sp</dimen>
    <dimen name="key_height">46dp</dimen>
    <dimen name="key_margin">2dp</dimen>
    <dimen name="key_text">16sp</dimen>
    <dimen name="screen_padding">12dp</dimen>
</resources>

ג. צורות: drawable ⏱️ 10 דק'

רקע של אריח הוא לא תמונה אלא צורה שמתוארת ב-XML: מלבן, צבע מילוי, מסגרת, פינות מעוגלות. ארבעה קבצים, אחד לכל מצב. הנה הירוק:

res/drawable/tile_correct.xml
<?xml version="1.0" encoding="utf-8"?>
<!-- רקע אריח במצב correct -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/tile_correct" />
    <stroke android:width="2dp" android:color="@color/tile_correct" />
    <corners android:radius="4dp" />
</shape>

צרו באותו אופן את tile_present, tile_absent ו-tile_empty (ללבן: מילוי לבן ומסגרת tile_border).

ד. ערכת נושא וסגנון ⏱️ 10 דק'

res/values/themes.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.App" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/primary</item>
        <item name="colorOnPrimary">@color/on_primary</item>
        <item name="colorSecondary">@color/secondary</item>
        <item name="colorOnSecondary">@color/on_secondary</item>
        <item name="android:statusBarColor">@color/primary</item>
        <item name="android:windowBackground">@color/screen_bg</item>
    </style>

    <!-- מקש אות במקלדת: כל 27 המקשים חולקים את הסגנון הזה -->
    <style name="KeyButton" parent="Widget.Material3.Button.TextButton">
        <item name="android:layout_width">0dp</item>
        <item name="android:layout_height">@dimen/key_height</item>
        <item name="android:layout_weight">1</item>
        <item name="android:layout_margin">@dimen/key_margin</item>
        <item name="android:minWidth">0dp</item>
        <item name="android:insetTop">0dp</item>
        <item name="android:insetBottom">0dp</item>
        <item name="android:padding">0dp</item>
        <item name="android:textSize">@dimen/key_text</item>
        <item name="android:textColor">@color/ink</item>
        <item name="backgroundTint">@color/key_bg</item>
        <item name="cornerRadius">6dp</item>
        <item name="android:onClick">onLetterKey</item>
    </style>
</resources>

Theme הוא סגנון של האפליקציה כולה (צבע ראשי, סרגל מצב, רקע). Style הוא חבילת מאפיינים לרכיב אחד. הסגנון KeyButton הוא הסיבה שבשלב 7 כל מקש במקלדת הוא שורה אחת של XML במקום עשר. שימו לב לשורה האחרונה בו – נחזור אליה.

ה. רשימת המילים כמשאב ⏱️ 10 דק'

המילים הן נתונים, לא קוד. string-array נותן לנו מערך מוכן: getResources().getStringArray(R.array.words). מי שרוצה להוסיף מילים לא צריך להבין Java.

res/values/words.xml (105 מילים)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- מאגר המילים: כל מילה בת 5 אותיות בדיוק. אפשר להוסיף ולמחוק מילים כאן בלי לגעת בקוד. -->
    <string-array name="words">
        <item>שולחן</item>
        <item>מקלדת</item>
        <item>תלמיד</item>
        <item>מורים</item>
        <item>ספרים</item>
        <item>חברים</item>
        <item>ילדים</item>
        <item>בניין</item>
        <item>מכונה</item>
        <item>מכשיר</item>
        <item>טלפון</item>
        <item>מחברת</item>
        <item>אבטיח</item>
        <item>אגסים</item>
        <item>שמשיה</item>
        <item>מטריה</item>
        <item>מחשבה</item>
        <item>ספינה</item>
        <item>שמיים</item>
        <item>מדינה</item>
        <item>גבינה</item>
        <item>עוגיה</item>
        <item>עוגות</item>
        <item>פלאפל</item>
        <item>חומוס</item>
        <item>סלטים</item>
        <item>בקבוק</item>
        <item>כוסות</item>
        <item>שמיכה</item>
        <item>מדפים</item>
        <item>דלתות</item>
        <item>קירות</item>
        <item>מנהלת</item>
        <item>רופאה</item>
        <item>שוטרת</item>
        <item>כבאים</item>
        <item>נהגים</item>
        <item>כלבים</item>
        <item>חתולה</item>
        <item>סוסים</item>
        <item>ציפור</item>
        <item>נמלים</item>
        <item>פילים</item>
        <item>קופים</item>
        <item>אריות</item>
        <item>נמרים</item>
        <item>זברות</item>
        <item>פרחים</item>
        <item>יערות</item>
        <item>נהרות</item>
        <item>אגמים</item>
        <item>חופים</item>
        <item>גשמים</item>
        <item>רוחות</item>
        <item>עננים</item>
        <item>שלגים</item>
        <item>תוכנה</item>
        <item>חומרה</item>
        <item>מסכים</item>
        <item>רמקול</item>
        <item>מצלמה</item>
        <item>מדפסת</item>
        <item>עברית</item>
        <item>ערבית</item>
        <item>כימיה</item>
        <item>שיעור</item>
        <item>הפסקה</item>
        <item>בחינה</item>
        <item>שאלות</item>
        <item>תשובה</item>
        <item>תעודה</item>
        <item>חידות</item>
        <item>קלפים</item>
        <item>שירים</item>
        <item>ריקוד</item>
        <item>גיטרה</item>
        <item>תופים</item>
        <item>כינור</item>
        <item>חברות</item>
        <item>משפחה</item>
        <item>הורים</item>
        <item>אחיות</item>
        <item>דודים</item>
        <item>תיקים</item>
        <item>מגפים</item>
        <item>חולצה</item>
        <item>שניות</item>
        <item>ארוחה</item>
        <item>לחמים</item>
        <item>ביצים</item>
        <item>ספורט</item>
        <item>שחייה</item>
        <item>גלידה</item>
        <item>סיפור</item>
        <item>משורר</item>
        <item>עיתון</item>
        <item>מספרה</item>
        <item>כרטיס</item>
        <item>תחנות</item>
        <item>רכבות</item>
        <item>טייסת</item>
        <item>קניון</item>
        <item>שכונה</item>
        <item>רמזור</item>
        <item>גשרים</item>
    </string-array>
</resources>

🤔 מה זה R?

בכל בנייה אנדרואיד סורק את res/ ומייצר מחלקה בשם R עם מספר לכל משאב: R.string.msg_win, R.color.tile_correct, R.id.btnCheck. זה למה שגיאת כתיב בשם משאב היא שגיאת קומפילציה ולא קריסה בזמן ריצה – המהדר בודק בשבילכם. ואם ה-R "אדום" פתאום – כמעט תמיד יש XML שבור באחד הקבצים.

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. אין בקוד Java אף מחרוזת עברית ואף צבע הקסדצימלי.
  2. ארבעת ה-drawables מוצגים נכון: שנו זמנית את paintTile בבדיקה של שלב 5 לכל מצב.
  3. הוספתם 5 מילים משלכם ל-words.xml, כולן בנות 5 אותיות.
שאלת הבנה (המורה ישאל):
רוצים שעל טאבלט האריח יהיה 80dp ועל טלפון 52dp, בלי אף if בקוד.
א. איזו תיקייה יוצרים ואיזה קובץ שמים בה?
ב. איך אנדרואיד יודע באיזו מהן להשתמש?

שלב 7: המקלדת העברית – דרך 1, מאזין ב-XML ⏱️ סה"כ כ-50 דק'

🎯 מטרה: לבנות מקלדת של 27 מקשים, שכולם מפעילים את אותה מתודה דרך android:onClick, ולהכניס אותיות לשורה הנוכחית.

למה ככה?

לחיצה על כפתור היא אירוע. מישהו צריך "להאזין" לו. באנדרואיד יש ארבע דרכים לרשום מאזין, וביחידה הזאת נשתמש בכל הארבע, כל אחת על כפתור אחר, כדי שתרגישו את ההבדל. הראשונה והפשוטה: לכתוב ב-XML את שם המתודה. המערכת, בזמן הלחיצה, מחפשת בפעילות מתודה public void בשם הזה שמקבלת View. זה נהדר ל-27 מקשים זהים: שורה אחת בסגנון, ואפס שורות חיבור בקוד.

א. קובץ המקלדת ⏱️ 20 דק'

res/layout/keyboard.xml
<?xml version="1.0" encoding="utf-8"?>
<!--
    המקלדת העברית. כל מקש הוא Button עם style="@style/KeyButton", והסגנון מכיל
    android:onClick="onLetterKey" - כלומר כל 27 המקשים קוראים לאותה מתודה בפעילות
    (דרך 1 לטיפול באירועים: מאזין ב-XML).
    הסידור הוא לפי המקלדת הפיזית, מימין לשמאל, כי supportsRtl="true".
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/keyboard"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Button style="@style/KeyButton" android:text="ק" />
        <Button style="@style/KeyButton" android:text="ר" />
        <Button style="@style/KeyButton" android:text="א" />
        <Button style="@style/KeyButton" android:text="ט" />
        <Button style="@style/KeyButton" android:text="ו" />
        <Button style="@style/KeyButton" android:text="ן" />
        <Button style="@style/KeyButton" android:text="ם" />
        <Button style="@style/KeyButton" android:text="פ" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Button style="@style/KeyButton" android:text="ש" />
        <Button style="@style/KeyButton" android:text="ד" />
        <Button style="@style/KeyButton" android:text="ג" />
        <Button style="@style/KeyButton" android:text="כ" />
        <Button style="@style/KeyButton" android:text="ע" />
        <Button style="@style/KeyButton" android:text="י" />
        <Button style="@style/KeyButton" android:text="ח" />
        <Button style="@style/KeyButton" android:text="ל" />
        <Button style="@style/KeyButton" android:text="ך" />
        <Button style="@style/KeyButton" android:text="ף" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Button style="@style/KeyButton" android:text="ז" />
        <Button style="@style/KeyButton" android:text="ס" />
        <Button style="@style/KeyButton" android:text="ב" />
        <Button style="@style/KeyButton" android:text="ה" />
        <Button style="@style/KeyButton" android:text="נ" />
        <Button style="@style/KeyButton" android:text="מ" />
        <Button style="@style/KeyButton" android:text="צ" />
        <Button style="@style/KeyButton" android:text="ת" />
        <Button style="@style/KeyButton" android:text="ץ" />
    </LinearLayout>

    <!-- שני המקשים האלה לא מקבלים onClick ב-XML בכוונה: הם מחוברים מהקוד (דרכים 2 ו-3) -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Button
            android:id="@+id/btnDelete"
            style="@style/KeyButton"
            android:layout_weight="2"
            android:onClick="@null"
            android:text="@string/key_delete" />
        <Button
            android:id="@+id/btnCheck"
            style="@style/KeyButton"
            android:layout_weight="2"
            android:onClick="@null"
            android:backgroundTint="@color/primary"
            android:textColor="@color/white"
            android:text="@string/key_check" />
    </LinearLayout>
</LinearLayout>
  • הסידור הוא של המקלדת הפיזית. בגלל supportsRtl, הילד הראשון ב-LinearLayout אופקי יושב מימין.
  • layout_weight="1" לכל מקש + layout_width="0dp" = חלוקה שווה של הרוחב. שורה של 10 מקשים מקבלת מקשים צרים יותר משורה של 8 – בדיוק כמו במקלדת.
  • "מחק" ו"בדיקה" מקבלים android:onClick="@null" כדי לבטל את ה-onClick שירשו מהסגנון. אותם נחבר בדרכים אחרות בשלב 8.

ב. המתודה בפעילות ⏱️ 15 דק'

MainActivity.java – onLetterKey
/**
 * דרך 1: מאזין ב-XML. כל 27 מקשי האותיות מצהירים android:onClick="onLetterKey"
 * (דרך הסגנון KeyButton), ולכן אנדרואיד מחפש כאן מתודה public בשם הזה
 * שמקבלת View אחד. הטקסט של הכפתור הוא האות עצמה.
 */
public void onLetterKey(View view) {
    if (game.isOver()) return;
    if (current.length() >= WordleGame.WORD_LENGTH) return;

    char letter = ((Button) view).getText().charAt(0);
    current.append(letter);
    int row = game.getGuessCount();
    tiles[row][current.length() - 1].setText(String.valueOf(letter));
}

הפרמטר view הוא הכפתור שנלחץ. אנחנו לא צריכים 27 מתודות, כי הטקסט של הכפתור הוא האות. שימו לב לשני תנאי העצירה: אחרי סיום המשחק ואחרי 5 אותיות אין מה להוסיף. current הוא StringBuilder שמחזיק את מה שהוקלד; הוא מוגדר בשלב 9 יחד עם game – בינתיים הוסיפו אותו כשדה ואתחלו את game במילה קבועה.

⚠️ השגיאה שתראו בוודאות

IllegalStateException: Could not find method onLetterKey(View) – המתודה לא public, או לא מקבלת View, או שיש טעות כתיב בין ה-XML לקוד. זו החולשה של דרך 1: הקשר בין ה-XML לקוד הוא מחרוזת, והמהדר לא בודק אותה. הקריסה מגיעה רק בלחיצה.

ג. בדיקה ⏱️ 15 דק'

הריצו, הקלידו 5 אותיות. האותיות צריכות להופיע בשורה הראשונה מימין לשמאל, והאות השישית לא נכנסת.

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. 27 מקשים, שלוש שורות, הסידור כמו במקלדת פיזית.
  2. אין אף setOnClickListener עבור מקשי האותיות.
  3. אתם יכולים להסביר מאיפה המתודה יודעת איזו אות נלחצה.
שאלת הבנה (המורה ישאל):
תלמידה שינתה את שם המתודה ל-onLetter ושכחה לעדכן את הסגנון. הפרויקט התקמפל והאפליקציה עלתה.
א. מתי בדיוק תתגלה הטעות?
ב. מה היה קורה באותו מקרה בדרך 2 (שלב הבא)?

שלב 8: מחיקה ובדיקה – דרכים 2 ו-3 ⏱️ סה"כ כ-50 דק'

🎯 מטרה: לחבר את "מחק" דרך הפעילות שמממשת OnClickListener, ואת "בדיקה" דרך מחלקה פנימית – ולהבין מתי כל דרך מתאימה.

למה ככה?

setOnClickListener מקבל אובייקט שמממש את הממשק View.OnClickListener (מתודה אחת: onClick(View)). השאלה היא רק מי האובייקט הזה. דרך 2: הפעילות עצמה (this). דרך 3: מחלקה פנימית שכתבנו. בשתיהן המהדר בודק שהמתודה קיימת – זה היתרון הגדול על דרך 1.

א. דרך 2: הפעילות מממשת ⏱️ 15 דק'

שלוש פעולות: (1) implements View.OnClickListener בכותרת המחלקה; (2) דורסים onClick; (3) ב-wireButtons מעבירים this.

MainActivity.java – onClick של הפעילות
/**
 * דרך 2: הפעילות עצמה מממשת OnClickListener. כפתור "מחק" מקבל this כמאזין
 * (ראו wireButtons), ולכן הלחיצה מגיעה לכאן. כשיש כמה כפתורים עם אותו מאזין,
 * מבדילים ביניהם לפי view.getId().
 */
@Override
public void onClick(View view) {
    if (view.getId() == R.id.btnDelete) {
        if (game.isOver() || current.length() == 0) return;
        current.deleteCharAt(current.length() - 1);
        int row = game.getGuessCount();
        tiles[row][current.length()].setText("");
    }
}

מתי זה מתאים? כשיש הרבה כפתורים ורוצים מקום אחד. החיסרון: onClick אחת הופכת לסדרת if (view.getId() == ...). אצלנו יש בה כפתור אחד, בכוונה, כדי שתראו את הצורה.

ב. דרך 3: מחלקה פנימית ⏱️ 20 דק'

MainActivity.java – CheckListener
/**
 * דרך 3: מחלקה פנימית. למחלקה פנימית (לא static) יש גישה לכל השדות של הפעילות
 * - game, tiles, current - בלי להעביר אותם כפרמטרים.
 */
private class CheckListener implements View.OnClickListener {
    @Override
    public void onClick(View view) {
        if (game.isOver()) return;
        if (current.length() < WordleGame.WORD_LENGTH) {
            txtMessage.setText(R.string.msg_too_short);
            return;
        }
        Guess guess = game.submit(current.toString());
        int row = game.getGuessCount() - 1;
        showGuess(row, guess);
        current.setLength(0);
        updateMessage();
    }
}

CheckListener היא מחלקה שלמה בתוך MainActivity. בגלל שהיא לא static, יש לה גישה ישירה ל-game, tiles, current ו-txtMessage. מתי זה מתאים? כשהמאזין עושה עבודה אמיתית (כאן: בדיקה, צביעה, הודעה) ומגיע לו שם משלו. המתודות showGuess ו-updateMessage נכתבות בשלב הבא.

ג. חיבור ⏱️ 5 דק'

שתי השורות הראשונות של wireButtons (השלישית – בשלב 9):

findViewById(R.id.btnDelete).setOnClickListener(this); // דרך 2 findViewById(R.id.btnCheck).setOnClickListener(new CheckListener()); // דרך 3

ד. בדיקה ⏱️ 10 דק'

הקלידו שלוש אותיות, לחצו "מחק" פעמיים – נשארת אחת. לחצו "בדיקה" עם 3 אותיות – ההודעה "צריך 5 אותיות".

תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. "מחק" עובד ולא מוחק מעבר לתחילת השורה (אין קריסה בלחיצה על שורה ריקה).
  2. "בדיקה" עם פחות מ-5 אותיות מציגה הודעה ולא זורקת חריגה.
  3. אתם יכולים להסביר למה CheckListener רואה את game בלי שהעברנו לה אותו.
שאלת הבנה (המורה ישאל):
הוספנו static ל-CheckListener. הפרויקט מפסיק להתקמפל.
א. איזו שורה בדיוק נשברת ולמה?
ב. איך הייתם מתקנים בלי להסיר את ה-static?

שלב 9: לוגיקת המשחק וצביעה – דרך 4 ⏱️ סה"כ כ-70 דק'

🎯 מטרה: לכתוב את חוקי המשחק ב-Java טהור עם בדיקות JUnit, לחבר אותם למסך, ולסגור את "משחק חדש" עם מאזין אנונימי.

למה ככה? למה המודל נפרד מהמסך?

WordleGame לא מייבא שום דבר מ-android.*. התוצאה: אפשר לבדוק אותו ב-JUnit על המחשב בחצי שנייה, בלי אמולטור, ובכל פעם שנוגעים בו הבדיקות אומרות מיד אם שברנו משהו. אלגוריתם שני המעברים מהתכנון (שלב 1) יושב כאן, ורק כאן.

א. המודל ⏱️ 25 דק'

צרו חבילה model (קליק ימני על החבילה → New → Package) ובה שלוש מחלקות:

model/LetterState.java
package com.example.hebrewwordle.model;

/**
 * מצב של אות אחת בניחוש. כל אריח על הלוח וכל מקש במקלדת מקבל אחד מארבעת המצבים.
 * enum הוא "מחלקה עם מספר קבוע של עצמים" - אין מצב חמישי, והמהדר שומר על זה בשבילנו.
 */
public enum LetterState {
    EMPTY,    // עדיין לא נבדק
    ABSENT,   // האות לא נמצאת במילה (אפור)
    PRESENT,  // האות נמצאת במילה אבל במקום אחר (צהוב)
    CORRECT   // האות נמצאת בדיוק במקום הזה (ירוק)
}
model/Guess.java
package com.example.hebrewwordle.model;

/**
 * ניחוש אחד: המילה שהוקלדה והמצב של כל אות בה.
 * העצם הזה "קפוא" (immutable): אחרי שנוצר אי אפשר לשנות אותו - כך שורה שכבר נבדקה
 * לעולם לא משתנה בטעות.
 */
public class Guess {

    private final String word;
    private final LetterState[] states;

    public Guess(String word, LetterState[] states) {
        this.word = word;
        this.states = states;
    }

    public String getWord() {
        return word;
    }

    public LetterState getState(int index) {
        return states[index];
    }

    public LetterState[] getStates() {
        return states.clone();   // עותק, כדי שאף אחד בחוץ לא ישנה את המערך הפנימי
    }

    /** ניחוש "מנצח" הוא ניחוש שכל אותיותיו ירוקות. */
    public boolean isAllCorrect() {
        for (LetterState s : states) {
            if (s != LetterState.CORRECT) return false;
        }
        return true;
    }
}
model/WordleGame.java
package com.example.hebrewwordle.model;

import java.util.ArrayList;
import java.util.List;

/**
 * חוקי המשחק, ב-Java טהור. המחלקה הזאת לא יודעת שקיים אנדרואיד:
 * אין כאן TextView, אין Button, אין צבעים. בגלל זה אפשר לבדוק אותה ב-JUnit
 * על המחשב, בלי אמולטור.
 */
public class WordleGame {

    public static final int WORD_LENGTH = 5;
    public static final int MAX_GUESSES = 6;

    private final String secret;
    private final List<Guess> guesses = new ArrayList<>();

    public WordleGame(String secret) {
        if (secret == null || secret.length() != WORD_LENGTH) {
            throw new IllegalArgumentException("המילה הסודית חייבת להיות בת " + WORD_LENGTH + " אותיות");
        }
        this.secret = secret;
    }

    /**
     * בודק ניחוש ומוסיף אותו לרשימה.
     * @throws IllegalArgumentException אם אורך המילה שגוי
     * @throws IllegalStateException אם המשחק כבר נגמר
     */
    public Guess submit(String word) {
        if (isOver()) {
            throw new IllegalStateException("המשחק נגמר");
        }
        if (word == null || word.length() != WORD_LENGTH) {
            throw new IllegalArgumentException("הניחוש חייב להיות בן " + WORD_LENGTH + " אותיות");
        }
        Guess guess = new Guess(word, evaluate(secret, word));
        guesses.add(guess);
        return guess;
    }

    /**
     * לב המשחק: משווה ניחוש למילה הסודית ומחזיר מצב לכל אות.
     * שני מעברים: קודם מסמנים ירוקים, ורק אחר כך צהובים - אחרת אות כפולה
     * בניחוש הייתה "גונבת" צהוב שלא מגיע לה.
     */
    public static LetterState[] evaluate(String secret, String word) {
        LetterState[] result = new LetterState[WORD_LENGTH];
        char[] s = normalize(secret).toCharArray();
        char[] g = normalize(word).toCharArray();
        boolean[] usedInSecret = new boolean[WORD_LENGTH];

        // מעבר 1: ירוקים
        for (int i = 0; i < WORD_LENGTH; i++) {
            if (g[i] == s[i]) {
                result[i] = LetterState.CORRECT;
                usedInSecret[i] = true;
            }
        }
        // מעבר 2: צהובים ואפורים
        for (int i = 0; i < WORD_LENGTH; i++) {
            if (result[i] != null) continue;          // כבר ירוק
            result[i] = LetterState.ABSENT;
            for (int j = 0; j < WORD_LENGTH; j++) {
                if (!usedInSecret[j] && g[i] == s[j]) {
                    result[i] = LetterState.PRESENT;
                    usedInSecret[j] = true;         // כל אות במילה הסודית "משלמת" פעם אחת
                    break;
                }
            }
        }
        return result;
    }

    /**
     * אותיות סופיות נחשבות כאות הרגילה: מי שהקליד "מ" במקום "ם" עדיין צודק לגבי האות.
     */
    public static String normalize(String word) {
        return word.replace('ך', 'כ')
                   .replace('ם', 'מ')
                   .replace('ן', 'נ')
                   .replace('ף', 'פ')
                   .replace('ץ', 'צ');
    }

    public boolean isWon() {
        return !guesses.isEmpty() && guesses.get(guesses.size() - 1).isAllCorrect();
    }

    public boolean isLost() {
        return !isWon() && guesses.size() >= MAX_GUESSES;
    }

    public boolean isOver() {
        return isWon() || isLost();
    }

    public String getSecret() {
        return secret;
    }

    public List<Guess> getGuesses() {
        return new ArrayList<>(guesses);
    }

    public int getGuessCount() {
        return guesses.size();
    }
}
  • evaluate היא static: היא פונקציה טהורה – קלט נכנס, פלט יוצא, בלי מצב. קל לבדוק.
  • normalize הופכת אותיות סופיות לרגילות לפני ההשוואה, כך ש-"מ" ו-"ם" נחשבות אותה אות. האותיות שמוצגות על הלוח נשארות כפי שהוקלדו.
  • submit זורק חריגות על קלט לא חוקי. ביחידה 1 למדתם למה זה עדיף על החזרת null.

ב. הבדיקות ⏱️ 15 דק'

ב-app/src/test/java/.../model/ (שימו לב: test, לא main):

test/.../WordleGameTest.java
package com.example.hebrewwordle.model;

import static org.junit.Assert.*;
import static com.example.hebrewwordle.model.LetterState.*;

import org.junit.Test;

public class WordleGameTest {

    @Test
    public void allCorrectWhenGuessEqualsSecret() {
        LetterState[] r = WordleGame.evaluate("שולחן", "שולחן");
        assertArrayEquals(new LetterState[]{CORRECT, CORRECT, CORRECT, CORRECT, CORRECT}, r);
    }

    @Test
    public void letterInWrongPlaceIsPresent() {
        // ל נמצאת במילה הסודית במקום 3, בניחוש היא במקום 1
        LetterState[] r = WordleGame.evaluate("שולחן", "למדים");
        assertEquals(PRESENT, r[0]);
        assertEquals(ABSENT, r[1]);
    }

    @Test
    public void duplicateLetterIsCountedOnce() {
        // במילה הסודית יש ת אחת. בניחוש יש שתיים: רק אחת מקבלת צבע.
        LetterState[] r = WordleGame.evaluate("תלמיד", "תתתתת");
        assertEquals(CORRECT, r[0]);
        for (int i = 1; i < 5; i++) assertEquals(ABSENT, r[i]);
    }

    @Test
    public void greenWinsOverYellowForDuplicates() {
        // ב"ספינה" יש י אחת, באינדקס 2. בניחוש "ימיים" יש י באינדקסים 0, 2, 3.
        // ה-י היחידה של המילה הסודית "משולמת" לירוק באינדקס 2, ולכן שתי האחרות אפורות.
        LetterState[] r = WordleGame.evaluate("ספינה", "ימיים");
        assertEquals(ABSENT, r[0]);
        assertEquals(CORRECT, r[2]);
        assertEquals(ABSENT, r[3]);
    }

    @Test
    public void finalLettersMatchRegularLetters() {
        LetterState[] r = WordleGame.evaluate("שולחן", "שולחנ");
        assertEquals(CORRECT, r[4]);
    }

    @Test
    public void winOnSixthGuess() {
        WordleGame game = new WordleGame("שולחן");
        for (int i = 0; i < 5; i++) game.submit("תלמיד");
        assertFalse(game.isOver());
        game.submit("שולחן");
        assertTrue(game.isWon());
        assertTrue(game.isOver());
    }

    @Test
    public void loseAfterSixWrongGuesses() {
        WordleGame game = new WordleGame("שולחן");
        for (int i = 0; i < 6; i++) game.submit("תלמיד");
        assertTrue(game.isLost());
        assertFalse(game.isWon());
    }

    @Test(expected = IllegalArgumentException.class)
    public void shortGuessIsRejected() {
        new WordleGame("שולחן").submit("שול");
    }

    @Test(expected = IllegalStateException.class)
    public void noGuessAfterGameOver() {
        WordleGame game = new WordleGame("שולחן");
        game.submit("שולחן");
        game.submit("תלמיד");
    }
}

הריצו (קליק ימני על הקובץ → Run). תשע בדיקות ירוקות. הבדיקה greenWinsOverYellowForDuplicates היא בדיוק השאלה מהתכנון: ספינה מול ימיים. אם כתבתם תשובה אחרת על הנייר בשלב 1 – עכשיו אתם יודעים למה.

ג. חיבור למסך ⏱️ 20 דק'

MainActivity.java – שדות המשחק
private WordleGame game;
private final StringBuilder current = new StringBuilder();   // האותיות שהוקלדו בשורה הנוכחית
private final Map<Character, Button> keyByLetter = new HashMap<>();
MainActivity.java – משחק חדש, צביעה והודעות
private void startNewGame() {
    String[] words = getResources().getStringArray(R.array.words);
    String secret = words[new Random().nextInt(words.length)];
    game = new WordleGame(secret);
    current.setLength(0);
    clearBoard();
    txtMessage.setText(R.string.msg_start);
    Log.d(TAG, "משחק חדש. המילה הסודית: " + secret);   // רק לפיתוח - מוחקים לפני הגשה
}

private void clearBoard() {
    for (TextView[] row : tiles) {
        for (TextView tile : row) {
            tile.setText("");
            paintTile(tile, LetterState.EMPTY);
        }
    }
    for (Button key : keyByLetter.values()) {
        key.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.key_bg));
        key.setTextColor(ContextCompat.getColor(this, R.color.ink));
    }
}

/** מציג ניחוש שנבדק בשורה נתונה: אותיות, צבעי אריחים וצבעי מקשים. */
private void showGuess(int row, Guess guess) {
    String word = guess.getWord();
    for (int col = 0; col < WordleGame.WORD_LENGTH; col++) {
        tiles[row][col].setText(String.valueOf(word.charAt(col)));
        paintTile(tiles[row][col], guess.getState(col));
        paintKey(word.charAt(col), guess.getState(col));
    }
}

/** מקש עולה בדרגה בלבד: אפור -> צהוב -> ירוק, אף פעם לא יורד. */
private void paintKey(char letter, LetterState state) {
    Button key = keyByLetter.get(letter);
    if (key == null) return;
    LetterState old = (LetterState) key.getTag();
    if (old != null && old.ordinal() >= state.ordinal()) return;
    key.setTag(state);

    int color;
    switch (state) {
        case CORRECT: color = R.color.tile_correct; break;
        case PRESENT: color = R.color.tile_present; break;
        default:      color = R.color.tile_absent;  break;
    }
    key.setBackgroundTintList(ContextCompat.getColorStateList(this, color));
    key.setTextColor(ContextCompat.getColor(this, R.color.tile_text_light));
}

private void updateMessage() {
    if (game.isWon()) {
        txtMessage.setText(getString(R.string.msg_win, game.getGuessCount()));
    } else if (game.isLost()) {
        txtMessage.setText(getString(R.string.msg_lose, game.getSecret()));
    } else {
        txtMessage.setText(getString(R.string.msg_guesses_left,
                WordleGame.MAX_GUESSES - game.getGuessCount()));
    }
}
  • showGuess צובעת שורה אחת ואת המקשים שלה. paintKey דואגת שמקש רק "עולה בדרגה": אות שכבר ירוקה לא תהפוך צהובה בניחוש הבא. זו הסיבה ל-ordinal() ול-setTag.
  • שורת ה-Log עם המילה הסודית היא כלי פיתוח. מוחקים לפני הגשה.

ד. דרך 4: מאזין אנונימי, ואיסוף המקשים ⏱️ 10 דק'

MainActivity.java – wireButtons ו-collectKeyboardButtons
/** מחבר את שלושת הכפתורים שלא מקבלים onClick ב-XML, כל אחד בדרך אחרת. */
private void wireButtons() {
    findViewById(R.id.btnDelete).setOnClickListener(this);                 // דרך 2
    findViewById(R.id.btnCheck).setOnClickListener(new CheckListener());   // דרך 3

    // דרך 4: מאזין אנונימי - מחלקה בלי שם שנכתבת במקום, מתאימה לקוד קצר של פעם אחת
    findViewById(R.id.btnNewGame).setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            startNewGame();
        }
    });
}

/** אוסף את כל מקשי האותיות למפה, כדי שנוכל לצבוע מקש לפי האות שלו. */
private void collectKeyboardButtons() {
    keyByLetter.clear();
    LinearLayout keyboard = findViewById(R.id.keyboard);
    for (int r = 0; r < keyboard.getChildCount(); r++) {
        View rowView = keyboard.getChildAt(r);
        if (!(rowView instanceof LinearLayout)) continue;
        LinearLayout row = (LinearLayout) rowView;
        for (int i = 0; i < row.getChildCount(); i++) {
            View child = row.getChildAt(i);
            if (child instanceof Button && child.getId() == View.NO_ID) {
                Button key = (Button) child;
                keyByLetter.put(key.getText().charAt(0), key);
            }
        }
    }
}

new View.OnClickListener() { ... } הוא מחלקה בלי שם שנוצרת במקום. מתאים לקוד של שורה-שתיים שלא יחזור. ארבע הדרכים, בטבלה אחת:

דרךאיפה אצלנומתי לבחורהמהדר בודק?
1. android:onClick ב-XML27 מקשי האותיותהרבה כפתורים זהיםלא
2. הפעילות מממשתמחקמקום אחד לכל הכפתוריםכן
3. מחלקה פנימיתבדיקהמאזין עם עבודה אמיתית ושםכן
4. אנונימימשחק חדששורה אחת, פעם אחתכן

ולבסוף, onCreate המלא שמחבר הכול:

MainActivity.java – onCreate
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);      // תמיד קוראים ל-super קודם
    setContentView(R.layout.activity_main);
    Log.d(TAG, "onCreate - המסך נבנה. savedInstanceState=" + (savedInstanceState == null ? "null" : "יש מצב שמור"));

    txtMessage = findViewById(R.id.txtMessage);
    boardContainer = findViewById(R.id.boardContainer);
    buildBoard();                            // שלב 5
    collectKeyboardButtons();                // שלב 9
    wireButtons();                           // שלבים 8-9

    if (savedInstanceState == null) {
        startNewGame();                      // הפעלה רגילה
    } else {
        restoreGame(savedInstanceState);     // שלב 10: חזרה מסיבוב מסך
    }
}

ה. משחקים!

הנה המשחק הגמור באמולטור, אחרי ניחוש אחד שגוי וניחוש נכון. שימו לב למקשים: ח' צהובה בניחוש הראשון הפכה ירוקה בשני.

ניצחון בשני ניחושים המסך לרוחב
מימין: ניצחון בשני ניחושים. משמאל: אותו משחק אחרי סיבוב, לפני שלב 10 הלוח היה מתאפס. שימו לב שהלוח נחתך לרוחב (הרחבה בשלב 4).
תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. 9 בדיקות JUnit ירוקות.
  2. משחק מלא: ניצחון, הפסד אחרי 6, "משחק חדש" מנקה לוח ומקשים.
  3. אתם יכולים להסביר למה WordleGame לא מכיר TextView, ומה היה קורה אם היה מכיר.
שאלת הבנה (המורה ישאל):
הסירו מ-paintKey את הבדיקה של old.ordinal() >= state.ordinal().
א. תארו רצף של שני ניחושים שבו מקש יוצג בצבע שגוי.
ב. למה הסדר של הקבועים ב-LetterState חשוב לקוד הזה? מה יקרה אם נחליף את מקומם של PRESENT ו-CORRECT?

שלב 10: שמירת מצב בסיבוב מסך ⏱️ סה"כ כ-50 דק'

🎯 מטרה: שסיבוב הטלפון לא ימחק את המשחק – לשמור את המינימום ב-onSaveInstanceState ולשחזר ממנו הכול.

למה ככה?

בשלב 3 ראיתם: סיבוב = onDestroy ואז onCreate חדש. אובייקט חדש, שדות חדשים, game ריק. המערכת נותנת לנו חלון הזדמנות: רגע לפני ההריסה היא קוראת ל-onSaveInstanceState(Bundle), ואת אותו Bundle היא מחזירה ל-onCreate החדש. הכלל מהתכנון (שלב 1, טבלת הישויות): שומרים מקור, לא תוצאה. מילה סודית + רשימת ניחושים + מה שהוקלד. את הצבעים מחשבים מחדש.

א. שמירה ⏱️ 15 דק'

MainActivity.java – מפתחות
// מפתחות ל-Bundle של שמירת המצב
private static final String KEY_SECRET = "secret";
private static final String KEY_GUESSES = "guesses";
private static final String KEY_CURRENT = "current";
MainActivity.java – שמירה ושחזור
/**
 * נקרא לפני שהפעילות נהרסת בגלל שינוי תצורה (סיבוב מסך, שינוי שפה...).
 * שומרים רק את המינימום שממנו אפשר לשחזר הכול: המילה הסודית, רשימת הניחושים
 * והאותיות שהוקלדו בשורה הנוכחית. את הצבעים לא שומרים - מחשבים אותם מחדש.
 */
@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    outState.putString(KEY_SECRET, game.getSecret());
    ArrayList<String> words = new ArrayList<>();
    for (Guess g : game.getGuesses()) words.add(g.getWord());
    outState.putStringArrayList(KEY_GUESSES, words);
    outState.putString(KEY_CURRENT, current.toString());
    Log.d(TAG, "onSaveInstanceState - נשמרו " + words.size() + " ניחושים");
}

/** בונה מחדש את המשחק מה-Bundle ומצייר את הלוח. */
private void restoreGame(Bundle state) {
    String secret = state.getString(KEY_SECRET);
    List<String> words = state.getStringArrayList(KEY_GUESSES);
    String typed = state.getString(KEY_CURRENT, "");

    game = new WordleGame(secret);
    clearBoard();
    if (words != null) {
        for (int row = 0; row < words.size(); row++) {
            Guess guess = game.submit(words.get(row));   // הבדיקה מחדש נותנת שוב את הצבעים
            showGuess(row, guess);
        }
    }
    current.setLength(0);
    current.append(typed);
    int row = game.getGuessCount();
    for (int col = 0; col < typed.length() && row < WordleGame.MAX_GUESSES; col++) {
        tiles[row][col].setText(String.valueOf(typed.charAt(col)));
    }
    updateMessage();
    Log.d(TAG, "restoreGame - שוחזרו " + (words == null ? 0 : words.size()) + " ניחושים");
}
  • Bundle הוא מילון: מפתח (מחרוזת) → ערך פשוט (מספר, מחרוזת, רשימת מחרוזות...). לא שמים בו אובייקטים שלנו ישירות; לכן שומרים את המילים ולא את Guess.
  • המפתחות הם קבועים. מחרוזת שכתובה פעמיים (בשמירה ובשחזור) היא באג שמחכה לקרות.
  • ב-restoreGame אנחנו פשוט משחקים מחדש את הניחושים דרך submit. הצבעים חוזרים לבד, והמקשים נצבעים דרך אותה showGuess.

ב. ההסתעפות ב-onCreate ⏱️ 5 דק'

הפרמטר savedInstanceState הוא null בהפעלה רגילה, ומלא אחרי סיבוב. זו כל ההחלטה: startNewGame() או restoreGame(savedInstanceState) (ראו onCreate בשלב 9).

ג. בדיקה ⏱️ 15 דק'

  1. נחשו מילה אחת, הקלידו שתי אותיות בשורה השנייה.
  2. סובבו. השורה הראשונה צבועה, שתי האותיות במקומן, המקשים צבועים, ההודעה "נשארו 5 ניסיונות".
  3. ב-Logcat: onSaveInstanceState - נשמרו 1 ניחושים ואז restoreGame - שוחזרו 1 ניחושים.
  4. לחצו "משחק חדש" וסובבו שוב: לוח ריק נשאר ריק.

🤔 ומה עם סגירה אמיתית?

onSaveInstanceState שורד סיבוב ושורד "הרג" של האפליקציה ברקע על ידי המערכת. הוא לא שורד סגירה על ידי המשתמש (אחורה / החלקה מהרשימה). כדי שהמשחק ישרוד גם את זה צריך לכתוב לדיסק – SharedPreferences ו-SQLite – וזו יחידה 5, שולחן השיאים.

ד. הרחבות ⏱️ 15 דק'

  • מילה לא במאגר: לפני submit בדקו שהמילה קיימת ב-words והציגו "לא במילון". שימו לב: השוואה אחרי normalize.
  • תבנית לרוחב: res/layout-land/activity_main.xml עם לוח ומקלדת זה לצד זה. הקוד לא משתנה – ה-ids זהים.
  • סטטיסטיקה: ספרו ניצחונות ברצף. איפה תשמרו? מה קורה בסיבוב? מה קורה בסגירה?
תחנת אישור מורה

✅ ודאו לפני שאתם ניגשים למורה:

  1. סיבוב באמצע משחק שומר לוח, מקשים, הודעה ואותיות שהוקלדו.
  2. ב-Bundle נשמרים רק שלושה מפתחות. אין בו צבעים.
  3. שורת ה-Log עם המילה הסודית נמחקה.
שאלת הבנה (המורה ישאל):
תלמיד שמר ב-Bundle גם את הצבע של כל אריח (30 מספרים), "ליתר ביטחון".
א. זה עובד. אז מה הבעיה? תנו דוגמה לבאג שיכול להיווצר כשיש שני מקורות לאותו מידע.
ב. מה הקשר בין השאלה הזאת לכלל "חוקי המשחק לא יודעים שקיים אנדרואיד"?

🎉 סיימתם את יחידה 2!

משחק שלם, מקלדת עברית, ארבע דרכים לטפל באירועים, ולוח ששורד סיבוב. ביחידה 3 נצא מהמסך היחיד: מסכים מרובים ו-Intents.