stanfordkarel-js-notebooks 0.1.0
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.
- package/LICENSE +22 -0
- package/README.md +220 -0
- package/package.json +45 -0
- package/stanfordkarel.js +712 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Albert Kennis
|
|
4
|
+
Copyright (c) 2022 Tyler Yep
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Stanford Karel — JavaScript
|
|
2
|
+
|
|
3
|
+
A JavaScript port of the [Stanford Karel](https://github.com/TylerYep/stanfordkarel) robot programming library (CS 106A), designed for [Observable](https://observablehq.com/) notebooks. Karel programs render as inline animated GIFs — no desktop window or Python environment required.
|
|
4
|
+
|
|
5
|
+
## Usage in Observable
|
|
6
|
+
|
|
7
|
+
### 1. Attach the file
|
|
8
|
+
|
|
9
|
+
Upload `stanfordkarel.js` as a file attachment in your notebook, then load it as an ES module:
|
|
10
|
+
|
|
11
|
+
```javascript
|
|
12
|
+
stanfordkarel = FileAttachment("stanfordkarel.js")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
karel = import(await stanfordkarel.url())
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 2. Define a world and run a program
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
animation = karel.runKarel(`
|
|
23
|
+
Dimension: (5, 5)
|
|
24
|
+
Karel: (1, 1); east
|
|
25
|
+
BeeperBag: INFINITY
|
|
26
|
+
`, function main(k) {
|
|
27
|
+
while (k.front_is_clear()) k.move();
|
|
28
|
+
k.turn_left();
|
|
29
|
+
while (k.front_is_clear()) k.move();
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 3. Destructure for Python-style syntax
|
|
34
|
+
|
|
35
|
+
If you prefer calling functions without the `k.` prefix, destructure the API:
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
animation = karel.runKarel(worldText, function main({
|
|
39
|
+
move, turn_left, front_is_clear, beepers_present, pick_beeper, put_beeper
|
|
40
|
+
}) {
|
|
41
|
+
while (front_is_clear()) move();
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## API Reference
|
|
48
|
+
|
|
49
|
+
### Actions
|
|
50
|
+
|
|
51
|
+
| Function | Description |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `k.move()` | Move one step forward. Throws if blocked by a wall or boundary. |
|
|
54
|
+
| `k.turn_left()` | Rotate 90° counterclockwise. |
|
|
55
|
+
| `k.put_beeper()` | Place a beeper at the current corner. Throws if bag is empty. |
|
|
56
|
+
| `k.pick_beeper()` | Pick up a beeper from the current corner. Throws if none present. |
|
|
57
|
+
| `k.paint_corner(color)` | Paint the current corner a color (use a color constant). |
|
|
58
|
+
|
|
59
|
+
### Conditions
|
|
60
|
+
|
|
61
|
+
| Function | Returns `true` when… |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `k.front_is_clear()` | No wall or boundary ahead |
|
|
64
|
+
| `k.front_is_blocked()` | Wall or boundary ahead |
|
|
65
|
+
| `k.left_is_clear()` | No wall or boundary to the left |
|
|
66
|
+
| `k.left_is_blocked()` | Wall or boundary to the left |
|
|
67
|
+
| `k.right_is_clear()` | No wall or boundary to the right |
|
|
68
|
+
| `k.right_is_blocked()` | Wall or boundary to the right |
|
|
69
|
+
| `k.beepers_present()` | At least one beeper on the current corner |
|
|
70
|
+
| `k.no_beepers_present()` | No beepers on the current corner |
|
|
71
|
+
| `k.beepers_in_bag()` | At least one beeper in Karel's bag |
|
|
72
|
+
| `k.no_beepers_in_bag()` | Karel's bag is empty |
|
|
73
|
+
| `k.facing_north()` | Karel faces north |
|
|
74
|
+
| `k.facing_east()` | Karel faces east |
|
|
75
|
+
| `k.facing_south()` | Karel faces south |
|
|
76
|
+
| `k.facing_west()` | Karel faces west |
|
|
77
|
+
| `k.not_facing_north()` | Karel does not face north |
|
|
78
|
+
| `k.not_facing_east()` | Karel does not face east |
|
|
79
|
+
| `k.not_facing_south()` | Karel does not face south |
|
|
80
|
+
| `k.not_facing_west()` | Karel does not face west |
|
|
81
|
+
| `k.corner_color_is(color)` | Current corner is painted the given color |
|
|
82
|
+
|
|
83
|
+
### Color Constants
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
const { RED, BLACK, CYAN, DARK_GRAY, GRAY, GREEN, LIGHT_GRAY,
|
|
87
|
+
MAGENTA, ORANGE, PINK, WHITE, BLUE, YELLOW, BLANK } = karel;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use these with `k.paint_corner(karel.RED)` or destructure them directly.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## `runKarel(worldText, mainFunc, options?)`
|
|
95
|
+
|
|
96
|
+
Returns a `Promise<HTMLImageElement>` — an animated GIF ready to display in an Observable cell.
|
|
97
|
+
|
|
98
|
+
| Option | Type | Default | Description |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| `cellSize` | `number` | `50` | Pixels per grid cell |
|
|
101
|
+
| `delay` | `number` | `100`* | Milliseconds per animation frame |
|
|
102
|
+
| `finalFrameDelay` | `number` | `1000` | Extra pause on the last frame (ms) |
|
|
103
|
+
| `icon` | `"karel"` \| `"simple"` | `"karel"` | Robot sprite style |
|
|
104
|
+
| `gifWorkers` | `number` | `2` | Web workers used by gif.js |
|
|
105
|
+
|
|
106
|
+
\* If the world includes a `Speed:` directive, that value sets the default delay (`delay = 100 / speed` ms). An explicit `delay` option always takes precedence.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## World File Format
|
|
111
|
+
|
|
112
|
+
Worlds are plain text strings, one directive per line. The same `.w` format used by the Python library is supported.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Dimension: (num_avenues, num_streets)
|
|
116
|
+
Karel: (avenue, street); direction
|
|
117
|
+
BeeperBag: num_beepers (or INFINITY)
|
|
118
|
+
Beeper: (avenue, street); count
|
|
119
|
+
Wall: (avenue, street); direction
|
|
120
|
+
Color: (avenue, street); color
|
|
121
|
+
Speed: speed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- **Avenues** run north–south (columns, x-axis)
|
|
125
|
+
- **Streets** run east–west (rows, y-axis)
|
|
126
|
+
- Coordinates are 1-indexed from the south-west corner
|
|
127
|
+
- `direction` is one of: `north`, `south`, `east`, `west`
|
|
128
|
+
|
|
129
|
+
### Example world
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
Dimension: (8, 8)
|
|
133
|
+
Karel: (1, 1); east
|
|
134
|
+
BeeperBag: INFINITY
|
|
135
|
+
Beeper: (4, 4); 3
|
|
136
|
+
Wall: (2, 1); north
|
|
137
|
+
Wall: (2, 2); north
|
|
138
|
+
Wall: (2, 3); north
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## `fetchWorld(url)`
|
|
144
|
+
|
|
145
|
+
Convenience helper that fetches a `.w` world file from a URL and returns its text.
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
worldText = karel.fetchWorld("https://example.com/worlds/my_world.w")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```javascript
|
|
152
|
+
animation = karel.runKarel(await worldText, main)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Complete Example
|
|
158
|
+
|
|
159
|
+
Karel navigates from the south-west corner to a 5×5 inner square and collects all 16 beepers around its perimeter:
|
|
160
|
+
|
|
161
|
+
```javascript
|
|
162
|
+
animation = {
|
|
163
|
+
const worldText = `
|
|
164
|
+
Dimension: (9, 9)
|
|
165
|
+
Karel: (1, 1); east
|
|
166
|
+
BeeperBag: 0
|
|
167
|
+
Beeper: (3, 3); 1
|
|
168
|
+
Beeper: (4, 3); 1
|
|
169
|
+
Beeper: (5, 3); 1
|
|
170
|
+
Beeper: (6, 3); 1
|
|
171
|
+
Beeper: (7, 3); 1
|
|
172
|
+
Beeper: (7, 4); 1
|
|
173
|
+
Beeper: (7, 5); 1
|
|
174
|
+
Beeper: (7, 6); 1
|
|
175
|
+
Beeper: (7, 7); 1
|
|
176
|
+
Beeper: (6, 7); 1
|
|
177
|
+
Beeper: (5, 7); 1
|
|
178
|
+
Beeper: (4, 7); 1
|
|
179
|
+
Beeper: (3, 7); 1
|
|
180
|
+
Beeper: (3, 6); 1
|
|
181
|
+
Beeper: (3, 5); 1
|
|
182
|
+
Beeper: (3, 4); 1
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
function main(k) {
|
|
186
|
+
function turn_right() {
|
|
187
|
+
k.turn_left(); k.turn_left(); k.turn_left();
|
|
188
|
+
}
|
|
189
|
+
function walkAndPick(steps) {
|
|
190
|
+
for (let i = 0; i < steps; i++) {
|
|
191
|
+
k.move();
|
|
192
|
+
if (k.beepers_present()) k.pick_beeper();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Navigate to the bottom-left corner of the inner square
|
|
197
|
+
k.move(); k.move(); // east to avenue 3
|
|
198
|
+
k.turn_left(); // face north
|
|
199
|
+
k.move(); k.move(); // north to street 3
|
|
200
|
+
k.pick_beeper(); // pick corner beeper at (3,3)
|
|
201
|
+
|
|
202
|
+
walkAndPick(4); // up the left side: (3,4) → (3,7)
|
|
203
|
+
turn_right(); // face east
|
|
204
|
+
walkAndPick(4); // across the top: (4,7) → (7,7)
|
|
205
|
+
turn_right(); // face south
|
|
206
|
+
walkAndPick(4); // down the right side: (7,6) → (7,3)
|
|
207
|
+
turn_right(); // face west
|
|
208
|
+
walkAndPick(3); // back along bottom: (6,3) → (4,3)
|
|
209
|
+
k.move(); // return to start — already cleared
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return karel.runKarel(worldText, main, { cellSize: 60, delay: 150 });
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stanfordkarel-js-notebooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stanford Karel robot simulation for Javascript notebooks — animate Karel programs as GIFs in the browser",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "stanfordkarel.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
"import": "./stanfordkarel.js"
|
|
9
|
+
},
|
|
10
|
+
"browser": true,
|
|
11
|
+
"unpkg": "stanfordkarel.js",
|
|
12
|
+
"jsdelivr": "stanfordkarel.js",
|
|
13
|
+
"files": [
|
|
14
|
+
"stanfordkarel.js",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"gif.js": "^0.2.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"karel",
|
|
23
|
+
"javascript",
|
|
24
|
+
"education",
|
|
25
|
+
"programming",
|
|
26
|
+
"computer science",
|
|
27
|
+
"stanford",
|
|
28
|
+
"observable",
|
|
29
|
+
"notebook",
|
|
30
|
+
"education",
|
|
31
|
+
"robot",
|
|
32
|
+
"animation",
|
|
33
|
+
"cs106a"
|
|
34
|
+
],
|
|
35
|
+
"author": "Albert Kennis",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://github.com/akennis/stanfordkarel-js-notebooks#readme",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/akennis/stanfordkarel-js-notebooks.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/akennis/stanfordkarel-js-notebooks/issues"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/stanfordkarel.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stanford Karel for Observable Notebooks
|
|
3
|
+
*
|
|
4
|
+
* A JavaScript port of the Stanford Karel library (CS 106A).
|
|
5
|
+
* Renders Karel programs as animated GIFs using gif.js.
|
|
6
|
+
*
|
|
7
|
+
* Observable usage:
|
|
8
|
+
*
|
|
9
|
+
* karel = import("https://raw.githubusercontent.com/.../stanfordkarel.js")
|
|
10
|
+
*
|
|
11
|
+
* animation = karel.runKarel(`
|
|
12
|
+
* Dimension: (5, 5)
|
|
13
|
+
* Karel: (1, 1); east
|
|
14
|
+
* BeeperBag: INFINITY
|
|
15
|
+
* `, function main(k) {
|
|
16
|
+
* while (k.front_is_clear()) k.move();
|
|
17
|
+
* k.turn_left();
|
|
18
|
+
* while (k.front_is_clear()) k.move();
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* Or destructure the API for a style closer to the Python version:
|
|
22
|
+
*
|
|
23
|
+
* animation = karel.runKarel(worldText, function main({
|
|
24
|
+
* move, turn_left, front_is_clear, beepers_present, put_beeper
|
|
25
|
+
* }) {
|
|
26
|
+
* while (front_is_clear()) { move(); }
|
|
27
|
+
* if (beepers_present()) { put_beeper(); }
|
|
28
|
+
* })
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────── COLOR CONSTANTS ────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const RED = "Red";
|
|
34
|
+
export const BLACK = "Black";
|
|
35
|
+
export const CYAN = "Cyan";
|
|
36
|
+
export const DARK_GRAY = "Dark Gray";
|
|
37
|
+
export const GRAY = "Gray";
|
|
38
|
+
export const GREEN = "Green";
|
|
39
|
+
export const LIGHT_GRAY = "Light Gray";
|
|
40
|
+
export const MAGENTA = "Magenta";
|
|
41
|
+
export const ORANGE = "Orange";
|
|
42
|
+
export const PINK = "Pink";
|
|
43
|
+
export const WHITE = "White";
|
|
44
|
+
export const BLUE = "Blue";
|
|
45
|
+
export const YELLOW = "Yellow";
|
|
46
|
+
export const BLANK = "";
|
|
47
|
+
|
|
48
|
+
// Map Karel color names to CSS colors (mirrors Python's COLOR_MAP)
|
|
49
|
+
const CSS_COLORS = {
|
|
50
|
+
Red: "red",
|
|
51
|
+
Black: "black",
|
|
52
|
+
Cyan: "cyan",
|
|
53
|
+
"Dark Gray": "#4d4d4d", // tkinter gray30
|
|
54
|
+
Gray: "#8c8c8c", // tkinter gray55
|
|
55
|
+
Green: "green",
|
|
56
|
+
"Light Gray": "#cccccc", // tkinter gray80
|
|
57
|
+
Magenta: "#cd00cd", // tkinter magenta3
|
|
58
|
+
Orange: "orange",
|
|
59
|
+
Pink: "pink",
|
|
60
|
+
White: "#fffafa", // tkinter snow
|
|
61
|
+
Blue: "blue",
|
|
62
|
+
Yellow: "yellow",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ─────────────────────────── WORLD PARSER ───────────────────────────────────
|
|
66
|
+
|
|
67
|
+
class KarelWorld {
|
|
68
|
+
constructor() {
|
|
69
|
+
this.numAvenues = 1;
|
|
70
|
+
this.numStreets = 1;
|
|
71
|
+
/** @type {Map<string, number>} "avenue,street" → count */
|
|
72
|
+
this.beepers = new Map();
|
|
73
|
+
/** @type {Set<string>} "avenue,street,direction" */
|
|
74
|
+
this.walls = new Set();
|
|
75
|
+
/** @type {Map<string, string>} "avenue,street" → color name */
|
|
76
|
+
this.cornerColors = new Map();
|
|
77
|
+
this.karelAvenue = 1;
|
|
78
|
+
this.karelStreet = 1;
|
|
79
|
+
this.karelDirection = "east";
|
|
80
|
+
this.karelBeeperCount = 0;
|
|
81
|
+
/** @type {number|null} Speed directive value (frames/sec multiplier); null if unset */
|
|
82
|
+
this.karelSpeed = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse a world file from its text contents.
|
|
87
|
+
* Accepts the same format as the Python library.
|
|
88
|
+
*/
|
|
89
|
+
loadFromText(text) {
|
|
90
|
+
for (const line of text.split("\n")) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed) continue;
|
|
93
|
+
const colonIdx = trimmed.indexOf(":");
|
|
94
|
+
if (colonIdx === -1) continue;
|
|
95
|
+
|
|
96
|
+
const keyword = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
97
|
+
const params = trimmed.slice(colonIdx + 1).trim(); // original case
|
|
98
|
+
const paramsLow = params.toLowerCase();
|
|
99
|
+
|
|
100
|
+
const coordMatch = paramsLow.match(/\((\d+),\s*(\d+)\)/);
|
|
101
|
+
const avenue = coordMatch ? parseInt(coordMatch[1]) : null;
|
|
102
|
+
const street = coordMatch ? parseInt(coordMatch[2]) : null;
|
|
103
|
+
|
|
104
|
+
const dirMatch = paramsLow.match(/\b(north|south|east|west)\b/);
|
|
105
|
+
const direction = dirMatch ? dirMatch[1] : null;
|
|
106
|
+
|
|
107
|
+
const numMatch = paramsLow.replace(/\(.*?\)/, "").match(/\d+/);
|
|
108
|
+
const num = numMatch ? parseInt(numMatch[0]) : null;
|
|
109
|
+
|
|
110
|
+
switch (keyword) {
|
|
111
|
+
case "dimension":
|
|
112
|
+
if (avenue !== null) { this.numAvenues = avenue; this.numStreets = street; }
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case "karel":
|
|
116
|
+
if (avenue !== null) { this.karelAvenue = avenue; this.karelStreet = street; }
|
|
117
|
+
if (direction) this.karelDirection = direction;
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case "beeper":
|
|
121
|
+
if (avenue !== null && num !== null) {
|
|
122
|
+
const key = `${avenue},${street}`;
|
|
123
|
+
this.beepers.set(key, (this.beepers.get(key) ?? 0) + num);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
|
|
127
|
+
case "wall":
|
|
128
|
+
if (avenue !== null && direction) {
|
|
129
|
+
this.walls.add(`${avenue},${street},${direction}`);
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
case "beeperbag":
|
|
134
|
+
if (paramsLow.includes("infinity") || paramsLow.includes("infinite")) {
|
|
135
|
+
this.karelBeeperCount = Infinity;
|
|
136
|
+
} else if (num !== null) {
|
|
137
|
+
this.karelBeeperCount = num;
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case "color": {
|
|
142
|
+
if (avenue !== null) {
|
|
143
|
+
// Extract color name with original casing, normalize to Title Case
|
|
144
|
+
const colorMatch = params.match(/;\s*(.+)$/);
|
|
145
|
+
if (colorMatch) {
|
|
146
|
+
const colorName = colorMatch[1].trim()
|
|
147
|
+
.split(" ")
|
|
148
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
149
|
+
.join(" ");
|
|
150
|
+
this.cornerColors.set(`${avenue},${street}`, colorName);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "speed": {
|
|
157
|
+
// Speed is a positive float; higher = faster animation.
|
|
158
|
+
// Stored here and converted to a GIF frame delay in runKarel().
|
|
159
|
+
const speed = parseFloat(params);
|
|
160
|
+
if (!isNaN(speed) && speed > 0) this.karelSpeed = speed;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
wallExists(avenue, street, direction) {
|
|
168
|
+
return this.walls.has(`${avenue},${street},${direction}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
inBounds(avenue, street) {
|
|
172
|
+
return avenue >= 1 && avenue <= this.numAvenues &&
|
|
173
|
+
street >= 1 && street <= this.numStreets;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
beeperCount(avenue, street) {
|
|
177
|
+
return this.beepers.get(`${avenue},${street}`) ?? 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
addBeeper(avenue, street) {
|
|
181
|
+
const key = `${avenue},${street}`;
|
|
182
|
+
this.beepers.set(key, (this.beepers.get(key) ?? 0) + 1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
removeBeeper(avenue, street) {
|
|
186
|
+
const key = `${avenue},${street}`;
|
|
187
|
+
const count = this.beepers.get(key) ?? 0;
|
|
188
|
+
if (count > 1) this.beepers.set(key, count - 1);
|
|
189
|
+
else this.beepers.delete(key);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
cornerColor(avenue, street) {
|
|
193
|
+
return this.cornerColors.get(`${avenue},${street}`) ?? "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
paintCorner(avenue, street, color) {
|
|
197
|
+
if (color) this.cornerColors.set(`${avenue},${street}`, color);
|
|
198
|
+
else this.cornerColors.delete(`${avenue},${street}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─────────────────────────── DIRECTION MAPS ─────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const TURN_LEFT_MAP = { north: "west", west: "south", south: "east", east: "north" };
|
|
205
|
+
const TURN_RIGHT_MAP = { north: "east", east: "south", south: "west", west: "north" };
|
|
206
|
+
const OPPOSITE_MAP = { north: "south", south: "north", east: "west", west: "east" };
|
|
207
|
+
const DELTA_MAP = { north: [0, 1], east: [1, 0], south: [0, -1], west: [-1, 0] };
|
|
208
|
+
|
|
209
|
+
// ─────────────────────────── KAREL PROGRAM ──────────────────────────────────
|
|
210
|
+
|
|
211
|
+
class KarelProgram {
|
|
212
|
+
constructor(world) {
|
|
213
|
+
this.world = world;
|
|
214
|
+
this.avenue = world.karelAvenue;
|
|
215
|
+
this.street = world.karelStreet;
|
|
216
|
+
this.direction = world.karelDirection;
|
|
217
|
+
this.numBeepers = world.karelBeeperCount;
|
|
218
|
+
/** @type {Array<()=>void>} */
|
|
219
|
+
this._callbacks = [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_notify() {
|
|
223
|
+
for (const cb of this._callbacks) cb();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Returns true if Karel can move in the given direction without hitting a
|
|
228
|
+
* wall or boundary. Checks both the direct wall and the opposite-face wall
|
|
229
|
+
* on the adjacent cell (mirrors Python's direction_is_clear logic).
|
|
230
|
+
*/
|
|
231
|
+
_directionIsClear(direction) {
|
|
232
|
+
const [da, ds] = DELTA_MAP[direction];
|
|
233
|
+
const na = this.avenue + da;
|
|
234
|
+
const ns = this.street + ds;
|
|
235
|
+
if (!this.world.inBounds(na, ns)) return false;
|
|
236
|
+
if (this.world.wallExists(this.avenue, this.street, direction)) return false;
|
|
237
|
+
return !this.world.wallExists(na, ns, OPPOSITE_MAP[direction]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
move() {
|
|
241
|
+
if (!this._directionIsClear(this.direction))
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Karel cannot move from (${this.avenue}, ${this.street}) facing ${this.direction}`
|
|
244
|
+
);
|
|
245
|
+
const [da, ds] = DELTA_MAP[this.direction];
|
|
246
|
+
this.avenue += da;
|
|
247
|
+
this.street += ds;
|
|
248
|
+
this._notify();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
turn_left() {
|
|
252
|
+
this.direction = TURN_LEFT_MAP[this.direction];
|
|
253
|
+
this._notify();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
put_beeper() {
|
|
257
|
+
if (this.numBeepers === 0)
|
|
258
|
+
throw new Error("Karel tried to put a beeper but has none in its bag");
|
|
259
|
+
this.world.addBeeper(this.avenue, this.street);
|
|
260
|
+
if (isFinite(this.numBeepers)) this.numBeepers--;
|
|
261
|
+
this._notify();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
pick_beeper() {
|
|
265
|
+
if (this.world.beeperCount(this.avenue, this.street) === 0)
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Karel tried to pick up a beeper at (${this.avenue}, ${this.street}) but there are none`
|
|
268
|
+
);
|
|
269
|
+
this.world.removeBeeper(this.avenue, this.street);
|
|
270
|
+
if (isFinite(this.numBeepers)) this.numBeepers++;
|
|
271
|
+
this._notify();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Sensing ──────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
front_is_clear() { return this._directionIsClear(this.direction); }
|
|
277
|
+
front_is_blocked() { return !this.front_is_clear(); }
|
|
278
|
+
|
|
279
|
+
left_is_clear() { return this._directionIsClear(TURN_LEFT_MAP[this.direction]); }
|
|
280
|
+
left_is_blocked() { return !this.left_is_clear(); }
|
|
281
|
+
|
|
282
|
+
right_is_clear() { return this._directionIsClear(TURN_RIGHT_MAP[this.direction]); }
|
|
283
|
+
right_is_blocked() { return !this.right_is_clear(); }
|
|
284
|
+
|
|
285
|
+
beepers_present() { return this.world.beeperCount(this.avenue, this.street) > 0; }
|
|
286
|
+
no_beepers_present() { return !this.beepers_present(); }
|
|
287
|
+
|
|
288
|
+
beepers_in_bag() { return this.numBeepers !== 0; }
|
|
289
|
+
no_beepers_in_bag() { return !this.beepers_in_bag(); }
|
|
290
|
+
|
|
291
|
+
facing_north() { return this.direction === "north"; }
|
|
292
|
+
not_facing_north() { return !this.facing_north(); }
|
|
293
|
+
facing_east() { return this.direction === "east"; }
|
|
294
|
+
not_facing_east() { return !this.facing_east(); }
|
|
295
|
+
facing_south() { return this.direction === "south"; }
|
|
296
|
+
not_facing_south() { return !this.facing_south(); }
|
|
297
|
+
facing_west() { return this.direction === "west"; }
|
|
298
|
+
not_facing_west() { return !this.facing_west(); }
|
|
299
|
+
|
|
300
|
+
// ── Color ────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
paint_corner(color) {
|
|
303
|
+
if (color && !Object.hasOwn(CSS_COLORS, color))
|
|
304
|
+
throw new Error(`Invalid Karel color: "${color}"`);
|
|
305
|
+
this.world.paintCorner(this.avenue, this.street, color);
|
|
306
|
+
this._notify();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
corner_color_is(color) {
|
|
310
|
+
return this.world.cornerColor(this.avenue, this.street) === color;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─────────────────────────── RENDERER ───────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
// Shared constants (mirror karel_constants.py)
|
|
317
|
+
const BORDER_OFFSET = 17;
|
|
318
|
+
const LABEL_OFFSET = 7;
|
|
319
|
+
const CORNER_SIZE = 2;
|
|
320
|
+
const BEEPER_CELL_SIZE_FRAC = 0.4;
|
|
321
|
+
const LINE_WIDTH = 2;
|
|
322
|
+
const KAREL_LINE_WIDTH = 2;
|
|
323
|
+
|
|
324
|
+
// Karel body proportions (fraction of cell size)
|
|
325
|
+
const KAREL_LEFT_HORIZONTAL_PAD = 0.29;
|
|
326
|
+
const KAREL_VERTICAL_OFFSET = 0.05;
|
|
327
|
+
const KAREL_WIDTH = 0.58;
|
|
328
|
+
const KAREL_HEIGHT = 0.76;
|
|
329
|
+
const KAREL_INNER_OFFSET = 0.125;
|
|
330
|
+
const KAREL_INNER_WIDTH = 0.28125;
|
|
331
|
+
const KAREL_INNER_HEIGHT = 0.38;
|
|
332
|
+
const KAREL_MOUTH_HORIZONTAL_OFFSET = 0.2625;
|
|
333
|
+
const KAREL_MOUTH_VERTICAL_OFFSET = 0.125;
|
|
334
|
+
const KAREL_MOUTH_WIDTH = 0.1375;
|
|
335
|
+
const KAREL_UPPER_RIGHT_DIAG = 0.2;
|
|
336
|
+
const KAREL_LOWER_LEFT_DIAG = 0.13125;
|
|
337
|
+
const KAREL_LEG_VERTICAL_OFFSET = 0.5;
|
|
338
|
+
const KAREL_LEG_LENGTH = 0.15;
|
|
339
|
+
const KAREL_FOOT_LENGTH = 0.1875;
|
|
340
|
+
const KAREL_LEG_FOOT_WIDTH = 0.075;
|
|
341
|
+
const KAREL_LEG_HORIZONTAL_OFFSET = 0.2625;
|
|
342
|
+
|
|
343
|
+
// Simple Karel proportions
|
|
344
|
+
const SIMPLE_KAREL_WIDTH = 0.8;
|
|
345
|
+
const SIMPLE_KAREL_HEIGHT = 0.7;
|
|
346
|
+
|
|
347
|
+
const DIRECTION_TO_RADIANS = {
|
|
348
|
+
east: 0,
|
|
349
|
+
south: Math.PI / 2,
|
|
350
|
+
west: Math.PI,
|
|
351
|
+
north: 3 * Math.PI / 2,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Rotate an array of [x0,y0, x1,y1, ...] coordinates around `center` by `angle` radians.
|
|
356
|
+
* Mutates `points` in place.
|
|
357
|
+
*/
|
|
358
|
+
function rotatePoints(center, points, angle) {
|
|
359
|
+
if (angle === 0) return;
|
|
360
|
+
const cos = Math.cos(angle), sin = Math.sin(angle);
|
|
361
|
+
const [cx, cy] = center;
|
|
362
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
363
|
+
const dx = points[i] - cx, dy = points[i + 1] - cy;
|
|
364
|
+
points[i] = cx + dx * cos - dy * sin;
|
|
365
|
+
points[i + 1] = cy + dx * sin + dy * cos;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function drawPolygon(ctx, points, fill, outline, lineWidth) {
|
|
370
|
+
ctx.beginPath();
|
|
371
|
+
ctx.moveTo(points[0], points[1]);
|
|
372
|
+
for (let i = 2; i < points.length; i += 2) ctx.lineTo(points[i], points[i + 1]);
|
|
373
|
+
ctx.closePath();
|
|
374
|
+
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
|
375
|
+
if (outline) { ctx.strokeStyle = outline; ctx.lineWidth = lineWidth; ctx.stroke(); }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function drawKarelBody(ctx, x, y, cs, center, angle) {
|
|
379
|
+
const w = cs * KAREL_WIDTH;
|
|
380
|
+
const h = cs * KAREL_HEIGHT;
|
|
381
|
+
const llDiag = (cs * KAREL_LOWER_LEFT_DIAG) / Math.SQRT2;
|
|
382
|
+
const urDiag = (cs * KAREL_UPPER_RIGHT_DIAG) / Math.SQRT2;
|
|
383
|
+
|
|
384
|
+
// Outer body: hexagon with clipped corners (faces east by default)
|
|
385
|
+
const outer = [
|
|
386
|
+
x, y,
|
|
387
|
+
x + w - urDiag, y,
|
|
388
|
+
x + w, y + urDiag,
|
|
389
|
+
x + w, y + h,
|
|
390
|
+
x + llDiag, y + h,
|
|
391
|
+
x, y + h - llDiag,
|
|
392
|
+
];
|
|
393
|
+
rotatePoints(center, outer, angle);
|
|
394
|
+
drawPolygon(ctx, outer, "white", "black", KAREL_LINE_WIDTH);
|
|
395
|
+
|
|
396
|
+
// Inner rectangle (window)
|
|
397
|
+
const ix = x + cs * KAREL_INNER_OFFSET;
|
|
398
|
+
const iy = y + cs * KAREL_INNER_OFFSET;
|
|
399
|
+
const iw = cs * KAREL_INNER_WIDTH;
|
|
400
|
+
const ih = cs * KAREL_INNER_HEIGHT;
|
|
401
|
+
const inner = [ix, iy, ix + iw, iy, ix + iw, iy + ih, ix, iy + ih];
|
|
402
|
+
rotatePoints(center, inner, angle);
|
|
403
|
+
drawPolygon(ctx, inner, "white", "black", KAREL_LINE_WIDTH);
|
|
404
|
+
|
|
405
|
+
// Mouth line
|
|
406
|
+
const mx = x + cs * KAREL_MOUTH_HORIZONTAL_OFFSET;
|
|
407
|
+
const my = iy + ih + cs * KAREL_MOUTH_VERTICAL_OFFSET;
|
|
408
|
+
const mouth = [mx, my, mx + cs * KAREL_MOUTH_WIDTH, my];
|
|
409
|
+
rotatePoints(center, mouth, angle);
|
|
410
|
+
ctx.strokeStyle = "black"; ctx.lineWidth = KAREL_LINE_WIDTH;
|
|
411
|
+
ctx.beginPath();
|
|
412
|
+
ctx.moveTo(mouth[0], mouth[1]);
|
|
413
|
+
ctx.lineTo(mouth[2], mouth[3]);
|
|
414
|
+
ctx.stroke();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function drawKarelLegs(ctx, x, y, cs, center, angle) {
|
|
418
|
+
const legLen = cs * KAREL_LEG_LENGTH;
|
|
419
|
+
const footLen = cs * KAREL_FOOT_LENGTH;
|
|
420
|
+
const footW = cs * KAREL_LEG_FOOT_WIDTH;
|
|
421
|
+
const vertOff = cs * KAREL_LEG_VERTICAL_OFFSET;
|
|
422
|
+
const horizOff = cs * KAREL_LEG_HORIZONTAL_OFFSET;
|
|
423
|
+
|
|
424
|
+
// Left leg (extends to the left, in upper body area)
|
|
425
|
+
const leftLeg = [
|
|
426
|
+
x, y + vertOff,
|
|
427
|
+
x - legLen, y + vertOff,
|
|
428
|
+
x - legLen, y + vertOff + footLen,
|
|
429
|
+
x - legLen + footW, y + vertOff + footLen,
|
|
430
|
+
x - legLen + footW, y + vertOff + footW,
|
|
431
|
+
x, y + vertOff + footW,
|
|
432
|
+
];
|
|
433
|
+
rotatePoints(center, leftLeg, angle);
|
|
434
|
+
drawPolygon(ctx, leftLeg, "black", "black", 1);
|
|
435
|
+
|
|
436
|
+
// Right leg (extends downward from body bottom)
|
|
437
|
+
const bodyBottom = y + cs * KAREL_HEIGHT;
|
|
438
|
+
const rightLeg = [
|
|
439
|
+
x + horizOff, bodyBottom,
|
|
440
|
+
x + horizOff, bodyBottom + legLen,
|
|
441
|
+
x + horizOff + footLen, bodyBottom + legLen,
|
|
442
|
+
x + horizOff + footLen, bodyBottom + legLen - footW,
|
|
443
|
+
x + horizOff + footW, bodyBottom + legLen - footW,
|
|
444
|
+
x + horizOff + footW, bodyBottom,
|
|
445
|
+
];
|
|
446
|
+
rotatePoints(center, rightLeg, angle);
|
|
447
|
+
drawPolygon(ctx, rightLeg, "black", "black", 1);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function drawKarelIcon(ctx, direction, kx, ky, cellSize, icon) {
|
|
451
|
+
const angle = DIRECTION_TO_RADIANS[direction];
|
|
452
|
+
const center = [kx, ky];
|
|
453
|
+
|
|
454
|
+
if (icon === "simple") {
|
|
455
|
+
const w = cellSize * SIMPLE_KAREL_WIDTH;
|
|
456
|
+
const h = cellSize * SIMPLE_KAREL_HEIGHT;
|
|
457
|
+
const pts = [
|
|
458
|
+
kx - w / 2, ky - h / 2,
|
|
459
|
+
kx - w / 2, ky + h / 2,
|
|
460
|
+
kx, ky + h / 2,
|
|
461
|
+
kx + w / 2, ky,
|
|
462
|
+
kx, ky - h / 2,
|
|
463
|
+
];
|
|
464
|
+
rotatePoints(center, pts, angle);
|
|
465
|
+
drawPolygon(ctx, pts, "white", "black", KAREL_LINE_WIDTH);
|
|
466
|
+
} else {
|
|
467
|
+
// Full Karel body (default)
|
|
468
|
+
const ox = kx - cellSize / 2 + KAREL_LEFT_HORIZONTAL_PAD * cellSize;
|
|
469
|
+
const oy = ky - cellSize / 2 + KAREL_VERTICAL_OFFSET * cellSize;
|
|
470
|
+
drawKarelBody(ctx, ox, oy, cellSize, center, angle);
|
|
471
|
+
drawKarelLegs(ctx, ox, oy, cellSize, center, angle);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Render the current state of the world + Karel to a canvas element.
|
|
477
|
+
* @param {KarelWorld} world
|
|
478
|
+
* @param {KarelProgram} karel
|
|
479
|
+
* @param {number} cellSize Pixels per cell
|
|
480
|
+
* @param {string} icon "karel" (default) or "simple"
|
|
481
|
+
* @returns {HTMLCanvasElement}
|
|
482
|
+
*/
|
|
483
|
+
function renderFrame(world, karel, cellSize, icon = "karel") {
|
|
484
|
+
const imgW = 2 * BORDER_OFFSET + world.numAvenues * cellSize;
|
|
485
|
+
const imgH = 2 * BORDER_OFFSET + world.numStreets * cellSize;
|
|
486
|
+
const leftX = BORDER_OFFSET;
|
|
487
|
+
const topY = BORDER_OFFSET;
|
|
488
|
+
|
|
489
|
+
// Pixel centre of cell (avenue, street)
|
|
490
|
+
const cornerX = a => leftX + cellSize / 2 + (a - 1) * cellSize;
|
|
491
|
+
const cornerY = s => topY + cellSize / 2 + (world.numStreets - s) * cellSize;
|
|
492
|
+
|
|
493
|
+
const canvas = document.createElement("canvas");
|
|
494
|
+
canvas.width = imgW;
|
|
495
|
+
canvas.height = imgH;
|
|
496
|
+
const ctx = canvas.getContext("2d");
|
|
497
|
+
|
|
498
|
+
// ── Background ───────────────────────────────────────────────────────────
|
|
499
|
+
ctx.fillStyle = "white";
|
|
500
|
+
ctx.fillRect(0, 0, imgW, imgH);
|
|
501
|
+
|
|
502
|
+
// ── Colored corners (fill whole cell) ────────────────────────────────────
|
|
503
|
+
for (const [key, color] of world.cornerColors) {
|
|
504
|
+
const [a, s] = key.split(",").map(Number);
|
|
505
|
+
ctx.fillStyle = CSS_COLORS[color] ?? color;
|
|
506
|
+
ctx.fillRect(cornerX(a) - cellSize / 2, cornerY(s) - cellSize / 2, cellSize, cellSize);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Corner crosshairs (uncolored corners only) ────────────────────────────
|
|
510
|
+
ctx.strokeStyle = "black";
|
|
511
|
+
ctx.lineWidth = 1;
|
|
512
|
+
for (let a = 1; a <= world.numAvenues; a++) {
|
|
513
|
+
for (let s = 1; s <= world.numStreets; s++) {
|
|
514
|
+
if (!world.cornerColors.has(`${a},${s}`)) {
|
|
515
|
+
const cx = cornerX(a), cy = cornerY(s);
|
|
516
|
+
ctx.beginPath(); ctx.moveTo(cx, cy - CORNER_SIZE); ctx.lineTo(cx, cy + CORNER_SIZE); ctx.stroke();
|
|
517
|
+
ctx.beginPath(); ctx.moveTo(cx - CORNER_SIZE, cy); ctx.lineTo(cx + CORNER_SIZE, cy); ctx.stroke();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── Beepers ───────────────────────────────────────────────────────────────
|
|
523
|
+
for (const [key, count] of world.beepers) {
|
|
524
|
+
if (count === 0) continue;
|
|
525
|
+
const [a, s] = key.split(",").map(Number);
|
|
526
|
+
const cx = cornerX(a), cy = cornerY(s);
|
|
527
|
+
const r = cellSize * BEEPER_CELL_SIZE_FRAC;
|
|
528
|
+
ctx.beginPath();
|
|
529
|
+
ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy);
|
|
530
|
+
ctx.lineTo(cx, cy + r); ctx.lineTo(cx - r, cy);
|
|
531
|
+
ctx.closePath();
|
|
532
|
+
ctx.fillStyle = "lightgrey"; ctx.fill();
|
|
533
|
+
ctx.strokeStyle = "black"; ctx.lineWidth = 1; ctx.stroke();
|
|
534
|
+
if (count > 1) {
|
|
535
|
+
ctx.fillStyle = "black";
|
|
536
|
+
ctx.font = `${Math.max(9, Math.round(cellSize * 0.28))}px Arial`;
|
|
537
|
+
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
538
|
+
ctx.fillText(String(count), cx, cy);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── Walls ─────────────────────────────────────────────────────────────────
|
|
543
|
+
ctx.strokeStyle = "black";
|
|
544
|
+
ctx.lineWidth = LINE_WIDTH;
|
|
545
|
+
for (const wallKey of world.walls) {
|
|
546
|
+
const parts = wallKey.split(",");
|
|
547
|
+
const a = parseInt(parts[0]), s = parseInt(parts[1]), dir = parts[2];
|
|
548
|
+
const cx = cornerX(a), cy = cornerY(s);
|
|
549
|
+
const half = cellSize / 2;
|
|
550
|
+
ctx.beginPath();
|
|
551
|
+
if (dir === "north") { ctx.moveTo(cx - half, cy - half); ctx.lineTo(cx + half, cy - half); }
|
|
552
|
+
else if (dir === "south") { ctx.moveTo(cx - half, cy + half); ctx.lineTo(cx + half, cy + half); }
|
|
553
|
+
else if (dir === "east") { ctx.moveTo(cx + half, cy - half); ctx.lineTo(cx + half, cy + half); }
|
|
554
|
+
else if (dir === "west") { ctx.moveTo(cx - half, cy - half); ctx.lineTo(cx - half, cy + half); }
|
|
555
|
+
ctx.stroke();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── Bounding rectangle ────────────────────────────────────────────────────
|
|
559
|
+
ctx.strokeStyle = "black";
|
|
560
|
+
ctx.lineWidth = LINE_WIDTH;
|
|
561
|
+
ctx.strokeRect(leftX, topY, world.numAvenues * cellSize, world.numStreets * cellSize);
|
|
562
|
+
|
|
563
|
+
// ── Axis labels ───────────────────────────────────────────────────────────
|
|
564
|
+
const bottomEdge = topY + world.numStreets * cellSize;
|
|
565
|
+
ctx.fillStyle = "black";
|
|
566
|
+
ctx.font = "10px Arial";
|
|
567
|
+
ctx.textAlign = "center"; ctx.textBaseline = "top";
|
|
568
|
+
for (let a = 1; a <= world.numAvenues; a++) {
|
|
569
|
+
ctx.fillText(String(a), cornerX(a), bottomEdge + LABEL_OFFSET);
|
|
570
|
+
}
|
|
571
|
+
ctx.textAlign = "right"; ctx.textBaseline = "middle";
|
|
572
|
+
for (let s = 1; s <= world.numStreets; s++) {
|
|
573
|
+
ctx.fillText(String(s), leftX - LABEL_OFFSET, cornerY(s));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── Karel robot ───────────────────────────────────────────────────────────
|
|
577
|
+
drawKarelIcon(ctx, karel.direction, cornerX(karel.avenue), cornerY(karel.street), cellSize, icon);
|
|
578
|
+
|
|
579
|
+
return canvas;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ─────────────────────────── GIF.JS LOADER ──────────────────────────────────
|
|
583
|
+
|
|
584
|
+
// gif.js consists of two runtime artifacts that must be loaded separately:
|
|
585
|
+
// • The main module — runs on the main thread, loaded via ESM import.
|
|
586
|
+
// • The worker script — runs inside a Worker; the browser requires a
|
|
587
|
+
// separate script URL for it and cannot reuse the module import.
|
|
588
|
+
//
|
|
589
|
+
// Both URLs are derived from the single version constant below so that a
|
|
590
|
+
// version bump requires exactly one change. The worker Blob is cached so
|
|
591
|
+
// subsequent runKarel() calls don't re-fetch it from the network.
|
|
592
|
+
|
|
593
|
+
let _gifDeps = null;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Load both gif.js artifacts in parallel and cache them.
|
|
597
|
+
* - Module: loaded via ESM dynamic import from esm.sh (no eval, no
|
|
598
|
+
* Observable-specific require, works under strict CSP).
|
|
599
|
+
* - Worker: raw script fetched from jsDelivr and stored as a Blob so a
|
|
600
|
+
* fresh object URL can be created per GIF (required because gif.js
|
|
601
|
+
* revokes the URL after the worker pool is torn down).
|
|
602
|
+
* When bundled with webpack/Vite/esbuild the gif.js dependency in
|
|
603
|
+
* package.json is resolved from node_modules instead of the network.
|
|
604
|
+
* @returns {{ GIF: Function, workerBlob: Blob }}
|
|
605
|
+
*/
|
|
606
|
+
async function loadGifDeps() {
|
|
607
|
+
if (_gifDeps) return _gifDeps;
|
|
608
|
+
const VERSION = "0.2.0";
|
|
609
|
+
const [mod, workerBlob] = await Promise.all([
|
|
610
|
+
import(`https://esm.sh/gif.js@${VERSION}`),
|
|
611
|
+
fetch(`https://cdn.jsdelivr.net/npm/gif.js@${VERSION}/dist/gif.worker.js`).then(r => r.blob()),
|
|
612
|
+
]);
|
|
613
|
+
_gifDeps = { GIF: mod.default ?? mod, workerBlob };
|
|
614
|
+
return _gifDeps;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ─────────────────────────── MAIN API ───────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Run a Karel program and return a Promise that resolves to an animated <img>.
|
|
621
|
+
*
|
|
622
|
+
* @param {string} worldText World file contents (same format as .w files)
|
|
623
|
+
* @param {Function} mainFunc Program to run. Receives a KarelProgram instance
|
|
624
|
+
* (or its destructured methods). May be async.
|
|
625
|
+
* @param {object} [options]
|
|
626
|
+
* @param {number} [options.cellSize=50] Pixels per grid cell
|
|
627
|
+
* @param {number} [options.delay] Milliseconds per frame in GIF.
|
|
628
|
+
* Defaults to the world's `Speed:` directive if present (Speed 1.0 → 100 ms,
|
|
629
|
+
* Speed 2.0 → 50 ms, Speed 0.5 → 200 ms), otherwise 100 ms.
|
|
630
|
+
* @param {number} [options.finalFrameDelay=1000] Extra pause on the last frame
|
|
631
|
+
* @param {number} [options.gifWorkers=2] Web workers for gif.js
|
|
632
|
+
* @param {"karel"|"simple"} [options.icon="karel"] Robot icon style
|
|
633
|
+
* @returns {Promise<HTMLImageElement>} Resolves with the animated GIF image.
|
|
634
|
+
* If the Karel program throws (e.g. hitting a wall), the promise still
|
|
635
|
+
* resolves with the partial animation; the error message is available on
|
|
636
|
+
* `img.dataset.error` and as the element's tooltip (`img.title`).
|
|
637
|
+
*/
|
|
638
|
+
export async function runKarel(worldText, mainFunc, options = {}) {
|
|
639
|
+
// Build world first so Speed: directive is available for the delay default.
|
|
640
|
+
const world = new KarelWorld();
|
|
641
|
+
world.loadFromText(worldText);
|
|
642
|
+
|
|
643
|
+
const {
|
|
644
|
+
cellSize = 50,
|
|
645
|
+
delay = world.karelSpeed != null ? Math.round(100 / world.karelSpeed) : 100,
|
|
646
|
+
finalFrameDelay = 1000,
|
|
647
|
+
gifWorkers = 2,
|
|
648
|
+
icon = "karel",
|
|
649
|
+
} = options;
|
|
650
|
+
|
|
651
|
+
const karel = new KarelProgram(world);
|
|
652
|
+
|
|
653
|
+
// Collect one canvas frame per action (plus initial state)
|
|
654
|
+
const frames = [];
|
|
655
|
+
const capture = () => frames.push(renderFrame(world, karel, cellSize, icon));
|
|
656
|
+
|
|
657
|
+
capture(); // frame 0: initial state
|
|
658
|
+
karel._callbacks.push(capture); // frame N: after each action
|
|
659
|
+
|
|
660
|
+
// Execute the program (supports sync and async main functions).
|
|
661
|
+
// Errors (e.g. Karel hitting a wall) are caught so the partial animation is
|
|
662
|
+
// still rendered; the error is surfaced on the returned <img> element.
|
|
663
|
+
let programError = null;
|
|
664
|
+
try {
|
|
665
|
+
await mainFunc(karel);
|
|
666
|
+
} catch (err) {
|
|
667
|
+
programError = err;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const { GIF, workerBlob } = await loadGifDeps();
|
|
671
|
+
const workerUrl = URL.createObjectURL(workerBlob);
|
|
672
|
+
|
|
673
|
+
const { width, height } = frames[0];
|
|
674
|
+
const gif = new GIF({ workers: gifWorkers, quality: 10, width, height, workerScript: workerUrl });
|
|
675
|
+
|
|
676
|
+
for (const frame of frames) {
|
|
677
|
+
gif.addFrame(frame.getContext("2d"), { copy: true, delay });
|
|
678
|
+
}
|
|
679
|
+
// Hold on the final frame longer so the viewer can see the end state
|
|
680
|
+
gif.addFrame(
|
|
681
|
+
frames[frames.length - 1].getContext("2d"),
|
|
682
|
+
{ copy: true, delay: finalFrameDelay }
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
return new Promise((resolve, reject) => {
|
|
686
|
+
gif.on("finished", (blob) => {
|
|
687
|
+
URL.revokeObjectURL(workerUrl);
|
|
688
|
+
const img = document.createElement("img");
|
|
689
|
+
img.src = URL.createObjectURL(blob);
|
|
690
|
+
if (programError) {
|
|
691
|
+
img.dataset.error = programError.message;
|
|
692
|
+
img.title = `Karel error: ${programError.message}`;
|
|
693
|
+
}
|
|
694
|
+
resolve(img);
|
|
695
|
+
});
|
|
696
|
+
gif.on("error", reject);
|
|
697
|
+
gif.render();
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Fetch a world file from a URL and return its text.
|
|
703
|
+
* Convenience helper for loading .w files hosted online.
|
|
704
|
+
*
|
|
705
|
+
* @param {string} url
|
|
706
|
+
* @returns {Promise<string>}
|
|
707
|
+
*/
|
|
708
|
+
export async function fetchWorld(url) {
|
|
709
|
+
const res = await fetch(url);
|
|
710
|
+
if (!res.ok) throw new Error(`Failed to fetch world: ${res.status} ${res.statusText}`);
|
|
711
|
+
return res.text();
|
|
712
|
+
}
|