Connect4 — 13: מלטשים את האפליקציה ומאמתים את התוצר


בונים מצב עובד אחד מתוך המסלול

מפת המסלול

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

העיצוב מתווסף אחרי שהזרימות עובדות. רוב הקוד בפרק הוא משאבים, מדידה ותצוגה. אין כאן מנגנון משחק חדש או אימון מודל. השינוי המבני של קובצי הפריסה מוצג כ־diff כי המעבר לפריסה החדשה הוא נושא הפרק.

סדר עבודה

צרו תחילה את הצבעים, ה־styles וה־drawables, אחר כך את הפריסות ואת משאב האנימציה, ולבסוף חברו את ה־Activity. במהלך ההעתקה ייתכנו הפניות אדומות עד שהמשאב התלוי נוצר; בונים אחרי השלמת הקבוצה. משאבי תבנית שאינם קשורים נשארים בפרויקט.

השינויים לפי סדר העבודה

התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.

colors.xml

app > res > values > colors.xml

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

-<?xml version="1.0" encoding="utf-8"?>
 <resources>
+    <color name="background">#F3F5FA</color>
+    <color name="surface">#FFFFFF</color>
+    <color name="surface_soft">#EAF0FA</color>
+    <color name="ink">#192A46</color>
+    <color name="ink_secondary">#566782</color>
+    <color name="accent">#345DC5</color>
+    <color name="on_accent">#FFFFFF</color>
+    <color name="stroke">#D9E1EE</color>
+    <color name="board">#345DC5</color>
+    <color name="board_edge">#25499F</color>
+    <color name="board_hole">#E5EBF6</color>
+    <color name="disc_red">#EA596B</color>
+    <color name="disc_yellow">#F3C34F</color>
+    <color name="disc_ring">#192A46</color>
     <color name="black">#FF000000</color>
     <color name="white">#FFFFFFFF</color>
 </resources>

colors.xml

app > res > values-night > colors.xml

צרו חלופת night של אותם שמות. Android בוחר את הערכים לפי מצב המערכת. אין להמציא שמות שונים לצבעי הלילה.

<resources>
    <color name="background">#101827</color>
    <color name="surface">#1B273B</color>
    <color name="surface_soft">#24334C</color>
    <color name="ink">#F0F4FC</color>
    <color name="ink_secondary">#ADBBD1</color>
    <color name="accent">#ADC6FF</color>
    <color name="on_accent">#153369</color>
    <color name="stroke">#354660</color>
    <color name="board">#355B9F</color>
    <color name="board_edge">#244478</color>
    <color name="board_hole">#142239</color>
    <color name="disc_red">#FF7485</color>
    <color name="disc_yellow">#F7D16B</color>
    <color name="disc_ring">#FFFFFF</color>
</resources>

panel.xml

app > res > drawable > panel.xml

משטח רקע משותף לפאנלים. corner radius ו־stroke הם עיצוב, לא גאומטריית הפגיעה של הלוח.

<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/surface" />
    <corners android:radius="24dp" />
    <stroke android:width="1dp" android:color="@color/stroke" />
</shape>

input_background.xml

app > res > drawable > input_background.xml

רקע שדות הקלט משתמש באותה שפת צבעים של המסך.

<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/surface_soft" />
    <corners android:radius="14dp" />
    <stroke android:width="1dp" android:color="@color/stroke" />
</shape>

themes.xml

app > res > values > themes.xml

מגדירים styles חוזרים לשורה, פאנל, טקסט, Spinner וכפתור. ירושה בין styles מאפשרת לשנות רק את ההבדל בין בורר, הודעה ומשחק אופקי. הגדרות רוחב וגובה ב־style עדיין ניתנות לדריסה בפריסה עצמה.

-<resources xmlns:tools="http://schemas.android.com/tools">
-    <!-- Base application theme. -->
+<resources>
     <style name="Base.Theme.Connect4" parent="Theme.Material3.DayNight.NoActionBar">
-        <!-- Customize your light theme here. -->
-        <!-- <item name="colorPrimary">@color/my_light_primary</item> -->
+        <item name="colorPrimary">@color/accent</item>
+        <item name="colorOnPrimary">@color/on_accent</item>
+        <item name="colorSecondary">@color/accent</item>
+        <item name="colorSurface">@color/surface</item>
+        <item name="colorOnSurface">@color/ink</item>
+        <item name="colorOnSurfaceVariant">@color/ink_secondary</item>
+        <item name="colorSurfaceContainerHigh">@color/surface</item>
+        <item name="colorSecondaryContainer">@color/surface_soft</item>
+        <item name="colorOnSecondaryContainer">@color/ink</item>
+        <item name="colorOutline">@color/stroke</item>
+        <item name="android:windowBackground">@color/background</item>
+        <item name="android:colorBackground">@color/background</item>
+        <item name="android:textColorPrimary">@color/ink</item>
+        <item name="android:textColorSecondary">@color/ink_secondary</item>
+        <item name="android:fontFamily">sans-serif</item>
+        <item name="materialButtonStyle">@style/ConnectButton</item>
     </style>
-
     <style name="Theme.Connect4" parent="Base.Theme.Connect4" />
