Spaces:
Runtime error
Runtime error
Commit ·
764e34c
1
Parent(s): 483e590
feat(cp0): vendor arc_grid engine + renderer into proteus
Browse files- proteus/arc_grid/README.md +99 -0
- proteus/arc_grid/__init__.py +78 -0
- proteus/arc_grid/arcengine/LICENSE +21 -0
- proteus/arc_grid/arcengine/OVERVIEW.md +81 -0
- proteus/arc_grid/arcengine/__init__.py +31 -0
- proteus/arc_grid/arcengine/base_game.py +593 -0
- proteus/arc_grid/arcengine/camera.py +350 -0
- proteus/arc_grid/arcengine/enums.py +191 -0
- proteus/arc_grid/arcengine/interfaces.py +275 -0
- proteus/arc_grid/arcengine/level.py +251 -0
- proteus/arc_grid/arcengine/py.typed +0 -0
- proteus/arc_grid/arcengine/sprites.py +651 -0
- proteus/arc_grid/rendering.py +301 -0
- tests/test_arc_grid_vendored.py +14 -0
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}"
|