tinysim 0.0.1__py3-none-any.whl

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.
tinysim/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class SimEnvironment(ABC):
5
+ @abstractmethod
6
+ def step(self, action) -> dict:
7
+ pass
8
+
9
+ @abstractmethod
10
+ def reset(self) -> dict:
11
+ pass
tinysim/_tk_base.py ADDED
@@ -0,0 +1,54 @@
1
+ import tkinter as tk
2
+ import threading
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class TkBaseFrontend(ABC):
7
+
8
+ def __init__(self):
9
+ self._root = None
10
+ self._canvas = None
11
+ self._thread = None
12
+
13
+ def render(self):
14
+ if self._thread is not None:
15
+ return
16
+ self._thread = threading.Thread(target=self._window_hook, daemon=True)
17
+ self._thread.start()
18
+
19
+ def _window_hook(self):
20
+ root = tk.Tk()
21
+ root.protocol("WM_DELETE_WINDOW", self._on_close)
22
+ self._create_window(root)
23
+
24
+ @abstractmethod
25
+ def _create_window(self, root):
26
+ pass
27
+
28
+ def bring_to_front(self, root):
29
+ root.lift()
30
+ root.attributes("-topmost", True)
31
+ root.after_idle(root.attributes, "-topmost", False)
32
+ root.focus_force()
33
+
34
+ def _on_close(self):
35
+ if self._root:
36
+ try:
37
+ self._root.destroy()
38
+ except tk.TclError:
39
+ pass
40
+ self._root = None
41
+ self._canvas = None
42
+
43
+ def _pump(self):
44
+ if not self._root:
45
+ return
46
+
47
+ try:
48
+ self._root.update_idletasks()
49
+ self._root.update()
50
+ except tk.TclError:
51
+ return
52
+
53
+ if self._root:
54
+ self._root.after(20, self._pump)
@@ -0,0 +1,103 @@
1
+ import numpy as np
2
+ from .. import SimEnvironment
3
+
4
+ WIDTH, HEIGHT = 800, 600
5
+ GRAVITY = 900.0
6
+ FLAP_STRENGTH = -300.0
7
+ PIPE_SPEED = -200.0
8
+ PIPE_WIDTH = 80
9
+ PIPE_GAP = 200
10
+ PIPE_INTERVAL = 1.6
11
+
12
+ BIRD_X = 200
13
+ BIRD_SIZE = 35
14
+
15
+
16
+ class FlappyEnv(SimEnvironment):
17
+ def __init__(self, num_envs: int = 1):
18
+ self.num_envs = num_envs
19
+ self.reset()
20
+
21
+ def reset(self):
22
+ # birds (vectorized)
23
+ self.bird_y = np.full(self.num_envs, HEIGHT / 2, dtype=np.float32)
24
+ self.bird_vel = np.zeros(self.num_envs, dtype=np.float32)
25
+ self.done = np.zeros(self.num_envs, dtype=bool)
26
+
27
+ # shared pipes
28
+ self.pipes_x = np.empty(0, dtype=np.float32)
29
+ self.pipes_y = np.empty(0, dtype=np.float32)
30
+ self.time_since_pipe = PIPE_INTERVAL
31
+ return self.step(np.zeros(self.num_envs, dtype=np.int32), dt=0.0)
32
+
33
+ def _step_physics(self, action, dt):
34
+ flap_mask = (action == 1) & (~self.done)
35
+ self.bird_vel[flap_mask] = FLAP_STRENGTH
36
+ self.bird_vel += GRAVITY * dt
37
+ self.bird_y += self.bird_vel * dt
38
+
39
+ def _spawn_pipe(self):
40
+ pipe_y = np.random.randint(120, HEIGHT - 120 - PIPE_GAP)
41
+ self.pipes_x = np.append(self.pipes_x, WIDTH)
42
+ self.pipes_y = np.append(self.pipes_y, pipe_y)
43
+
44
+ def _update_pipes(self, dt):
45
+ self.time_since_pipe += dt
46
+ if self.time_since_pipe > PIPE_INTERVAL:
47
+ self.time_since_pipe = 0.0
48
+ self._spawn_pipe()
49
+
50
+ self.pipes_x += PIPE_SPEED * dt
51
+
52
+ # keep pipes on screen
53
+ keep = self.pipes_x > -PIPE_WIDTH
54
+ self.pipes_x = self.pipes_x[keep]
55
+ self.pipes_y = self.pipes_y[keep]
56
+
57
+ def _check_collisions(self): # world bounds
58
+ hit_bounds = (self.bird_y < 0) | (self.bird_y + BIRD_SIZE > HEIGHT)
59
+ bx = BIRD_X
60
+ by = self.bird_y[:, None]
61
+ px = self.pipes_x[None, :]
62
+ upper_y = np.zeros_like(self.pipes_y)[None, :]
63
+ upper_h = self.pipes_y[None, :]
64
+ lower_y = (self.pipes_y + PIPE_GAP)[None, :]
65
+ lower_h = (HEIGHT - (self.pipes_y + PIPE_GAP))[None, :]
66
+ x_overlap = (bx < px + PIPE_WIDTH) & (bx + BIRD_SIZE > px)
67
+ upper_hit = x_overlap & (by < upper_y + upper_h) & (by + BIRD_SIZE > upper_y)
68
+ lower_hit = x_overlap & (by < lower_y + lower_h) & (by + BIRD_SIZE > lower_y)
69
+ hit_pipe = (upper_hit | lower_hit).any(axis=1)
70
+ self.done |= hit_bounds | hit_pipe
71
+
72
+ def step(self, action, dt=0.02):
73
+ if np.isscalar(action):
74
+ action = np.full(self.num_envs, action, dtype=np.float32)
75
+ else:
76
+ action = np.asarray(action, dtype=np.float32)
77
+ if action.shape[0] != self.num_envs:
78
+ raise ValueError(
79
+ f"Expected actions of shape ({self.num_envs},), got {action.shape}"
80
+ )
81
+
82
+ # mask done environments to have no action
83
+ action = action * (~self.done)
84
+ self._step_physics(action, dt)
85
+ self._update_pipes(dt)
86
+ self._check_collisions()
87
+
88
+ bird_y = self.bird_y.tolist()
89
+ bird_vel = self.bird_vel.tolist()
90
+ done = self.done.tolist()
91
+
92
+ if self.num_envs == 1:
93
+ bird_y = self.bird_y[0]
94
+ bird_vel = self.bird_vel[0]
95
+ done = bool(self.done[0])
96
+
97
+ return {
98
+ "bird_y": bird_y,
99
+ "bird_vel": bird_vel,
100
+ "pipes_x": self.pipes_x.tolist(),
101
+ "pipes_y": self.pipes_y.tolist(),
102
+ "done": done,
103
+ }
tinysim/flappy/sim.js ADDED
@@ -0,0 +1,118 @@
1
+ let simState = {};
2
+
3
+ export default {
4
+ initialize({ model }) {
5
+ model.on("change:sim_state", () => {
6
+ simState = model.get("sim_state") || {};
7
+ });
8
+ },
9
+
10
+ async render({ model, el }) {
11
+ const [width, height] = model.get("_viewport_size") || [800, 600];
12
+
13
+ const container = document.createElement("div");
14
+ container.style.position = "relative";
15
+ container.style.width = width + "px";
16
+ container.style.height = height + "px";
17
+ el.appendChild(container);
18
+
19
+ const canvas = document.createElement("canvas");
20
+ canvas.width = width;
21
+ canvas.height = height;
22
+ container.appendChild(canvas);
23
+ const ctx = canvas.getContext("2d");
24
+
25
+ const WORLD_WIDTH = 800;
26
+ const WORLD_HEIGHT = 600;
27
+ const BIRD_X = 200;
28
+ const BIRD_SIZE = 35;
29
+ const PIPE_WIDTH = 80;
30
+ const PIPE_GAP = 200;
31
+ const GROUND_HEIGHT = 80;
32
+
33
+ // Scale world to canvas (in case viewport differs from 800×600)
34
+ const scaleX = width / WORLD_WIDTH;
35
+ const scaleY = height / WORLD_HEIGHT;
36
+
37
+ function draw() {
38
+ const state = simState || {};
39
+ const birdY = state.bird_y ?? WORLD_HEIGHT / 2;
40
+ const pipes_x = state.pipes_x || [];
41
+ const pipes_y = state.pipes_y || [];
42
+ const done = state.done || false;
43
+
44
+ ctx.clearRect(0, 0, width, height);
45
+
46
+ // Background sky
47
+ ctx.fillStyle = "#70C5CE";
48
+ ctx.fillRect(0, 0, width, height);
49
+
50
+ // Ground
51
+ const groundH = GROUND_HEIGHT * scaleY;
52
+ ctx.fillStyle = "#DED895";
53
+ ctx.fillRect(0, height - groundH, width, groundH);
54
+
55
+ // Bird
56
+ const birdScreenX = BIRD_X * scaleX;
57
+ const birdScreenY = birdY * scaleY;
58
+ const birdSizeX = BIRD_SIZE * scaleX;
59
+ const birdSizeY = BIRD_SIZE * scaleY;
60
+
61
+ ctx.fillStyle = "#FFD700";
62
+ ctx.strokeStyle = "#000000";
63
+ ctx.lineWidth = 1;
64
+
65
+ if (!done) {
66
+ ctx.beginPath();
67
+ ctx.ellipse(
68
+ birdScreenX + birdSizeX / 2,
69
+ birdScreenY + birdSizeY / 2,
70
+ birdSizeX / 2,
71
+ birdSizeY / 2,
72
+ 0,
73
+ 0,
74
+ Math.PI * 2
75
+ );
76
+ ctx.fill();
77
+ ctx.stroke();
78
+ }
79
+
80
+ // Pipes
81
+ ctx.fillStyle = "#228B22";
82
+ ctx.strokeStyle = "#4a8d34";
83
+ ctx.lineWidth = 2 * ((scaleX + scaleY) / 2);
84
+
85
+ for (let i = 0; i < pipes_x.length; i++) {
86
+ const pipeX = pipes_x[i];
87
+ const pipeY = pipes_y[i];
88
+
89
+ const pw = PIPE_WIDTH * scaleX;
90
+ const ux = pipeX * scaleX;
91
+ const uy = 0; // Upper pipe starts at top of screen
92
+ const uh = pipeY * scaleY; // Upper pipe height extends down to pipeY
93
+ const lx = pipeX * scaleX;
94
+ const ly = (pipeY + PIPE_GAP) * scaleY; // Lower pipe starts after gap
95
+ const lh = (WORLD_HEIGHT - (pipeY + PIPE_GAP)) * scaleY; // Lower pipe extends to bottom
96
+
97
+ // Upper pipe
98
+ ctx.beginPath();
99
+ ctx.rect(ux, uy, pw, uh);
100
+ ctx.fill();
101
+ ctx.stroke();
102
+
103
+ // Lower pipe
104
+ ctx.beginPath();
105
+ ctx.rect(lx, ly, pw, lh);
106
+ ctx.fill();
107
+ ctx.stroke();
108
+ }
109
+
110
+ requestAnimationFrame(draw);
111
+ }
112
+
113
+ draw();
114
+
115
+ model.set("_view_ready", true);
116
+ model.save_changes();
117
+ }
118
+ };
tinysim/flappy/tk.py ADDED
@@ -0,0 +1,94 @@
1
+ import asyncio
2
+
3
+ try:
4
+ import tkinter as tk
5
+ from .. import _tk_base
6
+ except ImportError:
7
+ raise ImportError("tkinter is required for FlappyTkFrontend")
8
+
9
+ from . import (
10
+ FlappyEnv,
11
+ WIDTH,
12
+ HEIGHT,
13
+ PIPE_WIDTH,
14
+ BIRD_SIZE,
15
+ BIRD_X,
16
+ PIPE_GAP,
17
+ )
18
+
19
+
20
+ class FlappyTkFrontend(_tk_base.TkBaseFrontend):
21
+
22
+ def __init__(self, viewport_size=(800, 600), sim_env=None):
23
+ super().__init__()
24
+ if sim_env is None:
25
+ sim_env = FlappyEnv()
26
+
27
+ self.sim_env = sim_env
28
+ self._viewport_size = viewport_size
29
+
30
+ async def step(self, action, dt=0.02):
31
+ state = self.sim_env.step(action, dt=dt)
32
+ if self._root:
33
+ self._root.after(0, lambda: self._draw_state(state))
34
+
35
+ await asyncio.sleep(dt)
36
+ return state
37
+
38
+ async def reset(self):
39
+ state = self.sim_env.reset()
40
+ if self._root:
41
+ self._draw_state(state)
42
+ return state
43
+
44
+ def _create_window(self, root):
45
+ w, h = self._viewport_size
46
+ root.title("Flappy Bird")
47
+ canvas = tk.Canvas(root, width=w, height=h, bg="#1E1E1E")
48
+ canvas.pack(fill="both", expand=True)
49
+ self._root = root
50
+ self._canvas = canvas
51
+ self.bring_to_front(root)
52
+ self._draw_state(self.sim_env.reset())
53
+ self._pump()
54
+ root.mainloop()
55
+
56
+ def _draw_state(self, state):
57
+ if not self._canvas:
58
+ return
59
+
60
+ canvas = self._canvas
61
+ canvas.delete("all")
62
+ canvas.create_rectangle(0, 0, WIDTH, HEIGHT, fill="#70C5CE", outline="")
63
+ canvas.create_rectangle(
64
+ 0, HEIGHT - 80, WIDTH, HEIGHT, fill="#DED895", outline=""
65
+ )
66
+
67
+ # bird
68
+ by = state["bird_y"]
69
+
70
+ if not state.get("done", False):
71
+ canvas.create_oval(
72
+ BIRD_X,
73
+ by,
74
+ BIRD_X + BIRD_SIZE,
75
+ by + BIRD_SIZE,
76
+ fill="#FFD700",
77
+ outline="#000",
78
+ )
79
+
80
+ # pipes
81
+ for x, y in zip(state["pipes_x"], state["pipes_y"]):
82
+ # Upper pipe
83
+ canvas.create_rectangle(
84
+ x, 0, x + PIPE_WIDTH, y, fill="#228B22", outline="#4a8d34"
85
+ )
86
+ # Lower pipe
87
+ canvas.create_rectangle(
88
+ x,
89
+ y + PIPE_GAP,
90
+ x + PIPE_WIDTH,
91
+ HEIGHT,
92
+ fill="#228B22",
93
+ outline="#4a8d34",
94
+ )
@@ -0,0 +1,53 @@
1
+ import pathlib
2
+ import anywidget
3
+ import traitlets
4
+ import asyncio
5
+ from IPython.display import display
6
+ from jupyter_ui_poll import ui_events
7
+
8
+ from . import FlappyEnv
9
+
10
+
11
+ class FlappySim(anywidget.AnyWidget):
12
+ _esm = pathlib.Path(__file__).parent / "sim.js"
13
+
14
+ sim_state = traitlets.Dict(default_value={}).tag(sync=True)
15
+ _viewport_size = traitlets.Tuple(
16
+ traitlets.Int(), traitlets.Int(), default_value=(800, 600)
17
+ ).tag(sync=True)
18
+ _manual_control = traitlets.Bool(default_value=False).tag(sync=True)
19
+ _view_ready = traitlets.Bool(default_value=False).tag(sync=True)
20
+
21
+ def __init__(self, viewport_size=(800, 600), manual_control=False, sim_env=None):
22
+ super().__init__()
23
+ self._viewport_size = viewport_size
24
+ self._manual_control = manual_control
25
+ if sim_env is None:
26
+ sim_env = FlappyEnv()
27
+ if sim_env.num_envs != 1:
28
+ raise ValueError("FlappySim currently only supports single environment.")
29
+
30
+ self.sim_env = sim_env
31
+ self.sim_state = self.sim_env.reset()
32
+
33
+ def render(self):
34
+ display(self)
35
+
36
+ try:
37
+ with ui_events() as ui_poll:
38
+ while not self._view_ready:
39
+ ui_poll(100)
40
+ except Exception:
41
+ pass
42
+
43
+ async def step(self, action, dt=0.02):
44
+ state = self.sim_env.step(action, dt=dt)
45
+ self.sim_state = state
46
+ await asyncio.sleep(dt)
47
+ return state
48
+
49
+ async def reset(self):
50
+ state = self.sim_env.reset()
51
+ self.sim_state = state
52
+ await asyncio.sleep(0)
53
+ return state
@@ -0,0 +1,145 @@
1
+ import numpy as np
2
+ from .. import SimEnvironment
3
+
4
+ WIDTH, HEIGHT = 800, 600
5
+ CELL = 40
6
+ ROWS, COLS = HEIGHT // CELL, WIDTH // CELL
7
+
8
+
9
+ class FroggerEnv(SimEnvironment):
10
+ def __init__(self, num_envs=1):
11
+ self.num_envs = num_envs
12
+ self.total_height = ROWS - 1
13
+ self.num_cars_per_lane = 4
14
+ self.car_width = CELL * 2
15
+ self.traffic_rows = np.array([4, 5, 6, 8, 9])
16
+ self.speeds = np.array([120, -150, 200, -180, 140], dtype=np.float32)
17
+ self.reset()
18
+
19
+ def reset(self):
20
+ # Frog positions for all environments (num_envs, 2)
21
+ self.frog_pos = np.tile(np.array([COLS // 2, ROWS - 1]), (self.num_envs, 1))
22
+
23
+ # Cars (num_lanes, num_cars_per_lane)
24
+ self.car_x = np.zeros(
25
+ (len(self.traffic_rows), self.num_cars_per_lane), dtype=np.float32
26
+ )
27
+ for i in range(self.num_cars_per_lane):
28
+ self.car_x[:, i] = i * (WIDTH // self.num_cars_per_lane)
29
+
30
+ # Score and crossings per environment
31
+ self.crossings = np.zeros(self.num_envs, dtype=np.float32)
32
+ self.score = np.zeros(self.num_envs, dtype=np.float32)
33
+ return self.step(np.zeros(self.num_envs, dtype=np.int32), dt=0.0)
34
+
35
+ def _build_car_grid(self):
36
+ grid = np.zeros((ROWS, COLS), dtype=bool)
37
+ for lane_idx, row in enumerate(self.traffic_rows):
38
+ x0 = self.car_x[lane_idx, :]
39
+ x1 = x0 + self.car_width
40
+ col_start = np.clip((x0 // CELL).astype(int), 0, COLS - 1)
41
+ col_end = np.clip((x1 // CELL).astype(int), 0, COLS - 1)
42
+ for start, end in zip(col_start, col_end):
43
+ grid[row, start : end + 1] = True
44
+ return grid
45
+
46
+ def step(self, action, dt=0.01):
47
+ if np.isscalar(action):
48
+ action = np.full(self.num_envs, action, dtype=np.float32)
49
+ else:
50
+ action = np.asarray(action, dtype=np.float32)
51
+ if action.shape[0] != self.num_envs:
52
+ raise ValueError(
53
+ f"Expected actions of shape ({self.num_envs},), got {action.shape}"
54
+ )
55
+ action_map = {
56
+ 0: (0, 0),
57
+ 1: (-1, 0),
58
+ 2: (1, 0),
59
+ 3: (0, -1),
60
+ 4: (0, 1),
61
+ }
62
+ dx = np.array([action_map[a][0] for a in action])
63
+ dy = np.array([action_map[a][1] for a in action])
64
+
65
+ # Move frogs
66
+ self.frog_pos[:, 0] = np.clip(self.frog_pos[:, 0] + dx, 0, COLS - 1)
67
+ self.frog_pos[:, 1] = np.clip(self.frog_pos[:, 1] + dy, 0, ROWS - 1)
68
+
69
+ # Update car positions
70
+ self.car_x += self.speeds[:, None] * dt
71
+
72
+ # Wrap cars
73
+ pos_mask_pos = self.speeds[:, None] > 0
74
+ pos_mask_neg = self.speeds[:, None] < 0
75
+ self.car_x[pos_mask_pos & (self.car_x > WIDTH + 50)] = -self.car_width - 50
76
+ self.car_x[pos_mask_neg & (self.car_x < -self.car_width - 50)] = WIDTH + 50
77
+
78
+ done = np.zeros(self.num_envs, dtype=bool)
79
+ frog_rects = np.stack(
80
+ [
81
+ self.frog_pos[:, 0] * CELL,
82
+ self.frog_pos[:, 1] * CELL,
83
+ np.full(self.num_envs, CELL),
84
+ np.full(self.num_envs, CELL),
85
+ ],
86
+ axis=1,
87
+ )
88
+
89
+ self.car_rects = []
90
+ for lane_idx, row in enumerate(self.traffic_rows):
91
+ self.car_rects.append(
92
+ np.stack(
93
+ [
94
+ self.car_x[lane_idx, :], # x positions
95
+ np.full(self.num_cars_per_lane, row * CELL + 8), # y positions
96
+ np.full(self.num_cars_per_lane, self.car_width), # widths
97
+ np.full(self.num_cars_per_lane, CELL - 16), # heights
98
+ ],
99
+ axis=1,
100
+ )
101
+ ) # shape: (num_cars_per_lane, 4)
102
+
103
+ for env_idx in range(self.num_envs):
104
+ ax, ay, aw, ah = frog_rects[env_idx]
105
+ collision = False
106
+ for car_rects in self.car_rects:
107
+ bx, by, bw, bh = (
108
+ car_rects[:, 0],
109
+ car_rects[:, 1],
110
+ car_rects[:, 2],
111
+ car_rects[:, 3],
112
+ )
113
+ overlap = (
114
+ (ax < bx + bw) & (ax + aw > bx) & (ay < by + bh) & (ay + ah > by)
115
+ )
116
+ if np.any(overlap):
117
+ collision = True
118
+ break
119
+ done[env_idx] = collision
120
+
121
+ # Handle frogs that reached the top
122
+ reached_top = self.frog_pos[:, 1] == 0
123
+ self.crossings[reached_top] += 1
124
+ self.frog_pos[reached_top] = [COLS // 2, ROWS - 1]
125
+
126
+ # Update score
127
+ current_height = self.total_height - self.frog_pos[:, 1]
128
+ self.score = self.crossings + current_height / self.total_height
129
+
130
+ frog_pos = self.frog_pos.tolist()
131
+ grid = self._build_car_grid().tolist()
132
+ scores = self.score.tolist()
133
+ done = done.tolist()
134
+
135
+ if self.num_envs == 1:
136
+ frog_pos = frog_pos[0]
137
+ done = done[0]
138
+ scores = scores[0]
139
+
140
+ return {
141
+ "frog_pos": frog_pos,
142
+ "grid": grid,
143
+ "done": done,
144
+ "score": scores,
145
+ }
tinysim/frogger/sim.js ADDED
@@ -0,0 +1,110 @@
1
+ export default {
2
+ async render({ model, el }) {
3
+ const [width, height] = model.get("_viewport_size") || [800, 600];
4
+
5
+ const container = document.createElement("div");
6
+ container.style.position = "relative";
7
+ container.style.width = width + "px";
8
+ container.style.height = height + "px";
9
+ el.appendChild(container);
10
+
11
+ const canvas = document.createElement("canvas");
12
+ canvas.width = width;
13
+ canvas.height = height;
14
+ container.appendChild(canvas);
15
+ const ctx = canvas.getContext("2d");
16
+
17
+ // Current sim state
18
+ let simState = model.get("sim_state") || {};
19
+ let frogPos = simState.frog_pos || [0, 0]; // grid coords
20
+ let score = simState.score || 0;
21
+ let carRects = model.get("car_positions") || []; // pixel absolute coords
22
+
23
+ // Watch sim_state and car_positions
24
+ model.on("change:sim_state", () => {
25
+ simState = model.get("sim_state") || {};
26
+ frogPos = simState.frog_pos || frogPos;
27
+ score = simState.score ?? score;
28
+ });
29
+
30
+ model.on("change:car_positions", () => {
31
+ carRects = model.get("car_positions") || carRects;
32
+ });
33
+
34
+ const draw = () => {
35
+ ctx.clearRect(0, 0, width, height);
36
+
37
+ // Background
38
+ ctx.fillStyle = "#101010";
39
+ ctx.fillRect(0, 0, width, height);
40
+
41
+ // The frog is still grid-based
42
+ const grid = simState.grid || [];
43
+ const rows = grid.length || 15;
44
+ const cols = (grid[0] && grid[0].length) || 20;
45
+
46
+ const cellW = width / cols;
47
+ const cellH = height / rows;
48
+
49
+ // Safe zones
50
+ ctx.fillStyle = "#000050";
51
+ ctx.fillRect(0, 0, width, cellH);
52
+ ctx.fillStyle = "#004000";
53
+ ctx.fillRect(0, (rows - 1) * cellH, width, cellH);
54
+
55
+ // Road tint
56
+ for (let r = 1; r < rows - 1; r++) {
57
+ ctx.fillStyle = "#202020";
58
+ ctx.fillRect(0, r * cellH, width, cellH);
59
+ }
60
+
61
+ ctx.fillStyle = "#B43232";
62
+ for (let i = 0; i < carRects.length; i += 4) {
63
+ ctx.fillRect(carRects[i], carRects[i + 1], carRects[i + 2], carRects[i + 3]);
64
+ }
65
+
66
+ const [frogCol, frogRow] = frogPos;
67
+ const fx = frogCol * cellW;
68
+ const fy = frogRow * cellH;
69
+ const radius = Math.min(cellW, cellH) * 0.4;
70
+
71
+ ctx.fillStyle = "#32DC32";
72
+ ctx.beginPath();
73
+ ctx.arc(fx + cellW / 2, fy + cellH / 2, radius, 0, Math.PI * 2);
74
+ ctx.fill();
75
+
76
+ // Grid lines
77
+ ctx.strokeStyle = "#282828";
78
+ ctx.lineWidth = 1;
79
+
80
+ for (let r = 0; r <= rows; r++) {
81
+ const y = r * cellH;
82
+ ctx.beginPath();
83
+ ctx.moveTo(0, y);
84
+ ctx.lineTo(width, y);
85
+ ctx.stroke();
86
+ }
87
+
88
+ for (let c = 0; c <= cols; c++) {
89
+ const x = c * cellW;
90
+ ctx.beginPath();
91
+ ctx.moveTo(x, 0);
92
+ ctx.lineTo(x, height);
93
+ ctx.stroke();
94
+ }
95
+
96
+ // Score
97
+ ctx.fillStyle = "#FFFFFF";
98
+ ctx.font = "16px Arial";
99
+ ctx.textBaseline = "top";
100
+ ctx.fillText(`Score: ${score.toFixed(2)}`, 10, 10);
101
+
102
+ requestAnimationFrame(draw);
103
+ };
104
+
105
+ draw();
106
+
107
+ model.set("_view_ready", true);
108
+ model.save_changes();
109
+ },
110
+ };