+    <style name="ConnectButton" parent="Widget.Material3.Button">
+        <item name="android:layout_width">match_parent</item>
+        <item name="android:layout_height">wrap_content</item>
+        <item name="android:layout_marginTop">6dp</item>
+        <item name="cornerRadius">16dp</item>
+        <item name="android:minHeight">52dp</item>
+        <item name="android:textAllCaps">false</item>
+        <item name="android:textSize">15sp</item>
+    </style>
+    <style name="QuietButton" parent="Widget.Material3.Button.TextButton">
+        <item name="android:layout_width">match_parent</item>
+        <item name="android:layout_height">wrap_content</item>
+        <item name="android:layout_marginTop">6dp</item>
+        <item name="android:minHeight">48dp</item>
+        <item name="android:textAllCaps">false</item>
+    </style>
+    <style name="ScreenRow" parent="">
+        <item name="android:layout_width">match_parent</item>
+        <item name="android:layout_height">wrap_content</item>
+    </style>
+    <style name="GameText" parent="ScreenRow">
+        <item name="android:textColor">@color/ink</item>
+    </style>
+    <style name="VerticalStack" parent="ScreenRow">
+        <item name="android:orientation">vertical</item>
+    </style>
+    <style name="Panel" parent="VerticalStack">
+        <item name="android:background">@drawable/panel</item>
+        <item name="android:padding">16dp</item>
+        <item name="android:layout_marginBottom">16dp</item>
+    </style>
+    <style name="GameSpinner" parent="ScreenRow">
+        <item name="android:minHeight">52dp</item>
+        <item name="android:paddingStart">8dp</item>
+        <item name="android:paddingEnd">8dp</item>
+        <item name="android:backgroundTint">@color/accent</item>
+        <item name="android:popupBackground">@color/surface</item>
+    </style>
+    <style name="GameInput" parent="GameText">
+        <item name="android:textColorHint">@color/ink_secondary</item>
+        <item name="android:minHeight">56dp</item>
+        <item name="android:padding">12dp</item>
+        <item name="android:layout_marginBottom">8dp</item>
+        <item name="android:background">@drawable/input_background</item>
+    </style>
+    <style name="GameScreen" parent="">
+        <item name="android:layout_width">match_parent</item>
+        <item name="android:layout_height">match_parent</item>
+        <item name="android:fillViewport">true</item>
+        <item name="android:background">@color/background</item>
+        <item name="android:clipToPadding">false</item>
+    </style>
+    <style name="ScreenContent" parent="VerticalStack">
+        <item name="android:layout_width">0dp</item>
+        <item name="android:padding">20dp</item>
+        <item name="android:paddingBottom">28dp</item>
+    </style>
+    <style name="ModePanel" parent="Panel">
+        <item name="android:padding">12dp</item>
+        <item name="android:layout_marginBottom">20dp</item>
+    </style>
+    <style name="SectionLabel" parent="GameText">
+        <item name="android:textColor">@color/ink_secondary</item>
+        <item name="android:textSize">11sp</item>
+        <item name="android:letterSpacing">0.12</item>
+        <item name="android:paddingBottom">6dp</item>
+        <item name="android:paddingStart">12dp</item>
+    </style>
+    <style name="TurnStatus" parent="GameText">
+        <item name="android:textSize">24sp</item>
+        <item name="android:textStyle">bold</item>
+        <item name="android:paddingTop">0dp</item>
+    </style>
+    <style name="GameDetails" parent="GameText">
+        <item name="android:textColor">@color/ink_secondary</item>
+        <item name="android:textSize">13sp</item>
+        <item name="android:paddingTop">4dp</item>
+        <item name="android:paddingBottom">16dp</item>
+    </style>
+    <style name="BoardHint" parent="GameText">
+        <item name="android:textColor">@color/ink_secondary</item>
+        <item name="android:textSize">12sp</item>
+        <item name="android:gravity">center</item>
+        <item name="android:paddingTop">12dp</item>
+        <item name="android:paddingBottom">10dp</item>
+    </style>
+    <style name="OnlineStatus" parent="GameText">
+        <item name="android:padding">8dp</item>
+    </style>
+    <!-- Controls scroll independently; the board fits the available window height. -->
+    <style name="LandscapeControls" parent="">
+        <item name="android:layout_width">0dp</item>
+        <item name="android:layout_height">0dp</item>
+        <item name="android:fillViewport">true</item>
+    </style>
+    <style name="LandscapeControlContent" parent="VerticalStack">
+        <item name="android:padding">16dp</item>
+    </style>
+    <style name="LandscapeGamePanel" parent="Panel">
+        <item name="android:layout_width">0dp</item>
+        <item name="android:layout_height">0dp</item>
+        <item name="android:layout_margin">4dp</item>
+        <item name="android:layout_marginBottom">4dp</item>
+        <item name="android:padding">4dp</item>
+    </style>
+    <style name="LandscapeBoardArea" parent="ScreenRow">
+        <item name="android:layout_height">0dp</item>
+        <item name="android:layout_weight">1</item>
+    </style>
+    <style name="LandscapeBoard" parent="">
+        <item name="android:layout_width">0dp</item>
+        <item name="android:layout_height">0dp</item>
+    </style>
+    <style name="LandscapeStatusRow" parent="ScreenRow">
+        <item name="android:orientation">horizontal</item>
+        <item name="android:gravity">center_vertical</item>
+        <item name="android:minHeight">32dp</item>
+        <item name="android:paddingBottom">4dp</item>
+    </style>
+    <style name="LandscapeTurnStatus" parent="TurnStatus">
+        <item name="android:layout_width">wrap_content</item>
+        <item name="android:textSize">20sp</item>
+        <item name="android:paddingEnd">12dp</item>
+    </style>
+    <style name="LandscapeGameDetails" parent="GameDetails">
+        <item name="android:layout_width">0dp</item>
+        <item name="android:layout_weight">1</item>
+        <item name="android:paddingTop">0dp</item>
+        <item name="android:paddingBottom">0dp</item>
+    </style>
 </resources>

