cege-gopher 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.
- cege_gopher-0.1.0/LICENSE +21 -0
- cege_gopher-0.1.0/PKG-INFO +120 -0
- cege_gopher-0.1.0/README.md +83 -0
- cege_gopher-0.1.0/pyproject.toml +25 -0
- cege_gopher-0.1.0/setup.cfg +4 -0
- cege_gopher-0.1.0/src/cege_gopher/__init__.py +611 -0
- cege_gopher-0.1.0/src/cege_gopher/sprites/E.png +0 -0
- cege_gopher-0.1.0/src/cege_gopher/sprites/N.png +0 -0
- cege_gopher-0.1.0/src/cege_gopher/sprites/S.png +0 -0
- cege_gopher-0.1.0/src/cege_gopher/sprites/W.png +0 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/around1.wld +5 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/around2.wld +14 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/around3.wld +15 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/harvest1.wld +42 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/harvest2.wld +36 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/harvest3.wld +36 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/harvest5.wld +42 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/hurdles1.wld +12 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/hurdles2.wld +12 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/hurdles3.wld +15 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/hurdles4.wld +18 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/maze1.wld +32 -0
- cege_gopher-0.1.0/src/cege_gopher/worlds/newspaper.wld +18 -0
- cege_gopher-0.1.0/src/cege_gopher.egg-info/PKG-INFO +120 -0
- cege_gopher-0.1.0/src/cege_gopher.egg-info/SOURCES.txt +26 -0
- cege_gopher-0.1.0/src/cege_gopher.egg-info/dependency_links.txt +1 -0
- cege_gopher-0.1.0/src/cege_gopher.egg-info/requires.txt +1 -0
- cege_gopher-0.1.0/src/cege_gopher.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seongjin Choi
|
|
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,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cege-gopher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Karel-style grid robot for teaching Python, themed as Goldy Gopher. Records and replays, so it works in Colab.
|
|
5
|
+
Author-email: Seongjin Choi <chois@umn.edu>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Seongjin Choi
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: education,karel,robot,colab,cege3101
|
|
29
|
+
Classifier: Intended Audience :: Education
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Topic :: Education
|
|
32
|
+
Requires-Python: >=3.9
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Requires-Dist: matplotlib>=3.5
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# cege_gopher
|
|
39
|
+
|
|
40
|
+
A Karel-style grid robot for teaching introductory Python, themed as Goldy
|
|
41
|
+
Gopher. Written for **CEGE 3101 — Computer Applications I** at the University of
|
|
42
|
+
Minnesota.
|
|
43
|
+
|
|
44
|
+
Ported from the MATLAB robot used in earlier offerings of the course, which was
|
|
45
|
+
itself a port of the KAIST CS101 HUBO exercise
|
|
46
|
+
([otfried/cs101](https://github.com/otfried/cs101)) — Karel the Robot, at the
|
|
47
|
+
root of it.
|
|
48
|
+
|
|
49
|
+
## Why this exists
|
|
50
|
+
|
|
51
|
+
The original libraries open a live window and animate each command as it runs.
|
|
52
|
+
**Google Colab cannot host one.**
|
|
53
|
+
|
|
54
|
+
So this version **records, then replays**. Every command appends a frame to a
|
|
55
|
+
buffer; nothing is drawn until `show()`, which renders the whole run as an
|
|
56
|
+
inline animation:
|
|
57
|
+
|
|
58
|
+
> write the whole program → run the cell → watch it play back
|
|
59
|
+
|
|
60
|
+
Students never drive the robot interactively; every program is pre-coded, which
|
|
61
|
+
is also how the homework is graded.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install cege-gopher
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or, from the course repository:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install "git+https://github.com/benchoi93/CEGE3101_Computer_Applications.git#subdirectory=packages/cege-gopher"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The 13 world files ship **inside the package**, so nothing needs uploading —
|
|
76
|
+
`load_world("harvest1.wld")` just works.
|
|
77
|
+
|
|
78
|
+
## Use
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from cege_gopher import *
|
|
82
|
+
|
|
83
|
+
load_world("harvest1.wld") # or: create_world(10, 10)
|
|
84
|
+
goldy = Gopher()
|
|
85
|
+
goldy.set_trace("maroon")
|
|
86
|
+
|
|
87
|
+
while goldy.front_is_clear():
|
|
88
|
+
if goldy.on_carrot():
|
|
89
|
+
goldy.pick_carrot()
|
|
90
|
+
goldy.move()
|
|
91
|
+
|
|
92
|
+
show() # the animation renders here
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`list_worlds()` returns the bundled world names.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
| | |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Module | `create_world(avenues, streets)`, `load_world(name)`, `list_worlds()`, `show(fps=)`, `show_final()`, `get_world()` |
|
|
102
|
+
| Move | `move()`, `turn_left()`, `turn_right()` |
|
|
103
|
+
| Carrots | `pick_carrot()`, `drop_carrot()` |
|
|
104
|
+
| Sense | `front_is_clear()`, `left_is_clear()`, `right_is_clear()`, `facing_north()`, `on_carrot()`, `carries_carrots()` |
|
|
105
|
+
| Display | `set_trace(color)`, `get_pos()` |
|
|
106
|
+
|
|
107
|
+
`Robot` is an alias for `Gopher`, and `pick_beeper`/`pick_coin` for
|
|
108
|
+
`pick_carrot`, so code written against `cs1robots` or the MATLAB version still
|
|
109
|
+
runs.
|
|
110
|
+
|
|
111
|
+
## A bug fixed in this port
|
|
112
|
+
|
|
113
|
+
The MATLAB version had `leftIsClear` and `rightIsClear` **swapped** — facing
|
|
114
|
+
east it checked south for "left", when `turnLeft` rotates counter-clockwise
|
|
115
|
+
(E→N) so left of east is north. This port uses `left = (-dy, dx)`,
|
|
116
|
+
`right = (dy, -dx)`.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# cege_gopher
|
|
2
|
+
|
|
3
|
+
A Karel-style grid robot for teaching introductory Python, themed as Goldy
|
|
4
|
+
Gopher. Written for **CEGE 3101 — Computer Applications I** at the University of
|
|
5
|
+
Minnesota.
|
|
6
|
+
|
|
7
|
+
Ported from the MATLAB robot used in earlier offerings of the course, which was
|
|
8
|
+
itself a port of the KAIST CS101 HUBO exercise
|
|
9
|
+
([otfried/cs101](https://github.com/otfried/cs101)) — Karel the Robot, at the
|
|
10
|
+
root of it.
|
|
11
|
+
|
|
12
|
+
## Why this exists
|
|
13
|
+
|
|
14
|
+
The original libraries open a live window and animate each command as it runs.
|
|
15
|
+
**Google Colab cannot host one.**
|
|
16
|
+
|
|
17
|
+
So this version **records, then replays**. Every command appends a frame to a
|
|
18
|
+
buffer; nothing is drawn until `show()`, which renders the whole run as an
|
|
19
|
+
inline animation:
|
|
20
|
+
|
|
21
|
+
> write the whole program → run the cell → watch it play back
|
|
22
|
+
|
|
23
|
+
Students never drive the robot interactively; every program is pre-coded, which
|
|
24
|
+
is also how the homework is graded.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install cege-gopher
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or, from the course repository:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install "git+https://github.com/benchoi93/CEGE3101_Computer_Applications.git#subdirectory=packages/cege-gopher"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The 13 world files ship **inside the package**, so nothing needs uploading —
|
|
39
|
+
`load_world("harvest1.wld")` just works.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from cege_gopher import *
|
|
45
|
+
|
|
46
|
+
load_world("harvest1.wld") # or: create_world(10, 10)
|
|
47
|
+
goldy = Gopher()
|
|
48
|
+
goldy.set_trace("maroon")
|
|
49
|
+
|
|
50
|
+
while goldy.front_is_clear():
|
|
51
|
+
if goldy.on_carrot():
|
|
52
|
+
goldy.pick_carrot()
|
|
53
|
+
goldy.move()
|
|
54
|
+
|
|
55
|
+
show() # the animation renders here
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`list_worlds()` returns the bundled world names.
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
| | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Module | `create_world(avenues, streets)`, `load_world(name)`, `list_worlds()`, `show(fps=)`, `show_final()`, `get_world()` |
|
|
65
|
+
| Move | `move()`, `turn_left()`, `turn_right()` |
|
|
66
|
+
| Carrots | `pick_carrot()`, `drop_carrot()` |
|
|
67
|
+
| Sense | `front_is_clear()`, `left_is_clear()`, `right_is_clear()`, `facing_north()`, `on_carrot()`, `carries_carrots()` |
|
|
68
|
+
| Display | `set_trace(color)`, `get_pos()` |
|
|
69
|
+
|
|
70
|
+
`Robot` is an alias for `Gopher`, and `pick_beeper`/`pick_coin` for
|
|
71
|
+
`pick_carrot`, so code written against `cs1robots` or the MATLAB version still
|
|
72
|
+
runs.
|
|
73
|
+
|
|
74
|
+
## A bug fixed in this port
|
|
75
|
+
|
|
76
|
+
The MATLAB version had `leftIsClear` and `rightIsClear` **swapped** — facing
|
|
77
|
+
east it checked south for "left", when `turnLeft` rotates counter-clockwise
|
|
78
|
+
(E→N) so left of east is north. This port uses `left = (-dy, dx)`,
|
|
79
|
+
`right = (dy, -dx)`.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cege-gopher"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Karel-style grid robot for teaching Python, themed as Goldy Gopher. Records and replays, so it works in Colab."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Seongjin Choi", email = "chois@umn.edu" }]
|
|
13
|
+
keywords = ["education", "karel", "robot", "colab", "cege3101"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: Education",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Education",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["matplotlib>=3.5"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
cege_gopher = ["worlds/*.wld", "sprites/*.png"]
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""cege_gopher --- a Karel-style grid robot for CEGE 3101, themed as Goldy Gopher.
|
|
2
|
+
|
|
3
|
+
Ported from the MATLAB `robot_matlab` version in
|
|
4
|
+
`lectures/week-02/python_tutorial_with_gopher/`, which was itself a port of the
|
|
5
|
+
KAIST CS101 `cs1robots` library (https://github.com/otfried/cs101).
|
|
6
|
+
|
|
7
|
+
WHY THIS EXISTS / WHAT IS DIFFERENT
|
|
8
|
+
-----------------------------------
|
|
9
|
+
The original opens a live window and animates each command as it happens. That
|
|
10
|
+
does not work in Google Colab, which cannot host an interactive window.
|
|
11
|
+
|
|
12
|
+
So this version **records, then replays**. Every command appends a frame to a
|
|
13
|
+
buffer; nothing is drawn until you call `show()`, which renders the whole run as
|
|
14
|
+
an inline animation. The practical consequence for students:
|
|
15
|
+
|
|
16
|
+
write the whole program -> run the cell -> watch it play back
|
|
17
|
+
|
|
18
|
+
You never "drive" the gopher interactively. Everything is pre-coded, which is
|
|
19
|
+
also how the homework is graded.
|
|
20
|
+
|
|
21
|
+
QUICK START
|
|
22
|
+
-----------
|
|
23
|
+
from cege_gopher import *
|
|
24
|
+
|
|
25
|
+
load_world("harvest1.wld") # or: create_world(10, 10)
|
|
26
|
+
goldy = Gopher(carrots=0)
|
|
27
|
+
goldy.set_trace("maroon")
|
|
28
|
+
|
|
29
|
+
for i in range(5):
|
|
30
|
+
goldy.move()
|
|
31
|
+
if goldy.on_carrot():
|
|
32
|
+
goldy.pick_carrot()
|
|
33
|
+
|
|
34
|
+
show() # <- the animation appears here
|
|
35
|
+
|
|
36
|
+
API
|
|
37
|
+
---
|
|
38
|
+
Module: create_world(avenues, streets), load_world(path), show(), show_final()
|
|
39
|
+
Gopher: move, turn_left, turn_right, pick_carrot, drop_carrot,
|
|
40
|
+
front_is_clear, left_is_clear, right_is_clear,
|
|
41
|
+
facing_north, carries_carrots, on_carrot, set_trace, get_pos
|
|
42
|
+
|
|
43
|
+
Aliases are provided so code written against the original libraries still runs:
|
|
44
|
+
`Robot` for `Gopher`; `pick_beeper`/`pick_coin` for `pick_carrot`, and so on.
|
|
45
|
+
|
|
46
|
+
FIXED IN THIS PORT
|
|
47
|
+
------------------
|
|
48
|
+
The MATLAB version had `leftIsClear` and `rightIsClear` swapped -- facing East it
|
|
49
|
+
checked South for "left", when left of East is North. Verified against the
|
|
50
|
+
turn_left direction cycle, which is counter-clockwise (E -> N -> W -> S). This
|
|
51
|
+
port uses left = (-dy, dx), right = (dy, -dx).
|
|
52
|
+
"""
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import os
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
import matplotlib
|
|
59
|
+
import matplotlib.pyplot as plt
|
|
60
|
+
from matplotlib import animation
|
|
61
|
+
from matplotlib.patches import Circle, Ellipse, Polygon, Rectangle
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"Gopher", "Robot", "World",
|
|
65
|
+
"create_world", "load_world", "show", "show_final", "get_world",
|
|
66
|
+
"list_worlds", "GopherError",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
UMN_MAROON = "#7A0019"
|
|
70
|
+
UMN_GOLD = "#FFCC33"
|
|
71
|
+
_TRACE_COLORS = {"maroon": UMN_MAROON, "gold": UMN_GOLD}
|
|
72
|
+
|
|
73
|
+
# Orientation is stored as a unit vector (dx, dy). East is the start, matching
|
|
74
|
+
# the `robot = (1, 1, 'E', 0)` line in the .wld files.
|
|
75
|
+
_DIRS = {"E": (1, 0), "N": (0, 1), "W": (-1, 0), "S": (0, -1)}
|
|
76
|
+
_DIR_NAMES = {v: k for k, v in _DIRS.items()}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GopherError(Exception):
|
|
80
|
+
"""Raised when the gopher is asked to do something impossible."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ==========================================================================
|
|
84
|
+
# World
|
|
85
|
+
# ==========================================================================
|
|
86
|
+
class World:
|
|
87
|
+
"""A grid of avenues (columns) x streets (rows).
|
|
88
|
+
|
|
89
|
+
Two coordinate systems are in play, and mixing them up is the classic bug:
|
|
90
|
+
|
|
91
|
+
* **cell** coordinates ``(avenue, street)``, 1-based, are what the gopher
|
|
92
|
+
uses. Avenue is the column, street is the row.
|
|
93
|
+
* **doubled** coordinates ``(col, row)`` are what *walls* use, where a cell
|
|
94
|
+
``(a, s)`` sits at ``(2a-1, 2s-1)``. A wall lives between two cells, so it
|
|
95
|
+
always has one even coordinate. This is the same convention as the
|
|
96
|
+
original `.wld` files, so world files are compatible unchanged.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, avenues=10, streets=10, walls=None, carrots=None):
|
|
100
|
+
self.avenues = int(avenues)
|
|
101
|
+
self.streets = int(streets)
|
|
102
|
+
self.walls = set(walls or ())
|
|
103
|
+
# {(avenue, street): count}
|
|
104
|
+
self.carrots = dict(carrots or {})
|
|
105
|
+
self._borders = self._make_borders()
|
|
106
|
+
|
|
107
|
+
# ---- geometry ----------------------------------------------------
|
|
108
|
+
def _make_borders(self) -> set:
|
|
109
|
+
b = set()
|
|
110
|
+
for a in range(1, self.avenues + 1):
|
|
111
|
+
b.add((2 * a - 1, 0)) # south wall of the world
|
|
112
|
+
b.add((2 * a - 1, 2 * self.streets)) # north wall
|
|
113
|
+
for s in range(1, self.streets + 1):
|
|
114
|
+
b.add((0, 2 * s - 1)) # west wall
|
|
115
|
+
b.add((2 * self.avenues, 2 * s - 1)) # east wall
|
|
116
|
+
return b
|
|
117
|
+
|
|
118
|
+
def is_clear(self, col, row) -> bool:
|
|
119
|
+
"""True if no wall and no world border sits at this doubled coordinate."""
|
|
120
|
+
return (col, row) not in self.walls and (col, row) not in self._borders
|
|
121
|
+
|
|
122
|
+
def toggle_wall(self, col, row):
|
|
123
|
+
if (col + row) % 2 == 0:
|
|
124
|
+
raise GopherError(
|
|
125
|
+
f"A wall cannot sit at ({col}, {row}) -- walls go *between* "
|
|
126
|
+
"cells, so exactly one coordinate must be even.")
|
|
127
|
+
self.walls.symmetric_difference_update({(col, row)})
|
|
128
|
+
|
|
129
|
+
# ---- carrots -----------------------------------------------------
|
|
130
|
+
def count_carrots(self, avenue, street) -> int:
|
|
131
|
+
return self.carrots.get((avenue, street), 0)
|
|
132
|
+
|
|
133
|
+
def add_carrot(self, avenue, street):
|
|
134
|
+
self.carrots[(avenue, street)] = self.count_carrots(avenue, street) + 1
|
|
135
|
+
|
|
136
|
+
def remove_carrot(self, avenue, street):
|
|
137
|
+
n = self.count_carrots(avenue, street)
|
|
138
|
+
if n <= 1:
|
|
139
|
+
self.carrots.pop((avenue, street), None)
|
|
140
|
+
else:
|
|
141
|
+
self.carrots[(avenue, street)] = n - 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ==========================================================================
|
|
145
|
+
# World files
|
|
146
|
+
# ==========================================================================
|
|
147
|
+
def _parse_world(text: str):
|
|
148
|
+
"""Parse a `.wld` file.
|
|
149
|
+
|
|
150
|
+
Accepts ``carrots``, ``coins`` and ``beepers`` as the item key, so both the
|
|
151
|
+
MATLAB-era worlds in this repo and the original CS101 worlds load unchanged.
|
|
152
|
+
"""
|
|
153
|
+
def _int(key, default=10):
|
|
154
|
+
m = re.search(rf"{key}\s*=\s*(\d+)", text)
|
|
155
|
+
return int(m.group(1)) if m else default
|
|
156
|
+
|
|
157
|
+
avenues, streets = _int("avenues"), _int("streets")
|
|
158
|
+
|
|
159
|
+
walls = set()
|
|
160
|
+
m = re.search(r"walls\s*=\s*\[(.*?)\]", text, re.S)
|
|
161
|
+
if m:
|
|
162
|
+
for a, b in re.findall(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)", m.group(1)):
|
|
163
|
+
walls.add((int(a), int(b)))
|
|
164
|
+
|
|
165
|
+
carrots = {}
|
|
166
|
+
m = re.search(r"(?:carrots|coins|beepers)\s*=\s*\{(.*?)\}", text, re.S)
|
|
167
|
+
if m:
|
|
168
|
+
for a, b, n in re.findall(
|
|
169
|
+
r"\(\s*(\d+)\s*,\s*(\d+)\s*\)\s*:\s*(\d+)", m.group(1)):
|
|
170
|
+
carrots[(int(a), int(b))] = int(n)
|
|
171
|
+
|
|
172
|
+
start = (1, 1, "E", 0)
|
|
173
|
+
m = re.search(r"robot\s*=\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*'(\w)'\s*,\s*(\d+)",
|
|
174
|
+
text)
|
|
175
|
+
if m:
|
|
176
|
+
start = (int(m.group(1)), int(m.group(2)), m.group(3).upper(),
|
|
177
|
+
int(m.group(4)))
|
|
178
|
+
|
|
179
|
+
return World(avenues, streets, walls, carrots), start
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_world: World | None = None
|
|
183
|
+
_start = (1, 1, "E", 0)
|
|
184
|
+
_frames: list = []
|
|
185
|
+
_gophers: list = []
|
|
186
|
+
_last_anim = None # holds the most recent animation so it is not garbage-collected
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def create_world(avenues=10, streets=10):
|
|
190
|
+
"""Make an empty world and reset the recording."""
|
|
191
|
+
global _world, _start, _frames, _gophers
|
|
192
|
+
_world = World(avenues, streets)
|
|
193
|
+
_start = (1, 1, "E", 0)
|
|
194
|
+
_frames, _gophers = [], []
|
|
195
|
+
return _world
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
199
|
+
_BUNDLED = os.path.join(_HERE, "worlds")
|
|
200
|
+
_SPRITES = os.path.join(_HERE, "sprites")
|
|
201
|
+
_sprite_cache: dict = {}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _sprite(direction):
|
|
205
|
+
"""The gopher artwork for a facing direction, or None if unavailable.
|
|
206
|
+
|
|
207
|
+
One PNG per direction, so the sprite never has to be rotated. These are the
|
|
208
|
+
same images the MATLAB `visualizeWorld.m` used.
|
|
209
|
+
"""
|
|
210
|
+
name = _DIR_NAMES[direction]
|
|
211
|
+
if name not in _sprite_cache:
|
|
212
|
+
path = os.path.join(_SPRITES, f"{name}.png")
|
|
213
|
+
try:
|
|
214
|
+
_sprite_cache[name] = plt.imread(path)
|
|
215
|
+
except Exception:
|
|
216
|
+
_sprite_cache[name] = None
|
|
217
|
+
return _sprite_cache[name]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def list_worlds():
|
|
221
|
+
"""Names of the world files that ship with the package."""
|
|
222
|
+
try:
|
|
223
|
+
return sorted(f for f in os.listdir(_BUNDLED) if f.endswith(".wld"))
|
|
224
|
+
except OSError:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_world(filename):
|
|
229
|
+
"""Find a world file.
|
|
230
|
+
|
|
231
|
+
Looks in this order, so that no upload is ever required:
|
|
232
|
+
|
|
233
|
+
1. the path exactly as given (your own world files still work)
|
|
234
|
+
2. the worlds bundled inside the installed package, by bare name --
|
|
235
|
+
``load_world("harvest1.wld")`` and ``load_world("worlds/harvest1.wld")``
|
|
236
|
+
both resolve to the bundled copy
|
|
237
|
+
"""
|
|
238
|
+
if os.path.exists(filename):
|
|
239
|
+
return filename
|
|
240
|
+
bundled = os.path.join(_BUNDLED, os.path.basename(filename))
|
|
241
|
+
if os.path.exists(bundled):
|
|
242
|
+
return bundled
|
|
243
|
+
raise GopherError(
|
|
244
|
+
f"No world file called {os.path.basename(filename)!r}.\n"
|
|
245
|
+
f"Worlds bundled with cege_gopher: {', '.join(list_worlds()) or '(none)'}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_world(filename):
|
|
249
|
+
"""Load a `.wld` file and reset the recording.
|
|
250
|
+
|
|
251
|
+
The name alone is enough for the worlds that ship with the package:
|
|
252
|
+
``load_world("harvest1.wld")``.
|
|
253
|
+
"""
|
|
254
|
+
global _world, _start, _frames, _gophers
|
|
255
|
+
with open(_resolve_world(filename)) as fh:
|
|
256
|
+
_world, _start = _parse_world(fh.read())
|
|
257
|
+
_frames, _gophers = [], []
|
|
258
|
+
return _world
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_world() -> World:
|
|
262
|
+
if _world is None:
|
|
263
|
+
raise GopherError("No world yet -- call create_world() or load_world() first.")
|
|
264
|
+
return _world
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ==========================================================================
|
|
268
|
+
# Gopher
|
|
269
|
+
# ==========================================================================
|
|
270
|
+
class Gopher:
|
|
271
|
+
"""Goldy, on a grid. Every command records a frame; nothing draws until show()."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, orientation=None, avenue=None, street=None, carrots=None,
|
|
274
|
+
color=UMN_MAROON, **kw):
|
|
275
|
+
if _world is None:
|
|
276
|
+
create_world()
|
|
277
|
+
sa, ss, so, sc = _start
|
|
278
|
+
self.avenue = sa if avenue is None else int(avenue)
|
|
279
|
+
self.street = ss if street is None else int(street)
|
|
280
|
+
self.orientation = _DIRS[(orientation or so).upper()]
|
|
281
|
+
# accept beepers=/coins= as aliases for carrots=
|
|
282
|
+
if carrots is None:
|
|
283
|
+
carrots = kw.pop("beepers", kw.pop("coins", sc))
|
|
284
|
+
self.carrots = int(carrots)
|
|
285
|
+
self.color = color
|
|
286
|
+
self.trace_color = None
|
|
287
|
+
self.trace = []
|
|
288
|
+
self.steps = 0
|
|
289
|
+
_gophers.append(self)
|
|
290
|
+
self._record()
|
|
291
|
+
|
|
292
|
+
# ---- internals ---------------------------------------------------
|
|
293
|
+
def _record(self):
|
|
294
|
+
if self.trace_color:
|
|
295
|
+
self.trace.append((self.avenue, self.street))
|
|
296
|
+
_frames.append({
|
|
297
|
+
"avenue": self.avenue,
|
|
298
|
+
"street": self.street,
|
|
299
|
+
"dir": self.orientation,
|
|
300
|
+
"carried": self.carrots,
|
|
301
|
+
"world": dict(get_world().carrots),
|
|
302
|
+
"trace": list(self.trace),
|
|
303
|
+
"steps": self.steps,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
def _doubled(self):
|
|
307
|
+
return 2 * self.avenue - 1, 2 * self.street - 1
|
|
308
|
+
|
|
309
|
+
# ---- movement ----------------------------------------------------
|
|
310
|
+
def move(self):
|
|
311
|
+
"""Move one cell in the direction the gopher is facing."""
|
|
312
|
+
if not self.front_is_clear():
|
|
313
|
+
raise GopherError(
|
|
314
|
+
f"Ouch! There is a wall in front of me at avenue {self.avenue}, "
|
|
315
|
+
f"street {self.street}, facing {_DIR_NAMES[self.orientation]}.")
|
|
316
|
+
dx, dy = self.orientation
|
|
317
|
+
self.avenue += dx
|
|
318
|
+
self.street += dy
|
|
319
|
+
self.steps += 1
|
|
320
|
+
self._record()
|
|
321
|
+
|
|
322
|
+
def turn_left(self):
|
|
323
|
+
"""Turn 90 degrees counter-clockwise (E -> N -> W -> S -> E)."""
|
|
324
|
+
dx, dy = self.orientation
|
|
325
|
+
self.orientation = (-dy, dx)
|
|
326
|
+
self._record()
|
|
327
|
+
|
|
328
|
+
def turn_right(self):
|
|
329
|
+
"""Not in the original API -- three lefts, provided for convenience."""
|
|
330
|
+
for _ in range(3):
|
|
331
|
+
self.turn_left()
|
|
332
|
+
|
|
333
|
+
# ---- sensing -----------------------------------------------------
|
|
334
|
+
def front_is_clear(self) -> bool:
|
|
335
|
+
col, row = self._doubled()
|
|
336
|
+
dx, dy = self.orientation
|
|
337
|
+
return get_world().is_clear(col + dx, row + dy)
|
|
338
|
+
|
|
339
|
+
def left_is_clear(self) -> bool:
|
|
340
|
+
col, row = self._doubled()
|
|
341
|
+
dx, dy = self.orientation
|
|
342
|
+
return get_world().is_clear(col - dy, row + dx)
|
|
343
|
+
|
|
344
|
+
def right_is_clear(self) -> bool:
|
|
345
|
+
col, row = self._doubled()
|
|
346
|
+
dx, dy = self.orientation
|
|
347
|
+
return get_world().is_clear(col + dy, row - dx)
|
|
348
|
+
|
|
349
|
+
def facing_north(self) -> bool:
|
|
350
|
+
return self.orientation == _DIRS["N"]
|
|
351
|
+
|
|
352
|
+
def carries_carrots(self) -> bool:
|
|
353
|
+
return self.carrots > 0
|
|
354
|
+
|
|
355
|
+
def on_carrot(self) -> bool:
|
|
356
|
+
return get_world().count_carrots(self.avenue, self.street) > 0
|
|
357
|
+
|
|
358
|
+
def get_pos(self):
|
|
359
|
+
return self.avenue, self.street
|
|
360
|
+
|
|
361
|
+
# ---- carrots -----------------------------------------------------
|
|
362
|
+
def pick_carrot(self):
|
|
363
|
+
if not self.on_carrot():
|
|
364
|
+
raise GopherError(
|
|
365
|
+
f"There is no carrot at avenue {self.avenue}, street {self.street}.")
|
|
366
|
+
get_world().remove_carrot(self.avenue, self.street)
|
|
367
|
+
self.carrots += 1
|
|
368
|
+
self._record()
|
|
369
|
+
|
|
370
|
+
def drop_carrot(self):
|
|
371
|
+
if not self.carries_carrots():
|
|
372
|
+
raise GopherError("I am not carrying any carrots to drop.")
|
|
373
|
+
get_world().add_carrot(self.avenue, self.street)
|
|
374
|
+
self.carrots -= 1
|
|
375
|
+
self._record()
|
|
376
|
+
|
|
377
|
+
# ---- display -----------------------------------------------------
|
|
378
|
+
def set_trace(self, color="maroon"):
|
|
379
|
+
"""Draw a line behind the gopher as it moves."""
|
|
380
|
+
self.trace_color = _TRACE_COLORS.get(color, color)
|
|
381
|
+
self.trace = [(self.avenue, self.street)]
|
|
382
|
+
|
|
383
|
+
def set_pause(self, delay=0):
|
|
384
|
+
"""No-op. Kept so code from the original library still runs; pacing is
|
|
385
|
+
set by show(fps=...) instead, because playback is a recording."""
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
# ---- compatibility aliases --------------------------------------
|
|
389
|
+
pick_beeper = pick_coin = pick_carrot
|
|
390
|
+
drop_beeper = drop_coin = drop_carrot
|
|
391
|
+
on_beeper = on_coin = on_carrot
|
|
392
|
+
carries_beepers = carries_coins = carries_carrots
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
Robot = Gopher # so `Robot(...)` from the original examples still works
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ==========================================================================
|
|
399
|
+
# Rendering
|
|
400
|
+
# ==========================================================================
|
|
401
|
+
def _draw_gopher(ax, x, y, direction, color):
|
|
402
|
+
"""Draw the gopher facing `direction`, using the artwork if it is available."""
|
|
403
|
+
img = _sprite(direction)
|
|
404
|
+
if img is not None:
|
|
405
|
+
h = 0.86 # cell heights
|
|
406
|
+
w = h * img.shape[1] / img.shape[0] # keep the sprite's aspect
|
|
407
|
+
ax.imshow(img, extent=[x - w / 2, x + w / 2, y - h / 2, y + h / 2],
|
|
408
|
+
zorder=10, interpolation="antialiased")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
# Fallback: a simple drawn gopher, so the library still works if the
|
|
412
|
+
# sprite files are missing.
|
|
413
|
+
dx, dy = direction
|
|
414
|
+
px, py = -dy, dx
|
|
415
|
+
for sgn in (-1, 1):
|
|
416
|
+
ax.add_patch(Circle((x - 0.20 * dx + 0.17 * px * sgn,
|
|
417
|
+
y - 0.20 * dy + 0.17 * py * sgn),
|
|
418
|
+
0.11, facecolor=color, edgecolor="white",
|
|
419
|
+
linewidth=1.0, zorder=5))
|
|
420
|
+
ax.add_patch(Circle((x, y), 0.30, facecolor=color, edgecolor="white",
|
|
421
|
+
linewidth=1.4, zorder=6))
|
|
422
|
+
ax.add_patch(Ellipse((x + 0.13 * dx, y + 0.13 * dy), 0.24, 0.24,
|
|
423
|
+
facecolor=UMN_GOLD, edgecolor="white",
|
|
424
|
+
linewidth=1.0, zorder=7))
|
|
425
|
+
ax.add_patch(Polygon(
|
|
426
|
+
[(x + 0.36 * dx, y + 0.36 * dy),
|
|
427
|
+
(x + 0.16 * dx + 0.11 * px, y + 0.16 * dy + 0.11 * py),
|
|
428
|
+
(x + 0.16 * dx - 0.11 * px, y + 0.16 * dy - 0.11 * py)],
|
|
429
|
+
closed=True, facecolor="#2b2b2b", edgecolor="white",
|
|
430
|
+
linewidth=0.8, zorder=8))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _present(html: str, controls: bool = False) -> str:
|
|
434
|
+
"""Prepare a matplotlib jshtml animation for the classroom.
|
|
435
|
+
|
|
436
|
+
Two changes, both cosmetic but both worth having:
|
|
437
|
+
|
|
438
|
+
* **Autoplay.** `to_jshtml` always renders paused and has no autoplay option
|
|
439
|
+
(only `to_html5_video` autoplays). It does build a global `animNNN` object
|
|
440
|
+
inside a `setTimeout(..., 0)`, so we find that name and poll until it
|
|
441
|
+
exists, then press play.
|
|
442
|
+
* **Hide the controls** -- the slider, the button row and the
|
|
443
|
+
once/loop/reflect radios. Students are watching a replay, not scrubbing a
|
|
444
|
+
video, and the controls are more clutter than help.
|
|
445
|
+
|
|
446
|
+
The controls are *hidden with CSS, not removed*: the animation's JavaScript
|
|
447
|
+
looks the slider and the loop-select up by id at construction time, so
|
|
448
|
+
deleting them from the DOM breaks playback. The rule is scoped to this one
|
|
449
|
+
animation so it cannot affect anything else on the page.
|
|
450
|
+
|
|
451
|
+
The id is a hex uuid, not a number -- e.g. animef73a836c4794088b855e878cfca32da.
|
|
452
|
+
"""
|
|
453
|
+
m = re.search(r"\banim(\w+)\s*=\s*new\s+Animation", html)
|
|
454
|
+
if not m: # matplotlib changed its template; leave it be
|
|
455
|
+
return html
|
|
456
|
+
ident = m.group(1)
|
|
457
|
+
|
|
458
|
+
hide = "" if controls else f"""
|
|
459
|
+
<style>
|
|
460
|
+
#gopher-anim-{ident} .anim-controls {{ display: none !important; }}
|
|
461
|
+
</style>"""
|
|
462
|
+
|
|
463
|
+
return f"""<div id="gopher-anim-{ident}">
|
|
464
|
+
{html}
|
|
465
|
+
</div>{hide}
|
|
466
|
+
<script language="javascript">
|
|
467
|
+
(function() {{
|
|
468
|
+
var tries = 0;
|
|
469
|
+
var timer = setInterval(function() {{
|
|
470
|
+
if (typeof anim{ident} !== "undefined" && anim{ident}) {{
|
|
471
|
+
anim{ident}.play_animation();
|
|
472
|
+
clearInterval(timer);
|
|
473
|
+
}} else if (++tries > 100) {{
|
|
474
|
+
clearInterval(timer); /* give up rather than poll forever */
|
|
475
|
+
}}
|
|
476
|
+
}}, 50);
|
|
477
|
+
}})()
|
|
478
|
+
</script>
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _auto_figsize(world):
|
|
483
|
+
"""Size the figure from the world, the way visualizeWorld.m did.
|
|
484
|
+
|
|
485
|
+
MATLAB used 50 pixels per cell; at matplotlib's 100 dpi that is 0.5 inch.
|
|
486
|
+
Clamped so a 3x3 world is still readable and a 20x20 one still fits on a
|
|
487
|
+
screen, and the aspect always matches the grid.
|
|
488
|
+
"""
|
|
489
|
+
per_cell = 0.5
|
|
490
|
+
lo, hi = 3.2, 11.0
|
|
491
|
+
w = min(max(world.avenues * per_cell, lo), hi)
|
|
492
|
+
h = min(max(world.streets * per_cell, lo), hi)
|
|
493
|
+
return (w, h)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _draw_frame(ax, frame, world, title=None):
|
|
497
|
+
ax.clear()
|
|
498
|
+
av, st = world.avenues, world.streets
|
|
499
|
+
ax.set_xlim(0.3, av + 0.7)
|
|
500
|
+
ax.set_ylim(0.3, st + 0.7)
|
|
501
|
+
ax.set_aspect("equal")
|
|
502
|
+
ax.set_xticks(range(1, av + 1))
|
|
503
|
+
ax.set_yticks(range(1, st + 1))
|
|
504
|
+
ax.set_xlabel("avenue")
|
|
505
|
+
ax.set_ylabel("street")
|
|
506
|
+
ax.grid(True, color="0.85", linewidth=0.8, zorder=0)
|
|
507
|
+
for spine in ax.spines.values():
|
|
508
|
+
spine.set_visible(False)
|
|
509
|
+
|
|
510
|
+
# world border
|
|
511
|
+
ax.add_patch(Rectangle((0.5, 0.5), av, st, fill=False,
|
|
512
|
+
edgecolor="0.25", linewidth=2.5, zorder=3))
|
|
513
|
+
|
|
514
|
+
# Walls, in doubled coordinates, sit *between* cells.
|
|
515
|
+
# even col -> vertical wall east of avenue col/2, at x = col/2 + 0.5
|
|
516
|
+
# even row -> horizontal wall north of street row/2, at y = row/2 + 0.5
|
|
517
|
+
for (col, row) in world.walls:
|
|
518
|
+
if col % 2 == 0: # vertical
|
|
519
|
+
x = col / 2 + 0.5
|
|
520
|
+
s = (row + 1) / 2
|
|
521
|
+
ax.plot([x, x], [s - 0.5, s + 0.5], color="0.25", linewidth=4,
|
|
522
|
+
zorder=4, solid_capstyle="butt")
|
|
523
|
+
else: # horizontal
|
|
524
|
+
y = row / 2 + 0.5
|
|
525
|
+
a = (col + 1) / 2
|
|
526
|
+
ax.plot([a - 0.5, a + 0.5], [y, y], color="0.25", linewidth=4,
|
|
527
|
+
zorder=4, solid_capstyle="butt")
|
|
528
|
+
|
|
529
|
+
# carrots
|
|
530
|
+
for (a, s), n in frame["world"].items():
|
|
531
|
+
ax.add_patch(Circle((a, s), 0.17, color=UMN_GOLD,
|
|
532
|
+
ec="0.3", lw=1.0, zorder=2))
|
|
533
|
+
if n > 1:
|
|
534
|
+
ax.text(a, s, str(n), ha="center", va="center",
|
|
535
|
+
fontsize=8, fontweight="bold", zorder=3)
|
|
536
|
+
|
|
537
|
+
# trace
|
|
538
|
+
if len(frame["trace"]) > 1:
|
|
539
|
+
xs = [p[0] for p in frame["trace"]]
|
|
540
|
+
ys = [p[1] for p in frame["trace"]]
|
|
541
|
+
ax.plot(xs, ys, color=UMN_MAROON, linewidth=1.8, alpha=0.55, zorder=1)
|
|
542
|
+
|
|
543
|
+
_draw_gopher(ax, frame["avenue"], frame["street"], frame["dir"], UMN_MAROON)
|
|
544
|
+
|
|
545
|
+
head = title or (f"steps: {frame['steps']} carrots carried: "
|
|
546
|
+
f"{frame['carried']}")
|
|
547
|
+
ax.set_title(head, fontsize=10)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def show(fps=3, figsize=None, controls=False):
|
|
551
|
+
"""Replay the recorded run as an inline animation, and start it playing.
|
|
552
|
+
|
|
553
|
+
The animation is **displayed as a side effect**, not returned, so it appears
|
|
554
|
+
wherever you call it -- including with `print()` lines after it. (Returning
|
|
555
|
+
it would rely on Jupyter auto-displaying the last expression of the cell,
|
|
556
|
+
which silently drops the animation the moment anything follows.)
|
|
557
|
+
|
|
558
|
+
The player's slider and buttons are hidden by default; pass
|
|
559
|
+
``controls=True`` to get them back.
|
|
560
|
+
|
|
561
|
+
Outside a notebook there is nothing to animate into, so the final frame is
|
|
562
|
+
returned as a figure instead.
|
|
563
|
+
"""
|
|
564
|
+
world = get_world()
|
|
565
|
+
if not _frames:
|
|
566
|
+
raise GopherError("Nothing to show -- create a Gopher and move it first.")
|
|
567
|
+
if figsize is None:
|
|
568
|
+
figsize = _auto_figsize(world)
|
|
569
|
+
|
|
570
|
+
# Decide *before* building anything: outside a notebook the animation would
|
|
571
|
+
# be built and thrown away, which also trips a matplotlib warning.
|
|
572
|
+
try:
|
|
573
|
+
from IPython import get_ipython
|
|
574
|
+
from IPython.display import HTML, display
|
|
575
|
+
in_notebook = get_ipython() is not None
|
|
576
|
+
except ImportError:
|
|
577
|
+
in_notebook = False
|
|
578
|
+
if not in_notebook:
|
|
579
|
+
return show_final(figsize=figsize)
|
|
580
|
+
|
|
581
|
+
plt.close("all")
|
|
582
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
583
|
+
plt.close(fig) # keep the static figure out of the output
|
|
584
|
+
|
|
585
|
+
def update(i):
|
|
586
|
+
_draw_frame(ax, _frames[i], world)
|
|
587
|
+
return ax.patches + ax.lines
|
|
588
|
+
|
|
589
|
+
anim = animation.FuncAnimation(
|
|
590
|
+
fig, update, frames=len(_frames),
|
|
591
|
+
interval=max(1000 // max(fps, 1), 1), blit=False, repeat=True)
|
|
592
|
+
# Keep a reference: matplotlib warns (and can garbage-collect the animation)
|
|
593
|
+
# if nothing holds on to it.
|
|
594
|
+
global _last_anim
|
|
595
|
+
_last_anim = anim
|
|
596
|
+
|
|
597
|
+
display(HTML(_present(anim.to_jshtml(default_mode="once"), controls)))
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def show_final(figsize=None):
|
|
602
|
+
"""Draw just the last frame. Useful for a static figure in slides or a PDF."""
|
|
603
|
+
world = get_world()
|
|
604
|
+
if not _frames:
|
|
605
|
+
raise GopherError("Nothing to show -- create a Gopher and move it first.")
|
|
606
|
+
if figsize is None:
|
|
607
|
+
figsize = _auto_figsize(world)
|
|
608
|
+
plt.close("all") # do not leak a figure per call
|
|
609
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
610
|
+
_draw_frame(ax, _frames[-1], world, title="final state")
|
|
611
|
+
return fig
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
avenues = 7
|
|
2
|
+
streets = 7
|
|
3
|
+
robot = (1, 1, 'E', 0)
|
|
4
|
+
walls = []
|
|
5
|
+
coins = {
|
|
6
|
+
(3, 3): 1,
|
|
7
|
+
(3, 2): 1,
|
|
8
|
+
(3, 1): 1,
|
|
9
|
+
(5, 6): 1,
|
|
10
|
+
(5, 1): 1,
|
|
11
|
+
(3, 6): 1,
|
|
12
|
+
(5, 3): 1,
|
|
13
|
+
(5, 2): 1,
|
|
14
|
+
(7, 6): 1,
|
|
15
|
+
(7, 5): 1,
|
|
16
|
+
(7, 4): 1,
|
|
17
|
+
(7, 3): 1,
|
|
18
|
+
(7, 2): 1,
|
|
19
|
+
(7, 1): 1,
|
|
20
|
+
(3, 5): 1,
|
|
21
|
+
(3, 4): 1,
|
|
22
|
+
(2, 4): 1,
|
|
23
|
+
(2, 5): 1,
|
|
24
|
+
(2, 6): 1,
|
|
25
|
+
(2, 1): 1,
|
|
26
|
+
(2, 2): 1,
|
|
27
|
+
(2, 3): 1,
|
|
28
|
+
(4, 6): 1,
|
|
29
|
+
(4, 4): 1,
|
|
30
|
+
(4, 5): 1,
|
|
31
|
+
(4, 2): 1,
|
|
32
|
+
(4, 3): 1,
|
|
33
|
+
(4, 1): 1,
|
|
34
|
+
(6, 1): 1,
|
|
35
|
+
(6, 2): 1,
|
|
36
|
+
(6, 3): 1,
|
|
37
|
+
(6, 4): 1,
|
|
38
|
+
(6, 5): 1,
|
|
39
|
+
(6, 6): 1,
|
|
40
|
+
(5, 5): 1,
|
|
41
|
+
(5, 4): 1
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
avenues = 7
|
|
2
|
+
streets = 7
|
|
3
|
+
robot = (1, 1, 'E', 0)
|
|
4
|
+
walls = []
|
|
5
|
+
coins = {
|
|
6
|
+
(7, 3): 1,
|
|
7
|
+
(6, 6): 1,
|
|
8
|
+
(5, 6): 1,
|
|
9
|
+
(3, 2): 1,
|
|
10
|
+
(2, 1): 1,
|
|
11
|
+
(6, 2): 1,
|
|
12
|
+
(5, 1): 1,
|
|
13
|
+
(2, 5): 1,
|
|
14
|
+
(7, 2): 1,
|
|
15
|
+
(7, 6): 1,
|
|
16
|
+
(4, 4): 1,
|
|
17
|
+
(3, 6): 1,
|
|
18
|
+
(2, 2): 1,
|
|
19
|
+
(3, 5): 1,
|
|
20
|
+
(4, 1): 1,
|
|
21
|
+
(6, 4): 1,
|
|
22
|
+
(5, 4): 1,
|
|
23
|
+
(7, 1): 1,
|
|
24
|
+
(4, 5): 1,
|
|
25
|
+
(5, 5): 1,
|
|
26
|
+
(2, 3): 1,
|
|
27
|
+
(4, 2): 1,
|
|
28
|
+
(6, 5): 1,
|
|
29
|
+
(5, 3): 1,
|
|
30
|
+
(4, 6): 1,
|
|
31
|
+
(3, 4): 1,
|
|
32
|
+
(6, 1): 1,
|
|
33
|
+
(7, 4): 1,
|
|
34
|
+
(4, 3): 1,
|
|
35
|
+
(2, 4): 1
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
avenues = 7
|
|
2
|
+
streets = 7
|
|
3
|
+
robot = (1, 1, 'E', 99)
|
|
4
|
+
walls = []
|
|
5
|
+
coins = {
|
|
6
|
+
(7, 3): 1,
|
|
7
|
+
(6, 6): 1,
|
|
8
|
+
(5, 6): 1,
|
|
9
|
+
(3, 2): 1,
|
|
10
|
+
(2, 1): 1,
|
|
11
|
+
(6, 2): 1,
|
|
12
|
+
(5, 1): 2,
|
|
13
|
+
(2, 5): 1,
|
|
14
|
+
(7, 2): 1,
|
|
15
|
+
(5, 5): 1,
|
|
16
|
+
(7, 6): 1,
|
|
17
|
+
(4, 4): 1,
|
|
18
|
+
(3, 6): 1,
|
|
19
|
+
(2, 2): 2,
|
|
20
|
+
(3, 5): 1,
|
|
21
|
+
(4, 1): 1,
|
|
22
|
+
(6, 4): 1,
|
|
23
|
+
(5, 4): 1,
|
|
24
|
+
(7, 1): 1,
|
|
25
|
+
(4, 5): 1,
|
|
26
|
+
(2, 3): 1,
|
|
27
|
+
(4, 2): 1,
|
|
28
|
+
(6, 5): 2,
|
|
29
|
+
(5, 3): 2,
|
|
30
|
+
(4, 6): 1,
|
|
31
|
+
(6, 1): 1,
|
|
32
|
+
(7, 4): 1,
|
|
33
|
+
(4, 3): 1,
|
|
34
|
+
(3, 4): 2,
|
|
35
|
+
(2, 4): 1
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
avenues = 12
|
|
2
|
+
streets = 12
|
|
3
|
+
robot = (1, 1, 'E', 36)
|
|
4
|
+
walls = []
|
|
5
|
+
coins = {
|
|
6
|
+
(7, 3): 1,
|
|
7
|
+
(6, 10): 1,
|
|
8
|
+
(6, 6): 1,
|
|
9
|
+
(2, 8): 1,
|
|
10
|
+
(10, 6): 1,
|
|
11
|
+
(7, 7): 1,
|
|
12
|
+
(4, 6): 1,
|
|
13
|
+
(6, 2): 1,
|
|
14
|
+
(7, 11): 1,
|
|
15
|
+
(3, 7): 1,
|
|
16
|
+
(10, 8): 1,
|
|
17
|
+
(5, 5): 1,
|
|
18
|
+
(4, 4): 1,
|
|
19
|
+
(8, 10): 1,
|
|
20
|
+
(4, 8): 1,
|
|
21
|
+
(8, 6): 1,
|
|
22
|
+
(5, 3): 1,
|
|
23
|
+
(9, 7): 1,
|
|
24
|
+
(4, 10): 1,
|
|
25
|
+
(2, 6): 1,
|
|
26
|
+
(5, 11): 1,
|
|
27
|
+
(5, 9): 1,
|
|
28
|
+
(7, 5): 1,
|
|
29
|
+
(6, 12): 1,
|
|
30
|
+
(6, 4): 1,
|
|
31
|
+
(3, 5): 1,
|
|
32
|
+
(11, 7): 1,
|
|
33
|
+
(6, 8): 1,
|
|
34
|
+
(5, 7): 1,
|
|
35
|
+
(9, 9): 1,
|
|
36
|
+
(8, 8): 1,
|
|
37
|
+
(7, 9): 1,
|
|
38
|
+
(1, 7): 1,
|
|
39
|
+
(9, 5): 1,
|
|
40
|
+
(3, 9): 1,
|
|
41
|
+
(8, 4): 1
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
avenues = 10
|
|
2
|
+
streets = 10
|
|
3
|
+
robot = (1, 1, 'E', 0)
|
|
4
|
+
walls = [
|
|
5
|
+
(10, 1),
|
|
6
|
+
(10, 3),
|
|
7
|
+
(10, 5),
|
|
8
|
+
(1, 10),
|
|
9
|
+
(3, 10),
|
|
10
|
+
(5, 10),
|
|
11
|
+
(2, 1),
|
|
12
|
+
(2, 3),
|
|
13
|
+
(1, 6),
|
|
14
|
+
(3, 6),
|
|
15
|
+
(4, 5),
|
|
16
|
+
(4, 3),
|
|
17
|
+
(5, 2),
|
|
18
|
+
(6, 3),
|
|
19
|
+
(7, 8),
|
|
20
|
+
(5, 8),
|
|
21
|
+
(2, 7),
|
|
22
|
+
(7, 10),
|
|
23
|
+
(8, 7),
|
|
24
|
+
(9, 6),
|
|
25
|
+
(8, 3),
|
|
26
|
+
(9, 4),
|
|
27
|
+
(9, 10),
|
|
28
|
+
(10, 9)
|
|
29
|
+
]
|
|
30
|
+
coins = {
|
|
31
|
+
(6, 4): 1
|
|
32
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cege-gopher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Karel-style grid robot for teaching Python, themed as Goldy Gopher. Records and replays, so it works in Colab.
|
|
5
|
+
Author-email: Seongjin Choi <chois@umn.edu>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Seongjin Choi
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: education,karel,robot,colab,cege3101
|
|
29
|
+
Classifier: Intended Audience :: Education
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Topic :: Education
|
|
32
|
+
Requires-Python: >=3.9
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Requires-Dist: matplotlib>=3.5
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# cege_gopher
|
|
39
|
+
|
|
40
|
+
A Karel-style grid robot for teaching introductory Python, themed as Goldy
|
|
41
|
+
Gopher. Written for **CEGE 3101 — Computer Applications I** at the University of
|
|
42
|
+
Minnesota.
|
|
43
|
+
|
|
44
|
+
Ported from the MATLAB robot used in earlier offerings of the course, which was
|
|
45
|
+
itself a port of the KAIST CS101 HUBO exercise
|
|
46
|
+
([otfried/cs101](https://github.com/otfried/cs101)) — Karel the Robot, at the
|
|
47
|
+
root of it.
|
|
48
|
+
|
|
49
|
+
## Why this exists
|
|
50
|
+
|
|
51
|
+
The original libraries open a live window and animate each command as it runs.
|
|
52
|
+
**Google Colab cannot host one.**
|
|
53
|
+
|
|
54
|
+
So this version **records, then replays**. Every command appends a frame to a
|
|
55
|
+
buffer; nothing is drawn until `show()`, which renders the whole run as an
|
|
56
|
+
inline animation:
|
|
57
|
+
|
|
58
|
+
> write the whole program → run the cell → watch it play back
|
|
59
|
+
|
|
60
|
+
Students never drive the robot interactively; every program is pre-coded, which
|
|
61
|
+
is also how the homework is graded.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install cege-gopher
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or, from the course repository:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install "git+https://github.com/benchoi93/CEGE3101_Computer_Applications.git#subdirectory=packages/cege-gopher"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The 13 world files ship **inside the package**, so nothing needs uploading —
|
|
76
|
+
`load_world("harvest1.wld")` just works.
|
|
77
|
+
|
|
78
|
+
## Use
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from cege_gopher import *
|
|
82
|
+
|
|
83
|
+
load_world("harvest1.wld") # or: create_world(10, 10)
|
|
84
|
+
goldy = Gopher()
|
|
85
|
+
goldy.set_trace("maroon")
|
|
86
|
+
|
|
87
|
+
while goldy.front_is_clear():
|
|
88
|
+
if goldy.on_carrot():
|
|
89
|
+
goldy.pick_carrot()
|
|
90
|
+
goldy.move()
|
|
91
|
+
|
|
92
|
+
show() # the animation renders here
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`list_worlds()` returns the bundled world names.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
| | |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Module | `create_world(avenues, streets)`, `load_world(name)`, `list_worlds()`, `show(fps=)`, `show_final()`, `get_world()` |
|
|
102
|
+
| Move | `move()`, `turn_left()`, `turn_right()` |
|
|
103
|
+
| Carrots | `pick_carrot()`, `drop_carrot()` |
|
|
104
|
+
| Sense | `front_is_clear()`, `left_is_clear()`, `right_is_clear()`, `facing_north()`, `on_carrot()`, `carries_carrots()` |
|
|
105
|
+
| Display | `set_trace(color)`, `get_pos()` |
|
|
106
|
+
|
|
107
|
+
`Robot` is an alias for `Gopher`, and `pick_beeper`/`pick_coin` for
|
|
108
|
+
`pick_carrot`, so code written against `cs1robots` or the MATLAB version still
|
|
109
|
+
runs.
|
|
110
|
+
|
|
111
|
+
## A bug fixed in this port
|
|
112
|
+
|
|
113
|
+
The MATLAB version had `leftIsClear` and `rightIsClear` **swapped** — facing
|
|
114
|
+
east it checked south for "left", when `turnLeft` rotates counter-clockwise
|
|
115
|
+
(E→N) so left of east is north. This port uses `left = (-dy, dx)`,
|
|
116
|
+
`right = (dy, -dx)`.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/cege_gopher/__init__.py
|
|
5
|
+
src/cege_gopher.egg-info/PKG-INFO
|
|
6
|
+
src/cege_gopher.egg-info/SOURCES.txt
|
|
7
|
+
src/cege_gopher.egg-info/dependency_links.txt
|
|
8
|
+
src/cege_gopher.egg-info/requires.txt
|
|
9
|
+
src/cege_gopher.egg-info/top_level.txt
|
|
10
|
+
src/cege_gopher/sprites/E.png
|
|
11
|
+
src/cege_gopher/sprites/N.png
|
|
12
|
+
src/cege_gopher/sprites/S.png
|
|
13
|
+
src/cege_gopher/sprites/W.png
|
|
14
|
+
src/cege_gopher/worlds/around1.wld
|
|
15
|
+
src/cege_gopher/worlds/around2.wld
|
|
16
|
+
src/cege_gopher/worlds/around3.wld
|
|
17
|
+
src/cege_gopher/worlds/harvest1.wld
|
|
18
|
+
src/cege_gopher/worlds/harvest2.wld
|
|
19
|
+
src/cege_gopher/worlds/harvest3.wld
|
|
20
|
+
src/cege_gopher/worlds/harvest5.wld
|
|
21
|
+
src/cege_gopher/worlds/hurdles1.wld
|
|
22
|
+
src/cege_gopher/worlds/hurdles2.wld
|
|
23
|
+
src/cege_gopher/worlds/hurdles3.wld
|
|
24
|
+
src/cege_gopher/worlds/hurdles4.wld
|
|
25
|
+
src/cege_gopher/worlds/maze1.wld
|
|
26
|
+
src/cege_gopher/worlds/newspaper.wld
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
matplotlib>=3.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cege_gopher
|