wack-a-wombat 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. wack_a_wombat-1.0.0/LICENSE +21 -0
  2. wack_a_wombat-1.0.0/PKG-INFO +155 -0
  3. wack_a_wombat-1.0.0/README.md +136 -0
  4. wack_a_wombat-1.0.0/pyproject.toml +33 -0
  5. wack_a_wombat-1.0.0/setup.cfg +4 -0
  6. wack_a_wombat-1.0.0/tests/test_art.py +111 -0
  7. wack_a_wombat-1.0.0/tests/test_engine.py +305 -0
  8. wack_a_wombat-1.0.0/tests/test_model.py +295 -0
  9. wack_a_wombat-1.0.0/tests/test_persistence.py +220 -0
  10. wack_a_wombat-1.0.0/tests/test_render.py +131 -0
  11. wack_a_wombat-1.0.0/tests/test_rules.py +119 -0
  12. wack_a_wombat-1.0.0/tests/test_scenes.py +445 -0
  13. wack_a_wombat-1.0.0/tests/test_smoke_pty.py +86 -0
  14. wack_a_wombat-1.0.0/wack_a_wombat/__init__.py +3 -0
  15. wack_a_wombat-1.0.0/wack_a_wombat/__main__.py +4 -0
  16. wack_a_wombat-1.0.0/wack_a_wombat/app.py +181 -0
  17. wack_a_wombat-1.0.0/wack_a_wombat/art/__init__.py +1 -0
  18. wack_a_wombat-1.0.0/wack_a_wombat/art/banners.py +48 -0
  19. wack_a_wombat-1.0.0/wack_a_wombat/art/blockfont.py +76 -0
  20. wack_a_wombat-1.0.0/wack_a_wombat/art/skin.py +32 -0
  21. wack_a_wombat-1.0.0/wack_a_wombat/art/sprite.py +82 -0
  22. wack_a_wombat-1.0.0/wack_a_wombat/art/wombats.py +230 -0
  23. wack_a_wombat-1.0.0/wack_a_wombat/cli.py +83 -0
  24. wack_a_wombat-1.0.0/wack_a_wombat/engine/__init__.py +1 -0
  25. wack_a_wombat-1.0.0/wack_a_wombat/engine/canvas.py +147 -0
  26. wack_a_wombat-1.0.0/wack_a_wombat/engine/fx.py +144 -0
  27. wack_a_wombat-1.0.0/wack_a_wombat/engine/input.py +69 -0
  28. wack_a_wombat-1.0.0/wack_a_wombat/engine/palette.py +79 -0
  29. wack_a_wombat-1.0.0/wack_a_wombat/engine/scene.py +115 -0
  30. wack_a_wombat-1.0.0/wack_a_wombat/engine/tween.py +97 -0
  31. wack_a_wombat-1.0.0/wack_a_wombat/game/__init__.py +1 -0
  32. wack_a_wombat-1.0.0/wack_a_wombat/game/config.py +71 -0
  33. wack_a_wombat-1.0.0/wack_a_wombat/game/critters.py +49 -0
  34. wack_a_wombat-1.0.0/wack_a_wombat/game/director.py +64 -0
  35. wack_a_wombat-1.0.0/wack_a_wombat/game/model.py +244 -0
  36. wack_a_wombat-1.0.0/wack_a_wombat/game/scoring.py +36 -0
  37. wack_a_wombat-1.0.0/wack_a_wombat/game/stats.py +47 -0
  38. wack_a_wombat-1.0.0/wack_a_wombat/persistence/__init__.py +1 -0
  39. wack_a_wombat-1.0.0/wack_a_wombat/persistence/highscores.py +121 -0
  40. wack_a_wombat-1.0.0/wack_a_wombat/persistence/settings.py +50 -0
  41. wack_a_wombat-1.0.0/wack_a_wombat/persistence/store.py +70 -0
  42. wack_a_wombat-1.0.0/wack_a_wombat/scenes/__init__.py +1 -0
  43. wack_a_wombat-1.0.0/wack_a_wombat/scenes/attract.py +79 -0
  44. wack_a_wombat-1.0.0/wack_a_wombat/scenes/board_view.py +151 -0
  45. wack_a_wombat-1.0.0/wack_a_wombat/scenes/game.py +223 -0
  46. wack_a_wombat-1.0.0/wack_a_wombat/scenes/game_over.py +165 -0
  47. wack_a_wombat-1.0.0/wack_a_wombat/scenes/help.py +73 -0
  48. wack_a_wombat-1.0.0/wack_a_wombat/scenes/highscores.py +81 -0
  49. wack_a_wombat-1.0.0/wack_a_wombat/scenes/name_entry.py +66 -0
  50. wack_a_wombat-1.0.0/wack_a_wombat/scenes/pause.py +56 -0
  51. wack_a_wombat-1.0.0/wack_a_wombat/scenes/title.py +169 -0
  52. wack_a_wombat-1.0.0/wack_a_wombat/scenes/ui.py +53 -0
  53. wack_a_wombat-1.0.0/wack_a_wombat.egg-info/PKG-INFO +155 -0
  54. wack_a_wombat-1.0.0/wack_a_wombat.egg-info/SOURCES.txt +56 -0
  55. wack_a_wombat-1.0.0/wack_a_wombat.egg-info/dependency_links.txt +1 -0
  56. wack_a_wombat-1.0.0/wack_a_wombat.egg-info/entry_points.txt +2 -0
  57. wack_a_wombat-1.0.0/wack_a_wombat.egg-info/requires.txt +3 -0
  58. wack_a_wombat-1.0.0/wack_a_wombat.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The wack-a-wombat authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: wack-a-wombat