themes.xml

app > res > values-night > themes.xml

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

-<resources xmlns:tools="http://schemas.android.com/tools">
-    <!-- Base application theme. -->
-    <style name="Base.Theme.Connect4" parent="Theme.Material3.DayNight.NoActionBar">
-        <!-- Customize your dark theme here. -->
-        <!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
-    </style>
-</resources>
+<resources />

strings.xml

app > res > values > strings.xml

מעבירים את שמות הפקדים והמשוב למשאבים. הקוד מחליף רק ערכים כמו שם הצבע, ולא בונה משפטי ממשק במקומות רבים.

 <resources>
     <string name="app_name">Connect4</string>
     <string name="new_game">New game</string>
+    <string name="new_game_local_turn">New game — %1$s to play</string>
+    <string name="new_game_your_turn">New game — your turn. Tap a column to play.</string>
+    <string name="new_game_wait">New game — please wait for the computer to play.</string>
     <string name="player_red">Red</string>
     <string name="player_yellow">Yellow</string>
     <string name="game_draw">Draw — board full</string>
     <string name="player_wins">%1$s wins!</string>
+    <string name="player_to_play">%1$s to play</string>
     <string name="player_thinking">%1$s is thinking…</string>
-    <string name="player_to_play">%1$s to play</string>
     <string name="local_play_status">Local play · no account needed</string>
     <string name="heuristic_status">Heuristic · win, block, then center</string>
+    <string name="confirm_new_game_title">Start a new game?</string>
+    <string name="confirm_new_game_message">Your current game will be lost.</string>
+    <string name="play_mode">HOW WILL YOU PLAY?</string>
+    <string name="board_hint">Tap a column to drop a disc · Connect four to win</string>
+    <string name="computer_player_label">Computer player</string>
+    <string name="continue_with_google">Continue with Google</string>
+    <string name="email_label">Email</string>
+    <string name="password_label">Password</string>
+    <string name="sign_in">Sign in</string>
+    <string name="create_account">Create account</string>
+    <string name="room_name">Room name</string>
+    <string name="create_room">Create room</string>
+    <string name="return_to_lobby">Return to lobby</string>
+    <string name="retry_online">Reconnect / retry</string>
+    <string name="sign_out">Sign out</string>
     <string-array name="game_modes">
         <item>Two players · this device</item>
         <item>Play heuristic</item>
         <item>Play model / MCTS</item>
     <string-array name="player_sides">
         <item>You are Red · play first</item>
         <item>You are Yellow · play second</item>
     </string-array>
-
 </resources>

spinner_item.xml

app > res > layout > spinner_item.xml

מחברים את פריט הבורר ל־styles ולצבעי הטקסט, כדי שיישאר קריא גם בלילה.

-<?xml version="1.0" encoding="utf-8"?>
 <TextView xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent" android:layout_height="wrap_content"
-    android:padding="12dp" android:textSize="16sp" />
+    style="@style/GameText"
+    android:minHeight="52dp" android:gravity="center_vertical"
+    android:paddingHorizontal="12dp" android:paddingVertical="10dp"
+    android:textSize="15sp" />

fragment_game_setup.xml

app > res > layout > fragment_game_setup.xml

מוסיפים תווית לפאנל ומשתמשים ב־styles המשותפים. מזהי הבוררים נשארים זהים כדי שה־binding וה־listeners ימשיכו לעבוד.

 <?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent" android:layout_height="wrap_content"
-    android:orientation="vertical">
-    <Spinner android:id="@+id/mode" android:layout_width="match_parent"
-        android:layout_height="48dp" android:contentDescription="Game mode" />
-    <Spinner android:id="@+id/side" android:layout_width="match_parent"
-        android:layout_height="48dp" android:contentDescription="Your color" />
-    <Spinner android:id="@+id/computerPlayer" android:layout_width="match_parent"
-        android:layout_height="48dp" android:contentDescription="Computer player"
-        android:visibility="gone" />
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    style="@style/ModePanel">
+
+    <TextView
+        style="@style/SectionLabel"
+        android:text="@string/play_mode" />
+
+    <Spinner
+        android:id="@+id/mode"
+        style="@style/GameSpinner" />
+
+    <Spinner
+        android:id="@+id/side"
+        style="@style/GameSpinner" />
+
+    <Spinner
+        android:id="@+id/computerPlayer"
+        style="@style/GameSpinner"
+        android:visibility="gone"
+        android:contentDescription="@string/computer_player_label" />
 </LinearLayout>

activity_main.xml

app > res > layout > activity_main.xml

הפריסה האנכית משתמשת ב־ScrollView ובתוכן שרוחבו מוגבל, כך שעל מכשיר רחב הלוח אינו נמתח ללא צורך. משאירים אותם IDs לאותן אחריויות: gameSetup, offlineGame, onlinePanel ופקדי החדר.

 <?xml version="1.0" encoding="utf-8"?>
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-    android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent"
-    android:saveEnabled="false" android:fillViewport="true">
-    <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical">
-    <androidx.fragment.app.FragmentContainerView android:id="@+id/gameSetup"
-        android:name="com.example.connect4.GameSetupFragment"
-        android:layout_width="match_parent" android:layout_height="wrap_content" />
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/offlineGame"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    tools:context=".MainActivity">
+    android:id="@+id/main"
+    android:saveEnabled="false"
+    style="@style/GameScreen">
 
