lazy-bob 0.1.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.
- lazy_bob-0.1.0/LICENSE +21 -0
- lazy_bob-0.1.0/PKG-INFO +70 -0
- lazy_bob-0.1.0/README.md +48 -0
- lazy_bob-0.1.0/pyproject.toml +36 -0
- lazy_bob-0.1.0/setup.cfg +4 -0
- lazy_bob-0.1.0/src/lazy_bob/__init__.py +3 -0
- lazy_bob-0.1.0/src/lazy_bob/__main__.py +5 -0
- lazy_bob-0.1.0/src/lazy_bob/game.py +462 -0
- lazy_bob-0.1.0/src/lazy_bob.egg-info/PKG-INFO +70 -0
- lazy_bob-0.1.0/src/lazy_bob.egg-info/SOURCES.txt +12 -0
- lazy_bob-0.1.0/src/lazy_bob.egg-info/dependency_links.txt +1 -0
- lazy_bob-0.1.0/src/lazy_bob.egg-info/entry_points.txt +2 -0
- lazy_bob-0.1.0/src/lazy_bob.egg-info/top_level.txt +1 -0
- lazy_bob-0.1.0/tests/test_game.py +82 -0
lazy_bob-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anant Dhavale
|
|
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.
|
lazy_bob-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lazy-bob
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny terminal runner where Lazy Bob avoids life's hurdles with one reluctant jump.
|
|
5
|
+
Author: Anant
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: game,ascii,terminal,runner,pypi
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console :: Curses
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Games/Entertainment :: Arcade
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Lazy Bob
|
|
24
|
+
|
|
25
|
+
`Lazy Bob` is a tiny terminal game for people who appreciate low-effort heroics.
|
|
26
|
+
|
|
27
|
+
Bob is too laid back to run. He only jumps when absolutely necessary.
|
|
28
|
+
Your job is to hit `space` just in time so he avoids the next hurdle.
|
|
29
|
+
|
|
30
|
+
But you see, Bob is a philosopher too. Do not forget to read his takes on life ( known as Bob-isms) as he lazilty jumps through the hurdles of life !
|
|
31
|
+
|
|
32
|
+
Everything is intentionally bare-bones:
|
|
33
|
+
|
|
34
|
+
- terminal only
|
|
35
|
+
- ASCII only
|
|
36
|
+
- one button that matters
|
|
37
|
+
- no external dependencies
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install lazy-bob
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Run
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
lazy-bob
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Controls
|
|
58
|
+
|
|
59
|
+
- `space`: jump
|
|
60
|
+
- `r`: restart after a crash
|
|
61
|
+
- `q`: quit
|
|
62
|
+
|
|
63
|
+
## Notes
|
|
64
|
+
|
|
65
|
+
- The game uses Python's built-in `curses` module, so it works best on macOS and Linux terminals.
|
|
66
|
+
- Bob's best score is saved locally in `~/.lazy_bob_score`.
|
|
67
|
+
|
|
68
|
+
Drop me a line if you like Lazy Bob at anantdhavale@gmail.com.
|
|
69
|
+
|
|
70
|
+
Copyright for the Lazy Bob character and Bob-isms © Anant Dhavale.
|
lazy_bob-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Lazy Bob
|
|
2
|
+
|
|
3
|
+
`Lazy Bob` is a tiny terminal game for people who appreciate low-effort heroics.
|
|
4
|
+
|
|
5
|
+
Bob is too laid back to run. He only jumps when absolutely necessary.
|
|
6
|
+
Your job is to hit `space` just in time so he avoids the next hurdle.
|
|
7
|
+
|
|
8
|
+
But you see, Bob is a philosopher too. Do not forget to read his takes on life ( known as Bob-isms) as he lazilty jumps through the hurdles of life !
|
|
9
|
+
|
|
10
|
+
Everything is intentionally bare-bones:
|
|
11
|
+
|
|
12
|
+
- terminal only
|
|
13
|
+
- ASCII only
|
|
14
|
+
- one button that matters
|
|
15
|
+
- no external dependencies
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install lazy-bob
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For local development:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Run
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
lazy-bob
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Controls
|
|
36
|
+
|
|
37
|
+
- `space`: jump
|
|
38
|
+
- `r`: restart after a crash
|
|
39
|
+
- `q`: quit
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
|
|
43
|
+
- The game uses Python's built-in `curses` module, so it works best on macOS and Linux terminals.
|
|
44
|
+
- Bob's best score is saved locally in `~/.lazy_bob_score`.
|
|
45
|
+
|
|
46
|
+
Drop me a line if you like Lazy Bob at anantdhavale@gmail.com.
|
|
47
|
+
|
|
48
|
+
Copyright for the Lazy Bob character and Bob-isms © Anant Dhavale.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lazy-bob"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A tiny terminal runner where Lazy Bob avoids life's hurdles with one reluctant jump."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Anant" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["game", "ascii", "terminal", "runner", "pypi"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console :: Curses",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Games/Entertainment :: Arcade"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
lazy-bob = "lazy_bob:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
package-dir = { "" = "src" }
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
lazy_bob-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
FRAME_TIME = 0.05
|
|
10
|
+
GRAVITY = 0.42
|
|
11
|
+
JUMP_VELOCITY = -2.7
|
|
12
|
+
BASE_SPEED = 1.0
|
|
13
|
+
MAX_SPEED = 2.8
|
|
14
|
+
SCORE_FILE = Path.home() / ".lazy_bob_score"
|
|
15
|
+
MIN_WIDTH = 44
|
|
16
|
+
MIN_HEIGHT = 16
|
|
17
|
+
JUMP_EXCLAMATION_TICKS = 10
|
|
18
|
+
BOBISM_TICKS = 26
|
|
19
|
+
|
|
20
|
+
BOB_SPRITES: dict[str, list[str]] = {
|
|
21
|
+
"idle_a": [
|
|
22
|
+
" o ",
|
|
23
|
+
"/|_",
|
|
24
|
+
"/ \\",
|
|
25
|
+
],
|
|
26
|
+
"idle_b": [
|
|
27
|
+
" o ",
|
|
28
|
+
"/|_",
|
|
29
|
+
"/| ",
|
|
30
|
+
],
|
|
31
|
+
"crouch": [
|
|
32
|
+
" o ",
|
|
33
|
+
"/|_",
|
|
34
|
+
"_/ ",
|
|
35
|
+
],
|
|
36
|
+
"jump": [
|
|
37
|
+
" o ",
|
|
38
|
+
"_|\\",
|
|
39
|
+
" /\\",
|
|
40
|
+
],
|
|
41
|
+
"land": [
|
|
42
|
+
" o ",
|
|
43
|
+
"/|_",
|
|
44
|
+
"_\\\\",
|
|
45
|
+
],
|
|
46
|
+
"bonk": [
|
|
47
|
+
" x ",
|
|
48
|
+
"_|_",
|
|
49
|
+
"/ \\",
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
OBSTACLE_SHAPES: list[list[str]] = [
|
|
54
|
+
["|"],
|
|
55
|
+
["#", "#"],
|
|
56
|
+
["##", "##"],
|
|
57
|
+
["/|", "##"],
|
|
58
|
+
["[]"],
|
|
59
|
+
["|/", "##"],
|
|
60
|
+
["&&"],
|
|
61
|
+
["&&", "&&"],
|
|
62
|
+
["&&&"],
|
|
63
|
+
["& &"],
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
JUMP_EXCLAMATIONS_CASUAL = [
|
|
67
|
+
"drab",
|
|
68
|
+
"yawn",
|
|
69
|
+
"hmmm",
|
|
70
|
+
"meh",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
JUMP_EXCLAMATIONS_URGENT = [
|
|
74
|
+
"oof",
|
|
75
|
+
"ugh",
|
|
76
|
+
"hrmph",
|
|
77
|
+
"barely",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
JUMP_EXCLAMATIONS_FAST = [
|
|
81
|
+
"whoa",
|
|
82
|
+
"still fine",
|
|
83
|
+
"too much",
|
|
84
|
+
"why run",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
BOBISMS = [
|
|
88
|
+
"Lazies shall inherit the Earth.",
|
|
89
|
+
"Don't try too hard.",
|
|
90
|
+
"Don't sweat. Catch some z's.",
|
|
91
|
+
"Quit making others rich by working too hard.",
|
|
92
|
+
"Living is also chilling.",
|
|
93
|
+
"See, I got through.",
|
|
94
|
+
"Thinkers don't do nothing.",
|
|
95
|
+
"Your job is not your life.",
|
|
96
|
+
"Breathe. Live.",
|
|
97
|
+
"Don't forget livin.",
|
|
98
|
+
"Ease up, Fred!",
|
|
99
|
+
"By doing nothing, you save energy.",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Obstacle:
|
|
105
|
+
x: float
|
|
106
|
+
shape: list[str] = field(default_factory=lambda: ["|"])
|
|
107
|
+
passed: bool = False
|
|
108
|
+
previous_x: float | None = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def height(self) -> int:
|
|
112
|
+
return len(self.shape)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def width(self) -> int:
|
|
116
|
+
return max((len(row) for row in self.shape), default=1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class GameState:
|
|
121
|
+
width: int
|
|
122
|
+
height: int
|
|
123
|
+
bob_x: int
|
|
124
|
+
ground_y: int
|
|
125
|
+
bob_y: float
|
|
126
|
+
bob_velocity: float = 0.0
|
|
127
|
+
score: int = 0
|
|
128
|
+
best_score: int = 0
|
|
129
|
+
ticks: int = 0
|
|
130
|
+
speed: float = BASE_SPEED
|
|
131
|
+
spawn_cooldown: int = 0
|
|
132
|
+
alive: bool = True
|
|
133
|
+
obstacles: list[Obstacle] = field(default_factory=list)
|
|
134
|
+
crouch_ticks: int = 0
|
|
135
|
+
landing_ticks: int = 0
|
|
136
|
+
bonk_ticks: int = 0
|
|
137
|
+
jump_exclamation: str = ""
|
|
138
|
+
jump_exclamation_ticks: int = 0
|
|
139
|
+
bobism: str = ""
|
|
140
|
+
bobism_ticks: int = 0
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def bob_row(self) -> int:
|
|
144
|
+
return round(self.bob_y)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def on_ground(self) -> bool:
|
|
148
|
+
return self.bob_row >= self.ground_y
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_best_score() -> int:
|
|
152
|
+
try:
|
|
153
|
+
return int(SCORE_FILE.read_text(encoding="utf-8").strip() or "0")
|
|
154
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def save_best_score(score: int) -> None:
|
|
159
|
+
try:
|
|
160
|
+
SCORE_FILE.write_text(str(score), encoding="utf-8")
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_state(width: int, height: int, best_score: int | None = None) -> GameState:
|
|
166
|
+
best = load_best_score() if best_score is None else best_score
|
|
167
|
+
ground_y = height - 2
|
|
168
|
+
bob_x = max(4, width // 6)
|
|
169
|
+
return GameState(
|
|
170
|
+
width=width,
|
|
171
|
+
height=height,
|
|
172
|
+
bob_x=bob_x,
|
|
173
|
+
ground_y=ground_y,
|
|
174
|
+
bob_y=float(ground_y),
|
|
175
|
+
best_score=best,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def nearest_obstacle_distance(state: GameState) -> float | None:
|
|
180
|
+
ahead_distances = [
|
|
181
|
+
obstacle.x - (state.bob_x + 2)
|
|
182
|
+
for obstacle in state.obstacles
|
|
183
|
+
if obstacle.x + obstacle.width - 1 >= state.bob_x
|
|
184
|
+
]
|
|
185
|
+
return min(ahead_distances) if ahead_distances else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def pick_jump_exclamation(state: GameState, rng: random.Random) -> str:
|
|
189
|
+
nearest = nearest_obstacle_distance(state)
|
|
190
|
+
if state.speed >= 2.1:
|
|
191
|
+
pool = JUMP_EXCLAMATIONS_FAST
|
|
192
|
+
elif nearest is not None and nearest < 8:
|
|
193
|
+
pool = JUMP_EXCLAMATIONS_URGENT
|
|
194
|
+
else:
|
|
195
|
+
pool = JUMP_EXCLAMATIONS_CASUAL
|
|
196
|
+
return rng.choice(pool)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_bobism(state: GameState, rng: random.Random) -> None:
|
|
200
|
+
state.bobism = rng.choice(BOBISMS)
|
|
201
|
+
state.bobism_ticks = BOBISM_TICKS
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def maybe_set_bobism(state: GameState, rng: random.Random) -> None:
|
|
205
|
+
should_crack = state.score > 0 and (state.score % 5 == 0 or rng.random() < 0.18)
|
|
206
|
+
if should_crack:
|
|
207
|
+
set_bobism(state, rng)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def jump(state: GameState, rng: random.Random | None = None) -> None:
|
|
211
|
+
if state.on_ground and state.alive:
|
|
212
|
+
state.crouch_ticks = 2
|
|
213
|
+
state.landing_ticks = 0
|
|
214
|
+
state.bob_velocity = JUMP_VELOCITY
|
|
215
|
+
picker = rng if rng is not None else random
|
|
216
|
+
state.jump_exclamation = pick_jump_exclamation(state, picker)
|
|
217
|
+
state.jump_exclamation_ticks = JUMP_EXCLAMATION_TICKS
|
|
218
|
+
set_bobism(state, picker)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def choose_obstacle_shape(rng: random.Random, score: int) -> list[str]:
|
|
222
|
+
shape_limit = min(len(OBSTACLE_SHAPES), 3 + score // 3)
|
|
223
|
+
return list(rng.choice(OBSTACLE_SHAPES[:shape_limit]))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def maybe_spawn_obstacle(state: GameState, rng: random.Random) -> None:
|
|
227
|
+
if state.spawn_cooldown > 0:
|
|
228
|
+
state.spawn_cooldown -= 1
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
spawn_chance = min(0.1 + state.speed * 0.02, 0.22)
|
|
232
|
+
if rng.random() < spawn_chance:
|
|
233
|
+
shape = choose_obstacle_shape(rng, state.score)
|
|
234
|
+
state.obstacles.append(Obstacle(x=float(state.width - 2), shape=shape))
|
|
235
|
+
state.spawn_cooldown = rng.randint(10, 18)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def move_bob(state: GameState) -> None:
|
|
239
|
+
was_on_ground = state.on_ground
|
|
240
|
+
state.bob_velocity += GRAVITY
|
|
241
|
+
state.bob_y += state.bob_velocity
|
|
242
|
+
if state.bob_y >= state.ground_y:
|
|
243
|
+
state.bob_y = float(state.ground_y)
|
|
244
|
+
if not was_on_ground:
|
|
245
|
+
state.landing_ticks = 2
|
|
246
|
+
state.bob_velocity = 0.0
|
|
247
|
+
|
|
248
|
+
if state.crouch_ticks > 0:
|
|
249
|
+
state.crouch_ticks -= 1
|
|
250
|
+
if state.landing_ticks > 0 and state.on_ground:
|
|
251
|
+
state.landing_ticks -= 1
|
|
252
|
+
if state.bonk_ticks > 0:
|
|
253
|
+
state.bonk_ticks -= 1
|
|
254
|
+
if state.jump_exclamation_ticks > 0:
|
|
255
|
+
state.jump_exclamation_ticks -= 1
|
|
256
|
+
elif state.jump_exclamation:
|
|
257
|
+
state.jump_exclamation = ""
|
|
258
|
+
if state.bobism_ticks > 0:
|
|
259
|
+
state.bobism_ticks -= 1
|
|
260
|
+
elif state.bobism:
|
|
261
|
+
state.bobism = ""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def move_obstacles(state: GameState, rng: random.Random) -> None:
|
|
265
|
+
survivors: list[Obstacle] = []
|
|
266
|
+
for obstacle in state.obstacles:
|
|
267
|
+
obstacle.previous_x = obstacle.x
|
|
268
|
+
obstacle.x -= state.speed
|
|
269
|
+
if not obstacle.passed and obstacle.x + obstacle.width - 1 < state.bob_x:
|
|
270
|
+
obstacle.passed = True
|
|
271
|
+
state.score += 1
|
|
272
|
+
if state.score > state.best_score:
|
|
273
|
+
state.best_score = state.score
|
|
274
|
+
save_best_score(state.best_score)
|
|
275
|
+
maybe_set_bobism(state, rng)
|
|
276
|
+
if obstacle.x + obstacle.width > -1:
|
|
277
|
+
survivors.append(obstacle)
|
|
278
|
+
state.obstacles = survivors
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def detect_collision(state: GameState) -> bool:
|
|
282
|
+
bob_left = state.bob_x
|
|
283
|
+
bob_right = state.bob_x + 2
|
|
284
|
+
bob_top = state.bob_row - 2
|
|
285
|
+
bob_bottom = state.bob_row
|
|
286
|
+
|
|
287
|
+
for obstacle in state.obstacles:
|
|
288
|
+
previous_x = obstacle.previous_x if obstacle.previous_x is not None else obstacle.x
|
|
289
|
+
left_edge = min(previous_x, obstacle.x)
|
|
290
|
+
right_edge = max(previous_x, obstacle.x) + obstacle.width - 1
|
|
291
|
+
obstacle_top = state.ground_y - obstacle.height + 1
|
|
292
|
+
obstacle_bottom = state.ground_y
|
|
293
|
+
|
|
294
|
+
x_overlap = not (right_edge < bob_left or left_edge > bob_right)
|
|
295
|
+
y_overlap = not (obstacle_bottom < bob_top or obstacle_top > bob_bottom)
|
|
296
|
+
if not x_overlap or not y_overlap:
|
|
297
|
+
continue
|
|
298
|
+
return True
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def step(state: GameState, rng: random.Random) -> None:
|
|
303
|
+
if not state.alive:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
state.ticks += 1
|
|
307
|
+
state.speed = min(BASE_SPEED + state.score * 0.05, MAX_SPEED)
|
|
308
|
+
maybe_spawn_obstacle(state, rng)
|
|
309
|
+
move_bob(state)
|
|
310
|
+
move_obstacles(state, rng)
|
|
311
|
+
if detect_collision(state):
|
|
312
|
+
state.alive = False
|
|
313
|
+
state.bonk_ticks = 4
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def current_bob_sprite(state: GameState) -> list[str]:
|
|
317
|
+
if not state.alive:
|
|
318
|
+
return BOB_SPRITES["bonk"]
|
|
319
|
+
if state.landing_ticks > 0 and state.on_ground:
|
|
320
|
+
return BOB_SPRITES["land"]
|
|
321
|
+
if not state.on_ground:
|
|
322
|
+
return BOB_SPRITES["jump"]
|
|
323
|
+
if state.crouch_ticks > 0:
|
|
324
|
+
return BOB_SPRITES["crouch"]
|
|
325
|
+
if (state.ticks // 6) % 2 == 0:
|
|
326
|
+
return BOB_SPRITES["idle_a"]
|
|
327
|
+
return BOB_SPRITES["idle_b"]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def draw_text(canvas: list[list[str]], row: int, col: int, text: str) -> None:
|
|
331
|
+
if row < 0 or row >= len(canvas):
|
|
332
|
+
return
|
|
333
|
+
for offset, char in enumerate(text):
|
|
334
|
+
x = col + offset
|
|
335
|
+
if 0 <= x < len(canvas[row]):
|
|
336
|
+
canvas[row][x] = char
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def render_lines(state: GameState) -> list[str]:
|
|
340
|
+
canvas = [[" " for _ in range(state.width)] for _ in range(state.height)]
|
|
341
|
+
|
|
342
|
+
for x in range(state.width):
|
|
343
|
+
canvas[state.ground_y + 1][x] = "_"
|
|
344
|
+
|
|
345
|
+
bob_row = max(0, min(state.height - 1, state.bob_row))
|
|
346
|
+
bob_sprite = current_bob_sprite(state)
|
|
347
|
+
bob_top = bob_row - (len(bob_sprite) - 1)
|
|
348
|
+
for sprite_row, sprite_text in enumerate(bob_sprite):
|
|
349
|
+
draw_text(canvas, bob_top + sprite_row, state.bob_x, sprite_text)
|
|
350
|
+
|
|
351
|
+
if state.alive and state.on_ground and state.crouch_ticks == 0 and state.landing_ticks == 0:
|
|
352
|
+
if (state.ticks // 14) % 3 == 0:
|
|
353
|
+
draw_text(canvas, max(0, bob_top), state.bob_x + 4, "z")
|
|
354
|
+
if state.jump_exclamation_ticks > 0 and state.jump_exclamation:
|
|
355
|
+
exclamation_row = max(0, bob_top - 1)
|
|
356
|
+
exclamation_col = min(state.width - len(state.jump_exclamation), state.bob_x + 4)
|
|
357
|
+
draw_text(canvas, exclamation_row, max(0, exclamation_col), state.jump_exclamation)
|
|
358
|
+
|
|
359
|
+
for obstacle in state.obstacles:
|
|
360
|
+
obstacle_x = round(obstacle.x)
|
|
361
|
+
obstacle_top = state.ground_y - obstacle.height + 1
|
|
362
|
+
for row_offset, row_text in enumerate(obstacle.shape):
|
|
363
|
+
draw_text(canvas, obstacle_top + row_offset, obstacle_x, row_text)
|
|
364
|
+
|
|
365
|
+
title = " Lazy Bob "
|
|
366
|
+
controls = "space jump q quit"
|
|
367
|
+
score = f"score {state.score} best {state.best_score} speed {state.speed:.1f}"
|
|
368
|
+
if state.width > len(title):
|
|
369
|
+
start = max(0, (state.width - len(title)) // 2)
|
|
370
|
+
for index, char in enumerate(title):
|
|
371
|
+
canvas[0][start + index] = char
|
|
372
|
+
|
|
373
|
+
for row, text in ((1, controls), (2, score)):
|
|
374
|
+
if row < state.height:
|
|
375
|
+
for index, char in enumerate(text[: state.width]):
|
|
376
|
+
canvas[row][index] = char
|
|
377
|
+
|
|
378
|
+
if state.bobism_ticks > 0 and state.bobism:
|
|
379
|
+
bobism_row = max(4, bob_top - 2)
|
|
380
|
+
bobism_col = min(state.width - len(state.bobism), max(0, state.bob_x - 1))
|
|
381
|
+
draw_text(canvas, bobism_row, bobism_col, state.bobism)
|
|
382
|
+
|
|
383
|
+
if not state.alive:
|
|
384
|
+
message = " bonk. press r to retry or q to quit "
|
|
385
|
+
if state.width > len(message):
|
|
386
|
+
start = (state.width - len(message)) // 2
|
|
387
|
+
for index, char in enumerate(message):
|
|
388
|
+
canvas[state.height // 2][start + index] = char
|
|
389
|
+
|
|
390
|
+
return ["".join(row).rstrip() for row in canvas]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def handle_input(key: int, state: GameState, best_score: int) -> GameState | None:
|
|
394
|
+
if key in (-1,):
|
|
395
|
+
return state
|
|
396
|
+
if key in (ord("q"), ord("Q")):
|
|
397
|
+
return None
|
|
398
|
+
if key in (ord(" "),):
|
|
399
|
+
if state.alive:
|
|
400
|
+
jump(state)
|
|
401
|
+
return state
|
|
402
|
+
return create_state(state.width, state.height, best_score=best_score)
|
|
403
|
+
if key in (ord("r"), ord("R")) and not state.alive:
|
|
404
|
+
return create_state(state.width, state.height, best_score=best_score)
|
|
405
|
+
return state
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def run_curses(stdscr) -> None:
|
|
409
|
+
import curses
|
|
410
|
+
|
|
411
|
+
curses.curs_set(0)
|
|
412
|
+
stdscr.nodelay(True)
|
|
413
|
+
stdscr.timeout(0)
|
|
414
|
+
|
|
415
|
+
rng = random.Random()
|
|
416
|
+
|
|
417
|
+
while True:
|
|
418
|
+
height, width = stdscr.getmaxyx()
|
|
419
|
+
if width < MIN_WIDTH or height < MIN_HEIGHT:
|
|
420
|
+
stdscr.erase()
|
|
421
|
+
stdscr.addstr(0, 0, f"Need at least {MIN_WIDTH}x{MIN_HEIGHT}. Current: {width}x{height}")
|
|
422
|
+
stdscr.addstr(1, 0, "Resize the terminal, then press q to leave.")
|
|
423
|
+
stdscr.refresh()
|
|
424
|
+
key = stdscr.getch()
|
|
425
|
+
if key in (ord("q"), ord("Q")):
|
|
426
|
+
return
|
|
427
|
+
time.sleep(0.1)
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
state = create_state(width, height)
|
|
431
|
+
while True:
|
|
432
|
+
height, width = stdscr.getmaxyx()
|
|
433
|
+
if width != state.width or height != state.height:
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
loop_start = time.monotonic()
|
|
437
|
+
next_state = handle_input(stdscr.getch(), state, state.best_score)
|
|
438
|
+
if next_state is None:
|
|
439
|
+
return
|
|
440
|
+
state = next_state
|
|
441
|
+
step(state, rng)
|
|
442
|
+
|
|
443
|
+
stdscr.erase()
|
|
444
|
+
for row_index, line in enumerate(render_lines(state)):
|
|
445
|
+
try:
|
|
446
|
+
stdscr.addstr(row_index, 0, line)
|
|
447
|
+
except curses.error:
|
|
448
|
+
pass
|
|
449
|
+
stdscr.refresh()
|
|
450
|
+
|
|
451
|
+
remaining = FRAME_TIME - (time.monotonic() - loop_start)
|
|
452
|
+
if remaining > 0:
|
|
453
|
+
time.sleep(remaining)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def main() -> None:
|
|
457
|
+
try:
|
|
458
|
+
import curses
|
|
459
|
+
except ImportError as exc:
|
|
460
|
+
raise SystemExit("Lazy Bob needs the standard curses module to run in the terminal.") from exc
|
|
461
|
+
|
|
462
|
+
curses.wrapper(run_curses)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lazy-bob
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny terminal runner where Lazy Bob avoids life's hurdles with one reluctant jump.
|
|
5
|
+
Author: Anant
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: game,ascii,terminal,runner,pypi
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console :: Curses
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Games/Entertainment :: Arcade
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Lazy Bob
|
|
24
|
+
|
|
25
|
+
`Lazy Bob` is a tiny terminal game for people who appreciate low-effort heroics.
|
|
26
|
+
|
|
27
|
+
Bob is too laid back to run. He only jumps when absolutely necessary.
|
|
28
|
+
Your job is to hit `space` just in time so he avoids the next hurdle.
|
|
29
|
+
|
|
30
|
+
But you see, Bob is a philosopher too. Do not forget to read his takes on life ( known as Bob-isms) as he lazilty jumps through the hurdles of life !
|
|
31
|
+
|
|
32
|
+
Everything is intentionally bare-bones:
|
|
33
|
+
|
|
34
|
+
- terminal only
|
|
35
|
+
- ASCII only
|
|
36
|
+
- one button that matters
|
|
37
|
+
- no external dependencies
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install lazy-bob
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Run
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
lazy-bob
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Controls
|
|
58
|
+
|
|
59
|
+
- `space`: jump
|
|
60
|
+
- `r`: restart after a crash
|
|
61
|
+
- `q`: quit
|
|
62
|
+
|
|
63
|
+
## Notes
|
|
64
|
+
|
|
65
|
+
- The game uses Python's built-in `curses` module, so it works best on macOS and Linux terminals.
|
|
66
|
+
- Bob's best score is saved locally in `~/.lazy_bob_score`.
|
|
67
|
+
|
|
68
|
+
Drop me a line if you like Lazy Bob at anantdhavale@gmail.com.
|
|
69
|
+
|
|
70
|
+
Copyright for the Lazy Bob character and Bob-isms © Anant Dhavale.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/lazy_bob/__init__.py
|
|
5
|
+
src/lazy_bob/__main__.py
|
|
6
|
+
src/lazy_bob/game.py
|
|
7
|
+
src/lazy_bob.egg-info/PKG-INFO
|
|
8
|
+
src/lazy_bob.egg-info/SOURCES.txt
|
|
9
|
+
src/lazy_bob.egg-info/dependency_links.txt
|
|
10
|
+
src/lazy_bob.egg-info/entry_points.txt
|
|
11
|
+
src/lazy_bob.egg-info/top_level.txt
|
|
12
|
+
tests/test_game.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lazy_bob
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from lazy_bob.game import (
|
|
5
|
+
BOB_SPRITES,
|
|
6
|
+
JUMP_EXCLAMATIONS_CASUAL,
|
|
7
|
+
JUMP_EXCLAMATIONS_FAST,
|
|
8
|
+
Obstacle,
|
|
9
|
+
create_state,
|
|
10
|
+
current_bob_sprite,
|
|
11
|
+
detect_collision,
|
|
12
|
+
jump,
|
|
13
|
+
step,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LazyBobTests(unittest.TestCase):
|
|
18
|
+
def test_jump_only_when_grounded(self) -> None:
|
|
19
|
+
state = create_state(48, 16, best_score=0)
|
|
20
|
+
jump(state)
|
|
21
|
+
self.assertLess(state.bob_velocity, 0)
|
|
22
|
+
|
|
23
|
+
airborne = create_state(48, 16, best_score=0)
|
|
24
|
+
airborne.bob_y -= 2
|
|
25
|
+
jump(airborne)
|
|
26
|
+
self.assertEqual(airborne.bob_velocity, 0)
|
|
27
|
+
|
|
28
|
+
def test_collision_detected_on_ground(self) -> None:
|
|
29
|
+
state = create_state(48, 16, best_score=0)
|
|
30
|
+
state.obstacles.append(Obstacle(x=float(state.bob_x)))
|
|
31
|
+
self.assertTrue(detect_collision(state))
|
|
32
|
+
|
|
33
|
+
def test_collision_detected_when_obstacle_skips_past_bob(self) -> None:
|
|
34
|
+
state = create_state(48, 16, best_score=0)
|
|
35
|
+
state.obstacles.append(
|
|
36
|
+
Obstacle(x=float(state.bob_x) - 1.2, previous_x=float(state.bob_x) + 1.4)
|
|
37
|
+
)
|
|
38
|
+
self.assertTrue(detect_collision(state))
|
|
39
|
+
|
|
40
|
+
def test_step_awards_score_after_passing(self) -> None:
|
|
41
|
+
state = create_state(48, 16, best_score=0)
|
|
42
|
+
state.obstacles.append(Obstacle(x=float(state.bob_x) - 0.1))
|
|
43
|
+
step(state, random.Random(1))
|
|
44
|
+
self.assertEqual(state.score, 1)
|
|
45
|
+
self.assertEqual(state.best_score, 1)
|
|
46
|
+
|
|
47
|
+
def test_jump_switches_bob_into_air_pose(self) -> None:
|
|
48
|
+
state = create_state(48, 16, best_score=0)
|
|
49
|
+
jump(state, random.Random(1))
|
|
50
|
+
step(state, random.Random(1))
|
|
51
|
+
self.assertEqual(current_bob_sprite(state), BOB_SPRITES["jump"])
|
|
52
|
+
|
|
53
|
+
def test_wide_obstacle_counts_for_collision(self) -> None:
|
|
54
|
+
state = create_state(48, 16, best_score=0)
|
|
55
|
+
state.obstacles.append(Obstacle(x=float(state.bob_x + 1), shape=["[]"]))
|
|
56
|
+
self.assertTrue(detect_collision(state))
|
|
57
|
+
|
|
58
|
+
def test_jump_sets_a_lazy_exclamation(self) -> None:
|
|
59
|
+
state = create_state(48, 16, best_score=0)
|
|
60
|
+
jump(state, random.Random(1))
|
|
61
|
+
self.assertTrue(state.jump_exclamation)
|
|
62
|
+
self.assertGreater(state.jump_exclamation_ticks, 0)
|
|
63
|
+
self.assertTrue(state.bobism)
|
|
64
|
+
self.assertGreater(state.bobism_ticks, 0)
|
|
65
|
+
|
|
66
|
+
def test_fast_jump_uses_faster_exclamation_pool(self) -> None:
|
|
67
|
+
state = create_state(48, 16, best_score=0)
|
|
68
|
+
state.speed = 2.2
|
|
69
|
+
jump(state, random.Random(1))
|
|
70
|
+
self.assertIn(state.jump_exclamation, JUMP_EXCLAMATIONS_FAST)
|
|
71
|
+
|
|
72
|
+
def test_scoring_can_trigger_bobism(self) -> None:
|
|
73
|
+
state = create_state(48, 16, best_score=0)
|
|
74
|
+
state.score = 4
|
|
75
|
+
state.obstacles.append(Obstacle(x=float(state.bob_x) - 0.1))
|
|
76
|
+
step(state, random.Random(1))
|
|
77
|
+
self.assertEqual(state.score, 5)
|
|
78
|
+
self.assertTrue(state.bobism)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|