irregular6612 commited on
Commit
764e34c
·
1 Parent(s): 483e590

feat(cp0): vendor arc_grid engine + renderer into proteus

Browse files
proteus/arc_grid/README.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # arc_grid
2
+
3
+ The **ARC-AGI 64×64 grid system**, extracted as a self-contained package you can drop
4
+ into another project. Bundles the grid/sprite **engine** and the **visualization** layer.
5
+
6
+ ```
7
+ arc_grid/
8
+ ├── __init__.py # re-exports the public surface (engine + renderer)
9
+ ├── rendering.py # visualization: 16-color palette, frame -> RGB / terminal
10
+ └── arcengine/ # vendored engine (arcprize/ARCEngine, MIT)
11
+ ├── enums.py # FrameData / FrameDataRaw, GameAction, GameState, color/mode enums
12
+ ├── sprites.py # Sprite: the pixel-grid primitive (rotation, scale, collision)
13
+ ├── interfaces.py # RenderableUserDisplay / ToggleableUserDisplay (UI overlays)
14
+ ├── camera.py # Camera: sprites -> 64×64 frame (auto-scale + letterbox)
15
+ ├── level.py # Level: a group of sprites + placeable areas
16
+ ├── base_game.py # ARCBaseGame: the turn-based game loop
17
+ ├── OVERVIEW.md # engine design notes
18
+ ├── LICENSE # MIT (ARC Prize Foundation)
19
+ └── py.typed
20
+ ```
21
+
22
+ ## What's the "grid"?
23
+
24
+ A **frame** is a list of layers; each layer is a **64×64** array of integers **0–15**
25
+ (palette indices). Two representations of the same data live in `arcengine/enums.py`:
26
+
27
+ | Type | `frame` field | Use |
28
+ |---|---|---|
29
+ | `FrameData` (pydantic) | `list[list[list[int]]]` | JSON / wire format |
30
+ | `FrameDataRaw` | `list[numpy.ndarray]` (int8) | runtime format |
31
+
32
+ Engine invariants: 64×64 output · 16 colors · 6 actions + `RESET` · turn-based ·
33
+ each input yields 1–N frames.
34
+
35
+ ## Dependencies
36
+
37
+ | Need | Requires |
38
+ |---|---|
39
+ | Engine (`arc_grid.arcengine`) | `numpy`, `pydantic` |
40
+ | Renderer core (`frame_to_rgb_array`, `COLOR_MAP`) | `numpy` |
41
+ | Animated display (`render_frames`) | `matplotlib` (optional; guarded by a `try`-import) |
42
+
43
+ ```bash
44
+ pip install numpy pydantic # minimum
45
+ pip install matplotlib # only if you use render_frames()
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ### Draw a raw 64×64 grid (engine not required)
51
+
52
+ ```python
53
+ import numpy as np
54
+ from arc_grid import frame_to_rgb_array, COLOR_MAP
55
+
56
+ frame = np.zeros((64, 64), dtype=np.int8)
57
+ frame[10:20, 10:20] = 8 # a red square (palette index 8)
58
+
59
+ rgb = frame_to_rgb_array(0, frame, scale=4) # -> (256, 256, 3) uint8
60
+ # COLOR_MAP maps each 0–15 index to its hex color.
61
+ ```
62
+
63
+ ### Render in the terminal
64
+
65
+ ```python
66
+ from arc_grid import render_frames_terminal
67
+ from arc_grid import FrameDataRaw, GameState
68
+
69
+ fd = FrameDataRaw()
70
+ fd.frame = [frame] # list of 64×64 arrays
71
+ fd.state = GameState.NOT_FINISHED
72
+ render_frames_terminal(0, fd) # ANSI true-color, in-place
73
+ ```
74
+
75
+ ### Build a game with the engine
76
+
77
+ ```python
78
+ from arc_grid import ARCBaseGame, Camera, Level, Sprite
79
+
80
+ # Subclass ARCBaseGame, populate Levels with Sprites, drive it with GameActions.
81
+ # See arcengine/OVERVIEW.md and the upstream examples for the full loop.
82
+ ```
83
+
84
+ ## How this was extracted
85
+
86
+ * **Engine** — vendored verbatim from the `arcengine` pip package (v0.9.3). Internal imports
87
+ are all relative, so the folder works unmodified.
88
+ * **Renderer** — copied from the ARC-AGI toolkit's `arc_agi/rendering.py`; the single external
89
+ import `from arcengine import FrameDataRaw` was changed to the relative
90
+ `from .arcengine import FrameDataRaw` so the package is self-contained.
91
+
92
+ To move this into another project, copy the whole `arc_grid/` folder. To keep the engine fresh,
93
+ re-vendor from upstream or switch `arc_grid/arcengine/` to a pip dependency on `arcengine`.
94
+
95
+ ## Attribution & License
96
+
97
+ The `arcengine/` subpackage is **vendored from [arcprize/ARCEngine](https://github.com/arcprize/ARCEngine)**,
98
+ © 2026 ARC Prize Foundation, **MIT License** (see `arcengine/LICENSE`). The renderer originates
99
+ from the ARC-AGI toolkit (same foundation). Retain these notices when redistributing.
proteus/arc_grid/__init__.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """arc_grid — the ARC-AGI 64x64 grid system, extracted as a standalone package.
2
+
3
+ Bundles two pieces:
4
+
5
+ * ``arc_grid.arcengine`` — the grid/sprite engine (vendored from arcprize/ARCEngine).
6
+ 64x64 grid, 16-color palette, Sprite / Camera / Level / ARCBaseGame, collisions.
7
+ External deps: numpy, pydantic.
8
+ * ``arc_grid.rendering`` — the visualization layer (palette + frame -> RGB / terminal).
9
+ External deps: numpy (always), matplotlib (only for the animated display helpers).
10
+
11
+ Quick start::
12
+
13
+ import numpy as np
14
+ from arc_grid import frame_to_rgb_array, COLOR_MAP
15
+ from arc_grid import Sprite, Camera
16
+
17
+ frame = np.zeros((64, 64), dtype=np.int8) # a blank 64x64 grid
18
+ rgb = frame_to_rgb_array(0, frame, scale=4) # -> (256, 256, 3) uint8
19
+
20
+ See README.md for details and attribution.
21
+ """
22
+
23
+ # Engine surface (re-exported from the vendored arcengine package)
24
+ from .arcengine import (
25
+ ARCBaseGame,
26
+ ActionInput,
27
+ BlockingMode,
28
+ Camera,
29
+ ComplexAction,
30
+ FrameData,
31
+ FrameDataRaw,
32
+ GameAction,
33
+ GameState,
34
+ InteractionMode,
35
+ Level,
36
+ PlaceableArea,
37
+ RenderableUserDisplay,
38
+ SimpleAction,
39
+ Sprite,
40
+ ToggleableUserDisplay,
41
+ )
42
+
43
+ # Rendering surface
44
+ from .rendering import (
45
+ COLOR_MAP,
46
+ frame_to_rgb_array,
47
+ hex_to_rgb,
48
+ render_frames,
49
+ render_frames_terminal,
50
+ rgb_to_ansi,
51
+ )
52
+
53
+ __all__ = [
54
+ # engine
55
+ "ARCBaseGame",
56
+ "ActionInput",
57
+ "BlockingMode",
58
+ "Camera",
59
+ "ComplexAction",
60
+ "FrameData",
61
+ "FrameDataRaw",
62
+ "GameAction",
63
+ "GameState",
64
+ "InteractionMode",
65
+ "Level",
66
+ "PlaceableArea",
67
+ "RenderableUserDisplay",
68
+ "SimpleAction",
69
+ "Sprite",
70
+ "ToggleableUserDisplay",
71
+ # rendering
72
+ "COLOR_MAP",
73
+ "frame_to_rgb_array",
74
+ "hex_to_rgb",
75
+ "render_frames",
76
+ "render_frames_terminal",
77
+ "rgb_to_ansi",
78
+ ]
proteus/arc_grid/arcengine/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ARC Prize Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
proteus/arc_grid/arcengine/OVERVIEW.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ARCEngine Overview
2
+
3
+ ## Design Goals
4
+
5
+ 1. The engine only enforces the same opinions as ARC-AGI-3:
6
+ a. Visual: 64×64 output grid, 16 colors
7
+ b. Inputs: limited to 6 actions plus a `RESET`
8
+ c. Turn-based: no time advances without input
9
+ d. Frames: each input generates 1–N frames
10
+ 2. Beyond those opinions, ARCEngine handles:
11
+ a. Creation and rendering of sprites
12
+ b. Organization of sprites into levels
13
+ c. Organization of levels into a game
14
+ d. Sprite-based collisions
15
+ e. Main rendering pipeline
16
+ f. Sensible default settings
17
+
18
+ ## Sensible Default Settings
19
+
20
+ #### Camera Auto-Scaling (Not Overridable)
21
+
22
+ The camera automatically scales up to fill as much of the 64×64 area as possible. You can only control this by setting the camera’s `width` and `height`. Examples:
23
+
24
+ * `32×32` – Upscaled by 2×, fits perfectly into 64×64
25
+ * `30×30` – Upscaled by 2×, adds a 2-pixel letterbox around the screen
26
+ * `30×15` – Upscaled by 2×, adds 2-pixel borders on the sides and 17-pixel borders on top and bottom
27
+ * `15×15` – Upscaled by 4×, adds a 2-pixel letterbox around the screen
28
+
29
+ #### `self.next_level()` Behavior (Overridable)
30
+
31
+ By default, calling `self.next_level()` will:
32
+
33
+ * Increment `_current_level_index` by 1 on `frame + 1` (the frame after `self.next_level()` is called)
34
+ * Increment `_score` by 1
35
+ * Call `self.win()` if that was the last level
36
+
37
+ You can override this entire method if you need custom behavior.
38
+
39
+ #### `RESET` Action (Overridable)
40
+
41
+ By default, a `RESET` action will:
42
+
43
+ * Restart the current level (via `level_reset()`) if any actions have been taken
44
+ * Otherwise, perform a full game reset (via `full_reset()`)
45
+
46
+ Both `level_reset()` and `full_reset()` can be overridden with custom logic.
47
+
48
+ ## Main Game and Render Loops
49
+
50
+ #### Main Game Loop
51
+
52
+ The main game loop is fixed. In pseudocode:
53
+
54
+ ```pseudocode
55
+ handle RESET action
56
+ if game is won or lost:
57
+ return []
58
+
59
+ frames = []
60
+
61
+ while action is not complete:
62
+ STEP() ── game logic
63
+ frames.append(RENDER())
64
+
65
+ return frames
66
+ ```
67
+
68
+ #### Main Render Loop
69
+
70
+ 1. Render all sprites visible to the camera at the camera’s resolution
71
+ a. Uses the camera’s x and y properties
72
+ 2. Upscale the raw render from step 1 and add letterboxing
73
+ 3. Render the 64×64 UI on top
74
+ a. UI always spans 0,0 to 63,63
75
+
76
+ ## Example Games
77
+
78
+ [Simple Maze](https://github.com/arcprize/ARCEngine/blob/main/examples/simple_maze.py) - Dead simple game that really only uses Engine Logic (Pixel Perfect Collision).
79
+ [Merge](https://github.com/arcprize/ARCEngine/blob/main/examples/merge.py) - Another Dead simple game that shows off Pixel Perfect Collision and Sprite Merging.
80
+ [Complex Maze](https://github.com/arcprize/ARCEngine/blob/main/examples/complex_maze.py) - Added Mechanics onto Simple Maze and demonstrates `ToggleableUserDisplay`
81
+ [Merge/Detatch](https://github.com/arcprize/ARCEngine/blob/main/examples/merge_detach.py) - Adds in the ability to detatch merged sprites and demonstrates writing a custom `RenderableUserDisplay`
proteus/arc_grid/arcengine/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ARCEngine - A Python library for 2D sprite-based game development
3
+ """
4
+
5
+ from .base_game import ARCBaseGame
6
+ from .camera import Camera
7
+ from .enums import MAX_REASONING_BYTES, ActionInput, BlockingMode, ComplexAction, FrameData, FrameDataRaw, GameAction, GameState, InteractionMode, PlaceableArea, SimpleAction
8
+ from .interfaces import RenderableUserDisplay, ToggleableUserDisplay
9
+ from .level import Level
10
+ from .sprites import Sprite
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "Sprite",
15
+ "BlockingMode",
16
+ "InteractionMode",
17
+ "PlaceableArea",
18
+ "Camera",
19
+ "Level",
20
+ "GameAction",
21
+ "GameState",
22
+ "SimpleAction",
23
+ "ComplexAction",
24
+ "FrameData",
25
+ "FrameDataRaw",
26
+ "ARCBaseGame",
27
+ "ActionInput",
28
+ "RenderableUserDisplay",
29
+ "ToggleableUserDisplay",
30
+ "MAX_REASONING_BYTES",
31
+ ]
proteus/arc_grid/arcengine/base_game.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module for the base game class in ARCEngine.
3
+ """
4
+
5
+ import os
6
+ from abc import ABC
7
+ from typing import List, Optional, final
8
+
9
+ import numpy as np
10
+ from numpy import ndarray
11
+
12
+ from .camera import Camera
13
+ from .enums import ActionInput, FrameData, FrameDataRaw, GameAction, GameState
14
+ from .level import Level
15
+ from .sprites import Sprite
16
+
17
+ MAX_FRAME_PER_ACTION: int = 1000
18
+
19
+
20
+ class ARCBaseGame(ABC):
21
+ """Base class for ARCEngine games that manages levels and camera.
22
+
23
+ This is a base class that games should inherit from. and extend with game logic.
24
+ It handles the game loop and rendering.
25
+
26
+ Custom game logic should be implemented in the step() method.
27
+ """
28
+
29
+ _game_id: str
30
+ _levels: list[Level]
31
+ _clean_levels: list[Level]
32
+ _current_level_index: int
33
+ _camera: Camera
34
+ _debug: bool
35
+ _action: ActionInput
36
+ _action_complete: bool
37
+ _action_count: int
38
+ _state: GameState
39
+ _score: int
40
+ _next_level: bool
41
+ _full_reset: bool
42
+ _win_score: int
43
+ _available_actions: list[int]
44
+ _placeable_sprite: Optional[Sprite]
45
+ _seed: int
46
+
47
+ def __init__(
48
+ self,
49
+ game_id: str,
50
+ levels: List[Level],
51
+ camera: Optional[Camera] = None,
52
+ debug: bool = False,
53
+ win_score: int = 1,
54
+ available_actions: list[int] = [1, 2, 3, 4, 5, 6],
55
+ seed: int = 0,
56
+ ) -> None:
57
+ """Initialize a new game.
58
+
59
+ Args:
60
+ levels: List of levels to initialize the game with. Each level will be cloned.
61
+ camera: Optional camera to use. If not provided, a default 64x64 camera will be created.
62
+
63
+ Raises:
64
+ ValueError: If levels list is empty
65
+ """
66
+ if not levels:
67
+ raise ValueError("Game must have at least one level")
68
+
69
+ # Game ID should be set by subclasses
70
+ self._game_id = game_id
71
+
72
+ # Clone each level to prevent external modification
73
+ self._levels = [level.clone() for level in levels]
74
+ self._clean_levels = [level.clone() for level in levels]
75
+ self._current_level_index = 0
76
+
77
+ # Debug mode
78
+ self._debug = debug
79
+
80
+ # Camera
81
+ # Use provided camera or create default
82
+ self._camera = camera if camera is not None else Camera()
83
+
84
+ # Game state
85
+ self._state = GameState.NOT_PLAYED
86
+ self._score = 0
87
+ self._next_level = False
88
+ self._action = ActionInput()
89
+ self._action_complete = False
90
+ self._action_count = 0
91
+ self._full_reset = False
92
+ self._win_score = win_score if win_score > 1 else len(levels)
93
+ self.set_level(0)
94
+ self._available_actions = available_actions
95
+ self._placeable_sprite = None
96
+ self._seed = seed
97
+
98
+ def debug(self, message: str) -> None:
99
+ """Debug mode.
100
+
101
+ Args:
102
+ message: The message to print
103
+ """
104
+ if self._debug:
105
+ print(message)
106
+
107
+ @property
108
+ @final
109
+ def current_level(self) -> Level:
110
+ """Get the current level.
111
+
112
+ Returns:
113
+ Level: The current level
114
+ """
115
+ return self._levels[self._current_level_index]
116
+
117
+ @property
118
+ @final
119
+ def camera(self) -> Camera:
120
+ """Get the game's camera.
121
+
122
+ Returns:
123
+ Camera: The game's camera
124
+ """
125
+ return self._camera
126
+
127
+ @property
128
+ @final
129
+ def game_id(self) -> str:
130
+ """Get the game's ID.
131
+
132
+ Returns:
133
+ str: The game's ID
134
+ """
135
+ return self._game_id
136
+
137
+ @property
138
+ @final
139
+ def win_score(self) -> int:
140
+ """Get the game's max score.
141
+
142
+ Returns:
143
+ int: The game's max score
144
+ """
145
+ return self._win_score
146
+
147
+ @final
148
+ def set_level(self, index: int) -> None:
149
+ """Set the current level by index.
150
+
151
+ Args:
152
+ index: The index of the level to set as current
153
+
154
+ Raises:
155
+ IndexError: If index is out of range
156
+ """
157
+ if not 0 <= index < len(self._levels):
158
+ raise IndexError(f"Level index {index} out of range [0, {len(self._levels)})")
159
+ self._current_level_index = index
160
+ self._action_count = 0
161
+ level = self.current_level
162
+ if level.grid_size:
163
+ self.camera.resize(level.grid_size[0], level.grid_size[1])
164
+ self.on_set_level(level)
165
+
166
+ def set_level_by_name(self, name: str) -> None:
167
+ """Set the current level by name.
168
+
169
+ Args:
170
+ name: The name of the level to set as current
171
+ """
172
+ for index, level in enumerate(self._levels):
173
+ if level.name == name:
174
+ self.set_level(index)
175
+ return
176
+ raise ValueError(f"Level with name {name} not found")
177
+
178
+ @property
179
+ @final
180
+ def level_index(self) -> int:
181
+ """Get the current level index.
182
+
183
+ Returns:
184
+ int: The current level index
185
+ """
186
+ return self._current_level_index
187
+
188
+ @final
189
+ def perform_action(self, action_input: ActionInput, raw: bool = False) -> FrameData | FrameDataRaw:
190
+ """Perform an action and return the resulting frame data.
191
+
192
+ DO NOT OVERRIDE THIS METHOD, Your Game Logic should be in step()
193
+
194
+ The base implementation:
195
+ 1. While the action is not complete, call step() and render frames
196
+ 2. Returns a FrameData object with the current state
197
+
198
+ Args:
199
+ action_input: The action to perform
200
+
201
+ Returns:
202
+ FrameData: The resulting frame data
203
+ """
204
+ self._full_reset = False
205
+ if action_input.id == GameAction.RESET:
206
+ self.handle_reset()
207
+ elif self._state == GameState.GAME_OVER or self._state == GameState.WIN:
208
+ return FrameData(
209
+ game_id=self._game_id,
210
+ frame=[],
211
+ state=self._state,
212
+ score=self._score,
213
+ win_score=self._win_score,
214
+ action_input=action_input,
215
+ available_actions=self._available_actions,
216
+ )
217
+
218
+ self._set_action(action_input)
219
+
220
+ frame_list: list[ndarray | list[list[int]]] = []
221
+
222
+ count = 0
223
+
224
+ while not self.is_action_complete():
225
+ if count > MAX_FRAME_PER_ACTION:
226
+ raise ValueError("Action took too many frames")
227
+ count += 1
228
+ if self._next_level:
229
+ self._really_set_next_level()
230
+ else:
231
+ self.step()
232
+ frame = self.camera.render(self.current_level.get_sprites())
233
+ if raw:
234
+ frame_list.append(frame)
235
+ else:
236
+ frame_list.append(frame.tolist())
237
+
238
+ # Create and return FrameData
239
+ if raw:
240
+ frame_raw = FrameDataRaw()
241
+ frame_raw.game_id = self._game_id
242
+ frame_raw.frame = frame_list
243
+ frame_raw.state = self._state
244
+ frame_raw.levels_completed = self._score
245
+ frame_raw.win_levels = self._win_score
246
+ frame_raw.action_input = action_input
247
+ frame_raw.full_reset = self._full_reset
248
+ frame_raw.available_actions = self._available_actions
249
+ return frame_raw
250
+
251
+ return FrameData(
252
+ game_id=self._game_id,
253
+ frame=frame_list,
254
+ state=self._state,
255
+ levels_completed=self._score,
256
+ win_levels=self._win_score,
257
+ action_input=action_input,
258
+ full_reset=self._full_reset,
259
+ available_actions=self._available_actions,
260
+ )
261
+
262
+ @property
263
+ @final
264
+ def action(self) -> ActionInput:
265
+ """Get the current action."""
266
+ return self._action
267
+
268
+ @final
269
+ def _set_action(self, action_input: ActionInput) -> None:
270
+ """Set the action to perform.
271
+
272
+ Args:
273
+ action_input: The action to perform
274
+ """
275
+ self._state = GameState.NOT_FINISHED
276
+ self._action = action_input
277
+ self._action_complete = False
278
+ if action_input.id != GameAction.RESET:
279
+ self._action_count += 1
280
+
281
+ @final
282
+ def complete_action(self) -> None:
283
+ """Complete the action. Call this when the provided action is fully resolved"""
284
+ self._action_complete = True
285
+
286
+ @final
287
+ def is_action_complete(self) -> bool:
288
+ """Check if the action is complete.
289
+
290
+ Returns:
291
+ bool: True if the action is complete, False otherwise
292
+ """
293
+ return not self._next_level and self._action_complete
294
+
295
+ @final
296
+ def win(self) -> None:
297
+ """Call this when the player has beaten the game."""
298
+ self._state = GameState.WIN
299
+
300
+ @final
301
+ def lose(self) -> None:
302
+ """Call this when the player has losses the game."""
303
+ self._state = GameState.GAME_OVER
304
+
305
+ def handle_reset(self) -> None:
306
+ """Handle the reset of the game.
307
+
308
+ If the action count is 0, perform a full reset.
309
+ Otherwise, perform a level reset.
310
+ """
311
+ if os.getenv("ONLY_RESET_LEVELS") == "true" and self._state != GameState.WIN:
312
+ self.level_reset()
313
+ elif self._action_count == 0 or self._state == GameState.WIN:
314
+ self.full_reset()
315
+ else:
316
+ self.level_reset()
317
+
318
+ def full_reset(self) -> None:
319
+ self._levels = [level.clone() for level in self._clean_levels]
320
+ self._score = 0
321
+ self._action_count = 0
322
+ self._full_reset = True
323
+ self.set_level(0)
324
+ self._state = GameState.NOT_FINISHED
325
+
326
+ def level_reset(self) -> None:
327
+ self._levels[self._current_level_index] = self._clean_levels[self._current_level_index].clone()
328
+ self.set_level(self._current_level_index)
329
+ self._state = GameState.NOT_FINISHED
330
+
331
+ def step(self) -> None:
332
+ """Step the game. This is where your game logic should be implemented.
333
+
334
+ REQUIRED: Call complete_action() when the action is complete.
335
+ It does not need to be called every step, but once the action is complete.
336
+ The engine will keep calling step and rendering frames until the action is complete.
337
+ """
338
+
339
+ self.complete_action()
340
+
341
+ def try_move(self, sprite_name: str, dx: int, dy: int) -> List[Sprite]:
342
+ """Try to move a sprite and return a list of sprites it collides with.
343
+
344
+ This method attempts to move the sprite by the given deltas and checks for collisions.
345
+ If any collisions are detected, the sprite is not moved and the method returns a list
346
+ of sprite names that were collided with.
347
+
348
+ Args:
349
+ sprite_name: The name of the sprite to move.
350
+ dx: The change in x position (positive = right, negative = left).
351
+ dy: The change in y position (positive = down, negative = up).
352
+
353
+ Returns:
354
+ A list of sprite names that the sprite collided with. If no collisions occurred,
355
+ the sprite is moved and an empty list is returned.
356
+
357
+ Raises:
358
+ ValueError: If no sprite with the given name is found.
359
+ """
360
+ # Get the sprite to move
361
+ sprites = self.current_level.get_sprites_by_name(sprite_name)
362
+ if not sprites:
363
+ raise ValueError(f"No sprite found with name: {sprite_name}")
364
+ return self.try_move_sprite(sprites[0], dx, dy) # Use the first sprite with this name
365
+
366
+ def try_move_sprite(self, sprite: Sprite, dx: int, dy: int) -> List[Sprite]:
367
+ """Try to move a sprite and return a list of sprites it collides with.
368
+
369
+ This method attempts to move the sprite by the given deltas and checks for collisions.
370
+ If any collisions are detected, the sprite is not moved and the method returns a list
371
+ of sprite names that were collided with.
372
+
373
+ Args:
374
+ sprite_name: The name of the sprite to move.
375
+ dx: The change in x position (positive = right, negative = left).
376
+ dy: The change in y position (positive = down, negative = up).
377
+
378
+ Returns:
379
+ A list of sprite names that the sprite collided with. If no collisions occurred,
380
+ the sprite is moved and an empty list is returned.
381
+
382
+ Raises:
383
+ ValueError: If no sprite with the given name is found.
384
+ """ # Get the sprite to move
385
+ # Store original position
386
+ original_x = sprite.x
387
+ original_y = sprite.y
388
+
389
+ # Try the move
390
+ sprite.move(dx, dy)
391
+
392
+ # Check for collisions with all other sprites
393
+ collisions = []
394
+ for other in self.current_level.get_sprites():
395
+ if sprite.collides_with(other):
396
+ collisions.append(other)
397
+
398
+ # If there were collisions, revert the move
399
+ if collisions:
400
+ sprite.set_position(original_x, original_y)
401
+
402
+ return collisions
403
+
404
+ def is_last_level(self) -> bool:
405
+ """Check if the current level is the last level.
406
+
407
+ Returns:
408
+ bool: True if the current level is the last level, False otherwise
409
+ """
410
+ return self._current_level_index == len(self._levels) - 1
411
+
412
+ def next_level(self) -> None:
413
+ """Move to the next level."""
414
+ self._score += 1
415
+ if not self.is_last_level():
416
+ self._next_level = True
417
+ else:
418
+ self.win()
419
+
420
+ def _really_set_next_level(self) -> None:
421
+ self.set_level(self._current_level_index + 1)
422
+ self._next_level = False
423
+
424
+ def on_set_level(self, level: Level) -> None:
425
+ """Called when the level is set, use this to set level specific data."""
426
+ pass
427
+
428
+ def get_pixels_at_sprite(self, sprite: Sprite) -> ndarray:
429
+ """Get the camera pixels at a sprite.
430
+
431
+ Args:
432
+ sprite: The sprite to get the pixels at
433
+
434
+ Returns:
435
+ list[list[int]]: The camera returned pixels at the sprite
436
+ """
437
+ return self.get_pixels(sprite.x - self.camera.x, sprite.y - self.camera.y, sprite.width, sprite.height)
438
+
439
+ def get_pixels(self, x: int, y: int, width: int, height: int) -> ndarray:
440
+ """Get the camera pixels at a given position.
441
+
442
+ Args:
443
+ x: The x position to get the pixels at
444
+ y: The y position to get the pixels at
445
+ width: The width of the area to get the pixels at
446
+ height: The height of the area to get the pixels at
447
+
448
+ Returns:
449
+ list[list[int]]: The camera returned pixels at the given position and width/height
450
+ """
451
+
452
+ frame = self.camera._raw_render(self.current_level.get_sprites())
453
+ return frame[y : y + height, x : x + width]
454
+
455
+ def set_placeable_sprite(self, sprite: Sprite | None) -> None:
456
+ """Set the placeable sprite.
457
+
458
+ Args:
459
+ sprite: The sprite to set as placeable
460
+ """
461
+ self._placeable_sprite = sprite
462
+
463
+ def _get_graph_location(self) -> tuple[float, float, float] | None:
464
+ """Get the location for the graph builder to use for this state.
465
+
466
+ Returns:
467
+ tuple[float, float, float] | None: The location this state should report as its location in the graph,
468
+ or None if the graph builder should calculate the location.
469
+ """
470
+ return None
471
+
472
+ def _get_hidden_state(self) -> ndarray:
473
+ """Get the hidden state for the graph builder to use for this state.
474
+
475
+ Returns:
476
+ ndarray: Hidden state for the graph builder to use for this state.
477
+ """
478
+ return np.zeros((4, 4), dtype=np.int8)
479
+
480
+ def _get_valid_actions(self) -> list[ActionInput]:
481
+ """Get the valid actions for the current game state.
482
+
483
+ Note: This method is for internal use only, the data here is never exposed
484
+ via the API or to Users/Agents.
485
+
486
+ Returns:
487
+ list[int]: The valid actions for the current game state
488
+ """
489
+ valid_actions: list[ActionInput] = []
490
+
491
+ for action in self._available_actions:
492
+ match action:
493
+ case 1 | 2 | 3 | 4 | 5:
494
+ valid_actions.append(ActionInput(id=GameAction.from_id(action)))
495
+ case 6:
496
+ if self._placeable_sprite:
497
+ valid_actions.extend(self._get_valid_placeble_actions())
498
+ else:
499
+ valid_actions.extend(self._get_valid_clickable_actions())
500
+
501
+ return valid_actions
502
+
503
+ def _get_valid_placeble_actions(self) -> list[ActionInput]:
504
+ """Get valid placeable actions from placeable areas.
505
+
506
+ Returns:
507
+ list[ActionInput]: List of valid placeable actions with screen coordinates
508
+ """
509
+
510
+ scale, x_offset, y_offset = self.camera._calculate_scale_and_offset()
511
+
512
+ valid_actions: list[ActionInput] = []
513
+
514
+ for area in self.current_level.placeable_areas:
515
+ for y in range(area.y, area.y + area.height, area.y_scale):
516
+ for x in range(area.x, area.x + area.width, area.x_scale):
517
+ action_input = ActionInput(id=GameAction.ACTION6.value, data={"x": x * scale + x_offset, "y": y * scale + y_offset})
518
+ valid_actions.append(action_input)
519
+
520
+ return valid_actions
521
+
522
+ def _get_valid_clickable_actions(self) -> list[ActionInput]:
523
+ """Get valid clickable actions from sprites with the 'sys_click' tag.
524
+
525
+ This method finds all sprites tagged with 'sys_click' and generates clickable actions
526
+ based on their pixel data and the presence of the 'sys_every_pixel' tag.
527
+
528
+ Returns:
529
+ list[ActionInput]: List of valid clickable actions with screen coordinates
530
+ """
531
+ valid_actions: list[ActionInput] = []
532
+
533
+ # Get all sprites with the 'sys_click' tag
534
+ clickable_sprites = self.current_level.get_sprites_by_tag("sys_click")
535
+ clickable_sprites.extend(self.current_level.get_sprites_by_tag("sys_place"))
536
+
537
+ scale, x_offset, y_offset = self.camera._calculate_scale_and_offset()
538
+
539
+ for sprite in clickable_sprites:
540
+ if not self._is_sprite_clickable_now(sprite):
541
+ continue
542
+
543
+ # Check if sprite has the 'sys_every_pixel' tag
544
+ has_every_pixel = "sys_every_pixel" in sprite._tags
545
+
546
+ # Get the rendered sprite pixels (accounts for scale, rotation, etc.)
547
+ rendered_pixels = sprite.render()
548
+
549
+ if has_every_pixel:
550
+ # Every non-negative pixel is a valid action
551
+ for y in range(rendered_pixels.shape[0]):
552
+ for x in range(rendered_pixels.shape[1]):
553
+ if rendered_pixels[y, x] >= 0:
554
+ # Convert sprite-relative coordinates to screen coordinates
555
+ screen_x = (sprite._x + x) * scale + x_offset
556
+ screen_y = (sprite._y + y) * scale + y_offset
557
+
558
+ # Create ActionInput with ACTION6 (ComplexAction) and coordinates
559
+ action_input = ActionInput(id=GameAction.ACTION6.value, data={"x": screen_x, "y": screen_y})
560
+ valid_actions.append(action_input)
561
+ else:
562
+ # Find any single non-negative pixel to represent the entire sprite
563
+ found_pixel = False
564
+ for y in range(rendered_pixels.shape[0]):
565
+ for x in range(rendered_pixels.shape[1]):
566
+ if rendered_pixels[y, x] >= 0:
567
+ # Convert sprite-relative coordinates to screen coordinates
568
+ screen_x = (sprite._x + x) * scale + x_offset
569
+ screen_y = (sprite._y + y) * scale + y_offset
570
+
571
+ # Create ActionInput with ACTION6 (ComplexAction) and coordinates
572
+ action_input = ActionInput(id=GameAction.ACTION6.value, data={"x": screen_x, "y": screen_y})
573
+ valid_actions.append(action_input)
574
+ found_pixel = True
575
+ break
576
+ if found_pixel:
577
+ break
578
+
579
+ return valid_actions
580
+
581
+ def _is_sprite_clickable_now(self, sprite: Sprite) -> bool:
582
+ """
583
+ Check if a sprite is clickable now. This method is designed to be overridden by games
584
+ that have more complex clickable logic. (e.g. not all sprites flagged with 'sys_click'
585
+ are clickable at all times)
586
+
587
+ Args:
588
+ sprite: The sprite to check
589
+
590
+ Returns:
591
+ bool: True if the sprite is clickable now, False otherwise
592
+ """
593
+ return True
proteus/arc_grid/arcengine/camera.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module for camera-related functionality in the ARCEngine.
3
+ """
4
+
5
+ from typing import List, Tuple
6
+
7
+ import numpy as np
8
+
9
+ from .interfaces import RenderableUserDisplay
10
+ from .sprites import Sprite
11
+
12
+
13
+ class Camera:
14
+ """A camera that defines the viewport into the game world."""
15
+
16
+ # Maximum allowed dimensions
17
+ MAX_DIMENSION = 64
18
+
19
+ _x: int
20
+ _y: int
21
+ _width: int
22
+ _height: int
23
+ _background: int
24
+ _letter_box: int
25
+ _interfaces: list[RenderableUserDisplay]
26
+
27
+ def __init__(
28
+ self,
29
+ x: int = 0,
30
+ y: int = 0,
31
+ width: int = 64,
32
+ height: int = 64,
33
+ background: int = 5,
34
+ letter_box: int = 5,
35
+ interfaces: list[RenderableUserDisplay] = [],
36
+ ):
37
+ """Initialize a new Camera.
38
+
39
+ Args:
40
+ x: X coordinate in pixels (default: 0)
41
+ y: Y coordinate in pixels (default: 0)
42
+ width: Viewport width in pixels (default: 64, max: 64)
43
+ height: Viewport height in pixels (default: 64, max: 64)
44
+ background: Background color index (default: 5 - Black)
45
+ letter_box: Letter box color index (default: 5 - Black)
46
+ interfaces: Optional list of renderable interfaces to initialize with
47
+
48
+ Raises:
49
+ ValueError: If width or height exceed 64 pixels
50
+ """
51
+ if width > 64 or height > 64 or width < 0 or height < 0:
52
+ raise ValueError("Camera dimensions cannot exceed 64x64 pixels and must be positive")
53
+
54
+ self._x = x
55
+ self._y = y
56
+ self.width = width
57
+ self.height = height
58
+ self._background = background
59
+ self._letter_box = letter_box
60
+ self._interfaces: List[RenderableUserDisplay] = []
61
+
62
+ if interfaces:
63
+ for interface in interfaces:
64
+ self._interfaces.append(interface)
65
+
66
+ @property
67
+ def x(self) -> int:
68
+ """Get the camera's x position.
69
+
70
+ Returns:
71
+ int: The camera's x position
72
+ """
73
+ return self._x
74
+
75
+ @x.setter
76
+ def x(self, value: int) -> None:
77
+ """Set the camera's x position.
78
+
79
+ Args:
80
+ value: The new x position
81
+ """
82
+ self._x = int(value)
83
+
84
+ @property
85
+ def y(self) -> int:
86
+ """Get the camera's y position.
87
+
88
+ Returns:
89
+ int: The camera's y position
90
+ """
91
+ return self._y
92
+
93
+ @y.setter
94
+ def y(self, value: int) -> None:
95
+ """Set the camera's y position.
96
+
97
+ Args:
98
+ value: The new y position
99
+ """
100
+ self._y = int(value)
101
+
102
+ @property
103
+ def width(self) -> int:
104
+ """Get the camera's width.
105
+
106
+ Returns:
107
+ int: The camera's width
108
+ """
109
+ return self._width
110
+
111
+ @width.setter
112
+ def width(self, value: int) -> None:
113
+ """Set the camera's width.
114
+
115
+ Args:
116
+ value: The new width
117
+
118
+ Raises:
119
+ ValueError: If width exceeds MAX_DIMENSION
120
+ """
121
+ width_int = int(value)
122
+ if width_int > self.MAX_DIMENSION:
123
+ raise ValueError(f"Width cannot exceed {self.MAX_DIMENSION} pixels")
124
+ self._width = width_int
125
+
126
+ @property
127
+ def height(self) -> int:
128
+ """Get the camera's height.
129
+
130
+ Returns:
131
+ int: The camera's height
132
+ """
133
+ return self._height
134
+
135
+ @height.setter
136
+ def height(self, value: int) -> None:
137
+ """Set the camera's height.
138
+
139
+ Args:
140
+ value: The new height
141
+
142
+ Raises:
143
+ ValueError: If height exceeds MAX_DIMENSION
144
+ """
145
+ height_int = int(value)
146
+ if height_int > self.MAX_DIMENSION:
147
+ raise ValueError(f"Height cannot exceed {self.MAX_DIMENSION} pixels")
148
+ self._height = height_int
149
+
150
+ @property
151
+ def background(self) -> int:
152
+ """Get the camera's background color."""
153
+ return self._background
154
+
155
+ @background.setter
156
+ def background(self, value: int) -> None:
157
+ """Set the camera's background color."""
158
+ self._background = value
159
+
160
+ @property
161
+ def letter_box(self) -> int:
162
+ """Get the camera's letter box color."""
163
+ return self._letter_box
164
+
165
+ @letter_box.setter
166
+ def letter_box(self, value: int) -> None:
167
+ """Set the camera's letter box color."""
168
+ self._letter_box = value
169
+
170
+ def resize(self, width: int, height: int) -> None:
171
+ """Resize the camera.
172
+
173
+ Args:
174
+ width: The new width
175
+ height: The new height
176
+ """
177
+ self.width = width
178
+ self.height = height
179
+
180
+ def move(self, dx: int, dy: int) -> None:
181
+ """Move the camera by the specified delta.
182
+
183
+ Args:
184
+ dx: The change in x position
185
+ dy: The change in y position
186
+ """
187
+ self._x += int(dx)
188
+ self._y += int(dy)
189
+
190
+ def _calculate_scale_and_offset(self) -> Tuple[int, int, int]:
191
+ """Calculate the scale factor and offsets for letterboxing.
192
+
193
+ Returns:
194
+ Tuple[int, int, int]: (scale, x_offset, y_offset)
195
+ - scale: The uniform scale factor to fit viewport in 64x64
196
+ - x_offset: Horizontal offset for centering
197
+ - y_offset: Vertical offset for centering
198
+ """
199
+ # Calculate maximum possible scale that fits in MAX_DIMENSION
200
+ scale_x = self.MAX_DIMENSION // self._width
201
+ scale_y = self.MAX_DIMENSION // self._height
202
+ scale = min(scale_x, scale_y)
203
+
204
+ # Calculate scaled dimensions
205
+ scaled_width = self._width * scale
206
+ scaled_height = self._height * scale
207
+
208
+ # Only use offsets if we can't scale up to fill the screen
209
+ x_offset = (self.MAX_DIMENSION - scaled_width) // 2
210
+ y_offset = (self.MAX_DIMENSION - scaled_height) // 2
211
+
212
+ return scale, x_offset, y_offset
213
+
214
+ def _raw_render(self, sprites: List[Sprite]) -> np.ndarray:
215
+ """Internal method to render the camera view.
216
+
217
+ Args:
218
+ sprites: List of sprites to render. Sprites are rendered in order of their layer
219
+ value (lower layers first). Negative pixel values are treated as transparent.
220
+ Only visible sprites (based on their interaction mode) will be rendered.
221
+
222
+ Returns:
223
+ np.ndarray: The rendered view as a 2D numpy array
224
+ """
225
+ # Create background array filled with background color
226
+ output = np.full((self._height, self._width), self._background, dtype=np.int8)
227
+
228
+ if not sprites:
229
+ return output
230
+
231
+ # Sort sprites by layer (lower layers first) and filter out non-visible sprites
232
+ sorted_sprites = sorted((s for s in sprites if s.is_visible), key=lambda s: s.layer)
233
+
234
+ for sprite in sorted_sprites:
235
+ # Get the sprite's rendered pixels (handles rotation and scaling)
236
+ sprite_pixels = sprite.render()
237
+ sprite_height, sprite_width = sprite_pixels.shape
238
+
239
+ # Calculate sprite position relative to camera
240
+ rel_x = sprite.x - self._x
241
+ rel_y = sprite.y - self._y
242
+
243
+ # Calculate the intersection with viewport
244
+ dest_x_start = max(0, rel_x)
245
+ dest_x_end = min(self._width, rel_x + sprite_width)
246
+ dest_y_start = max(0, rel_y)
247
+ dest_y_end = min(self._height, rel_y + sprite_height)
248
+
249
+ # Skip if sprite is completely outside viewport
250
+ if dest_x_end <= dest_x_start or dest_y_end <= dest_y_start:
251
+ continue
252
+
253
+ # Calculate source region from sprite
254
+ sprite_x_start = max(0, -rel_x)
255
+ sprite_x_end = sprite_width - max(0, (rel_x + sprite_width) - self._width)
256
+ sprite_y_start = max(0, -rel_y)
257
+ sprite_y_end = sprite_height - max(0, (rel_y + sprite_height) - self._height)
258
+
259
+ # Get the sprite region we're going to copy
260
+ sprite_region = sprite_pixels[sprite_y_start:sprite_y_end, sprite_x_start:sprite_x_end]
261
+
262
+ # Create a mask for non-negative (visible) pixels
263
+ visible_mask = sprite_region >= 0
264
+
265
+ # Update only the non-transparent pixels
266
+ output[dest_y_start:dest_y_end, dest_x_start:dest_x_end][visible_mask] = sprite_region[visible_mask]
267
+
268
+ return output
269
+
270
+ def render(self, sprites: List[Sprite]) -> np.ndarray:
271
+ """Render the camera view.
272
+
273
+ The rendered output is always 64x64 pixels. If the camera's viewport is smaller,
274
+ the view will be scaled up uniformly (maintaining aspect ratio) to fit within
275
+ 64x64, and the remaining space will be filled with the letter_box color.
276
+
277
+ Args:
278
+ sprites: List of sprites to render (currently unused)
279
+
280
+ Returns:
281
+ np.ndarray: The rendered view as a 64x64 numpy array
282
+ """
283
+ # Start with a letter-boxed canvas
284
+ output = np.full((self.MAX_DIMENSION, self.MAX_DIMENSION), self._letter_box, dtype=np.int8)
285
+
286
+ # Get the raw camera view
287
+ view = self._raw_render(sprites)
288
+
289
+ # Calculate scaling and offsets
290
+ scale, x_offset, y_offset = self._calculate_scale_and_offset()
291
+
292
+ # Scale up the view using numpy's repeat
293
+ if scale > 1:
294
+ view = np.repeat(np.repeat(view, scale, axis=0), scale, axis=1)
295
+
296
+ # Insert the scaled view into the letter-boxed output
297
+ output[y_offset : y_offset + view.shape[0], x_offset : x_offset + view.shape[1]] = view
298
+
299
+ for interface in self._interfaces:
300
+ output = interface.render_interface(output)
301
+
302
+ return output
303
+
304
+ def replace_interface(self, new_interfaces: list[RenderableUserDisplay]) -> None:
305
+ """Replace the current interfaces with new ones.
306
+
307
+ This method replaces all current interfaces with the provided ones. Each interface
308
+ in the new list will be cloned to prevent external modification.
309
+
310
+ Args:
311
+ new_interfaces: List of new interfaces to use. These should be cloned before passing them in.
312
+ """
313
+ self._interfaces = []
314
+ if new_interfaces:
315
+ for interface in new_interfaces:
316
+ self._interfaces.append(interface)
317
+
318
+ def display_to_grid(self, display_x: int, display_y: int) -> tuple[int, int] | None:
319
+ """Convert display coordinates (64x64) to camera grid coordinates.
320
+
321
+ This takes into account:
322
+ - Camera scaling max(64/camera_width, 64/camera_height)
323
+ - Letterbox padding
324
+ - Grid boundaries
325
+
326
+ Args:
327
+ display_x: X coordinate in display space (0-63)
328
+ display_y: Y coordinate in display space (0-63)
329
+
330
+ Returns:
331
+ tuple[int, int] | None: The corresponding grid coordinates (x, y) or None if the display coordinates are within the letterbox
332
+ """
333
+ # Calculate scaling factor
334
+ scale_x = int(64 / self.width)
335
+ scale_y = int(64 / self.height)
336
+ scale = min(scale_x, scale_y)
337
+
338
+ # Calculate letterbox padding
339
+ x_padding = int((64 - (self.width * scale)) / 2)
340
+ y_padding = int((64 - (self.height * scale)) / 2)
341
+
342
+ # Remove padding and scale down
343
+ grid_x = int((display_x - x_padding) / scale) if display_x - x_padding >= 0 else -1
344
+ grid_y = int((display_y - y_padding) / scale) if display_y - y_padding >= 0 else -1
345
+
346
+ if grid_x < 0 or grid_y < 0 or grid_x >= self.width or grid_y >= self.height:
347
+ # Given X, Y is off the camera screen
348
+ return None
349
+
350
+ return grid_x + self.x, grid_y + self.y
proteus/arc_grid/arcengine/enums.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module containing enums used throughout the ARCEngine.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from enum import Enum, auto
9
+ from typing import Any, List, Optional, Type, Union
10
+
11
+ from numpy import ndarray
12
+ from pydantic import BaseModel, Field, PrivateAttr, field_validator
13
+
14
+ MAX_REASONING_BYTES = 16 * 1024 # 16 KB guard-rail
15
+
16
+
17
+ class BlockingMode(Enum):
18
+ """Enum defining different collision detection behaviors for sprites."""
19
+
20
+ NOT_BLOCKED = auto() # No collision detection
21
+ BOUNDING_BOX = auto() # Simple rectangular collision detection
22
+ PIXEL_PERFECT = auto() # Precise pixel-level collision detection
23
+
24
+
25
+ class InteractionMode(Enum):
26
+ """Enum defining how a sprite interacts with the game world visually and physically."""
27
+
28
+ TANGIBLE = auto() # Visible and can be collided with
29
+ INTANGIBLE = auto() # Visible but cannot be collided with (ghost-like)
30
+ INVISIBLE = auto() # Not visible but can be collided with (invisible wall)
31
+ REMOVED = auto() # Not visible and cannot be collided with (effectively removed)
32
+
33
+
34
+ class GameState(str, Enum):
35
+ NOT_PLAYED = "NOT_PLAYED"
36
+ NOT_FINISHED = "NOT_FINISHED"
37
+ WIN = "WIN"
38
+ GAME_OVER = "GAME_OVER"
39
+
40
+
41
+ class SimpleAction(BaseModel):
42
+ game_id: str = ""
43
+
44
+
45
+ class ComplexAction(BaseModel):
46
+ game_id: str = ""
47
+ x: int = Field(0, ge=0, le=63)
48
+ y: int = Field(0, ge=0, le=63)
49
+
50
+
51
+ class GameAction(Enum):
52
+ RESET = (0, SimpleAction)
53
+ ACTION1 = (1, SimpleAction)
54
+ ACTION2 = (2, SimpleAction)
55
+ ACTION3 = (3, SimpleAction)
56
+ ACTION4 = (4, SimpleAction)
57
+ ACTION5 = (5, SimpleAction)
58
+ ACTION6 = (6, ComplexAction)
59
+ ACTION7 = (7, SimpleAction)
60
+
61
+ action_type: Union[Type[SimpleAction], Type[ComplexAction]]
62
+ action_data: Union[SimpleAction | ComplexAction]
63
+
64
+ def __init__(
65
+ self,
66
+ action_id: int,
67
+ action_type: Union[Type[SimpleAction], Type[ComplexAction]],
68
+ ) -> None:
69
+ self._value_ = action_id
70
+ self.action_type = action_type
71
+ self.action_data = action_type()
72
+
73
+ def is_simple(self) -> bool:
74
+ return self.action_type is SimpleAction
75
+
76
+ def is_complex(self) -> bool:
77
+ return self.action_type is ComplexAction
78
+
79
+ def validate_data(self, data: dict[str, Any]) -> bool:
80
+ """Raise exception on invalid parse of incoming JSON data."""
81
+ self.action_type.model_validate(data)
82
+ return True
83
+
84
+ def set_data(self, data: dict[str, Any]) -> Union[SimpleAction | ComplexAction]:
85
+ self.action_data = self.action_type(**data)
86
+ return self.action_data
87
+
88
+ @classmethod
89
+ def from_id(cls, action_id: int) -> GameAction:
90
+ for action in cls:
91
+ if action.value == action_id:
92
+ return action
93
+ raise ValueError(f"No GameAction with id {action_id}")
94
+
95
+ @classmethod
96
+ def from_name(cls, name: str) -> "GameAction":
97
+ try:
98
+ return cls[name.upper()]
99
+ except KeyError:
100
+ raise ValueError(f"No GameAction with name '{name}'")
101
+
102
+ @classmethod
103
+ def all_simple(cls) -> list[GameAction]:
104
+ return [a for a in cls if a.is_simple()]
105
+
106
+ @classmethod
107
+ def all_complex(cls) -> list[GameAction]:
108
+ return [a for a in cls if a.is_complex()]
109
+
110
+
111
+ class ActionInput(BaseModel):
112
+ id: GameAction = GameAction.RESET
113
+ data: dict[str, Any] = {}
114
+ reasoning: Optional[Any] = Field(default=None, description="Opaque client-supplied blob; stored & echoed back verbatim.")
115
+
116
+ # Optional size / serialisability guard
117
+ @field_validator("reasoning")
118
+ @classmethod
119
+ def _check_reasoning(cls, v: Any) -> Any:
120
+ if v is None:
121
+ return v # field omitted → fine
122
+ try:
123
+ raw = json.dumps(v, separators=(",", ":")).encode("utf-8")
124
+ except (TypeError, ValueError):
125
+ raise ValueError("reasoning must be JSON-serialisable")
126
+ if len(raw) > MAX_REASONING_BYTES:
127
+ raise ValueError(f"reasoning exceeds {MAX_REASONING_BYTES} bytes")
128
+ return v
129
+
130
+
131
+ class FrameData(BaseModel):
132
+ game_id: str = ""
133
+ frame: list[list[list[int]]] = []
134
+ state: GameState = GameState.NOT_PLAYED
135
+ levels_completed: int = Field(0, ge=0, le=254)
136
+ win_levels: int = Field(0, ge=0, le=254)
137
+ action_input: ActionInput = Field(default_factory=lambda: ActionInput())
138
+ guid: Optional[str] = None
139
+ full_reset: bool = False
140
+ available_actions: list[int] = []
141
+
142
+ def is_empty(self) -> bool:
143
+ return len(self.frame) == 0
144
+
145
+ def __str__(self) -> str:
146
+ return self.model_dump_json(indent=2)
147
+
148
+
149
+ class FrameDataRaw(BaseModel):
150
+ game_id: str = ""
151
+ state: GameState = GameState.NOT_PLAYED
152
+ levels_completed: int = 0
153
+ win_levels: int = 0
154
+ action_input: ActionInput = Field(default_factory=ActionInput)
155
+ guid: Optional[str] = None
156
+ full_reset: bool = False
157
+ available_actions: list[int] = Field(default_factory=list)
158
+
159
+ # runtime-only, not validated, not serialized
160
+ _frame: List[ndarray] = PrivateAttr(default_factory=list)
161
+
162
+ @property
163
+ def frame(self) -> List[ndarray]:
164
+ return self._frame
165
+
166
+ @frame.setter
167
+ def frame(self, value: List[ndarray]) -> None:
168
+ self._frame = value
169
+
170
+ def is_empty(self) -> bool:
171
+ return len(self._frame) == 0
172
+
173
+ def __str__(self) -> str:
174
+ return self.model_dump_json(indent=2)
175
+
176
+
177
+ class PlaceableArea:
178
+ x: int = 0
179
+ y: int = 0
180
+ width: int = 0
181
+ height: int = 0
182
+ x_scale: int = 1
183
+ y_scale: int = 1
184
+
185
+ def __init__(self, x: int, y: int, width: int, height: int, x_scale: int = 1, y_scale: int = 1):
186
+ self.x = x
187
+ self.y = y
188
+ self.width = width
189
+ self.height = height
190
+ self.x_scale = x_scale
191
+ self.y_scale = y_scale
proteus/arc_grid/arcengine/interfaces.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module for user display interfaces in the ARCEngine.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ import numpy as np
8
+
9
+ from .enums import InteractionMode
10
+ from .sprites import Sprite
11
+
12
+
13
+ class RenderableUserDisplay(ABC):
14
+ """Abstract base class for UI elements that can be rendered by the camera.
15
+
16
+ This class defines the interface for UI elements that can be rendered by the camera.
17
+ It is used as the final step in the camera's rendering pipeline to produce the 64x64 output frame.
18
+ """
19
+
20
+ @abstractmethod
21
+ def render_interface(self, frame: np.ndarray) -> np.ndarray:
22
+ """Render this UI element onto the given frame.
23
+
24
+ Args:
25
+ frame: The 64x64 numpy array to render onto
26
+ """
27
+ return frame
28
+
29
+ def draw_sprite(self, frame: np.ndarray, sprite: Sprite, start_x: int, start_y: int) -> np.ndarray:
30
+ sprite_pixels = sprite.render()
31
+ sprite_height, sprite_width = sprite_pixels.shape
32
+
33
+ # Calculate sprite boundaries
34
+ end_x = start_x + sprite_width
35
+ end_y = start_y + sprite_height
36
+
37
+ # Only render if sprite is at least partially visible
38
+ if start_x < 64 and start_y < 64 and end_x > 0 and end_y > 0:
39
+ # Calculate the visible portion of the sprite
40
+ sprite_start_y = max(0, -start_y)
41
+ sprite_start_x = max(0, -start_x)
42
+ sprite_end_y = sprite_height - max(0, end_y - 64)
43
+ sprite_end_x = sprite_width - max(0, end_x - 64)
44
+
45
+ # Calculate frame boundaries
46
+ frame_start_y = max(0, start_y)
47
+ frame_start_x = max(0, start_x)
48
+ frame_end_y = min(64, end_y)
49
+ frame_end_x = min(64, end_x)
50
+
51
+ # Only render non-negative pixels
52
+ frame[frame_start_y:frame_end_y, frame_start_x:frame_end_x] = np.where(
53
+ sprite_pixels[sprite_start_y:sprite_end_y, sprite_start_x:sprite_end_x] >= 0,
54
+ sprite_pixels[sprite_start_y:sprite_end_y, sprite_start_x:sprite_end_x],
55
+ frame[frame_start_y:frame_end_y, frame_start_x:frame_end_x],
56
+ )
57
+ return frame
58
+
59
+
60
+ class ToggleableUserDisplay(RenderableUserDisplay):
61
+ """A UI element that manages a collection of sprite pairs (enabled/disabled states).
62
+
63
+ This class provides methods to toggle between enabled and disabled states of sprite pairs,
64
+ and renders the appropriate sprite based on the current state.
65
+ """
66
+
67
+ _sprite_pairs: list[tuple[Sprite, Sprite]]
68
+
69
+ def __init__(self, sprite_pairs: list[tuple[Sprite, Sprite]] = []):
70
+ """Initialize the toggleable interface with sprite pairs.
71
+
72
+ Each sprite pair consists of two sprites:
73
+ - The first sprite is used when the object is enabled
74
+ - The second sprite is used when the object is disabled
75
+
76
+ Args:
77
+ sprite_pairs: List of sprite pairs to initialize with. Each pair will be cloned
78
+ to prevent external modification. Defaults to an empty list.
79
+ """
80
+ self._sprite_pairs: list[tuple[Sprite, Sprite]] = []
81
+ if sprite_pairs:
82
+ for sprite_pair in sprite_pairs:
83
+ self._sprite_pairs.append((sprite_pair[0].clone(), sprite_pair[1].clone()))
84
+
85
+ def clone(self) -> "ToggleableUserDisplay":
86
+ """Create a deep copy of this toggleable interface.
87
+
88
+ This method creates a new ToggleableInterface instance with cloned sprite pairs.
89
+ Each sprite in each pair is cloned, ensuring that the new interface has
90
+ completely independent sprites from the original.
91
+
92
+ Returns:
93
+ ToggleableInterface: A new instance with cloned sprite pairs
94
+ """
95
+ # Create new instance with empty sprite pairs
96
+ cloned_pairs: list[tuple[Sprite, Sprite]] = []
97
+
98
+ # Clone each sprite pair
99
+ for sprite_pair in self._sprite_pairs:
100
+ cloned_pairs.append((sprite_pair[0].clone(), sprite_pair[1].clone()))
101
+
102
+ return ToggleableUserDisplay(cloned_pairs)
103
+
104
+ def is_enabled(self, index: int) -> bool:
105
+ """Check if a sprite pair is enabled.
106
+
107
+ Args:
108
+ index: The index of the sprite pair to check.
109
+
110
+ Returns:
111
+ bool: True if the pair is enabled, False otherwise.
112
+
113
+ Raises:
114
+ ValueError: If the index is out of bounds.
115
+ """
116
+ if index < 0 or index >= len(self._sprite_pairs):
117
+ raise ValueError(f"Index {index} is out of bounds")
118
+ return self._sprite_pairs[index][0].interaction != InteractionMode.REMOVED
119
+
120
+ def enable(self, index: int) -> None:
121
+ """Enable the sprite pair at the given index.
122
+
123
+ This will make the first sprite in the pair visible and interactive,
124
+ while making the second sprite invisible and non-interactive.
125
+
126
+ Args:
127
+ index: The index of the sprite pair to enable.
128
+
129
+ Raises:
130
+ ValueError: If the index is out of bounds.
131
+ """
132
+ if index < 0 or index >= len(self._sprite_pairs):
133
+ raise ValueError(f"Index {index} is out of bounds")
134
+ self._enable_sprite_pair(self._sprite_pairs[index])
135
+
136
+ def disable(self, index: int) -> None:
137
+ """Disable the sprite pair at the given index.
138
+
139
+ This will make the first sprite in the pair invisible and non-interactive,
140
+ while making the second sprite visible and interactive.
141
+
142
+ Args:
143
+ index: The index of the sprite pair to disable.
144
+
145
+ Raises:
146
+ ValueError: If the index is out of bounds.
147
+ """
148
+ if index < 0 or index >= len(self._sprite_pairs):
149
+ raise ValueError(f"Index {index} is out of bounds")
150
+ self._disable_sprite_pair(self._sprite_pairs[index])
151
+
152
+ def enable_all_by_tag(self, tag: str) -> None:
153
+ """Enable all sprite pairs that have the given tag.
154
+
155
+ This will enable all sprite pairs where either sprite in the pair
156
+ has the specified tag.
157
+
158
+ Args:
159
+ tag: The tag to search for in the sprites.
160
+ """
161
+ sprites = self._find_sprites_by_tag(tag)
162
+ for sprite in sprites:
163
+ self._enable_sprite_pair(sprite)
164
+
165
+ def disabled_all_by_tag(self, tag: str) -> None:
166
+ """Disable all sprite pairs that have the given tag.
167
+
168
+ This will disable all sprite pairs where either sprite in the pair
169
+ has the specified tag.
170
+
171
+ Args:
172
+ tag: The tag to search for in the sprites.
173
+ """
174
+ sprites = self._find_sprites_by_tag(tag)
175
+ for sprite in sprites:
176
+ self._disable_sprite_pair(sprite)
177
+
178
+ def enable_first_by_tag(self, tag: str) -> bool:
179
+ """Enable the first disabled sprite pair that has the given tag.
180
+
181
+ This will find the first sprite pair with the given tag where the first
182
+ sprite is currently disabled, and enable it.
183
+
184
+ Args:
185
+ tag: The tag to search for in the sprites.
186
+
187
+ Returns:
188
+ bool: True if a sprite pair was enabled, False if no disabled pairs
189
+ with the tag were found.
190
+ """
191
+ sprites = self._find_sprites_by_tag(tag)
192
+ for sprite in sprites:
193
+ if sprite[0].interaction == InteractionMode.REMOVED:
194
+ self._enable_sprite_pair(sprite)
195
+ return True
196
+ return False
197
+
198
+ def disabled_first_by_tag(self, tag: str) -> bool:
199
+ """Disable the first enabled sprite pair that has the given tag.
200
+
201
+ This will find the first sprite pair with the given tag where the first
202
+ sprite is currently enabled, and disable it.
203
+
204
+ Args:
205
+ tag: The tag to search for in the sprites.
206
+
207
+ Returns:
208
+ bool: True if a sprite pair was disabled, False if no enabled pairs
209
+ with the tag were found.
210
+ """
211
+ sprites = self._find_sprites_by_tag(tag)
212
+ for sprite in sprites:
213
+ if sprite[0].interaction == InteractionMode.INTANGIBLE:
214
+ self._disable_sprite_pair(sprite)
215
+ return True
216
+ return False
217
+
218
+ def _find_sprites_by_tag(self, tag: str) -> list[tuple[Sprite, Sprite]]:
219
+ """Find all sprite pairs that have the given tag.
220
+
221
+ Args:
222
+ tag: The tag to search for in the sprites.
223
+
224
+ Returns:
225
+ list[Sprite]: List of sprite pairs where either sprite has the tag.
226
+ """
227
+ return [sprite_pair for sprite_pair in self._sprite_pairs if tag in sprite_pair[0].tags]
228
+
229
+ def _enable_sprite_pair(self, sprite_pair: tuple[Sprite, Sprite]) -> None:
230
+ """Enable a sprite pair by setting appropriate interaction modes.
231
+
232
+ This makes the first sprite visible and interactive, while making
233
+ the second sprite invisible and non-interactive.
234
+
235
+ Args:
236
+ sprite_pair: The pair of sprites to enable.
237
+ """
238
+ sprite_pair[0].set_interaction(InteractionMode.INTANGIBLE)
239
+ sprite_pair[1].set_interaction(InteractionMode.REMOVED)
240
+
241
+ def _disable_sprite_pair(self, sprite_pair: tuple[Sprite, Sprite]) -> None:
242
+ """Disable a sprite pair by setting appropriate interaction modes.
243
+
244
+ This makes the first sprite invisible and non-interactive, while making
245
+ the second sprite visible and interactive.
246
+
247
+ Args:
248
+ sprite_pair: The pair of sprites to disable.
249
+ """
250
+ sprite_pair[0].set_interaction(InteractionMode.REMOVED)
251
+ sprite_pair[1].set_interaction(InteractionMode.INTANGIBLE)
252
+
253
+ def render_interface(self, frame: np.ndarray) -> np.ndarray:
254
+ """Render all visible sprites to the given frame.
255
+
256
+ This method renders all sprites that are currently visible (not in REMOVED mode)
257
+ to the provided frame. The frame is modified in-place and returned.
258
+
259
+ Args:
260
+ frame: A 64x64 numpy array to render the sprites onto.
261
+
262
+ Returns:
263
+ np.ndarray: The modified frame with all visible sprites rendered.
264
+ """
265
+ # Get all sprites from pairs
266
+ all_sprites: list[Sprite] = []
267
+ for pair in self._sprite_pairs:
268
+ all_sprites.extend(pair)
269
+
270
+ # Render each visible sprite
271
+ for sprite in all_sprites:
272
+ if sprite.interaction != InteractionMode.REMOVED:
273
+ frame = self.draw_sprite(frame, sprite, sprite.x, sprite.y)
274
+
275
+ return frame
proteus/arc_grid/arcengine/level.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module for level-related functionality in the ARCEngine.
3
+ """
4
+
5
+ import copy
6
+ from typing import Any, List, Optional, Tuple
7
+
8
+ from .enums import BlockingMode, PlaceableArea
9
+ from .sprites import Sprite
10
+
11
+
12
+ class Level:
13
+ """A level that manages a collection of sprites."""
14
+
15
+ _sprites: List[Sprite]
16
+ _sorted_sprites: List[Sprite] | None # Sorted High to low
17
+ _grid_size: Tuple[int, int] | None
18
+ _data: dict[str, Any]
19
+ _name: str
20
+ _placeable_areas: List[PlaceableArea]
21
+ _need_sort: bool
22
+
23
+ def __init__(
24
+ self,
25
+ sprites: Optional[List[Sprite]] = None,
26
+ grid_size: Tuple[int, int] | None = None,
27
+ data: dict[str, Any] = {},
28
+ name: str = "Level",
29
+ placeable_areas: Optional[List[PlaceableArea]] = None,
30
+ ):
31
+ """Initialize a new level.
32
+
33
+ Args:
34
+ sprites: List of sprites to add to the level
35
+ grid_size: Tuple of width and height of the grid
36
+ data: Dictionary of data to store in the level
37
+ name: Name of the level
38
+ placeable_areas: List of placeable areas in the level
39
+ """
40
+ self._sprites = []
41
+ self._grid_size = grid_size
42
+ self._data = data
43
+ self._name = name
44
+ self._placeable_areas = placeable_areas if placeable_areas is not None else []
45
+ self._need_sort = True
46
+
47
+ if sprites:
48
+ # Add first (fast path), then do one-time merge+sort.
49
+ self._sprites.extend(sprites)
50
+ self._merge_sys_static_pixel_perfect_on_init()
51
+
52
+ def _merge_sys_static_pixel_perfect_on_init(self) -> None:
53
+ """
54
+ Merge any sprites that are:
55
+ - PIXEL_PERFECT
56
+ - have tag "sys_static"
57
+ into ONE sprite per layer.
58
+
59
+ This runs only during construction.
60
+ """
61
+ if not self._sprites:
62
+ return
63
+
64
+ # Partition sprites into merge-candidates (by layer) and others.
65
+ by_layer: dict[int, List[Sprite]] = {}
66
+ others: List[Sprite] = []
67
+
68
+ for s in self._sprites:
69
+ if s.blocking == BlockingMode.PIXEL_PERFECT and "sys_static" in s.tags:
70
+ by_layer.setdefault(s.layer, []).append(s)
71
+ else:
72
+ others.append(s)
73
+
74
+ merged: List[Sprite] = []
75
+ for layer, group in by_layer.items():
76
+ if not group:
77
+ continue
78
+ if len(group) == 1:
79
+ merged.append(group[0])
80
+ continue
81
+
82
+ # Merge left-to-right; merge() returns a NEW Sprite each time.
83
+ base = group[0]
84
+ for nxt in group[1:]:
85
+ base = base.merge(nxt)
86
+
87
+ # Ensure the merged sprite stays on this layer.
88
+ # (merge() uses max layer, but all are same layer anyway; set explicitly for safety.)
89
+ base.set_layer(layer)
90
+
91
+ # Ensure sys_static remains (merge unions tags, so it should already be present)
92
+ if "sys_static" not in base.tags:
93
+ base.tags.append("sys_static")
94
+
95
+ merged.append(base)
96
+
97
+ self._sprites = others + merged
98
+
99
+ def remove_all_sprites(self) -> None:
100
+ """Remove all sprites from the level."""
101
+ self._sprites = []
102
+
103
+ def add_sprite(self, sprite: Sprite) -> None:
104
+ """Add a sprite to the level.
105
+
106
+ Args:
107
+ sprite: The sprite to add
108
+ """
109
+ self._sprites.append(sprite)
110
+ self._need_sort = True
111
+
112
+ def remove_sprite(self, sprite: Sprite) -> None:
113
+ """Remove a sprite from the level.
114
+
115
+ Args:
116
+ sprite: The sprite to remove
117
+ """
118
+ if sprite in self._sprites:
119
+ self._sprites.remove(sprite)
120
+
121
+ def get_sprites(self) -> List[Sprite]:
122
+ """Get all sprites in the level.
123
+
124
+ Returns:
125
+ List[Sprite]: All sprites in the level
126
+ """
127
+ return self._sprites.copy() # Return copy to prevent external modification
128
+
129
+ def get_sprites_by_name(self, name: str) -> List[Sprite]:
130
+ """Get all sprites with the given name.
131
+
132
+ Args:
133
+ name: The name to search for
134
+
135
+ Returns:
136
+ List[Sprite]: All sprites with the given name
137
+ """
138
+ return [s for s in self._sprites if s.name == name]
139
+
140
+ def get_sprites_by_tag(self, tag: str) -> List[Sprite]:
141
+ """Get all sprites that have the given tag.
142
+
143
+ Args:
144
+ tag: The tag to search for
145
+
146
+ Returns:
147
+ List[Sprite]: All sprites that have the given tag
148
+ """
149
+ return [s for s in self._sprites if tag in s.tags]
150
+
151
+ def get_sprites_by_tags(self, tags: List[str]) -> List[Sprite]:
152
+ """Get all sprites that have all of the given tags.
153
+
154
+ Args:
155
+ tags: The tags to search for
156
+
157
+ Returns:
158
+ List[Sprite]: All sprites that have all of the given tags
159
+ """
160
+ if not tags:
161
+ return []
162
+ return [s for s in self._sprites if all(tag in s.tags for tag in tags)]
163
+
164
+ def get_sprites_by_any_tag(self, tags: List[str]) -> List[Sprite]:
165
+ """Get all sprites that have any of the specified tags.
166
+
167
+ Args:
168
+ tags: List of tags to search for
169
+
170
+ Returns:
171
+ List[Sprite]: List of sprites that have any of the specified tags
172
+ """
173
+ return [sprite for sprite in self._sprites if any(tag in sprite.tags for tag in tags)]
174
+
175
+ def get_all_tags(self) -> set[str]:
176
+ """Get all unique tags from all sprites in the level.
177
+
178
+ This method collects all tags from all sprites and returns them as a set,
179
+ ensuring each tag appears only once in the result.
180
+
181
+ Returns:
182
+ set[str]: A set containing all unique tags from all sprites
183
+ """
184
+ all_tags = set()
185
+ for sprite in self._sprites:
186
+ all_tags.update(sprite.tags)
187
+ return all_tags
188
+
189
+ def get_sprite_at(self, x: int, y: int, tag: Optional[str] = None, ignore_collidable: bool = False) -> Sprite | None:
190
+ """Get the sprite at the given coordinates.
191
+
192
+ This method returns the first sprite that is at the given coordinates.
193
+ If a tag is provided, it will return the first sprite that has the given tag.
194
+
195
+ Args:
196
+ x: The x coordinate
197
+ y: The y coordinate
198
+ tag: The tag to search for
199
+ """
200
+ if self._need_sort or self._sorted_sprites is None or len(self._sorted_sprites) != len(self._sprites):
201
+ self._sorted_sprites = sorted(self._sprites, key=lambda sprite: sprite.layer, reverse=True)
202
+ self._need_sort = False
203
+ for sprite in self._sorted_sprites:
204
+ if (ignore_collidable or sprite.is_collidable) and x >= sprite.x and y >= sprite.y and x < sprite.x + sprite.width and y < sprite.y + sprite.height:
205
+ if sprite.blocking == BlockingMode.PIXEL_PERFECT:
206
+ pixels = sprite.render()
207
+ if pixels[y - sprite.y][x - sprite.x] == -1:
208
+ continue
209
+ if tag is None or tag in sprite.tags:
210
+ return sprite
211
+ return None
212
+
213
+ def collides_with(self, sprite: Sprite, ignoreMode: bool = False) -> List[Sprite]:
214
+ """Checks all sprites in the level for collisions with the given sprite.
215
+
216
+ Args:
217
+ sprite: The sprite to check for collisions
218
+ """
219
+ return [s for s in self._sprites if sprite.collides_with(other=s, ignoreMode=ignoreMode)]
220
+
221
+ @property
222
+ def name(self) -> str:
223
+ """Get the name of the level."""
224
+ return self._name
225
+
226
+ def get_data(self, key: str) -> Any:
227
+ return self._data.get(key)
228
+
229
+ @property
230
+ def grid_size(self) -> Tuple[int, int] | None:
231
+ """Get the grid size of the level.
232
+
233
+ Returns:
234
+ Tuple[int, int]: The grid size of the level
235
+ """
236
+ return self._grid_size
237
+
238
+ @property
239
+ def placeable_areas(self) -> List[PlaceableArea]:
240
+ """Get the placeable areas of the level."""
241
+ return self._placeable_areas
242
+
243
+ def clone(self) -> "Level":
244
+ """Create a deep copy of this level.
245
+
246
+ Returns:
247
+ Level: A new Level instance with cloned sprites
248
+ """
249
+ # Clone each sprite and create new level
250
+ cloned_sprites = [sprite.clone() for sprite in self._sprites]
251
+ return Level(name=self._name, sprites=cloned_sprites, grid_size=self._grid_size, data=copy.deepcopy(self._data), placeable_areas=self._placeable_areas)
proteus/arc_grid/arcengine/py.typed ADDED
File without changes
proteus/arc_grid/arcengine/sprites.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module for sprite-related classes and functionality in the ARCEngine.
3
+ """
4
+
5
+ import uuid
6
+ from typing import List, Optional
7
+
8
+ import numpy as np
9
+ from numpy import ndarray
10
+
11
+ from .enums import BlockingMode, InteractionMode
12
+
13
+
14
+ def _downscale_mode(arr: np.ndarray, factor: int) -> np.ndarray:
15
+ """
16
+ Nearest-neighbor style down-scaling for palette images.
17
+ For each non-overlapping block it keeps the dominant color
18
+ (mode), breaking ties by the highest palette index.
19
+ Transparent pixels (-1) are ignored, but if all pixels are
20
+ transparent, the block remains transparent.
21
+
22
+ Parameters
23
+ ----------
24
+ arr : 2-D np.ndarray
25
+ Input image of dtype int8 / uint8 holding palette indices.
26
+ Transparency is denoted by -1.
27
+ factor : int
28
+ The integer scale factor (e.g. 2 turns 64×64 → 32×32).
29
+
30
+ Returns
31
+ -------
32
+ np.ndarray
33
+ The down-scaled image, same dtype as the input.
34
+ """
35
+ H, W = arr.shape
36
+ if H % factor != 0 or W % factor != 0:
37
+ raise ValueError(f"Array dimensions ({H}, {W}) must be divisible by scale factor {factor}")
38
+
39
+ # Step 1: split into blocks → shape (out_h, out_w, factor, factor)
40
+ blocks = arr.reshape(H // factor, factor, -1, factor).swapaxes(1, 2)
41
+ blocks = blocks.reshape(-1, factor * factor)
42
+
43
+ # Step 2: find dominant color for each block
44
+ out = np.empty(len(blocks), dtype=arr.dtype)
45
+
46
+ for i, blk in enumerate(blocks):
47
+ non_transparent = blk[blk != -1]
48
+ transparent = blk[blk < 0]
49
+ # Check to see if this is majority transparent
50
+ if transparent.size > non_transparent.size:
51
+ out[i] = -1 # if all values are -1, keep block transparent
52
+ else:
53
+ cnts = np.bincount(non_transparent.astype(np.int16))
54
+ max_count = cnts.max()
55
+ max_indices = np.where(cnts == max_count)[0]
56
+ out[i] = max_indices[-1] # break ties by highest index
57
+
58
+ # Step 3: reshape to 2-D image
59
+ return out.reshape(H // factor, W // factor)
60
+
61
+
62
+ def _interaction_mode_from(visible: bool, collidable: bool) -> InteractionMode:
63
+ if visible and collidable:
64
+ return InteractionMode.TANGIBLE
65
+ elif visible and not collidable:
66
+ return InteractionMode.INTANGIBLE
67
+ elif not visible and collidable:
68
+ return InteractionMode.INVISIBLE
69
+ else:
70
+ return InteractionMode.REMOVED
71
+
72
+
73
+ class Sprite:
74
+ """A 2D sprite that can be positioned and scaled in the game world."""
75
+
76
+ # Valid rotation values in degrees (clockwise)
77
+ VALID_ROTATIONS = {0, 90, 180, 270}
78
+
79
+ pixels: ndarray
80
+ _name: str
81
+ _x: int
82
+ _y: int
83
+ _layer: int
84
+ _rotation: int
85
+ _mirror_ud: bool
86
+ _mirror_lr: bool
87
+ _scale: int # Use set_scale to validate scale factor
88
+ _blocking: BlockingMode
89
+ _interaction: InteractionMode
90
+ _tags: list[str]
91
+
92
+ def __init__(
93
+ self,
94
+ pixels: List[List[int]] | np.ndarray,
95
+ name: Optional[str] = None,
96
+ x: int = 0,
97
+ y: int = 0,
98
+ layer: int = 0,
99
+ scale: int = 1,
100
+ rotation: int = 0,
101
+ mirror_ud: bool = False,
102
+ mirror_lr: bool = False,
103
+ blocking: BlockingMode = BlockingMode.PIXEL_PERFECT,
104
+ interaction: InteractionMode | None = None,
105
+ visible: bool = True,
106
+ collidable: bool = True,
107
+ tags: list[str] = [],
108
+ ):
109
+ """Initialize a new Sprite.
110
+
111
+ Args:
112
+ pixels: 2D list representing the sprite's pixels
113
+ name: Sprite name (default: None, will generate UUID)
114
+ x: X coordinate in pixels (default: 0)
115
+ y: Y coordinate in pixels (default: 0)
116
+ layer: Z-order layer for rendering (default: 0, higher values render on top)
117
+ scale: Scale factor (default: 1)
118
+ rotation: Rotation in degrees (default: 0)
119
+ blocking: Collision detection method (default: NOT_BLOCKED)
120
+ interaction: How the sprite interacts with the game world (default: TANGIBLE)
121
+
122
+ Raises:
123
+ ValueError: If scale is 0, pixels is not a 2D list, rotation is invalid,
124
+ or if downscaling factor doesn't evenly divide sprite dimensions
125
+ """
126
+ if isinstance(pixels, np.ndarray):
127
+ if pixels.ndim != 2:
128
+ raise ValueError("Pixels must be a 2D array 111")
129
+ if pixels.dtype != np.int8:
130
+ base = pixels.astype(np.int8, copy=False)
131
+ else:
132
+ base = pixels
133
+ else:
134
+ if not isinstance(pixels, list) or not all(isinstance(row, list) for row in pixels):
135
+ raise ValueError("Pixels must be a 2D list or a 2D numpy array")
136
+ base = np.array(pixels, dtype=np.int8)
137
+
138
+ self.pixels = base
139
+ if self.pixels.ndim != 2:
140
+ raise ValueError("Pixels must be a 2D array 222")
141
+
142
+ self._name = name if name is not None else str(uuid.uuid4())
143
+ self._x = int(x)
144
+ self._y = int(y)
145
+ self._layer = int(layer)
146
+ self._set_rotation(rotation)
147
+ self._mirror_ud = mirror_ud
148
+ self._mirror_lr = mirror_lr
149
+ self._blocking = blocking
150
+ self.set_scale(scale) # Use set_scale to validate scale factor
151
+ if interaction is None:
152
+ self._interaction = _interaction_mode_from(visible, collidable)
153
+ else:
154
+ self._interaction = interaction
155
+ self._tags = tags
156
+
157
+ def clone(self, new_name: Optional[str] = None) -> "Sprite":
158
+ """Create an independent copy of this sprite.
159
+
160
+ Args:
161
+ new_name: Optional name for the cloned sprite. If None, reuses current name.
162
+
163
+ Returns:
164
+ A new Sprite instance with the same properties but independent state.
165
+ """
166
+ # Create a deep copy of the pixels array
167
+ pixels_copy = self.pixels.copy()
168
+
169
+ # Create a new sprite with copied properties
170
+ return Sprite(
171
+ pixels=pixels_copy.tolist(), # Convert back to list for constructor
172
+ name=new_name if new_name is not None else self._name, # Use new name or generate new UUID
173
+ x=self._x,
174
+ y=self._y,
175
+ scale=self._scale,
176
+ rotation=self.rotation, # Use the public property to get normalized value
177
+ mirror_ud=self._mirror_ud,
178
+ mirror_lr=self._mirror_lr,
179
+ blocking=self._blocking,
180
+ layer=self._layer,
181
+ interaction=self._interaction,
182
+ tags=self._tags.copy(), # Copy the tags list
183
+ )
184
+
185
+ def _set_rotation(self, rotation: int) -> None:
186
+ """Internal method to set rotation with validation.
187
+
188
+ Args:
189
+ rotation: The rotation value in degrees
190
+
191
+ Raises:
192
+ ValueError: If rotation is not a valid 90-degree increment
193
+ """
194
+ normalized = rotation % 360
195
+ if normalized not in self.VALID_ROTATIONS:
196
+ raise ValueError(f"Rotation must be one of {self.VALID_ROTATIONS}, got {rotation}")
197
+ self.rotation = normalized
198
+
199
+ def set_rotation(self, rotation: int) -> "Sprite":
200
+ """Set the sprite's rotation to a specific value.
201
+
202
+ Args:
203
+ rotation: The new rotation in degrees (must be 0, 90, 180, or 270)
204
+
205
+ Raises:
206
+ ValueError: If rotation is not a valid 90-degree increment
207
+ """
208
+ self._set_rotation(int(rotation))
209
+ return self
210
+
211
+ def rotate(self, delta: int) -> "Sprite":
212
+ """Rotate the sprite by a given amount.
213
+
214
+ Args:
215
+ delta: The change in rotation in degrees (must result in a valid rotation)
216
+
217
+ Raises:
218
+ ValueError: If resulting rotation is not a valid 90-degree increment
219
+ """
220
+ if delta < 0:
221
+ delta = 360 + (delta % 360)
222
+ new_rotation = (self.rotation + delta) % 360
223
+ self._set_rotation(new_rotation)
224
+ return self
225
+
226
+ def set_position(self, x: int, y: int) -> "Sprite":
227
+ """Set the sprite's position.
228
+
229
+ Args:
230
+ x: New X coordinate in pixels
231
+ y: New Y coordinate in pixels
232
+ """
233
+ self._x = int(x)
234
+ self._y = int(y)
235
+ return self
236
+
237
+ def set_scale(self, scale: int) -> "Sprite":
238
+ """Set the sprite's scale factor.
239
+
240
+ Args:
241
+ scale: The new scale factor. Positive values scale up, negative values scale down.
242
+ Negative values indicate divisor: -1 means half size (divide by 2), -2 means one-third size, etc.
243
+
244
+ Raises:
245
+ ValueError: If scale is 0 or if downscaling factor doesn't evenly divide sprite dimensions
246
+ """
247
+ scale_int = int(scale)
248
+ if scale_int == 0:
249
+ raise ValueError("Scale cannot be zero")
250
+
251
+ # For downscaling, validate dimensions are divisible by scale factor
252
+ if scale_int < 0:
253
+ H, W = self.pixels.shape
254
+ factor = -scale_int + 1 # -1 -> 2, -2 -> 3, -3 -> 4, etc.
255
+ if H % factor != 0 or W % factor != 0:
256
+ raise ValueError(f"Array dimensions ({H}, {W}) must be divisible by scale factor {factor}")
257
+
258
+ self._scale = scale_int
259
+ return self
260
+
261
+ def adjust_scale(self, delta: int) -> None:
262
+ """Adjust the sprite's scale by a delta value, moving one step at a time.
263
+
264
+ The method will adjust the scale by incrementing or decrementing by 1
265
+ repeatedly until reaching the target scale. This ensures smooth transitions
266
+ and validates each step.
267
+
268
+ Negative scales indicate downscaling factors:
269
+ -1 = half size (1/2)
270
+ -2 = one-third size (1/3)
271
+ -3 = one-fourth size (1/4)
272
+ etc.
273
+
274
+ For example:
275
+ - Current scale 1, delta +2 -> Steps through: 1 -> 2 -> 3
276
+ - Current scale 1, delta -2 -> Steps through: 1 -> 0 -> -1 (half size)
277
+ - Current scale -2, delta +3 -> Steps through: -2 -> -1 -> 0 -> 1
278
+
279
+ Args:
280
+ delta: The total change in scale to apply. Positive values increase scale,
281
+ negative values decrease it.
282
+
283
+ Raises:
284
+ ValueError: If any intermediate scale would be 0 or if a downscaling factor
285
+ doesn't evenly divide sprite dimensions
286
+ """
287
+ if delta == 0:
288
+ return
289
+
290
+ # Determine direction of change
291
+ step = 1 if delta > 0 else -1
292
+ target_scale = self._scale + delta
293
+
294
+ # Take steps one at a time
295
+ while self._scale != target_scale:
296
+ next_scale = self._scale + step
297
+
298
+ # Skip over zero since it's invalid
299
+ if next_scale == 0:
300
+ next_scale = step
301
+
302
+ # Let ValueError propagate up
303
+ self.set_scale(next_scale)
304
+
305
+ def set_blocking(self, blocking: BlockingMode) -> "Sprite":
306
+ """Set the sprite's blocking behavior.
307
+
308
+ Args:
309
+ blocking: The new blocking behavior
310
+ """
311
+ if not isinstance(blocking, BlockingMode):
312
+ raise ValueError("blocking must be a BlockingMode enum value")
313
+ self._blocking = blocking
314
+ return self
315
+
316
+ def set_name(self, name: str) -> "Sprite":
317
+ """Set the sprite's name.
318
+
319
+ Args:
320
+ name: New name for the sprite
321
+ """
322
+ if not name:
323
+ raise ValueError("Name cannot be empty")
324
+ self._name = name
325
+ return self
326
+
327
+ @property
328
+ def name(self) -> str:
329
+ """Get the sprite's name."""
330
+ return self._name
331
+
332
+ @property
333
+ def x(self) -> int:
334
+ """Get the current X coordinate."""
335
+ return self._x
336
+
337
+ @property
338
+ def y(self) -> int:
339
+ """Get the current Y coordinate."""
340
+ return self._y
341
+
342
+ @property
343
+ def scale(self) -> int:
344
+ """Get the current scale factor."""
345
+ return self._scale
346
+
347
+ @property
348
+ def blocking(self) -> BlockingMode:
349
+ """Get the current blocking behavior."""
350
+ return self._blocking
351
+
352
+ @property
353
+ def layer(self) -> int:
354
+ """Get the current rendering layer."""
355
+ return self._layer
356
+
357
+ @property
358
+ def tags(self) -> list[str]:
359
+ """Get the current tags."""
360
+ return self._tags
361
+
362
+ @property
363
+ def mirror_ud(self) -> bool:
364
+ """Get the current mirror up/down state."""
365
+ return self._mirror_ud
366
+
367
+ @property
368
+ def mirror_lr(self) -> bool:
369
+ """Get the current mirror left/right state."""
370
+ return self._mirror_lr
371
+
372
+ def set_mirror_ud(self, mirror_ud: bool) -> "Sprite":
373
+ """Set the sprite's mirror up/down state."""
374
+ self._mirror_ud = mirror_ud
375
+ return self
376
+
377
+ def set_mirror_lr(self, mirror_lr: bool) -> "Sprite":
378
+ """Set the sprite's mirror left/right state."""
379
+ self._mirror_lr = mirror_lr
380
+ return self
381
+
382
+ def set_layer(self, layer: int) -> "Sprite":
383
+ """Set the sprite's rendering layer.
384
+
385
+ Args:
386
+ layer: New layer value. Higher values render on top.
387
+ """
388
+ self._layer = int(layer)
389
+ return self
390
+
391
+ @property
392
+ def interaction(self) -> InteractionMode:
393
+ """Get the current interaction mode."""
394
+ return self._interaction
395
+
396
+ def set_interaction(self, interaction: InteractionMode) -> "Sprite":
397
+ """Set the sprite's interaction mode.
398
+
399
+ Args:
400
+ interaction: The new interaction mode
401
+
402
+ Raises:
403
+ ValueError: If interaction is not an InteractionMode enum value
404
+ """
405
+ if not isinstance(interaction, InteractionMode):
406
+ raise ValueError("interaction must be an InteractionMode enum value")
407
+ self._interaction = interaction
408
+ return self
409
+
410
+ @property
411
+ def is_visible(self) -> bool:
412
+ """Check if a sprite with this interaction mode should be rendered.
413
+
414
+ Returns:
415
+ bool: True if the sprite should be visible, False otherwise
416
+ """
417
+ return self._interaction == InteractionMode.TANGIBLE or self._interaction == InteractionMode.INTANGIBLE
418
+
419
+ def set_visible(self, visible: bool) -> "Sprite":
420
+ """Set the sprite's visibility.
421
+
422
+ Args:
423
+ visible: The new visibility state
424
+ """
425
+ self._interaction = _interaction_mode_from(visible, self.is_collidable)
426
+ return self
427
+
428
+ @property
429
+ def width(self) -> int:
430
+ """Get the sprite's width."""
431
+ return int(self.render().shape[1])
432
+
433
+ @property
434
+ def height(self) -> int:
435
+ """Get the sprite's height."""
436
+ return int(self.render().shape[0])
437
+
438
+ @property
439
+ def is_collidable(self) -> bool:
440
+ """Check if a sprite with this interaction mode should participate in collisions.
441
+
442
+ Returns:
443
+ bool: True if the sprite should be checked for collisions, False otherwise
444
+ """
445
+ return self._interaction == InteractionMode.TANGIBLE or self._interaction == InteractionMode.INVISIBLE
446
+
447
+ def set_collidable(self, collidable: bool) -> "Sprite":
448
+ """Set the sprite's collidable state.
449
+
450
+ Args:
451
+ collidable: The new collidable state
452
+ """
453
+ self._interaction = _interaction_mode_from(self.is_visible, collidable)
454
+ return self
455
+
456
+ def render(self) -> np.ndarray:
457
+ """Render the sprite with current scale and rotation.
458
+
459
+ Returns:
460
+ np.ndarray: The rendered sprite as a 2D numpy array
461
+ """
462
+ # Start with the base pixels
463
+ result = self.pixels.copy()
464
+
465
+ # Handle rotation first (if any)
466
+ if self.rotation != 0:
467
+ # Convert degrees to number of 90-degree rotations (clockwise)
468
+ k = int((-self.rotation % 360) / 90) # Negative for clockwise rotation
469
+ if k != 0:
470
+ result = np.rot90(result, k=k)
471
+
472
+ if self._mirror_ud:
473
+ result = np.flipud(result)
474
+ if self._mirror_lr:
475
+ result = np.fliplr(result)
476
+
477
+ # Handle scaling
478
+ if self._scale != 1:
479
+ if self._scale > 1:
480
+ # For upscaling, repeat the array in both dimensions
481
+ result = np.repeat(np.repeat(result, self._scale, axis=0), self._scale, axis=1)
482
+ else: # self._scale < 0
483
+ # For downscaling, use mode-based approach
484
+ # Convert negative scale to actual divisor (e.g. -1 -> 2, -2 -> 3)
485
+ factor = -self._scale + 1 # -1 -> 2, -2 -> 3, -3 -> 4, etc.
486
+ result = _downscale_mode(result, factor)
487
+
488
+ return result
489
+
490
+ def collides_with(self, other: "Sprite", ignoreMode: bool = False) -> bool:
491
+ """Check if this sprite collides with another sprite.
492
+
493
+ The collision check follows these rules:
494
+ 1. A sprite cannot collide with itself
495
+ 2. Non-collidable sprites (based on interaction mode) never collide
496
+ 3. For collidable sprites, the collision detection method is based on their blocking mode:
497
+ - NOT_BLOCKED: Always returns False
498
+ - BOUNDING_BOX: Simple rectangular collision check
499
+ - PIXEL_PERFECT: Precise pixel-level collision detection
500
+
501
+ Args:
502
+ other: The other sprite to check collision with
503
+
504
+ Returns:
505
+ bool: True if the sprites collide, False otherwise
506
+ """
507
+ # Rule 1: A sprite cannot collide with itself
508
+ if self is other:
509
+ return False
510
+
511
+ if not ignoreMode:
512
+ # Rule 2: Both sprites must be collidable
513
+ if not (self.is_collidable and other.is_collidable):
514
+ return False
515
+
516
+ # Rule 3: Handle different blocking modes
517
+ if self._blocking == BlockingMode.NOT_BLOCKED or other._blocking == BlockingMode.NOT_BLOCKED:
518
+ return False
519
+
520
+ # Get sprite dimensions after rendering (accounts for rotation and scaling)
521
+ self_pixels = self.render()
522
+ other_pixels = other.render()
523
+ self_height, self_width = self_pixels.shape
524
+ other_height, other_width = other_pixels.shape
525
+
526
+ # First check bounding box collision
527
+ # If there's no bounding box collision, there can't be pixel collision
528
+ if self._x >= other._x + other_width or self._x + self_width <= other._x or self._y >= other._y + other_height or self._y + self_height <= other._y:
529
+ return False
530
+
531
+ # If either sprite uses PIXEL_PERFECT, do pixel-level collision detection
532
+ if self._blocking == BlockingMode.PIXEL_PERFECT or other._blocking == BlockingMode.PIXEL_PERFECT:
533
+ # Calculate intersection region
534
+ x_min = max(self._x, other._x)
535
+ x_max = min(self._x + self_width, other._x + other_width)
536
+ y_min = max(self._y, other._y)
537
+ y_max = min(self._y + self_height, other._y + other_height)
538
+
539
+ # Get the overlapping regions from both sprites
540
+ self_x_start = x_min - self._x
541
+ self_x_end = x_max - self._x
542
+ self_y_start = y_min - self._y
543
+ self_y_end = y_max - self._y
544
+
545
+ other_x_start = x_min - other._x
546
+ other_x_end = x_max - other._x
547
+ other_y_start = y_min - other._y
548
+ other_y_end = y_max - other._y
549
+
550
+ # Extract overlapping regions
551
+ self_region = self_pixels[self_y_start:self_y_end, self_x_start:self_x_end]
552
+ other_region = other_pixels[other_y_start:other_y_end, other_x_start:other_x_end]
553
+
554
+ # Check if any non-transparent pixels overlap
555
+ self_mask = self_region != -1
556
+ other_mask = other_region != -1
557
+ return bool(np.any(self_mask & other_mask))
558
+
559
+ # Otherwise, we already know there's a bounding box collision
560
+ return True
561
+
562
+ def move(self, dx: int, dy: int) -> None:
563
+ """Move the sprite by the given deltas.
564
+
565
+ Args:
566
+ dx: Change in x position (positive = right, negative = left)
567
+ dy: Change in y position (positive = down, negative = up)
568
+ """
569
+ self._x += int(dx)
570
+ self._y += int(dy)
571
+
572
+ def color_remap(self, old_color: int | None, new_color: int) -> "Sprite":
573
+ """Remap the sprite's color.
574
+
575
+ Args:
576
+ old_color: The old color to remap, or None to remap all colors
577
+ new_color: The new color to remap to
578
+ """
579
+ if old_color is None:
580
+ # Replace all non-negative pixels with new_color
581
+ self.pixels = np.where(self.pixels >= 0, new_color, self.pixels)
582
+ else:
583
+ # Replace only pixels matching old_color
584
+ self.pixels = np.where(self.pixels == old_color, new_color, self.pixels)
585
+ return self
586
+
587
+ def merge(self, other: "Sprite") -> "Sprite":
588
+ """Merge two sprites together.
589
+
590
+ This method creates a new sprite that combines the pixels of both sprites.
591
+ When pixels overlap, non-negative pixels take precedence over negative ones.
592
+
593
+ Args:
594
+ other: The other sprite to merge with
595
+
596
+ Returns:
597
+ Sprite: A new sprite containing the merged pixels
598
+ """
599
+ # Get rendered versions of both sprites to handle scaling/rotation
600
+ self_pixels = self.render()
601
+ other_pixels = other.render()
602
+
603
+ # Calculate the bounds of the merged sprite
604
+ min_x = min(self._x, other._x)
605
+ min_y = min(self._y, other._y)
606
+ max_x = max(self._x + self_pixels.shape[1], other._x + other_pixels.shape[1])
607
+ max_y = max(self._y + self_pixels.shape[0], other._y + other_pixels.shape[0])
608
+
609
+ # Create a new array for the merged sprite
610
+ merged_height = max_y - min_y
611
+ merged_width = max_x - min_x
612
+ merged_pixels = np.full((merged_height, merged_width), -1, dtype=np.int8)
613
+
614
+ # Copy other's pixels, keeping non-negative pixels
615
+ other_y_start = other._y - min_y
616
+ other_x_start = other._x - min_x
617
+ other_region = merged_pixels[other_y_start : other_y_start + other_pixels.shape[0], other_x_start : other_x_start + other_pixels.shape[1]]
618
+ merged_pixels[other_y_start : other_y_start + other_pixels.shape[0], other_x_start : other_x_start + other_pixels.shape[1]] = np.where(other_pixels != -1, other_pixels, other_region)
619
+
620
+ # Copy self's pixels
621
+ self_y_start = self._y - min_y
622
+ self_x_start = self._x - min_x
623
+ merged_pixels[self_y_start : self_y_start + self_pixels.shape[0], self_x_start : self_x_start + self_pixels.shape[1]] = np.where(
624
+ self_pixels != -1, self_pixels, merged_pixels[self_y_start : self_y_start + self_pixels.shape[0], self_x_start : self_x_start + self_pixels.shape[1]]
625
+ )
626
+
627
+ blocking = self._blocking
628
+ if blocking == BlockingMode.NOT_BLOCKED:
629
+ blocking = other._blocking
630
+ elif blocking == BlockingMode.BOUNDING_BOX and other._blocking == BlockingMode.PIXEL_PERFECT:
631
+ blocking = BlockingMode.PIXEL_PERFECT
632
+
633
+ interaction = self._interaction
634
+ if interaction == InteractionMode.REMOVED:
635
+ interaction = other._interaction
636
+ elif interaction == InteractionMode.INVISIBLE and other._interaction == InteractionMode.TANGIBLE:
637
+ interaction = InteractionMode.TANGIBLE
638
+ elif interaction == InteractionMode.INTANGIBLE and other._interaction == InteractionMode.TANGIBLE:
639
+ interaction = InteractionMode.TANGIBLE
640
+
641
+ # Create and return new sprite
642
+ return Sprite(
643
+ name=self._name,
644
+ pixels=merged_pixels,
645
+ x=min_x,
646
+ y=min_y,
647
+ layer=max(self._layer, other._layer), # Use higher layer
648
+ blocking=blocking,
649
+ interaction=interaction,
650
+ tags=list(set(self._tags + other._tags)), # Combine unique tags
651
+ )
proteus/arc_grid/rendering.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendering utilities for ARC-AGI-3 grids (64x64, 16-color palette).
2
+
3
+ Vendored from the ARC-AGI toolkit (`arc_agi/rendering.py`) and decoupled to
4
+ sit alongside the vendored ``arcengine`` engine in this folder. The core
5
+ ``frame_to_rgb_array(frame)`` works on a plain 64x64 numpy array, so the
6
+ renderer can be used without the engine; ``FrameDataRaw`` is only needed by
7
+ the animation convenience functions.
8
+ """
9
+
10
+ import sys
11
+ import time
12
+ from typing import Any, Optional, Tuple
13
+
14
+ try:
15
+ import matplotlib.animation as animation
16
+ import matplotlib.pyplot as plt
17
+
18
+ HAS_MATPLOTLIB = True
19
+ except ImportError:
20
+ plt = None
21
+ animation = None
22
+ HAS_MATPLOTLIB = False
23
+
24
+ from .arcengine import FrameDataRaw
25
+ from numpy import ndarray
26
+
27
+ # Color mapping for frame values (0-15)
28
+ COLOR_MAP: dict[int, str] = {
29
+ 0: "#FFFFFFFF", # White
30
+ 1: "#CCCCCCFF", # Off-white
31
+ 2: "#999999FF", # neutral Light
32
+ 3: "#666666FF", # neutral
33
+ 4: "#333333FF", # Off Black
34
+ 5: "#000000FF", # Black
35
+ 6: "#E53AA3FF", # Magenta
36
+ 7: "#FF7BCCFF", # Magenta Light
37
+ 8: "#F93C31FF", # Red
38
+ 9: "#1E93FFFF", # Blue
39
+ 10: "#88D8F1FF", # Blue Light
40
+ 11: "#FFDC00FF", # Yellow
41
+ 12: "#FF851BFF", # Orange
42
+ 13: "#921231FF", # Maroon
43
+ 14: "#4FCC30FF", # Green
44
+ 15: "#A356D6FF", # Purple
45
+ }
46
+
47
+
48
+ def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
49
+ """Convert hex color string to RGB tuple.
50
+
51
+ Args:
52
+ hex_color: Hex color string (e.g., "#FFFFFFFF").
53
+
54
+ Returns:
55
+ RGB tuple (r, g, b).
56
+ """
57
+ # Remove '#' and convert to int
58
+ hex_color = hex_color.lstrip("#")
59
+ # Parse RGBA hex (8 chars) or RGB hex (6 chars)
60
+ if len(hex_color) == 8:
61
+ r = int(hex_color[0:2], 16)
62
+ g = int(hex_color[2:4], 16)
63
+ b = int(hex_color[4:6], 16)
64
+ # Alpha is ignored for display
65
+ else:
66
+ r = int(hex_color[0:2], 16)
67
+ g = int(hex_color[2:4], 16)
68
+ b = int(hex_color[4:6], 16)
69
+ return (r, g, b)
70
+
71
+
72
+ def frame_to_rgb_array(
73
+ steps: int,
74
+ frame: ndarray,
75
+ scale: int = 4,
76
+ color_map: Optional[dict[int, str]] = None,
77
+ ) -> ndarray:
78
+ """Convert a frame to an RGB numpy array for matplotlib.
79
+
80
+ Args:
81
+ frame: 2D numpy array (64x64) with values 0-15.
82
+ scale: Upscaling factor (default 4, so 64x64 -> 256x256).
83
+ color_map: Optional color mapping dict. Uses default if None.
84
+
85
+ Returns:
86
+ 3D numpy array (height, width, 3) with RGB values.
87
+ """
88
+ import numpy as np
89
+
90
+ if color_map is None:
91
+ color_map = COLOR_MAP
92
+
93
+ height, width = frame.shape
94
+ upscaled_height = height * scale
95
+ upscaled_width = width * scale
96
+
97
+ # Create RGB array
98
+ rgb_array = np.zeros((upscaled_height, upscaled_width, 3), dtype=np.uint8)
99
+
100
+ # Fill pixels
101
+ for y in range(height):
102
+ for x in range(width):
103
+ value = int(frame[y, x])
104
+ hex_color = color_map.get(value, "#000000FF")
105
+ rgb = hex_to_rgb(hex_color)
106
+
107
+ # Fill the scaled block
108
+ for dy in range(scale):
109
+ for dx in range(scale):
110
+ rgb_array[y * scale + dy, x * scale + dx] = rgb
111
+
112
+ return rgb_array
113
+
114
+
115
+ def render_frames(
116
+ steps: int,
117
+ frame_data: FrameDataRaw,
118
+ default_fps: Optional[int] = None,
119
+ scale: int = 4,
120
+ color_map: Optional[dict[int, str]] = None,
121
+ ) -> None:
122
+ """Render multiple frames with optional FPS control using matplotlib.
123
+
124
+ Args:
125
+ frame_data: FrameDataRaw object containing frame data and other information.
126
+ default_fps: Optional FPS for frame timing. If None, displays immediately.
127
+ scale: Upscaling factor (default 4, so 64x64 -> 256x256).
128
+ color_map: Optional color mapping dict. Uses default if None.
129
+ """
130
+ if not HAS_MATPLOTLIB or plt is None or animation is None:
131
+ raise ImportError(
132
+ "matplotlib is required for rendering. Install with: pip install matplotlib"
133
+ )
134
+
135
+ frames = frame_data.frame
136
+ if not frames:
137
+ return
138
+
139
+ # Convert frames to RGB arrays
140
+ frame_images = [
141
+ frame_to_rgb_array(steps, frame, scale, color_map) for frame in frames
142
+ ]
143
+
144
+ # Calculate interval between frames if FPS is specified
145
+ interval = (1000.0 / default_fps) if default_fps and default_fps > 0 else 100
146
+
147
+ # Create figure and axis
148
+ fig, ax = plt.subplots(figsize=(8, 8))
149
+ ax.axis("off")
150
+ ax.set_title("ARC-AGI-3 Environment")
151
+
152
+ # Display first frame
153
+ im = ax.imshow(frame_images[0], interpolation="nearest")
154
+ plt.tight_layout()
155
+
156
+ def update_frame(frame_num: int) -> Tuple[Any, ...]:
157
+ """Update the displayed frame."""
158
+ if frame_num < len(frame_images):
159
+ im.set_array(frame_images[frame_num])
160
+ return (im,)
161
+ return (im,)
162
+
163
+ # Create animation that plays automatically
164
+ # Keep reference to prevent garbage collection
165
+ anim = animation.FuncAnimation(
166
+ fig,
167
+ update_frame,
168
+ frames=len(frame_images),
169
+ interval=interval,
170
+ blit=False,
171
+ repeat=False,
172
+ )
173
+
174
+ # Show the plot - animation will play automatically
175
+ # Use non-blocking mode so frames play and code continues
176
+ plt.ion() # Turn on interactive mode
177
+ plt.show(block=False)
178
+
179
+ # Wait for animation to complete (frames * interval + small buffer)
180
+ if interval > 0:
181
+ total_time = len(frame_images) * interval / 1000.0
182
+ else:
183
+ total_time = len(frame_images) * 0.1 # Default 100ms per frame
184
+
185
+ # Small initial pause to ensure window appears and animation starts
186
+ time.sleep(0.1)
187
+
188
+ # Use plt.pause to process events and let animation play
189
+ # This ensures the animation actually renders
190
+ plt.pause(total_time + 0.1)
191
+
192
+ # Keep animation reference alive until we're done
193
+ # Close the figure after animation completes
194
+ plt.close(fig)
195
+ plt.ioff() # Turn off interactive mode
196
+
197
+ # Explicitly delete animation after closing to avoid warnings
198
+ del anim
199
+
200
+
201
+ def rgb_to_ansi(rgb: tuple[int, int, int]) -> str:
202
+ """Convert RGB tuple to ANSI color code.
203
+
204
+ Args:
205
+ rgb: RGB tuple (r, g, b).
206
+
207
+ Returns:
208
+ ANSI escape sequence for the color.
209
+ """
210
+ r, g, b = rgb
211
+ return f"\033[38;2;{r};{g};{b}m"
212
+
213
+
214
+ def render_frames_terminal(
215
+ steps: int,
216
+ frame_data: FrameDataRaw,
217
+ default_fps: Optional[int] = None,
218
+ scale: int = 1,
219
+ color_map: Optional[dict[int, str]] = None,
220
+ skip_deplay: bool = False,
221
+ ) -> None:
222
+ """Render frames in the terminal using ANSI color codes, overwriting in place.
223
+
224
+ Args:
225
+ frame_data: FrameDataRaw object containing frame data and other information.
226
+ default_fps: Optional FPS for frame timing. If None, displays immediately.
227
+ scale: Scaling factor (default 1, terminal uses 2 chars per pixel for better visibility).
228
+ color_map: Optional color mapping dict. Uses default if None.
229
+ """
230
+ frames = frame_data.frame
231
+ if not frames:
232
+ return
233
+
234
+ if color_map is None:
235
+ color_map = COLOR_MAP
236
+
237
+ # Check if terminal supports colors
238
+ if not sys.stdout.isatty():
239
+ print(
240
+ "Warning: Not a terminal, colors may not display correctly", file=sys.stderr
241
+ )
242
+
243
+ # Calculate delay between frames (default 5 FPS if not specified)
244
+ fps = default_fps if default_fps and default_fps > 0 else 5
245
+ delay = 1.0 / fps
246
+
247
+ # ANSI codes
248
+ RESET = "\033[0m"
249
+ HOME = "\033[H" # Move cursor to home position (top-left)
250
+ HIDE_CURSOR = "\033[?25l" # Hide cursor
251
+ SHOW_CURSOR = "\033[?25h" # Show cursor
252
+ # Use block character for better visibility (2 chars wide)
253
+ BLOCK = "██"
254
+
255
+ # Get frame dimensions
256
+ height, width = frames[0].shape
257
+
258
+ # Build frame strings for all frames (as single strings to reduce flicker)
259
+ frame_strings = []
260
+ for frame_idx, frame in enumerate(frames):
261
+ # Build entire frame as one string
262
+ frame_str = f"Step: {steps} - State: {frame_data.state.name}\n\n"
263
+
264
+ # Frame content - build all lines first, then join
265
+ frame_lines = []
266
+ for y in range(height):
267
+ line_parts = []
268
+ for x in range(width):
269
+ value = int(frame[y, x])
270
+ hex_color = color_map.get(value, "#000000FF")
271
+ rgb = hex_to_rgb(hex_color)
272
+ ansi_color = rgb_to_ansi(rgb)
273
+ # Use block character (2 chars) for better visibility
274
+ line_parts.append(f"{ansi_color}{BLOCK}{RESET}")
275
+ frame_lines.append("".join(line_parts))
276
+
277
+ frame_str += "\n".join(frame_lines)
278
+ frame_strings.append(frame_str)
279
+
280
+ # Hide cursor to reduce flicker
281
+ print(HIDE_CURSOR, end="", flush=True)
282
+
283
+ try:
284
+ # First frame: clear screen and display in single operation to reduce flicker
285
+ print(f"{HOME}\033[2J{frame_strings[0]}", end="", flush=True)
286
+
287
+ # Update frames in place - single operation per frame to minimize flicker
288
+ for frame_idx in range(1, len(frames)):
289
+ # Single operation: move home + print new frame (no separate operations)
290
+ print(f"{HOME}{frame_strings[frame_idx]}", end="", flush=True)
291
+ # Sleep for 1/fps seconds between frames
292
+ if not skip_deplay:
293
+ time.sleep(delay)
294
+ finally:
295
+ # Always show cursor again
296
+ print(SHOW_CURSOR, end="", flush=True)
297
+
298
+ # Reset terminal colors and move cursor below the frame
299
+ print(f"\n{RESET}", end="", flush=True)
300
+ if not skip_deplay:
301
+ time.sleep(delay)
tests/test_arc_grid_vendored.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_arc_grid_engine_surface_importable():
2
+ from proteus.arc_grid import Sprite, Level, Camera, ARCBaseGame, GameAction
3
+ sprite = Sprite(pixels=[[1]], name="x", x=0, y=0)
4
+ assert sprite.x == 0 and sprite.y == 0
5
+
6
+
7
+ def test_arc_grid_has_no_squid_game_imports():
8
+ import pathlib
9
+ root = pathlib.Path("proteus/arc_grid")
10
+ offenders = [
11
+ p for p in root.rglob("*.py")
12
+ if "squid_game" in p.read_text(encoding="utf-8")
13
+ ]
14
+ assert offenders == [], f"vendored arc_grid must not import squid_game: {offenders}"