-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:id="@+id/status"
-        android:text="Tap a column"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
+    <androidx.constraintlayout.widget.ConstraintLayout
+        style="@style/ScreenRow">
 
-    <TextView
-        android:id="@+id/aiStatus" android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        app:layout_constraintTop_toBottomOf="@id/status"
-        app:layout_constraintStart_toStartOf="parent" />
+        <LinearLayout
+            style="@style/ScreenContent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintWidth_max="600dp">
 
-    <com.example.connect4.ui.BoardView
-        android:id="@+id/board"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        app:layout_constraintDimensionRatio="7:6"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/aiStatus" />
+            <androidx.fragment.app.FragmentContainerView
+                android:id="@+id/gameSetup"
+                android:name="com.example.connect4.GameSetupFragment"
+                style="@style/ScreenRow" />
 
-    <Button
-        android:id="@+id/restart"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="New game"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/board"
-        app:layout_constraintStart_toStartOf="parent" />
+            <LinearLayout
+                android:id="@+id/offlineGame"
+                style="@style/Panel">
 
-</androidx.constraintlayout.widget.ConstraintLayout>    <LinearLayout android:id="@+id/onlinePanel" android:orientation="vertical"
-        android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone">
-        <TextView android:id="@+id/onlineStatus" android:layout_width="match_parent" android:layout_height="wrap_content" />
-        <LinearLayout android:id="@+id/authPanel" android:orientation="vertical"
-            android:layout_width="match_parent" android:layout_height="wrap_content">
-            <Button android:id="@+id/googleSignIn" android:layout_width="match_parent"
-                android:layout_height="wrap_content" android:text="Continue with Google" />
-            <EditText android:id="@+id/email" android:layout_width="match_parent" android:layout_height="wrap_content"
-                android:hint="Email" android:inputType="textEmailAddress" android:autofillHints="emailAddress" />
-            <EditText android:id="@+id/password" android:layout_width="match_parent" android:layout_height="wrap_content"
-                android:hint="Password" android:inputType="textPassword" android:autofillHints="password" android:saveEnabled="false" />
-            <Button android:id="@+id/signIn" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign in" />
-            <Button android:id="@+id/signUp" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Create account" />
+                <TextView
+                    android:id="@+id/status"
+                    style="@style/TurnStatus"
+                    android:accessibilityLiveRegion="polite" />
+
+                <TextView
+                    android:id="@+id/aiStatus"
+                    style="@style/GameDetails" />
+
+                <com.example.connect4.ui.BoardView
+                    android:id="@+id/board"
+                    style="@style/ScreenRow" />
+
+                <TextView
+                    style="@style/BoardHint"
+                    android:text="@string/board_hint" />
+
+                <Button
+                    android:id="@+id/restart"
+                    style="@style/ConnectButton"
+                    android:text="@string/new_game" />
+            </LinearLayout>
+
+            <LinearLayout
+                android:id="@+id/onlinePanel"
+                style="@style/Panel"
+                android:visibility="gone">
+
+                <TextView
+                    android:id="@+id/onlineStatus"
+                    style="@style/OnlineStatus"
+                    android:accessibilityLiveRegion="polite" />
+
+                <LinearLayout
+                    android:id="@+id/authPanel"
+                    style="@style/VerticalStack">
+
+                    <Button
+                        android:id="@+id/googleSignIn"
+                        style="@style/ConnectButton"
+                        android:text="@string/continue_with_google" />
+
+                    <EditText
+                        android:id="@+id/email"
+                        style="@style/GameInput"
+                        android:hint="@string/email_label"
+                        android:inputType="textEmailAddress"
+                        android:autofillHints="emailAddress" />
+
+                    <EditText
+                        android:id="@+id/password"
+                        style="@style/GameInput"
+                        android:hint="@string/password_label"
+                        android:inputType="textPassword"
+                        android:autofillHints="password"
+                        android:saveEnabled="false" />
+
+                    <Button
+                        android:id="@+id/signIn"
+                        style="@style/ConnectButton"
+                        android:text="@string/sign_in" />
+
+                    <Button
+                        android:id="@+id/signUp"
+                        style="@style/QuietButton"
+                        android:text="@string/create_account" />
+                </LinearLayout>
+
+                <LinearLayout
+                    android:id="@+id/lobbyPanel"
+                    style="@style/VerticalStack"
+                    android:visibility="gone">
+
+                    <EditText
+                        android:id="@+id/roomName"
+                        style="@style/GameInput"
+                        android:hint="@string/room_name"
+                        android:maxLength="60"
+                        android:inputType="textCapSentences" />
+
+                    <Button
+                        android:id="@+id/createRoom"
+                        style="@style/ConnectButton"
+                        android:text="@string/create_room" />
+
+                    <LinearLayout
+                        android:id="@+id/rooms"
+                        style="@style/VerticalStack" />
+                </LinearLayout>
+
+                <Button
+                    android:id="@+id/backToLobby"
+                    style="@style/QuietButton"
+                    android:text="@string/return_to_lobby"
+                    android:visibility="gone" />
+
+                <Button
+                    android:id="@+id/retryOnline"
+                    style="@style/QuietButton"
+                    android:text="@string/retry_online" />
+
+                <Button
+                    android:id="@+id/signOut"
+                    style="@style/QuietButton"
+                    android:text="@string/sign_out"
+                    android:visibility="gone" />
+            </LinearLayout>
         </LinearLayout>