3
+ Version: 1.0.0
4
+ Summary: A terminal whack-a-mole starring wombats. WASD to move, SPACE to whack.
5
+ License-Expression: MIT
6
+ Keywords: game,tui,curses,terminal,wombat,whack-a-mole,arcade
7
+ Classifier: Development Status :: 5 - Production/Stable
8
+ Classifier: Environment :: Console :: Curses
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Games/Entertainment :: Arcade
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: windows-curses; sys_platform == "win32"
18
+ Dynamic: license-file
19
+
20
+ # Wack-a-Wombat
21
+
22
+ A terminal whack-a-mole with 100% more wombat. Pure Python 3.9+, no
23
+ dependencies (just the standard library's `curses`; on Windows it pulls in
24
+ `windows-curses`).
25
+
26
+ Play it right now, nothing to install:
27
+
28
+ ```
29
+ uvx wack-a-wombat
30
+ ```
31
+
32
+ or install the `wack-a-wombat` command:
33
+
34
+ ```
35
+ pipx install wack-a-wombat # or: pip install --user wack-a-wombat
36
+ ```
37
+
38
+ From a checkout: `python3 -m wack_a_wombat`.
39
+
40
+ ## How to play
41
+
42
+ Wombats pop out of their burrows. Move the mallet onto one and whack it before it
43
+ ducks back down. One round on the clock; score as much as you can.
44
+
45
+ | Key | Action |
46
+ | --------------------------- | ------------------------------ |
47
+ | `W A S D` / arrows / `HJKL` | move the mallet |
48
+ | `SPACE` / `ENTER` | whack |
49
+ | `P` / `ESC` | pause (resume, end round, menu)|
50
+ | `M` | toggle the terminal bell |
51
+ | `?` | how to play (title screen) |
52
+
53
+ ### Critters
54
+
55
+ | Sprite | What to do | Points |
56
+ | ------------ | ------------------------------ | ------------------------------- |
57
+ | Wombat | whack it | 10 x multiplier (+ speed bonus) |
58
+ | Golden wombat| whack it, fast - it's brief | 50 x multiplier (+ speed bonus) |
59
+ | Sleeping | **leave it alone** | -25 and your streak is gone |
60
+
61
+ - **Streak**: every 5 consecutive hits raise your multiplier, up to x4. A whiff, a
62
+ bonked sleeper or an escaped wombat resets it.
63
+ - **Speed bonus**: up to +10 for hitting a wombat the instant it appears.
64
+ - **Danger**: the final 5 seconds - wombats stay up for less time and come faster.
65
+
66
+ ### Modes
67
+
68
+ | Mode | Grid | Round | Feel |
69
+ | -------- | ---- | ----- | ------------------------------------ |
70
+ | Backyard | 3x3 | 30s | one wombat at a time, gentle ramp |
71
+ | Outback | 4x4 | 45s | up to two at once, more sleepers |
72
+ | Frenzy | 4x4 | 30s | up to three at once, very short gaps |
73
+
74
+ Each mode has its own top-10 leaderboard with name, score, hits, accuracy, best
75
+ streak, fastest whack and date.
76
+
77
+ ### Wombat skins
78
+
79
+ Three hand-drawn wombats, all with the signature big flat nose, small round ears
80
+ and loaf-shaped body. Switch with `WOMBAT < >` on the title screen or `--skin`.
81
+
82
+ | Skin | Style |
83
+ | ---------- | ------------------------------------------------- |
84
+ | `classic` | nose-first: a giant filled nose pad on a flat head |
85
+ | `arcade` | bursting out of a grassy mound, paws on the rim |
86
+ | `lineart` | 90s ASCII-forum line art |
87
+
88
+ The art came from a panel of ASCII artists each drawing in a different style,
89
+ scored by judges on wombat-likeness, how readable each state is mid-game, charm
90
+ and the title showpiece; `classic` won and was refined from the judges' notes.
91
+
92
+ ## Command line
93
+
94
+ ```
95
+ wack-a-wombat [--mode backyard|outback|frenzy] [--seed N] [--no-color] [--fps N]
96
+ [--sound | --no-sound] [--skin NAME] [--data-dir DIR]
97
+ [--list-modes] [--list-skins] [--reset-scores] [--version]
98
+ ```
99
+
100
+ Settings and high scores are JSON files in `~/.config/wack-a-wombat/` (or
101
+ `$XDG_CONFIG_HOME/wack-a-wombat/`, or `$WACK_A_WOMBAT_HOME`). Writes are atomic;
102
+ a corrupt file is moved aside rather than overwritten.
103
+
104
+ `--seed N` makes every round reproducible: the first round after launch always
105
+ gets the same wombats, as does the second, and so on, however long you idle on
106
+ the title screen.
107
+
108
+ Backyard fits a classic 80x24 terminal (the HUD squeezes to two lines below 28
109
+ rows); the 4x4 modes need at least 31 rows, 35 for the full HUD. Anything smaller
110
+ gets a resize prompt and the round pauses until there's room.
111
+
112
+ ## Architecture
113
+
114
+ ```
115
+ wack_a_wombat/
116
+ cli.py argparse entry point
117
+ app.py curses bootstrap, frame loop, scene stack, shared services
118
+ engine/ reusable TUI bits - no game knowledge
119
+ input.py key -> Action mapping (wasd / arrows / hjkl)
120
+ palette.py named attrs; 256 -> 8 colours -> mono degradation
121
+ canvas.py clipped, exception-free drawing over a curses window
122
+ scene.py Scene + SceneStack with transparent overlays
123
+ tween.py clamp/lerp/easing, Timer, Blink, RollingNumber
124
+ fx.py score popups, labels, screen shake, confetti
125
+ game/ pure game logic - no curses, deterministic given a seed
126
+ config.py Mode definitions (grid, timing, difficulty curve)
127
+ critters.py CritterKind, Phase, Critter
128
+ director.py difficulty ramp: up-time, gaps, kind mix, simultaneous cap
129
+ scoring.py points, multipliers, speed bonus
130
+ stats.py per-round statistics
131
+ model.py Round: the state machine, emits events
132
+ art/ Sprite type, wombat skins, block-letter banners, block font
133
+ persistence/ atomic JSON store, per-mode leaderboard, settings
134
+ scenes/ title (+attract demo), game, pause, game over, name entry,
135
+ high scores, help; board_view draws and colours the grid,
136
+ ui draws the shadowed banners
137
+ tests/ unit tests + headless end-to-end via a fake curses window
138
+ tools/preview.py render every screen at any terminal size, as text or colour HTML
139
+ ```
140
+
141
+ The `Round` in `game/model.py` is the heart: feed it `update(dt)`, `move()` and
142
+ `whack()`, drain its events, and it never touches the screen. Scenes translate
143
+ events into effects. That split is what makes the game fully testable without a
144
+ terminal - `tests/test_scenes.py` plays whole rounds through the real scenes into
145
+ a fake window.
146
+
147
+ ## Development
148
+
149
+ ```
150
+ python3 -m unittest discover -s tests -v # everything, incl. a ~10s pty smoke test
151
+ WOMBAT_SKIP_PTY=1 python3 -m unittest discover -s tests
152
+ python3 tools/preview.py 40 100 # eyeball every screen as text
153
+ python3 tools/preview.py --html shots/all.html --skin arcade # in colour
154
+ python3 -m wack_a_wombat --seed 1 # deterministic wombats
155
+ ```
@@ -0,0 +1,136 @@
1
+ # Wack-a-Wombat
2
+
3
+ A terminal whack-a-mole with 100% more wombat. Pure Python 3.9+, no
4
+ dependencies (just the standard library's `curses`; on Windows it pulls in
5
+ `windows-curses`).
6
+
7
+ Play it right now, nothing to install:
8
+
9
+ ```
10
+ uvx wack-a-wombat
11
+ ```
12
+
13
+ or install the `wack-a-wombat` command:
14
+
15
+ ```
16
+ pipx install wack-a-wombat # or: pip install --user wack-a-wombat
17
+ ```
18
+
19
+ From a checkout: `python3 -m wack_a_wombat`.
20
+
21
+ ## How to play
22
+
23
+ Wombats pop out of their burrows. Move the mallet onto one and whack it before it
24
+ ducks back down. One round on the clock; score as much as you can.
25
+
26
+ | Key | Action |
27
+ | --------------------------- | ------------------------------ |
28
+ | `W A S D` / arrows / `HJKL` | move the mallet |
29
+ | `SPACE` / `ENTER` | whack |
30
+ | `P` / `ESC` | pause (resume, end round, menu)|
31
+ | `M` | toggle the terminal bell |
32
+ | `?` | how to play (title screen) |
33
+
34
+ ### Critters
35
+
36
+ | Sprite | What to do | Points |
37
+ | ------------ | ------------------------------ | ------------------------------- |
38
+ | Wombat | whack it | 10 x multiplier (+ speed bonus) |
39
+ | Golden wombat| whack it, fast - it's brief | 50 x multiplier (+ speed bonus) |
40
+ | Sleeping | **leave it alone** | -25 and your streak is gone |
41
+
42
+ - **Streak**: every 5 consecutive hits raise your multiplier, up to x4. A whiff, a
43
+ bonked sleeper or an escaped wombat resets it.
44
+ - **Speed bonus**: up to +10 for hitting a wombat the instant it appears.
45
+ - **Danger**: the final 5 seconds - wombats stay up for less time and come faster.
46
+
47
+ ### Modes
48
+
49
+ | Mode | Grid | Round | Feel |
50
+ | -------- | ---- | ----- | ------------------------------------ |
51
+ | Backyard | 3x3 | 30s | one wombat at a time, gentle ramp |
52
+ | Outback | 4x4 | 45s | up to two at once, more sleepers |
53
+ | Frenzy | 4x4 | 30s | up to three at once, very short gaps |
54
+
55
+ Each mode has its own top-10 leaderboard with name, score, hits, accuracy, best
56
+ streak, fastest whack and date.
57
+
58
+ ### Wombat skins
59
+
60
+ Three hand-drawn wombats, all with the signature big flat nose, small round ears
61
+ and loaf-shaped body. Switch with `WOMBAT < >` on the title screen or `--skin`.
62
+
63
+ | Skin | Style |
64
+ | ---------- | ------------------------------------------------- |
65
+ | `classic` | nose-first: a giant filled nose pad on a flat head |
66
+ | `arcade` | bursting out of a grassy mound, paws on the rim |
67
+ | `lineart` | 90s ASCII-forum line art |
68
+
69
+ The art came from a panel of ASCII artists each drawing in a different style,
70
+ scored by judges on wombat-likeness, how readable each state is mid-game, charm
71
+ and the title showpiece; `classic` won and was refined from the judges' notes.
72
+
73
+ ## Command line
74
+
75
+ ```
76
+ wack-a-wombat [--mode backyard|outback|frenzy] [--seed N] [--no-color] [--fps N]
77
+ [--sound | --no-sound] [--skin NAME] [--data-dir DIR]
78
+ [--list-modes] [--list-skins] [--reset-scores] [--version]
79
+ ```
80
+
81
+ Settings and high scores are JSON files in `~/.config/wack-a-wombat/` (or
82
+ `$XDG_CONFIG_HOME/wack-a-wombat/`, or `$WACK_A_WOMBAT_HOME`). Writes are atomic;
83
+ a corrupt file is moved aside rather than overwritten.
84
+
85
+ `--seed N` makes every round reproducible: the first round after launch always
86
+ gets the same wombats, as does the second, and so on, however long you idle on
87
+ the title screen.
88
+
89
+ Backyard fits a classic 80x24 terminal (the HUD squeezes to two lines below 28
90
+ rows); the 4x4 modes need at least 31 rows, 35 for the full HUD. Anything smaller
91
+ gets a resize prompt and the round pauses until there's room.
92
+
93
+ ## Architecture
94
+
95
+ ```
96
+ wack_a_wombat/
97
+ cli.py argparse entry point
98
+ app.py curses bootstrap, frame loop, scene stack, shared services
99
+ engine/ reusable TUI bits - no game knowledge
100
+ input.py key -> Action mapping (wasd / arrows / hjkl)
101
+ palette.py named attrs; 256 -> 8 colours -> mono degradation
102
+ canvas.py clipped, exception-free drawing over a curses window
103
+ scene.py Scene + SceneStack with transparent overlays
104
+ tween.py clamp/lerp/easing, Timer, Blink, RollingNumber
105
+ fx.py score popups, labels, screen shake, confetti
106
+ game/ pure game logic - no curses, deterministic given a seed
107
+ config.py Mode definitions (grid, timing, difficulty curve)
108
+ critters.py CritterKind, Phase, Critter
109
+ director.py difficulty ramp: up-time, gaps, kind mix, simultaneous cap
110
+ scoring.py points, multipliers, speed bonus
111
+ stats.py per-round statistics
112
+ model.py Round: the state machine, emits events
113
+ art/ Sprite type, wombat skins, block-letter banners, block font
114
+ persistence/ atomic JSON store, per-mode leaderboard, settings
115
+ scenes/ title (+attract demo), game, pause, game over, name entry,
116
+ high scores, help; board_view draws and colours the grid,
117
+ ui draws the shadowed banners
118
+ tests/ unit tests + headless end-to-end via a fake curses window
119
+ tools/preview.py render every screen at any terminal size, as text or colour HTML
120
+ ```
121
+
122
+ The `Round` in `game/model.py` is the heart: feed it `update(dt)`, `move()` and
123
+ `whack()`, drain its events, and it never touches the screen. Scenes translate
124
+ events into effects. That split is what makes the game fully testable without a
125
+ terminal - `tests/test_scenes.py` plays whole rounds through the real scenes into
126
+ a fake window.
127
+
128
+ ## Development
129
+
130
+ ```
131
+ python3 -m unittest discover -s tests -v # everything, incl. a ~10s pty smoke test
132
+ WOMBAT_SKIP_PTY=1 python3 -m unittest discover -s tests
133
+ python3 tools/preview.py 40 100 # eyeball every screen as text
134
+ python3 tools/preview.py --html shots/all.html --skin arcade # in colour
135
+ python3 -m wack_a_wombat --seed 1 # deterministic wombats
136
+ ```
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wack-a-wombat"
7
+ dynamic = ["version"] # from wack_a_wombat/__init__.py
8
+ description = "A terminal whack-a-mole starring wombats. WASD to move, SPACE to whack."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["game", "tui", "curses", "terminal", "wombat", "whack-a-mole", "arcade"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Console :: Curses",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Games/Entertainment :: Arcade",
22
+ ]
23
+ # curses ships with Python everywhere except Windows
24
+ dependencies = ["windows-curses; sys_platform == 'win32'"]
25
+
26
+ [project.scripts]
27
+ wack-a-wombat = "wack_a_wombat.cli:main"
28
+
29
+ [tool.setuptools.dynamic]
30
+ version = { attr = "wack_a_wombat.__version__" }
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["wack_a_wombat*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,111 @@
1
+ import unittest
2
+
3
+ from wack_a_wombat.art import banners, blockfont
4
+ from wack_a_wombat.art.skin import CELL_SPRITE_NAMES
5
+ from wack_a_wombat.art.sprite import CELL_H, CELL_W, TITLE_MAX_H, TITLE_MAX_W, Sprite, is_ascii_row
6
+ from wack_a_wombat.art.wombats import DEFAULT_SKIN, SKINS, get_skin, next_skin, skin_keys
7
+
8
+
9
+ class SpriteType(unittest.TestCase):
10
+ def test_make_pads_and_anchors_bottom(self):
11
+ s = Sprite.make(["ab", "c"], width=4, height=3)
12
+ self.assertEqual(s.rows, (" ", "ab ", "c "))
13
+ self.assertEqual((s.h, s.w), (3, 4))
14
+
15
+ def test_make_anchor_top(self):
16
+ s = Sprite.make(["ab"], width=2, height=2, anchor="top")
17
+ self.assertEqual(s.rows, ("ab", " "))
18
+
19
+ def test_make_rejects_non_ascii_and_overflow(self):
20
+ with self.assertRaises(ValueError):
21
+ Sprite.make(["café"])
22
+ with self.assertRaises(ValueError):
23
+ Sprite.make(["toolong"], width=3)
24
+ with self.assertRaises(ValueError):
25
+ Sprite.make(["a", "b", "c"], height=2)
26
+ with self.assertRaises(ValueError):
27
+ Sprite.make(["tab\there"])
28
+
29
+ def test_shift_and_hide(self):
30
+ s = Sprite.make(["ab", "cd", "ef"])
31
+ self.assertEqual(s.shifted_down(1).rows, (" ", "ab", "cd"))
32
+ self.assertEqual(s.shifted_down(0), s)
33
+ self.assertEqual(s.shifted_down(9).rows, (" ", " ", " "))
34
+ self.assertEqual(Sprite.make(["zZa"]).hide("zZ").rows, (" a",))
35
+ self.assertEqual(Sprite.make(["ab"]).translate(str.upper).rows, ("AB",))
36
+
37
+
38
+ class Skins(unittest.TestCase):
39
+ def test_registry(self):
40
+ self.assertIn(DEFAULT_SKIN, SKINS)
41
+ self.assertIs(get_skin("does-not-exist"), SKINS[DEFAULT_SKIN])
42
+ keys = skin_keys()
43
+ self.assertEqual(next_skin(keys[-1]), keys[0])
44
+ self.assertEqual(next_skin("bogus"), keys[1 % len(keys)])
45
+
46
+ def test_every_cell_sprite_is_exactly_cell_sized_ascii(self):
47
+ for skin in SKINS.values():
48
+ for name in CELL_SPRITE_NAMES:
49
+ sprite = getattr(skin, name)
50
+ self.assertEqual(sprite.h, CELL_H, f"{skin.key}.{name}")
51
+ for row in sprite.rows:
52
+ self.assertEqual(len(row), CELL_W, f"{skin.key}.{name}: {row!r}")
53
+ self.assertTrue(is_ascii_row(row), f"{skin.key}.{name}: {row!r}")
54
+ self.assertLessEqual(skin.title.h, TITLE_MAX_H)
55
+ self.assertLessEqual(skin.title.w, TITLE_MAX_W)
56
+ self.assertTrue(all(is_ascii_row(r) for r in skin.title.rows))
57
+
58
+ def test_states_differ_and_burrow_is_empty_on_top(self):
59
+ for skin in SKINS.values():
60
+ self.assertNotEqual(skin.up.rows, skin.sleeping.rows, skin.key)
61
+ self.assertNotEqual(skin.up.rows, skin.golden.rows, skin.key)
62
+ self.assertNotEqual(skin.up.rows, skin.bonked.rows, skin.key)
63
+ self.assertNotEqual(skin.up.rows, skin.peek.rows, skin.key)
64
+ self.assertNotEqual(skin.up.rows, skin.burrow.rows, skin.key)
65
+ # a sleeping wombat needs z's, a burrow has nothing above the rim
66
+ self.assertTrue(any(ch in "zZ" for row in skin.sleeping.rows for ch in row), skin.key)
67
+ self.assertEqual(skin.burrow.rows[0].strip(), "", skin.key)
68
+
69
+ def test_cell_sprites_share_a_rim_with_the_burrow(self):
70
+ """The bottom row of every wombat state should mostly coincide with the burrow's,
71
+ so states swap in place without the hole jumping around."""
72
+ for skin in SKINS.values():
73
+ rim = skin.burrow.rows[-1]
74
+ for name in ("peek", "up", "bonked", "sleeping", "golden"):
75
+ row = getattr(skin, name).rows[-1]
76
+ shared = sum(1 for a, b in zip(rim, row) if a == b and a != " ")
77
+ self.assertGreaterEqual(shared, CELL_W // 3, f"{skin.key}.{name} rim: {row!r} vs {rim!r}")
78
+
79
+
80
+ class Banners(unittest.TestCase):
81
+ def test_banners_are_ascii_and_fit_80_columns(self):
82
+ for name in ("TITLE", "GAME_OVER", "HIGH_SCORES", "PAUSED"):
83
+ banner = getattr(banners, name)
84
+ self.assertIsInstance(banner, Sprite)
85
+ self.assertGreater(banner.h, 0)
86
+ self.assertLessEqual(banner.w, 78, name)
87
+ self.assertTrue(all(is_ascii_row(r) for r in banner.rows), name)
88
+ self.assertTrue(all(len(r) == banner.w for r in banner.rows), name)
89
+ self.assertLessEqual(banners.PAUSED.w, 44)
90
+
91
+
92
+ class BlockFont(unittest.TestCase):
93
+ def test_all_glyphs_are_five_rows_and_rectangular(self):
94
+ for ch, rows in blockfont.GLYPHS.items():
95
+ self.assertEqual(len(rows), blockfont.GLYPH_H, ch)
96
+ self.assertEqual(len({len(r) for r in rows}), 1, ch)
97
+ self.assertTrue(all(set(r) <= {"#", " "} for r in rows), ch)
98
+
99
+ def test_render_covers_what_the_game_prints(self):
100
+ for text in ("3", "2", "1", "GO!", "TIME'S UP!", "ROUND OVER", "0123456789"):
101
+ rows = blockfont.render(text)
102
+ self.assertEqual(len(rows), blockfont.GLYPH_H)
103
+ self.assertEqual(len({len(r) for r in rows}), 1)
104
+ self.assertEqual(len(rows[0]), blockfont.width(text))
105
+ self.assertEqual(blockfont.width(""), 0)
106
+ self.assertEqual(blockfont.glyph("~"), blockfont.GLYPHS["?"])
107
+ self.assertEqual(blockfont.render("a"), blockfont.render("A"))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()