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 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
+ }
@@ -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
+ }