-        <LinearLayout android:id="@+id/lobbyPanel" android:orientation="vertical"
-            android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone">
-            <EditText android:id="@+id/roomName" android:layout_width="match_parent" android:layout_height="wrap_content"
-                android:hint="Room name" android:maxLength="60" android:inputType="textCapSentences" />
-            <Button android:id="@+id/createRoom" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Create room" />
-            <LinearLayout android:id="@+id/rooms" android:orientation="vertical"
-                android:layout_width="match_parent" android:layout_height="wrap_content" />
-        </LinearLayout>
-        <Button android:id="@+id/backToLobby" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Return to lobby" />
-        <Button android:id="@+id/retryOnline" android:layout_width="match_parent"
-            android:layout_height="wrap_content" android:text="Reconnect / retry" />
-        <Button android:id="@+id/signOut" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign out" />
-    </LinearLayout>
-    </LinearLayout>
+    </androidx.constraintlayout.widget.ConstraintLayout>
 </ScrollView>

activity_main.xml

app > res > layout-land > activity_main.xml

צרו חלופת layout עם qualifier של Orientation = Landscape ושם זהה activity_main. משתמשים באותם IDs ובאותם טיפוסי Views בשתי החלופות, כדי ש־ActivityMainBinding אחד יתאים לשתיהן. פאנל הגדרות והלוח מסודרים לרוחב.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main"
    android:saveEnabled="false"
    style="@style/GameScreen">

    <ScrollView
        style="@style/LandscapeControls"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/offlineGame"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent">

        <LinearLayout
            style="@style/LandscapeControlContent">

            <Button
                android:id="@+id/restart"
                style="@style/ConnectButton"
                android:text="@string/new_game" />

            <androidx.fragment.app.FragmentContainerView
                android:id="@+id/gameSetup"
                android:name="com.example.connect4.GameSetupFragment"
                style="@style/ScreenRow" />

            <LinearLayout
                android:id="@+id/onlinePanel"
                style="@style/Panel"
                android:visibility="gone">

                <TextView
                    android:id="@+id/onlineStatus"
                    style="@style/OnlineStatus"
                    android:accessibilityLiveRegion="polite" />

                <LinearLayout
                    android:id="@+id/authPanel"
                    style="@style/VerticalStack">

                    <Button
                        android:id="@+id/googleSignIn"
                        style="@style/ConnectButton"
                        android:text="@string/continue_with_google" />

                    <EditText
                        android:id="@+id/email"
                        style="@style/GameInput"
                        android:hint="@string/email_label"
                        android:inputType="textEmailAddress"
                        android:autofillHints="emailAddress" />

                    <EditText
                        android:id="@+id/password"
                        style="@style/GameInput"
                        android:hint="@string/password_label"
                        android:inputType="textPassword"
                        android:autofillHints="password"
                        android:saveEnabled="false" />

                    <Button
                        android:id="@+id/signIn"
                        style="@style/ConnectButton"
                        android:text="@string/sign_in" />

                    <Button
                        android:id="@+id/signUp"
                        style="@style/QuietButton"
                        android:text="@string/create_account" />
                </LinearLayout>

                <LinearLayout
                    android:id="@+id/lobbyPanel"
                    style="@style/VerticalStack"
                    android:visibility="gone">

                    <EditText
                        android:id="@+id/roomName"
                        style="@style/GameInput"
                        android:hint="@string/room_name"
                        android:maxLength="60"
                        android:inputType="textCapSentences" />

                    <Button
                        android:id="@+id/createRoom"
                        style="@style/ConnectButton"
                        android:text="@string/create_room" />

                    <LinearLayout
                        android:id="@+id/rooms"
                        style="@style/VerticalStack" />
                </LinearLayout>

                <Button
                    android:id="@+id/backToLobby"
                    style="@style/QuietButton"
                    android:text="@string/return_to_lobby"
                    android:visibility="gone" />

                <Button
                    android:id="@+id/retryOnline"
                    style="@style/QuietButton"
                    android:text="@string/retry_online" />

                <Button
                    android:id="@+id/signOut"
                    style="@style/QuietButton"
                    android:text="@string/sign_out"
                    android:visibility="gone" />
            </LinearLayout>
        </LinearLayout>
    </ScrollView>

    <LinearLayout
        android:id="@+id/offlineGame"
        style="@style/LandscapeGamePanel"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintWidth_percent="0.64">

        <LinearLayout
            style="@style/LandscapeStatusRow">

            <TextView
                android:id="@+id/status"
                style="@style/LandscapeTurnStatus"
                android:accessibilityLiveRegion="polite" />

            <TextView
                android:id="@+id/aiStatus"
                style="@style/LandscapeGameDetails" />
        </LinearLayout>

        <androidx.constraintlayout.widget.ConstraintLayout
            style="@style/LandscapeBoardArea">

            <com.example.connect4.ui.BoardView
                android:id="@+id/board"
                style="@style/LandscapeBoard"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintDimensionRatio="7:6" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

winner_message.xml

app > res > anim > winner_message.xml

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

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:shareInterpolator="false">
    <scale
        android:duration="550"
        android:fromXScale="0.85"
        android:fromYScale="0.85"
        android:toXScale="1.0"
        android:toYScale="1.0"
        android:pivotX="50%"
        android:pivotY="50%"
        android:interpolator="@android:anim/overshoot_interpolator" />
    <alpha
        android:duration="300"
        android:fromAlpha="0.4"
        android:toAlpha="1.0" />
</set>

BoardView.java

app > kotlin+java > com.example.connect4 > ui > BoardView.java

מוסיפים onMeasure עבור פריסה שבה גובה הלוח הוא wrap_content. גודל התא נשאר המינימום בין רוחב חלקי 7 לגובה חלקי 6 — אותו חישוב לציור ולמגע. מעבירים את הצבעים למשאבים ומוסיפים צל/טבעת. ציור ניצחון עדיין נקבע מתוך winningCells של המודל.

 package com.example.connect4.ui;
 
-import com.example.connect4.core.GameState;
 import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Paint;
 import android.util.AttributeSet;
 import android.view.MotionEvent;
 import android.view.View;
+import com.example.connect4.core.GameState;
+import com.example.connect4.R;
 
-/** Draws a six-by-seven board and reports column taps to its controller. */
+/** Rendering and column hit testing only; GameEngine owns every rule. */
 public final class BoardView extends View {
-    private static final int ROWS = GameState.ROWS;
-    private static final int COLUMNS = GameState.COLUMNS;
+    /** Receives the zero-based column selected by the player. */
+    public interface ColumnListener { void onColumn(int column); }
+    private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
     private GameState state;
+    private ColumnListener listener;
     private boolean inputEnabled;
-    private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
-    private ColumnListener listener;
 
-    /** Receives a zero-based column; the View does not apply game rules. */
-    public interface ColumnListener { void onColumn(int column); }
-
-    /** Called by Android when the custom View is inflated from XML. */
+    /** Constructs the XML-inflated board view and its accessibility description. */
     public BoardView(Context context, AttributeSet attrs) {
         super(context, attrs);
         setContentDescription("Connect Four board. Tap a column to drop a disc.");
     }
 
-    /** Connects the Activity's response to a board tap. */
+    /** Replaces the rendered snapshot and enables or disables touch input. */
+    public void show(GameState state, boolean enabled) {
+        this.state = state;
+        inputEnabled = enabled;
+        invalidate();
+    }
+
+    /** Assigns the controller callback used after a column tap. */
     public void setColumnListener(ColumnListener listener) { this.listener = listener; }
 
-    /** Replaces the snapshot and requests a redraw. */
-    public void show(GameState state, boolean enabled) {
-        inputEnabled = enabled;
-        this.state = state;
-        invalidate();
+    @Override protected void onMeasure(int widthSpec, int heightSpec) {
+        int width = MeasureSpec.getSize(widthSpec);
+        setMeasuredDimension(width, resolveSize(width * GameState.ROWS / GameState.COLUMNS, heightSpec));
     }
 
     @Override protected void onDraw(Canvas canvas) {
         super.onDraw(canvas);
         float size = cellSize();
-        paint.setColor(0xff1565c0);
-        canvas.drawRect(0, 0, size * COLUMNS, size * ROWS, paint);
-        for (int row = 0; row < ROWS; row++) {
-            for (int column = 0; column < COLUMNS; column++) {
-                int player = state == null ? GameState.EMPTY : state.cell(row, column);
-                paint.setColor(player == GameState.RED ? 0xffd32f2f
-                        : player == GameState.YELLOW ? 0xffffc107 : 0xffeeeeee);
-                canvas.drawCircle((column + .5f) * size, (row + .5f) * size,
-                        size * .38f, paint);
+        float radius = size * .32f;
+        paint.setColor(getContext().getColor(R.color.board_edge));
+        canvas.drawRoundRect(0, 0, size * GameState.COLUMNS, size * GameState.ROWS, radius, radius, paint);
+        paint.setColor(getContext().getColor(R.color.board));
+        canvas.drawRoundRect(0, 0, size * GameState.COLUMNS,
+                size * GameState.ROWS - size * .08f, radius, radius, paint);
+        for (int row = 0; row < GameState.ROWS; row++) {
+            for (int col = 0; col < GameState.COLUMNS; col++) {
+                int player = state == null ? GameState.EMPTY : state.cell(row, col);
+                paint.setStyle(Paint.Style.FILL);
+                paint.setColor(getContext().getColor(player == GameState.RED ? R.color.disc_red
+                        : player == GameState.YELLOW ? R.color.disc_yellow : R.color.board_hole));
+                float x = (col + .5f) * size, y = (row + .5f) * size;
+                canvas.drawCircle(x, y, size * .38f, paint);
+                if (player != GameState.EMPTY) {
+                    paint.setStyle(Paint.Style.STROKE);
+                    paint.setStrokeWidth(size * .025f);
+                    paint.setColor(0x40FFFFFF);
+                    canvas.drawCircle(x, y - size * .015f, size * .30f, paint);
+                    paint.setStyle(Paint.Style.FILL);
+                }
                 if (state != null) {
                     for (GameState.Cell cell : state.winningCells()) {
-                        if (cell.row() == row && cell.column() == column) {
+                        if (cell.row() == row && cell.column() == col) {
                             paint.setStyle(Paint.Style.STROKE);
                             paint.setStrokeWidth(size * .07f);
-                            paint.setColor(0xffffffff);
-                            canvas.drawCircle((column + .5f) * size, (row + .5f) * size,
-                                    size * .29f, paint);
+                            paint.setColor(getContext().getColor(R.color.disc_ring));
+                            canvas.drawCircle(x, y, size * .29f, paint);
                             paint.setStyle(Paint.Style.FILL);
                         }
                     }
                 }
             }
         }
     }
 
-    /** Uses the same geometry for drawing and hit testing. */
-    private float cellSize() {
-        return Math.min(getWidth() / (float) COLUMNS, getHeight() / (float) ROWS);
-    }
-
     @Override public boolean onTouchEvent(MotionEvent event) {
         if (!inputEnabled) return false;
         if (event.getAction() == MotionEvent.ACTION_UP) {
             float size = cellSize();
-            float x = event.getX(), y = event.getY();
-            if (size > 0 && x >= 0 && x < size * COLUMNS && y >= 0 && y < size * ROWS) {
+            float x = event.getX();
+            float y = event.getY();
+            // Use the drawn board bounds, even when the View has extra space around it.
+            if (size > 0 && x >= 0 && x < size * GameState.COLUMNS
+                    && y >= 0 && y < size * GameState.ROWS) {
                 performClick();
-                if (listener != null) listener.onColumn((int) (x / size));
+                int column = (int) (x / size);
+                if (listener != null) listener.onColumn(column);
             }
         }
         return true;
     }
 
+    private float cellSize() {
+        return Math.min(getWidth() / (float) GameState.COLUMNS,
+                getHeight() / (float) GameState.ROWS);
+    }
+
     @Override public boolean performClick() { super.performClick(); return true; }
 }

MainActivity.java

app > kotlin+java > com.example.connect4 > MainActivity.java

מפעילים את ערכת היום/לילה של המערכת. במצב אופקי מקומי מסתירים את סרגל הניווט תוך השארת גישה במחווה; ב־online וב־multi-window משתמשים ב־insets הרגילים. אין להסתיר פקדי מערכת בכל מסך בלי תנאי.

New game מבקש אישור רק כאשר יש משחק מקומי פעיל עם מהלכים; אחרי סיום או בלוח ריק אין טעם באישור. ביטול הדיאלוג משאיר את המשחק. winnerMessageVisible מונע הפעלה מחדש של אנימציית הניצחון בכל render. Toast מאשר התחלה חדשה, בעוד מצב המשחק נותר בהודעה הקבועה.

 
 import android.os.Bundle;
 import android.content.Context;
 import android.view.View;
+import android.view.animation.AnimationUtils;
+import android.widget.Toast;
 import androidx.activity.EdgeToEdge;
 import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.appcompat.app.AlertDialog;
 import androidx.core.graphics.Insets;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
+import androidx.core.view.WindowCompat;
+import androidx.core.view.WindowInsetsControllerCompat;
 import androidx.lifecycle.ViewModelProvider;
 import com.example.connect4.core.GameAction;
 import com.example.connect4.core.GameEngine;
 import com.example.connect4.core.GameState;
     private ActivityMainBinding binding;
     private GameSetupFragment setup;
     private GameSession session;
     private boolean started;
+    private boolean winnerMessageVisible;
     private OnlineController online;
     private ModelCatalog modelCatalog;
 
     /** Exposes the activity binding only to the package-local online controller. */
         return binding;
     }
 
     @Override protected void onCreate(Bundle savedInstanceState) {
+        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
         super.onCreate(savedInstanceState);
         EdgeToEdge.enable(this);
         binding = ActivityMainBinding.inflate(getLayoutInflater());
         setContentView(binding.getRoot());
         setup = (GameSetupFragment) getSupportFragmentManager().findFragmentById(R.id.gameSetup);
         ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
-            Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+            int barsType = landscapePlay()
+                    ? WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.captionBar()
+                    : WindowInsetsCompat.Type.systemBars();
+            Insets bars = insets.getInsets(barsType | WindowInsetsCompat.Type.displayCutout());
             v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
             return insets;
         });
         session = new ViewModelProvider(this).get(GameSession.class);
                 render();
             }
         });
         binding.board.setColumnListener(this::playColumn);
-        binding.restart.setOnClickListener(v -> newGame());
+        binding.restart.setOnClickListener(v -> {
+            GameState state = session.engine.snapshot();
+            if (session.mode == GameSession.MODE_ONLINE || state.isTerminal() || state.moves().isEmpty()) {
+                newGameWithFeedback();
+                return;
+            }
+            new AlertDialog.Builder(this)
+                .setTitle(R.string.confirm_new_game_title)
+                .setMessage(R.string.confirm_new_game_message)
+                .setNegativeButton(android.R.string.cancel, null)
+                .setPositiveButton(R.string.new_game, (dialog, which) -> newGameWithFeedback())
+                .show();
+        });
         render();
     }
 
     com.example.connect4.databinding.FragmentGameSetupBinding setupBinding() {
         persist();
         render();
     }
 
+    private void newGameWithFeedback() {
+        newGame();
+        if (session.mode == GameSession.MODE_ONLINE) return;
+        String message;
+        if (session.mode == GameSession.MODE_LOCAL) {
+            message = getString(R.string.new_game_local_turn,
+                    playerName(session.engine.snapshot().currentPlayer()));
+        } else {
+            message = getString(humanTurn() ? R.string.new_game_your_turn : R.string.new_game_wait);
+        }
+        Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
+    }
 
     private boolean humanTurn() {
         GameState state = session.engine.snapshot();
         if (session.mode == GameSession.MODE_ONLINE) return online != null && online.canMove();
         } else {
             int message = session.isComputerThinking() ? R.string.player_thinking : R.string.player_to_play;
             binding.status.setText(getString(message, player));
         }
+        boolean showWinner = state.isTerminal() && !state.isDraw();
+        binding.status.setTextColor(getColor(showWinner ? R.color.accent : R.color.ink));
+        if (showWinner && !winnerMessageVisible) {
+            binding.status.startAnimation(AnimationUtils.loadAnimation(this, R.anim.winner_message));
+        } else if (!showWinner) {
+            binding.status.clearAnimation();
+        }
+        winnerMessageVisible = showWinner;
         if (session.mode == GameSession.MODE_LOCAL) {
             binding.aiStatus.setText(R.string.local_play_status);
         } else if (session.mode == GameSession.MODE_HEURISTIC && !session.hasComputerMoveFailed()) {
             binding.aiStatus.setText(R.string.heuristic_status);
         }
         binding.board.show(state, humanTurn());
         if (online && this.online != null) this.online.render();
         binding.restart.setVisibility(binding.offlineGame.getVisibility());
+        updateSystemBars();
         requestComputerMoveIfNeeded();
     }
 
-
-
+    private boolean landscapePlay() {
+        return getResources().getConfiguration().orientation
+                == android.content.res.Configuration.ORIENTATION_LANDSCAPE
+                && !isInMultiWindowMode() && binding.offlineGame.getVisibility() == View.VISIBLE;
+    }
+
+    private void updateSystemBars() {
+        WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(getWindow(), binding.main);
+        if (landscapePlay()) {
+            controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+            controller.show(WindowInsetsCompat.Type.statusBars());
+            controller.hide(WindowInsetsCompat.Type.navigationBars());
+        } else {
+            controller.show(WindowInsetsCompat.Type.systemBars());
+        }
+        ViewCompat.requestApplyInsets(binding.main);
+    }
+
+    @Override public void onWindowFocusChanged(boolean hasFocus) {
+        super.onWindowFocusChanged(hasFocus);
+        if (hasFocus && binding != null) updateSystemBars();
+    }
 
     private String playerName(int player) {
         return getString(player == GameState.RED ? R.string.player_red : R.string.player_yellow);
     }

OnlineController.java

app > kotlin+java > com.example.connect4 > OnlineController.java

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

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

         } else {
             screen = session.repository.subscribeRoom(session.roomId, new MultiplayerRepository.RoomListener() {
                 @Override public void onRoom(RoomSnapshot room) {
                     if (!active || token != screenGeneration) return;
-                    session.room = room;
-                    host.received(room);
+                    receiveRoom(room);
                 }
                 @Override public void onError(RepositoryError error) { if (token == screenGeneration) showError(error); }
             });
             if (session.connected) {
                 session.repository.getRoom(session.roomId, new MultiplayerRepository.ResultCallback<RoomSnapshot>() {
                     @Override public void onSuccess(RoomSnapshot room) {
                         if (!active || token != screenGeneration || !session.connected) return;
-                        session.room = room;
                         session.reconciled = true;
-                        host.received(room);
+                        receiveRoom(room);
                     }
                     @Override public void onError(RepositoryError error) {
                         if (active && token == screenGeneration) showError(error);
                     }
     }
 
     private void refreshLatestScreen() {
         if (session.refreshOnline != null) session.refreshOnline.run();
+    }
+
+    /** A late one-shot read must not replace a newer append-only listener update. */
+    void receiveRoom(RoomSnapshot incoming) {
+        RoomSnapshot current = session.room;
+        if (current == null || !current.getRoomId().equals(incoming.getRoomId())
+                || incoming.getGameState().moveCount() >= current.getGameState().moveCount()) {
+            session.room = incoming;
+        }
+        host.received(session.room);
     }
 
     private void authenticate(boolean create) {
         if (session.repository == null || session.authPending) return;

החבילה שהמורה מספק

הורדת חבילת פרק 13. פתחו את ה־ZIP בתיקייה זמנית והעבירו רק את קבצי החבילה למיקומים המתאימים בפרויקט. אין בחבילה Activity חלופי או פרויקט שלם. צרו חבילת Java דרך קליק ימני על com.example.connect4 ובחירת New > Package; צרו את תיקיית הנכסים דרך app > New > Folder > Assets Folder אם היא חסרה. את קובצי המודל וה־JSON מעתיקים ל־app > assets. קובצי משאבים נכנסים לסוג המשאב המתאים תחת app > res.

  • ic_launcher_background.xml
  • ic_launcher_foreground.xml
  • ic_launcher_monochrome.xml
  • ic_launcher.xml
  • ic_launcher_round.xml
  • ic_launcher.webp
  • ic_launcher_round.webp
  • ic_launcher.webp
  • ic_launcher_round.webp
  • ic_launcher.webp
  • ic_launcher_round.webp
  • ic_launcher.webp
  • ic_launcher_round.webp
  • ic_launcher.webp
  • ic_launcher_round.webp

מריצים ובודקים

  1. בדקו portrait ו־landscape במשחק מקומי, נגד שני סוגי מחשב ובמסך online. הלוח אינו מסתיר את פקדי המשחק.
  2. החליפו מצב יום/לילה והגדילו טקסט: הכיתובים נשארים קריאים וניתן לגלול לפקדים.
  3. התחילו משחק ולחצו New game: Cancel משאיר את הלוח; אישור מאפס אותו. אחרי ניצחון האיפוס מיידי.
  4. השלימו ניצחון: הטבעות והאנימציה אינן משנות את ההיסטוריה. סובבו — התוצאה נשמרת.
  5. חזרו על משחק רשת בשני מכשירים ובצופה, כולל סיבוב באחד המכשירים. כולם מגיעים לאותה תוצאה.
  6. בדקו פתיחה מחדש לאחר סיום תהליך, ובדקו החלפת מודל בזמן חישוב.

מצב הבדיקות שבוצעו בפועל, כולל Google והפריסה החיה שעדיין דורשים בדיקה נפרדת, מופיע ב־דף המורה.

בדיקת הבנה

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