Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
486fea38e7 | ||
|
|
9421d8d47c | ||
|
|
509b3433b8 | ||
|
|
bbafc3bbba | ||
|
|
b243cf04d3 | ||
|
|
eaafd92dc2 | ||
|
|
b60ffd87aa |
@@ -0,0 +1,107 @@
|
||||
---
|
||||
applyTo: "tools/vernon/**,assets/Rat/**"
|
||||
---
|
||||
|
||||
# Pixel Art Sprite Workflow — mice project
|
||||
|
||||
## Strumenti disponibili
|
||||
|
||||
| Script | Uso |
|
||||
|--------|-----|
|
||||
| `tools/vernon/image_to_json.py <INPUT.png> <OUTPUT.json>` | Converte PNG → matrice JSON RGBA 64×64 |
|
||||
| `tools/vernon/json_to_png.py <INPUT.json> <OUTPUT.png>` | Converte matrice JSON RGBA → PNG |
|
||||
|
||||
Entrambi usano Pillow e richiedono il `venv` attivo:
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
## Formato JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "BMP_BOMB0.png",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"mode": "RGBA",
|
||||
"pixels": [
|
||||
[ [R, G, B, A], ... ], // riga 0, 64 pixel
|
||||
... // 64 righe totali
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ogni pixel è `[R, G, B, A]` con valori 0–255.
|
||||
|
||||
## Convenzioni cromatiche del gioco
|
||||
|
||||
- **Colore trasparente (chromakey):** `[128, 128, 128, 192]` — usato come sfondo, il motore lo rende hidden
|
||||
- **Alpha standard:** `192` per tutti i pixel visibili (coerente con gli asset originali)
|
||||
|
||||
## Workflow iterativo di redesign (passi 0–4)
|
||||
|
||||
```
|
||||
0. BACKUP → prima di sovrascrivere, copia l'originale:
|
||||
cp assets/Rat/<NAME>.png assets/Rat/backup/<NAME>_original.png
|
||||
1. image_to_json.py → esamina JSON e PNG originale
|
||||
2. capire struttura: sfondo, palette, forma principale
|
||||
3. modificare JSON (o generarlo via script Python) con:
|
||||
- più livelli di shading (8+ valori invece di 3)
|
||||
- dettagli geometrici aggiuntivi (texture, bordi, ombre interne)
|
||||
- palette più ricca mantenendo stile pixel art (bordi netti, no anti-alias)
|
||||
4. json_to_png.py → valuta risultato visivo; se non soddisfacente, torna a 3
|
||||
```
|
||||
|
||||
## Pattern Python per generare JSON programmaticamente
|
||||
|
||||
```python
|
||||
import json, math
|
||||
from pathlib import Path
|
||||
|
||||
W, H = 64, 64
|
||||
A = 192 # alpha standard
|
||||
|
||||
def px(r, g, b): return [r, g, b, A]
|
||||
|
||||
TRANSPARENT = px(128, 128, 128)
|
||||
grid = [[TRANSPARENT[:] for _ in range(W)] for _ in range(H)]
|
||||
|
||||
def put(x, y, col):
|
||||
if 0 <= x < W and 0 <= y < H:
|
||||
grid[y][x] = col[:]
|
||||
|
||||
# ... disegna su grid ...
|
||||
|
||||
data = {"source": "BMP_X.png", "width": W, "height": H, "mode": "RGBA", "pixels": grid}
|
||||
Path("tools/vernon/output/BMP_X_v2.json").write_text(json.dumps(data, indent=2))
|
||||
```
|
||||
|
||||
## Tecniche pixel art a 64×64
|
||||
|
||||
- **Shading sferico:** calcola normale + dot product con luce per N livelli di grigio discreti
|
||||
- **Rope/miccia:** traccia bezier quadratica, alterna 2–3 toni in sequenza (effetto intrecciato)
|
||||
- **Scintilla:** pixel centrali chiari (bianco/giallo), bordi che degradano in arancio → rosso
|
||||
- **Outline:** bordo di 1px nero (`[0,0,0,192]`) attorno a tutte le forme principali
|
||||
- **Nessun anti-aliasing:** ogni pixel è un colore solido discreto della palette scelta
|
||||
|
||||
## Asset da redesignare (tutti 64×64)
|
||||
|
||||
| File | Gruppo |
|
||||
|------|--------|
|
||||
| `BMP_BOMB0.png` … `BMP_BOMB4.png` | Animazione bomba (0=quieta, 4=accesa) |
|
||||
| `BMP_1_GRASS_1.png` … `BMP_1_GRASS_4.png` | Tile erba tema 1 (verde) — **redesignate con FBM 7-toni** |
|
||||
| `BMP_2_GRASS_1.png` … `BMP_2_GRASS_4.png` | Tile erba tema 2 (secca/autunnale) |
|
||||
| `BMP_3_GRASS_1.png` … `BMP_3_GRASS_4.png` | Tile erba tema 3 (dungeon/pietra) |
|
||||
| `BMP_4_GRASS_1.png` … `BMP_4_GRASS_4.png` | Tile erba tema 4 (fuoco/lava) |
|
||||
| `BMP_GAS.png`, `BMP_GAS_{DIR}.png` | Gas generico + 4 direzioni |
|
||||
| `BMP_EXPLOSION.png`, `BMP_EXPLOSION_{DIR}.png` | Esplosione generica + 4 direzioni |
|
||||
| `BMP_NUCLEAR.png` | Fungo nucleare |
|
||||
| `BMP_POISON.png` | Veleno |
|
||||
|
||||
## Note sull'animazione BOMB (frame 0–4)
|
||||
|
||||
- `BOMB0`: bomba ferma, scintilla piccola a riposo
|
||||
- `BOMB1`–`BOMB3`: miccia che brucia (la scintilla avanza verso il corpo, la corda si accorcia)
|
||||
- `BOMB4`: quasi esplode (glow rosso/arancio sul corpo, scintilla grande)
|
||||
|
||||
Per i frame animati: mantieni identici corpo + miccia, varia solo posizione/dimensione scintilla e eventuale glow progressivo.
|
||||
@@ -0,0 +1,419 @@
|
||||
# regenerate_background() Analysis And Refactor Plan
|
||||
|
||||
## Scope
|
||||
|
||||
This document analyzes `Graphics.regenerate_background()` and proposes a staged refactor plan.
|
||||
|
||||
Relevant code paths:
|
||||
|
||||
- `engine/graphics.py#L146` `draw_maze()` lazily triggers background generation.
|
||||
- `engine/graphics.py#L155` `draw_cave_foreground()` consumes cave overlay metadata produced during regeneration.
|
||||
- `engine/graphics.py#L175` `regenerate_background()` builds the static background texture and cave overlay placements.
|
||||
- `engine/graphics.py#L311` `add_blood_stain()` confirms that blood is intentionally excluded from the background texture and rendered as a separate overlay.
|
||||
- `engine/sdl2.py#L98` `create_texture()` composites surface tiles into one SDL texture.
|
||||
- `engine/sdl2.py#L118` `load_image()` explains why the code keeps both surfaces and textures for the same themed assets.
|
||||
- `engine/maze.py#L13-L15` define `MAP_EMPTY`, `MAP_WALL`, and `MAP_TUNNEL`.
|
||||
- `rats.py#L79`, `rats.py#L117-L121`, and `rats.py#L163-L165` show where background state is invalidated.
|
||||
- `rats.py#L335` shows cave foreground rendering happens after the background draw and before units are drawn.
|
||||
|
||||
## What The Method Actually Does
|
||||
|
||||
`regenerate_background()` is doing more than the name suggests. It is not only “regenerating a background”; it is handling five separate concerns in one place:
|
||||
|
||||
1. It walks the logical map cell by cell.
|
||||
2. It analyzes neighborhood topology around each wall or tunnel cell.
|
||||
3. It chooses visual variants, including random grass and flower decoration.
|
||||
4. It builds cave foreground overlay metadata for later explosion-aware rendering.
|
||||
5. It commits the accumulated surfaces into a single SDL background texture.
|
||||
|
||||
That makes it both a planner and a renderer.
|
||||
|
||||
## Current Inputs, Outputs, And Side Effects
|
||||
|
||||
### Inputs read from `self`
|
||||
|
||||
- `self.map.tiles`, `self.map.width`, `self.map.height`
|
||||
- `self.cell_size`
|
||||
- `self.grasses`, `self.grass_textures`
|
||||
- `self.flowers`, `self.flower_textures`
|
||||
- `self.edges`, `self.corners`, `self.inner_corners`
|
||||
- `self.caves`
|
||||
- `self.render_engine`
|
||||
|
||||
### Derived helpers inside the method
|
||||
|
||||
- `occupied(x, y)` treats every non-empty cell as occupied, so both walls and tunnels count as solid neighbors for topology decisions.
|
||||
- `is_tunnel(x, y)` is used only for the flower suppression logic in the bottom-right quadrant.
|
||||
- `draw(...)` appends background surface tiles.
|
||||
- `draw_cave(...)` appends cave overlay tuples in the format consumed later by `draw_cave_foreground()`.
|
||||
- `random_wall()`, `random_wall_texture()`, `random_flower()`, `random_flower_texture()` embed random selection directly in the traversal logic.
|
||||
|
||||
### Outputs and side effects
|
||||
|
||||
- Resets `self.cave_foreground_tiles`
|
||||
- Builds a local `texture_tiles` list
|
||||
- Sets `self.background_texture`
|
||||
- Does not return a value
|
||||
|
||||
This means the method is hard to test in isolation because the real output is split across mutable instance state and SDL object creation.
|
||||
|
||||
## Functional Walkthrough
|
||||
|
||||
### 1. Initialization
|
||||
|
||||
The method creates:
|
||||
|
||||
- `texture_tiles`: a list of `(surface, x, y)` tuples for the static background
|
||||
- `self.cave_foreground_tiles`: a list of `(cell_x, cell_y, direction, surface, x, y)` tuples for overlay rendering
|
||||
- `half_cell`: used to place quarter-cell tiles at 20 px offsets when `cell_size` is 40
|
||||
|
||||
This immediately shows a hidden design choice: one map cell can emit up to four quarter tiles rather than a single full-tile sprite.
|
||||
|
||||
### 2. Cell iteration
|
||||
|
||||
The outer loop traverses every cell of `self.map.tiles`.
|
||||
|
||||
- `MAP_EMPTY`: skipped completely
|
||||
- `MAP_WALL`: potentially emits several quarter tiles
|
||||
- `MAP_TUNNEL`: emits cave overlays and sometimes grass filler tiles
|
||||
|
||||
### 3. Wall rendering logic
|
||||
|
||||
For `MAP_WALL`, the method evaluates the four quadrants independently.
|
||||
|
||||
#### Top-left quadrant
|
||||
|
||||
If the north-west corner is exposed, it chooses among:
|
||||
|
||||
- `inner_corners["WN"]`
|
||||
- `edges["W"]`
|
||||
- `edges["N"]`
|
||||
- `corners["NW"]`
|
||||
|
||||
based on whether the north and west neighbors are occupied.
|
||||
|
||||
#### Bottom-right quadrant
|
||||
|
||||
This is the densest branch. It checks south, east, and south-east occupancy.
|
||||
|
||||
- If all three are occupied, it usually draws a random grass tile.
|
||||
- With a 10% chance, it draws a flower instead, but only if the cell is not near the border and none of the neighboring cells involved are tunnels.
|
||||
- If only south or east are occupied, it chooses `inner_corners["ES"]`, `edges["E"]`, or `edges["S"]`.
|
||||
- Otherwise it uses `corners["SE"]`.
|
||||
|
||||
This branch mixes topology, decoration policy, border constraints, and tunnel suppression all in one nested block.
|
||||
|
||||
#### Top-right quadrant
|
||||
|
||||
Mirrors the top-left logic using north and east occupancy:
|
||||
|
||||
- `inner_corners["EN"]`
|
||||
- `edges["E"]`
|
||||
- `edges["N"]`
|
||||
- `corners["NE"]`
|
||||
|
||||
#### Bottom-left quadrant
|
||||
|
||||
Mirrors the same pattern using south and west occupancy:
|
||||
|
||||
- `inner_corners["WS"]`
|
||||
- `edges["W"]`
|
||||
- `edges["S"]`
|
||||
- `corners["SW"]`
|
||||
|
||||
### 4. Tunnel rendering logic
|
||||
|
||||
For `MAP_TUNNEL`, the method checks `above`, `below`, `left`, and `right` occupancy and chooses cave overlay sprites.
|
||||
|
||||
Observed behavior:
|
||||
|
||||
- If there is no occupied tile above, it always draws a grass filler in the bottom-right quarter and uses the `UP` cave sprite.
|
||||
- If there is an occupied tile above but not below, it uses the `DOWN` cave sprite.
|
||||
- If both above and below are occupied and the left side is blocked, it may use a full-quarter wall/flower texture in the cave list.
|
||||
- If both above and below are occupied and the left side is open, it draws a grass filler plus the `LEFT` cave sprite.
|
||||
- If above and below are occupied, left is blocked, and right is open, it uses the `RIGHT` cave sprite.
|
||||
|
||||
This logic appears tuned to the current level topology and asset set rather than representing a complete, explicit rule system for all tunnel neighbor combinations.
|
||||
|
||||
### 5. Commit phase
|
||||
|
||||
After traversal, the method calls `render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))` to compose one static SDL texture for the entire maze background.
|
||||
|
||||
This is the correct optimization boundary for the current architecture, but it also means SDL concerns leak directly into the generation logic.
|
||||
|
||||
## Why The Method Feels Complex
|
||||
|
||||
The complexity is not only “too many lines”. It comes from multiple kinds of coupling.
|
||||
|
||||
### 1. Mixed responsibilities
|
||||
|
||||
The method mixes:
|
||||
|
||||
- map analysis
|
||||
- rule selection
|
||||
- random decoration
|
||||
- cave overlay planning
|
||||
- final rendering commit
|
||||
|
||||
Each of these changes for different reasons, so they should not live in the same function.
|
||||
|
||||
### 2. Repeated neighborhood queries
|
||||
|
||||
Neighbor checks like `occupied(x, y - 1)` and `occupied(x + 1, y)` are recomputed many times, often inside overlapping branches. That makes the code noisy and increases the chance of introducing asymmetric bugs during edits.
|
||||
|
||||
### 3. Hidden representation mismatch
|
||||
|
||||
Background composition uses SDL surfaces, while cave overlays use textures. That is why the code has parallel helpers like `random_wall()` and `random_wall_texture()`. The behavior is valid, but the representation split is leaking into every branch.
|
||||
|
||||
### 4. Randomness is embedded in rule logic
|
||||
|
||||
The function directly calls global `random` during traversal. That makes visual behavior hard to snapshot-test or compare before and after a refactor.
|
||||
|
||||
### 5. Side effects are scattered across the class lifecycle
|
||||
|
||||
Invalidation is controlled elsewhere in `rats.py`, where the code manually clears:
|
||||
|
||||
- `self.background_texture`
|
||||
- `self.blood_layer_sprites`
|
||||
- `self.cave_foreground_tiles`
|
||||
|
||||
This is correct today, but it creates a fragile contract between game flow code and rendering code.
|
||||
|
||||
### 6. Tunnel rules are implicit
|
||||
|
||||
The tunnel branch contains nested assumptions that are hard to verify by inspection. It is not obvious whether the logic is exhaustive, map-specific, or intentionally asymmetric.
|
||||
|
||||
## Important Invariants To Preserve
|
||||
|
||||
Any refactor must keep these behaviors unless you explicitly choose to change them:
|
||||
|
||||
1. Blood stains remain outside the static background texture.
|
||||
2. `draw_cave_foreground()` must still be able to swap cave sprites for explosion sprites at runtime.
|
||||
3. Quarter-tile placement and offsets must remain visually identical.
|
||||
4. Random flower placement must preserve the current frequency and tunnel/border exclusions, or the change must be documented as a visual redesign.
|
||||
5. Theme asset selection must keep using surfaces for background composition and textures for runtime overlays unless the render-engine API changes.
|
||||
|
||||
## Refactor Goals
|
||||
|
||||
The target should be:
|
||||
|
||||
- easier to read
|
||||
- behaviorally stable
|
||||
- testable without SDL
|
||||
- explicit about map-topology rules
|
||||
- easy to extend with new wall or tunnel tile rules
|
||||
|
||||
## Recommended Refactor Direction
|
||||
|
||||
The safest path is not a full rewrite. It is a staged extraction toward a pure planning layer.
|
||||
|
||||
### Stage 1: Name The Concepts
|
||||
|
||||
Extract small private helpers without changing data structures yet.
|
||||
|
||||
Suggested helpers:
|
||||
|
||||
- `_is_occupied(x, y)`
|
||||
- `_is_tunnel(x, y)`
|
||||
- `_make_cell_context(x, y)`
|
||||
- `_append_background_tile(surface, x, y, texture_tiles)`
|
||||
- `_append_cave_tile(surface, x, y, direction)`
|
||||
- `_choose_wall_fill(x, y, allow_flower)`
|
||||
|
||||
This alone will remove repeated neighbor reads and make the current logic easier to reason about.
|
||||
|
||||
### Stage 2: Introduce A Pure Planning Model
|
||||
|
||||
Create lightweight data containers, for example:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TilePlacement:
|
||||
surface: object
|
||||
x: int
|
||||
y: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CavePlacement:
|
||||
cell_x: int
|
||||
cell_y: int
|
||||
direction: str | None
|
||||
sprite: object
|
||||
x: int
|
||||
y: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CellContext:
|
||||
x: int
|
||||
y: int
|
||||
cell: int
|
||||
north: bool
|
||||
south: bool
|
||||
east: bool
|
||||
west: bool
|
||||
north_west: bool
|
||||
north_east: bool
|
||||
south_west: bool
|
||||
south_east: bool
|
||||
```
|
||||
|
||||
Then split the method into:
|
||||
|
||||
- `_build_background_plan()`
|
||||
- `_plan_wall_cell(context, plan)`
|
||||
- `_plan_tunnel_cell(context, plan)`
|
||||
- `_commit_background_plan(plan)`
|
||||
|
||||
The important shift is this: planning should produce plain Python data first, and SDL texture creation should happen only in the commit step.
|
||||
|
||||
### Stage 3: Replace Nested Branches With Rule Helpers
|
||||
|
||||
The wall logic is currently “four quadrants, each with a small rule tree”. Keep that structure, but make it explicit.
|
||||
|
||||
Suggested helpers:
|
||||
|
||||
- `_plan_wall_nw(context, px, py, plan)`
|
||||
- `_plan_wall_ne(context, px, py, half_cell, plan)`
|
||||
- `_plan_wall_sw(context, px, py, half_cell, plan)`
|
||||
- `_plan_wall_se(context, px, py, half_cell, plan)`
|
||||
|
||||
This sounds verbose, but it is much easier to review because each helper owns one visual quadrant and one set of rules.
|
||||
|
||||
### Stage 4: Isolate Decoration Policy
|
||||
|
||||
The flower rule is currently buried inside the `SE` branch. Extract it into a dedicated function such as:
|
||||
|
||||
```python
|
||||
def _should_place_flower(self, x, y, context) -> bool:
|
||||
...
|
||||
```
|
||||
|
||||
That function should own:
|
||||
|
||||
- the 10% probability
|
||||
- border exclusions
|
||||
- tunnel exclusions
|
||||
|
||||
This makes visual tuning possible without reopening the topology logic.
|
||||
|
||||
### Stage 5: Make Tunnel Rules Explicit
|
||||
|
||||
Tunnel behavior needs a named rule function with documented cases.
|
||||
|
||||
For example:
|
||||
|
||||
- `_classify_tunnel(context) -> TunnelPattern`
|
||||
- `_plan_tunnel_pattern(pattern, px, py, plan)`
|
||||
|
||||
Even if the final logic stays the same, naming the tunnel patterns will expose whether the code is intentionally map-specific or accidentally incomplete.
|
||||
|
||||
### Stage 6: Centralize Invalidation
|
||||
|
||||
Introduce one method such as:
|
||||
|
||||
```python
|
||||
def invalidate_background(self):
|
||||
self.background_texture = None
|
||||
self.cave_foreground_tiles.clear()
|
||||
```
|
||||
|
||||
Then use that method from lifecycle points in `rats.py`.
|
||||
|
||||
This reduces the chance of future bugs where one part of the cached rendering state is reset and another is forgotten.
|
||||
|
||||
## Suggested Final Shape
|
||||
|
||||
The long-term shape can stay inside `Graphics` and still be much cleaner:
|
||||
|
||||
```python
|
||||
def regenerate_background(self):
|
||||
plan = self._build_background_plan()
|
||||
self._commit_background_plan(plan)
|
||||
|
||||
def _build_background_plan(self):
|
||||
...
|
||||
|
||||
def _plan_wall_cell(self, context, plan):
|
||||
...
|
||||
|
||||
def _plan_tunnel_cell(self, context, plan):
|
||||
...
|
||||
|
||||
def _commit_background_plan(self, plan):
|
||||
self.cave_foreground_tiles = plan.cave_tiles
|
||||
self.background_texture = self.render_engine.create_texture(
|
||||
plan.background_tiles,
|
||||
fill_color=(128, 128, 128),
|
||||
)
|
||||
```
|
||||
|
||||
This would preserve the current class boundaries while making the core algorithm testable.
|
||||
|
||||
## Test Strategy Before Refactoring
|
||||
|
||||
Because the function is visual and randomized, refactoring without a guardrail is risky.
|
||||
|
||||
Recommended safety steps:
|
||||
|
||||
1. Introduce a seeded RNG path so map generation can be deterministic during tests.
|
||||
2. Add a small test map fixture that exercises walls, corners, borders, and tunnels.
|
||||
3. Snapshot the produced tile plan, not the SDL texture object.
|
||||
4. Verify cave overlay tuples are identical before and after the extraction.
|
||||
5. Add a smoke test for `draw_cave_foreground()` with an explosion unit to ensure cave sprite replacement still works.
|
||||
|
||||
## Proposed Implementation Order
|
||||
|
||||
### Phase 0: Freeze Current Behavior
|
||||
|
||||
- Add a deterministic RNG entry point or injectable random source.
|
||||
- Capture the current background plan for one or two representative maps.
|
||||
|
||||
### Phase 1: Extract Context And Emit Helpers
|
||||
|
||||
- Remove repeated `occupied(...)` calls.
|
||||
- Keep current tuple outputs and current SDL commit behavior.
|
||||
|
||||
### Phase 2: Split Wall And Tunnel Planning
|
||||
|
||||
- Move wall rules into quadrant helpers.
|
||||
- Move tunnel rules into a dedicated planner.
|
||||
|
||||
### Phase 3: Introduce A `BackgroundPlan`
|
||||
|
||||
- Return plain data from planning.
|
||||
- Keep SDL texture creation in one place.
|
||||
|
||||
### Phase 4: Centralize Cache Invalidation
|
||||
|
||||
- Replace direct state resets with a single background invalidation method.
|
||||
|
||||
### Phase 5: Optional Optimization Pass
|
||||
|
||||
- Consider caching immutable plans by `(level_index, theme_index)` if needed.
|
||||
- Consider precomputing per-cell contexts if profiling shows the planner is still hot.
|
||||
|
||||
## Refactor Risks And Questions
|
||||
|
||||
These should be clarified before implementation:
|
||||
|
||||
1. Are tunnel patterns guaranteed by the level data, or should the code become exhaustive for arbitrary maps?
|
||||
2. Is `occupied()` intentionally treating tunnels as “solid” for wall topology, or is that only a rendering shortcut?
|
||||
3. Is the flower placement rule part of the visual identity, or can it be simplified?
|
||||
4. Do we want to keep both surfaces and textures in the theme cache, or would a render-engine API change be acceptable later?
|
||||
|
||||
## Recommended First Refactor PR
|
||||
|
||||
The lowest-risk first PR would do only this:
|
||||
|
||||
1. Extract `CellContext` creation.
|
||||
2. Extract the four wall-quadrant planners.
|
||||
3. Extract tunnel planning into one helper.
|
||||
4. Leave the tuple formats and SDL commit step unchanged.
|
||||
|
||||
That PR would reduce complexity sharply while keeping the visual output almost certainly identical.
|
||||
|
||||
## Summary
|
||||
|
||||
`regenerate_background()` is complex because it is simultaneously a topology analyzer, decoration policy engine, cave overlay planner, and SDL background composer. The safest refactor is to separate planning from rendering, then isolate wall rules, tunnel rules, and decoration policy into named helpers with deterministic test coverage.
|
||||
|
Before Width: | Height: | Size: 336 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 337 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 473 B |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 390 B |
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 435 B After Width: | Height: | Size: 403 B |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 255 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 250 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 785 B After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 797 B After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 788 B After Width: | Height: | Size: 771 B |
|
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 775 B |
|
Before Width: | Height: | Size: 843 B After Width: | Height: | Size: 780 B |
|
Before Width: | Height: | Size: 865 B After Width: | Height: | Size: 800 B |
|
Before Width: | Height: | Size: 841 B After Width: | Height: | Size: 780 B |
|
Before Width: | Height: | Size: 830 B After Width: | Height: | Size: 773 B |
|
After Width: | Height: | Size: 257 B |
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 296 B |
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 400 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 325 B |
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 325 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 193 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 171 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 272 B |
|
After Width: | Height: | Size: 304 B |
|
After Width: | Height: | Size: 297 B |
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 297 B |
|
After Width: | Height: | Size: 304 B |
|
After Width: | Height: | Size: 299 B |
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 354 B |
|
After Width: | Height: | Size: 314 B |
|
After Width: | Height: | Size: 324 B |
|
After Width: | Height: | Size: 331 B |
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 354 B |
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"keybinding_game": {
|
||||
"keydown_Return": "spawn_rat",
|
||||
"keydown_D": "kill_rat",
|
||||
"keydown_M": "toggle_audio",
|
||||
"keydown_F": "toggle_full_screen",
|
||||
"keydown_Up": "start_scrolling|Up",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
keybinding_game:
|
||||
controllerbuttondown_a: spawn_rat
|
||||
controllerbuttondown_dpad_up: start_scrolling|Up
|
||||
controllerbuttondown_dpad_down: start_scrolling|Down
|
||||
controllerbuttondown_dpad_left: start_scrolling|Left
|
||||
controllerbuttondown_dpad_right: start_scrolling|Right
|
||||
controllerbuttonup_dpad_up: stop_scrolling
|
||||
controllerbuttonup_dpad_down: stop_scrolling
|
||||
controllerbuttonup_dpad_left: stop_scrolling
|
||||
controllerbuttonup_dpad_right: stop_scrolling
|
||||
controllerbuttondown_x: spawn_new_bomb
|
||||
controllerbuttondown_y: spawn_new_nuclear_bomb
|
||||
controllerbuttondown_leftshoulder: spawn_new_mine
|
||||
controllerbuttondown_rightshoulder: spawn_gas
|
||||
controllerbuttondown_start: toggle_pause
|
||||
|
||||
keybinding_start_menu:
|
||||
controllerbuttondown_a: reset_game
|
||||
controllerbuttondown_start: reset_game
|
||||
controllerbuttondown_b: quit_game
|
||||
controllerbuttondown_back: quit_game
|
||||
|
||||
keybinding_paused:
|
||||
controllerbuttondown_a: reset_game
|
||||
controllerbuttondown_start: toggle_pause
|
||||
controllerbuttondown_b: quit_game
|
||||
controllerbuttondown_back: quit_game
|
||||
@@ -1,6 +1,5 @@
|
||||
keybinding_game:
|
||||
keydown_Return: spawn_rat
|
||||
keydown_D: kill_rat
|
||||
keydown_M: toggle_audio
|
||||
keydown_F: toggle_full_screen
|
||||
keydown_Up: start_scrolling|Up
|
||||
|
||||
@@ -1,44 +1,255 @@
|
||||
# This file contains the Controls class, which is responsible for handling user input.
|
||||
# The key_pressed method is called when a key is pressed, and it contains the logic for handling different key presses.
|
||||
|
||||
import random
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from runtime_paths import resolve_bundle_path
|
||||
|
||||
bindings = {}
|
||||
json_bindings_path = resolve_bundle_path("conf/keybindings.json")
|
||||
yaml_bindings_path = resolve_bundle_path("conf/keybindings.yaml")
|
||||
if os.path.exists(json_bindings_path):
|
||||
with open(json_bindings_path, "r") as f:
|
||||
bindings = json.load(f)
|
||||
else:
|
||||
import yaml
|
||||
# read yaml config file
|
||||
with open(yaml_bindings_path, "r") as f:
|
||||
bindings = yaml.safe_load(f)
|
||||
|
||||
class KeyBindings:
|
||||
def trigger(self, action):
|
||||
#print(f"Triggering action: {action}")
|
||||
# Check if the action is in the bindings
|
||||
if action in bindings[f"keybinding_{self.game_status}"]:
|
||||
value = bindings[f"keybinding_{self.game_status}"][action]
|
||||
# Call the corresponding method
|
||||
if value:
|
||||
#print(f"Calling method: {value}")
|
||||
if "|" in value:
|
||||
method_name, *args = value.split("|")
|
||||
method = getattr(self, method_name)
|
||||
method(*args)
|
||||
else:
|
||||
getattr(self, value)()
|
||||
#else:
|
||||
#print(f"Action {action} not found in keybindings for {self.game_status}")
|
||||
|
||||
DEFAULT_KEYBINDINGS_PROFILE = "pc"
|
||||
KEYBINDINGS_FILE_ENV = "MICE_KEYBINDINGS_FILE"
|
||||
KEYBINDINGS_PROFILE_ENV = "MICE_KEYBINDINGS_PROFILE"
|
||||
|
||||
|
||||
def _normalize_profile_name(profile_name):
|
||||
if not profile_name:
|
||||
return None
|
||||
return profile_name.strip().lower().replace("-", "_")
|
||||
|
||||
|
||||
def _read_text_hint(path_like):
|
||||
path = Path(path_like)
|
||||
if not path.exists():
|
||||
return ""
|
||||
|
||||
try:
|
||||
value = path.read_bytes().replace(b"\x00", b" ").decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
return " ".join(value.split())
|
||||
|
||||
|
||||
def _load_bindings_from_file(path):
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
if path.suffix.lower() == ".json":
|
||||
data = json.load(handle)
|
||||
elif path.suffix.lower() in {".yaml", ".yml"}:
|
||||
data = yaml.safe_load(handle) or {}
|
||||
else:
|
||||
raise ValueError(f"Unsupported keybindings format: {path.suffix}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Keybindings file must contain a mapping: {path}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _profile_candidates(profile_name):
|
||||
profile = _normalize_profile_name(profile_name)
|
||||
if not profile:
|
||||
return []
|
||||
|
||||
conf_dir = resolve_bundle_path("conf")
|
||||
candidates = [
|
||||
conf_dir / f"keybindings_{profile}.json",
|
||||
conf_dir / f"keybindings_{profile}.yaml",
|
||||
conf_dir / f"keybindings_{profile}.yml",
|
||||
]
|
||||
|
||||
if profile == DEFAULT_KEYBINDINGS_PROFILE:
|
||||
candidates.extend([
|
||||
conf_dir / "keybindings.json",
|
||||
conf_dir / "keybindings.yaml",
|
||||
conf_dir / "keybindings.yml",
|
||||
])
|
||||
|
||||
unique_candidates = []
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
candidate_str = str(candidate)
|
||||
if candidate_str in seen:
|
||||
continue
|
||||
seen.add(candidate_str)
|
||||
unique_candidates.append(candidate)
|
||||
return unique_candidates
|
||||
|
||||
|
||||
def _detect_linux_hardware_profile():
|
||||
hints = []
|
||||
for path_like in [
|
||||
"/proc/device-tree/model",
|
||||
"/sys/firmware/devicetree/base/model",
|
||||
"/sys/devices/virtual/dmi/id/sys_vendor",
|
||||
"/sys/devices/virtual/dmi/id/product_name",
|
||||
"/sys/devices/virtual/dmi/id/product_version",
|
||||
"/tmp/sysinfo/model",
|
||||
]:
|
||||
hint = _read_text_hint(path_like)
|
||||
if hint:
|
||||
hints.append(hint)
|
||||
|
||||
combined = " ".join(hints).casefold()
|
||||
if not combined:
|
||||
return None, None
|
||||
|
||||
hardware_profiles = {
|
||||
"r36s": ("r36s",),
|
||||
"rg40xx": ("rg40xx", "anbernic rg40xx", "rg40xx h"),
|
||||
}
|
||||
|
||||
for profile, tokens in hardware_profiles.items():
|
||||
if any(token in combined for token in tokens):
|
||||
return profile, "; ".join(hints)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _detect_runtime_profile(render_engine):
|
||||
if render_engine is None:
|
||||
return None, None
|
||||
|
||||
device_kind = getattr(render_engine, "input_device_kind", None)
|
||||
device_name = (getattr(render_engine, "input_device_name", "") or "").casefold()
|
||||
|
||||
if device_kind == "gamecontroller":
|
||||
return "gamepad", device_name or "SDL GameController"
|
||||
|
||||
if device_kind == "joystick":
|
||||
if "r36s" in device_name:
|
||||
return "r36s", device_name
|
||||
if "rg40xx" in device_name:
|
||||
return "rg40xx", device_name
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def resolve_keybindings(preferred_profile=None, preferred_file=None, render_engine=None):
|
||||
explicit_file = preferred_file or os.environ.get(KEYBINDINGS_FILE_ENV)
|
||||
if explicit_file:
|
||||
path = resolve_bundle_path(explicit_file)
|
||||
if path.exists():
|
||||
explicit_profile = path.stem
|
||||
if explicit_profile.startswith("keybindings_"):
|
||||
explicit_profile = explicit_profile[len("keybindings_"):]
|
||||
return _load_bindings_from_file(path), path, explicit_profile, KEYBINDINGS_FILE_ENV
|
||||
print(f"[input] requested keybindings file not found: {path}")
|
||||
|
||||
profiles_to_try = []
|
||||
seen_profiles = set()
|
||||
|
||||
def add_profile(profile_name, reason):
|
||||
profile = _normalize_profile_name(profile_name)
|
||||
if not profile or profile in seen_profiles:
|
||||
return
|
||||
|
||||
#print(f"Action {action} not found in keybindings for {self.game_status}")
|
||||
seen_profiles.add(profile)
|
||||
profiles_to_try.append((profile, reason))
|
||||
|
||||
add_profile(os.environ.get(KEYBINDINGS_PROFILE_ENV), KEYBINDINGS_PROFILE_ENV)
|
||||
add_profile(preferred_profile, "profile_setting")
|
||||
|
||||
hardware_profile, hardware_reason = _detect_linux_hardware_profile()
|
||||
if hardware_profile:
|
||||
add_profile(hardware_profile, f"hardware:{hardware_reason}")
|
||||
|
||||
runtime_profile, runtime_reason = _detect_runtime_profile(render_engine)
|
||||
if runtime_profile:
|
||||
add_profile(runtime_profile, f"runtime:{runtime_reason}")
|
||||
|
||||
add_profile(DEFAULT_KEYBINDINGS_PROFILE, "default")
|
||||
|
||||
errors = []
|
||||
for profile, reason in profiles_to_try:
|
||||
for candidate in _profile_candidates(profile):
|
||||
if not candidate.exists():
|
||||
continue
|
||||
try:
|
||||
bindings = _load_bindings_from_file(candidate)
|
||||
return bindings, candidate, profile, reason
|
||||
except (OSError, ValueError, json.JSONDecodeError, yaml.YAMLError) as exc:
|
||||
errors.append(f"{candidate}: {exc}")
|
||||
|
||||
if errors:
|
||||
raise RuntimeError("Failed to load keybindings:\n" + "\n".join(errors))
|
||||
|
||||
raise FileNotFoundError("No keybindings configuration could be resolved")
|
||||
|
||||
|
||||
class KeyBindings:
|
||||
def initialize_keybindings(self):
|
||||
preferred_profile = None
|
||||
preferred_file = None
|
||||
|
||||
if hasattr(self, "profile_integration") and self.profile_integration:
|
||||
preferred_profile = self.profile_integration.get_setting("keybindings_profile")
|
||||
preferred_file = self.profile_integration.get_setting("keybindings_file")
|
||||
|
||||
bindings, source_path, profile_name, reason = resolve_keybindings(
|
||||
preferred_profile=preferred_profile,
|
||||
preferred_file=preferred_file,
|
||||
render_engine=getattr(self, "render_engine", None),
|
||||
)
|
||||
|
||||
self.bindings = self._validate_bindings(bindings, source_path)
|
||||
self.keybindings_profile = profile_name
|
||||
self.keybindings_source = str(source_path)
|
||||
self.keybindings_reason = reason
|
||||
print(
|
||||
f"[input] keybindings profile={profile_name} source={source_path.name} reason={reason}"
|
||||
)
|
||||
|
||||
def _validate_bindings(self, bindings, source_path):
|
||||
validated = {}
|
||||
invalid_bindings = 0
|
||||
|
||||
for section_name, action_map in bindings.items():
|
||||
if not isinstance(action_map, dict):
|
||||
print(f"[input] ignoring invalid section {section_name!r} in {source_path}")
|
||||
continue
|
||||
|
||||
validated[section_name] = {}
|
||||
for action, value in action_map.items():
|
||||
if not value:
|
||||
continue
|
||||
|
||||
method_name = value.split("|", 1)[0]
|
||||
method = getattr(self, method_name, None)
|
||||
if callable(method):
|
||||
validated[section_name][action] = value
|
||||
continue
|
||||
|
||||
invalid_bindings += 1
|
||||
print(
|
||||
f"[input] ignoring binding {section_name}.{action} -> {value}: "
|
||||
f"missing method {method_name}"
|
||||
)
|
||||
|
||||
if invalid_bindings:
|
||||
print(f"[input] discarded {invalid_bindings} invalid binding(s) from {source_path.name}")
|
||||
|
||||
return validated
|
||||
|
||||
def trigger(self, action):
|
||||
if not hasattr(self, "bindings"):
|
||||
self.initialize_keybindings()
|
||||
|
||||
value = self.bindings.get(f"keybinding_{self.game_status}", {}).get(action)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if "|" in value:
|
||||
method_name, *args = value.split("|")
|
||||
method = getattr(self, method_name, None)
|
||||
if callable(method):
|
||||
method(*args)
|
||||
return None
|
||||
|
||||
method = getattr(self, value, None)
|
||||
if callable(method):
|
||||
method()
|
||||
return None
|
||||
|
||||
def spawn_new_bomb(self):
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import random
|
||||
|
||||
from engine import maze
|
||||
from engine.collision_system import CollisionLayer
|
||||
from runtime_paths import bundle_path
|
||||
|
||||
class Graphics():
|
||||
@@ -71,15 +72,23 @@ class Graphics():
|
||||
print(f"Loading theme assets {theme_index}...")
|
||||
self.theme_assets_cache[theme_index] = {
|
||||
"floor_tile": self.render_engine.create_color_surface((128, 128, 128)),
|
||||
"tunnel": self.render_engine.load_image("Rat/BMP_TUNNEL.png", surface=True),
|
||||
"tunnel": self.render_engine.load_image("Rat/BMP_TUNNEL.png"),
|
||||
"grasses": [
|
||||
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True)
|
||||
for i in range(4)
|
||||
],
|
||||
"grass_textures": [
|
||||
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png")
|
||||
for i in range(4)
|
||||
],
|
||||
"flowers": [
|
||||
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True)
|
||||
for i in range(4)
|
||||
],
|
||||
"flower_textures": [
|
||||
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png")
|
||||
for i in range(4)
|
||||
],
|
||||
"caves": {
|
||||
direction: self.render_engine.load_image(
|
||||
f"Rat/BMP_{theme_index}_CAVE_{direction}.png",
|
||||
@@ -88,6 +97,14 @@ class Graphics():
|
||||
)
|
||||
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
|
||||
},
|
||||
"explosions": {
|
||||
direction: self.render_engine.load_image(
|
||||
f"Rat/BMP_{theme_index}_EXPLOSION_{direction}.png",
|
||||
transparent_color=((125, 125, 125), (128, 128, 128)),
|
||||
surface=False,
|
||||
)
|
||||
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
|
||||
},
|
||||
"edges": {
|
||||
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
|
||||
for direction in ["N", "S", "E", "W"]
|
||||
@@ -110,8 +127,11 @@ class Graphics():
|
||||
self.floor_tile = theme_assets["floor_tile"]
|
||||
self.tunnel = theme_assets["tunnel"]
|
||||
self.grasses = theme_assets["grasses"]
|
||||
self.grass_textures = theme_assets["grass_textures"]
|
||||
self.flowers = theme_assets["flowers"]
|
||||
self.flower_textures = theme_assets["flower_textures"]
|
||||
self.caves = theme_assets["caves"]
|
||||
self.explosions = theme_assets["explosions"]
|
||||
self.edges = theme_assets["edges"]
|
||||
self.corners = theme_assets["corners"]
|
||||
self.inner_corners = theme_assets["inner_corners"]
|
||||
@@ -133,7 +153,18 @@ class Graphics():
|
||||
self.draw_blood_layer()
|
||||
|
||||
def draw_cave_foreground(self):
|
||||
for surface, x, y in self.cave_foreground_tiles:
|
||||
active_cave_explosions = {}
|
||||
for unit in self.units.values():
|
||||
if unit.collision_layer != CollisionLayer.EXPLOSION:
|
||||
continue
|
||||
if not self.map.is_tunnel(*unit.position):
|
||||
continue
|
||||
active_cave_explosions[unit.position] = getattr(unit, "cave_direction", None)
|
||||
|
||||
for cell_x, cell_y, direction, surface, x, y in self.cave_foreground_tiles:
|
||||
if (cell_x, cell_y) in active_cave_explosions:
|
||||
explosion_direction = active_cave_explosions[(cell_x, cell_y)] or direction
|
||||
surface = self.explosions.get(explosion_direction, surface)
|
||||
self.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave")
|
||||
|
||||
def draw_blood_layer(self):
|
||||
@@ -150,8 +181,8 @@ class Graphics():
|
||||
def draw(surface, x, y):
|
||||
texture_tiles.append((surface, x, y))
|
||||
|
||||
def draw_cave(surface, x, y):
|
||||
self.cave_foreground_tiles.append((surface, x, y))
|
||||
def draw_cave(surface, x, y, direction):
|
||||
self.cave_foreground_tiles.append((x // self.cell_size, y // self.cell_size, direction, surface, x, y))
|
||||
|
||||
def occupied(x, y):
|
||||
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) != maze.MAP_EMPTY
|
||||
@@ -162,9 +193,14 @@ class Graphics():
|
||||
def random_wall():
|
||||
return random.choice(self.grasses)
|
||||
|
||||
def random_wall_texture():
|
||||
return random.choice(self.grass_textures)
|
||||
|
||||
def random_flower():
|
||||
return random.choice(self.flowers)
|
||||
|
||||
def random_flower_texture():
|
||||
return random.choice(self.flower_textures)
|
||||
for y, row in enumerate(self.map.tiles):
|
||||
for x, cell in enumerate(row):
|
||||
px = x * self.cell_size
|
||||
@@ -255,25 +291,25 @@ class Graphics():
|
||||
if left:
|
||||
if right:
|
||||
if random.randrange(10) != 0:
|
||||
draw(random_wall(), px + half_cell, py + half_cell)
|
||||
draw_cave(random_wall_texture(), px + half_cell, py + half_cell, None)
|
||||
else:
|
||||
draw(random_flower(), px + half_cell, py + half_cell)
|
||||
draw_cave(random_flower_texture(), px + half_cell, py + half_cell, None)
|
||||
else:
|
||||
draw_cave(self.caves["RIGHT"], px, py)
|
||||
draw_cave(self.caves["RIGHT"], px, py, "RIGHT")
|
||||
else:
|
||||
draw(self.grasses[0], px + half_cell, py + half_cell)
|
||||
draw_cave(self.caves["LEFT"], px, py)
|
||||
draw_cave(self.caves["LEFT"], px, py, "LEFT")
|
||||
else:
|
||||
draw_cave(self.caves["DOWN"], px, py)
|
||||
draw_cave(self.caves["DOWN"], px, py, "DOWN")
|
||||
else:
|
||||
draw(self.grasses[0], px + half_cell, py + half_cell)
|
||||
draw_cave(self.caves["UP"], px, py)
|
||||
draw_cave(self.caves["UP"], px, py, "UP")
|
||||
|
||||
# Blood stains now handled separately as overlay layer
|
||||
self.background_texture = self.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))
|
||||
|
||||
def add_blood_stain(self, position):
|
||||
"""Add a blood stain as sprite overlay (optimized - no background regeneration)"""
|
||||
"""Add a blood stain as sprite overlay (opti mized - no background regeneration)"""
|
||||
# Pick random blood texture from pre-generated pool
|
||||
if not self.blood_stain_textures:
|
||||
return
|
||||
|
||||
@@ -12,6 +12,36 @@ from PIL import Image
|
||||
from runtime_paths import resolve_bundle_path
|
||||
|
||||
|
||||
CONTROLLER_BUTTON_NAMES = {
|
||||
sdl2.SDL_CONTROLLER_BUTTON_A: "a",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_B: "b",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_X: "x",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_Y: "y",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_BACK: "back",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_GUIDE: "guide",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_START: "start",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_LEFTSTICK: "leftstick",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_RIGHTSTICK: "rightstick",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: "leftshoulder",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: "rightshoulder",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP: "dpad_up",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN: "dpad_down",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT: "dpad_left",
|
||||
sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT: "dpad_right",
|
||||
}
|
||||
|
||||
|
||||
def _decode_sdl_string(value):
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return bytes(value).decode("utf-8", errors="ignore")
|
||||
try:
|
||||
return value.decode("utf-8", errors="ignore")
|
||||
except AttributeError:
|
||||
return str(value)
|
||||
|
||||
|
||||
class GameWindow:
|
||||
def __init__(self, width, height, cell_size, title="Default", key_callback=None):
|
||||
# Display configuration
|
||||
@@ -76,6 +106,10 @@ class GameWindow:
|
||||
self.trigger = key_callback
|
||||
self.button_cursor = [0, 0]
|
||||
self.buttons = {}
|
||||
self.joystick = None
|
||||
self.game_controller = None
|
||||
self.input_device_kind = "keyboard"
|
||||
self.input_device_name = None
|
||||
|
||||
# Audio system initialization
|
||||
self._init_audio_system()
|
||||
@@ -412,9 +446,43 @@ class GameWindow:
|
||||
# ======================
|
||||
|
||||
def load_joystick(self):
|
||||
"""Initialize joystick support"""
|
||||
sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK)
|
||||
sdl2.SDL_JoystickOpen(0)
|
||||
"""Initialize joystick and game controller support."""
|
||||
sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER)
|
||||
sdl2.SDL_JoystickEventState(sdl2.SDL_ENABLE)
|
||||
if hasattr(sdl2, "SDL_GameControllerEventState"):
|
||||
sdl2.SDL_GameControllerEventState(sdl2.SDL_ENABLE)
|
||||
|
||||
num_joysticks = sdl2.SDL_NumJoysticks()
|
||||
if num_joysticks < 1:
|
||||
print("[input] no joystick detected")
|
||||
return
|
||||
|
||||
for device_index in range(num_joysticks):
|
||||
if hasattr(sdl2, "SDL_IsGameController") and sdl2.SDL_IsGameController(device_index):
|
||||
controller = sdl2.SDL_GameControllerOpen(device_index)
|
||||
if controller:
|
||||
self.game_controller = controller
|
||||
self.input_device_kind = "gamecontroller"
|
||||
self.input_device_name = _decode_sdl_string(
|
||||
sdl2.SDL_GameControllerName(controller)
|
||||
)
|
||||
print(f"[input] game controller detected: {self.input_device_name}")
|
||||
return
|
||||
|
||||
self.joystick = sdl2.SDL_JoystickOpen(0)
|
||||
if self.joystick:
|
||||
self.input_device_kind = "joystick"
|
||||
self.input_device_name = _decode_sdl_string(sdl2.SDL_JoystickName(self.joystick))
|
||||
print(f"[input] raw joystick detected: {self.input_device_name}")
|
||||
|
||||
def get_input_device_summary(self):
|
||||
return {
|
||||
"kind": self.input_device_kind,
|
||||
"name": self.input_device_name,
|
||||
}
|
||||
|
||||
def _controller_button_name(self, button):
|
||||
return CONTROLLER_BUTTON_NAMES.get(button, str(button))
|
||||
|
||||
# ======================
|
||||
# MAIN GAME LOOP
|
||||
@@ -456,15 +524,24 @@ class GameWindow:
|
||||
elif event.type == sdl2.SDL_MOUSEMOTION:
|
||||
self.trigger(f"mousemove_{event.motion.x}, {event.motion.y}")
|
||||
elif event.type == sdl2.SDL_JOYBUTTONDOWN:
|
||||
key = event.jbutton.button
|
||||
self.trigger(f"joybuttondown_{key}")
|
||||
if self.game_controller is None:
|
||||
key = event.jbutton.button
|
||||
self.trigger(f"joybuttondown_{key}")
|
||||
elif event.type == sdl2.SDL_JOYBUTTONUP:
|
||||
key = event.jbutton.button
|
||||
self.trigger(f"joybuttonup_{key}")
|
||||
if self.game_controller is None:
|
||||
key = event.jbutton.button
|
||||
self.trigger(f"joybuttonup_{key}")
|
||||
elif event.type == sdl2.SDL_JOYHATMOTION:
|
||||
hat = event.jhat.hat
|
||||
value = event.jhat.value
|
||||
self.trigger(f"joyhatmotion_{hat}_{value}")
|
||||
if self.game_controller is None:
|
||||
hat = event.jhat.hat
|
||||
value = event.jhat.value
|
||||
self.trigger(f"joyhatmotion_{hat}_{value}")
|
||||
elif hasattr(sdl2, "SDL_CONTROLLERBUTTONDOWN") and event.type == sdl2.SDL_CONTROLLERBUTTONDOWN:
|
||||
button_name = self._controller_button_name(event.cbutton.button)
|
||||
self.trigger(f"controllerbuttondown_{button_name}")
|
||||
elif hasattr(sdl2, "SDL_CONTROLLERBUTTONUP") and event.type == sdl2.SDL_CONTROLLERBUTTONUP:
|
||||
button_name = self._controller_button_name(event.cbutton.button)
|
||||
self.trigger(f"controllerbuttonup_{button_name}")
|
||||
|
||||
|
||||
|
||||
@@ -537,7 +614,7 @@ class GameWindow:
|
||||
if event.type == sdl2.SDL_QUIT:
|
||||
self.running = False
|
||||
return
|
||||
elif event.type in (sdl2.SDL_KEYDOWN, sdl2.SDL_JOYBUTTONDOWN):
|
||||
elif event.type in (sdl2.SDL_KEYDOWN, sdl2.SDL_JOYBUTTONDOWN, sdl2.SDL_CONTROLLERBUTTONDOWN):
|
||||
skipped = True
|
||||
|
||||
if skipped:
|
||||
@@ -645,6 +722,12 @@ class GameWindow:
|
||||
|
||||
def close(self):
|
||||
"""Close the game window and cleanup"""
|
||||
if self.game_controller:
|
||||
sdl2.SDL_GameControllerClose(self.game_controller)
|
||||
self.game_controller = None
|
||||
if self.joystick:
|
||||
sdl2.SDL_JoystickClose(self.joystick)
|
||||
self.joystick = None
|
||||
self.running = False
|
||||
sdl2.ext.quit()
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class MiceMaze(
|
||||
# Apply profile settings
|
||||
if hasattr(self.render_engine, 'set_volume'):
|
||||
self.render_engine.set_volume(sound_volume)
|
||||
self.initialize_keybindings()
|
||||
|
||||
self.load_assets()
|
||||
self.render_engine.window.show()
|
||||
@@ -281,7 +282,6 @@ class MiceMaze(
|
||||
self.render_engine.delete_tag("unit")
|
||||
self.render_engine.delete_tag("effect")
|
||||
self.render_engine.delete_tag("cave")
|
||||
self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size)
|
||||
|
||||
# Clear collision system for new frame
|
||||
self.collision_system.clear()
|
||||
@@ -332,13 +332,15 @@ class MiceMaze(
|
||||
|
||||
self.unit_positions.setdefault(unit.position, []).append(unit)
|
||||
self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
|
||||
|
||||
# Fourth pass: check collisions and draw
|
||||
for unit in self.units.copy().values():
|
||||
unit.collisions()
|
||||
unit.draw()
|
||||
|
||||
|
||||
self.draw_cave_foreground()
|
||||
self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size)
|
||||
|
||||
|
||||
|
||||
self.render_engine.update_status(f"Mice: {self.count_rats()} - Points: {self.points}")
|
||||
self.refill_ammo()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pysdl2
|
||||
Pillow
|
||||
pyaml
|
||||
numpy
|
||||
numpy
|
||||
requests
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate directional rat sprites (LEFT, RIGHT, UP, DOWN) from source LEFT PNGs.
|
||||
|
||||
Source files (tools/vernon/output/):
|
||||
BMP_MALE_LEFT.png
|
||||
BMP_FEMALE_LEFT.png
|
||||
BMP_BABY_LEFT.png
|
||||
|
||||
Output goes to assets/Rat/ as:
|
||||
BMP_<SEX>_LEFT.png — copy of source
|
||||
BMP_<SEX>_RIGHT.png — horizontal flip
|
||||
BMP_<SEX>_UP.png — rotate 270° (nose up)
|
||||
BMP_<SEX>_DOWN.png — rotate 90° (nose down)
|
||||
|
||||
Usage (from project root):
|
||||
python tools/generate_rat_sprites.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE_DIR = REPO_ROOT / "tools" / "vernon" / "output"
|
||||
OUTPUT_DIR = REPO_ROOT / "assets" / "Rat"
|
||||
|
||||
SEXES = ["MALE", "FEMALE", "BABY"]
|
||||
|
||||
|
||||
def generate(sex: str) -> None:
|
||||
src_path = SOURCE_DIR / f"BMP_{sex}_LEFT.png"
|
||||
if not src_path.exists():
|
||||
print(f" SKIP {sex}: source not found at {src_path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
src = Image.open(src_path).convert("RGBA")
|
||||
|
||||
variants = {
|
||||
"LEFT": src,
|
||||
"RIGHT": src.transpose(Image.FLIP_LEFT_RIGHT),
|
||||
"UP": src.rotate(270, expand=True),
|
||||
"DOWN": src.rotate(90, expand=True),
|
||||
}
|
||||
|
||||
for direction, img in variants.items():
|
||||
out_path = OUTPUT_DIR / f"BMP_{sex}_{direction}.png"
|
||||
img.save(out_path)
|
||||
print(f" wrote {out_path.relative_to(REPO_ROOT)} {img.size}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for sex in SEXES:
|
||||
print(f"[{sex}]")
|
||||
generate(sex)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate detailed 64×64 pixel art weapon sprites for mice game.
|
||||
|
||||
Logical grid: 16×16, each logical pixel = 4×4 real pixels → 64×64 output.
|
||||
|
||||
Sprites generated:
|
||||
BMP_BOMB0 … BMP_BOMB4 — bomb fuse animation (0=long fuse, 4=spark)
|
||||
BMP_GAS — toxic gas cloud (symmetric)
|
||||
BMP_GAS_LEFT/RIGHT/UP/DOWN — gas half-sprites (directional clip)
|
||||
BMP_EXPLOSION — central starburst
|
||||
BMP_EXPLOSION_LEFT/RIGHT/UP/DOWN
|
||||
BMP_NUCLEAR — mushroom cloud
|
||||
BMP_POISON — poison vial with skull
|
||||
|
||||
Usage (from project root):
|
||||
python tools/generate_weapon_sprites.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
import math
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
OUT = REPO / "assets" / "Rat"
|
||||
|
||||
SZ = 64 # canvas size (real pixels)
|
||||
LP = 4 # logical pixel size in real pixels
|
||||
LG = SZ // LP # 16 — logical grid dimension
|
||||
|
||||
# ─── Palette ───────────────────────────────────────────────────────────────────
|
||||
T = (0, 0, 0, 0)
|
||||
|
||||
# Bomb body
|
||||
BK = (10, 10, 20, 255) # outline
|
||||
BD = (35, 35, 60, 255) # dark body
|
||||
BM = (58, 58, 90, 255) # mid body
|
||||
BH = (88, 92, 130, 255) # highlight
|
||||
BG = (150, 155, 205, 255) # gloss spot
|
||||
|
||||
# Fuse rope
|
||||
FD = (70, 40, 10, 255) # dark strand
|
||||
FL = (120, 80, 28, 255) # light strand
|
||||
|
||||
# Sparks
|
||||
SK = (255, 225, 30, 255) # yellow
|
||||
SO = (255, 130, 0, 255) # orange
|
||||
EW = (255, 255, 210, 255) # spark white core
|
||||
|
||||
# Gas / toxic cloud
|
||||
GK = (15, 80, 8, 255) # dark-green outline
|
||||
GD = (30, 140, 18, 255) # dark green body
|
||||
GM = (55, 195, 40, 255) # mid green
|
||||
GL = (120, 235, 75, 255) # light green
|
||||
GH = (205, 255, 155, 255) # gloss highlight
|
||||
|
||||
# Explosion
|
||||
EK = (140, 10, 0, 255) # dark red core
|
||||
EM = (230, 70, 0, 255) # orange rays
|
||||
EL = (255, 200, 0, 255) # yellow outer
|
||||
|
||||
# Nuclear cloud
|
||||
NC = (120, 120, 120, 255) # cloud dark grey
|
||||
NL = (185, 185, 185, 255) # cloud mid grey
|
||||
NH = (245, 245, 245, 255) # cloud light / highlight
|
||||
NK = (155, 30, 0, 255) # stem dark red
|
||||
NM = (235, 110, 15, 255) # stem orange
|
||||
NY = (255, 215, 25, 255) # inner glow yellow
|
||||
|
||||
# Poison vial
|
||||
PD = (70, 0, 115, 255) # dark purple stopper
|
||||
PM = (125, 20, 170, 255) # mid purple cork
|
||||
PW = (235, 235, 235, 255) # white glass / label
|
||||
PBK = (5, 5, 10, 255) # skull black
|
||||
PG = (25, 165, 20, 255) # green liquid
|
||||
PGH = (85, 230, 55, 255) # green highlight
|
||||
|
||||
|
||||
# ─── Drawing helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _img():
|
||||
return Image.new("RGBA", (SZ, SZ), (0, 0, 0, 0))
|
||||
|
||||
|
||||
def _put(img, lx, ly, color):
|
||||
"""Paint one logical pixel (LP×LP block)."""
|
||||
if 0 <= lx < LG and 0 <= ly < LG:
|
||||
drw = ImageDraw.Draw(img)
|
||||
x0, y0 = lx * LP, ly * LP
|
||||
drw.rectangle([x0, y0, x0 + LP - 1, y0 + LP - 1], fill=color)
|
||||
|
||||
|
||||
def _circle(img, cx, cy, layers):
|
||||
"""
|
||||
Paint concentric circles. cx/cy in logical float coords.
|
||||
layers = [(outer_radius, color), ...] tested in order; first hit wins.
|
||||
"""
|
||||
drw = ImageDraw.Draw(img)
|
||||
for ly in range(LG):
|
||||
for lx in range(LG):
|
||||
d = math.sqrt((lx - cx) ** 2 + (ly - cy) ** 2)
|
||||
for r, color in layers:
|
||||
if d <= r:
|
||||
x0, y0 = lx * LP, ly * LP
|
||||
drw.rectangle([x0, y0, x0 + LP - 1, y0 + LP - 1], fill=color)
|
||||
break
|
||||
|
||||
|
||||
def _grid(img, rows, palette):
|
||||
"""
|
||||
Paint from a character-grid string list.
|
||||
rows: list of 16 strings, each with 16 non-space chars.
|
||||
'.' = skip (transparent). palette maps char → RGBA.
|
||||
"""
|
||||
for row_idx, row_str in enumerate(rows):
|
||||
chars = [c for c in row_str if c != ' ']
|
||||
for col_idx, ch in enumerate(chars):
|
||||
if ch == '.' or col_idx >= LG or row_idx >= LG:
|
||||
continue
|
||||
color = palette.get(ch)
|
||||
if color:
|
||||
_put(img, col_idx, row_idx, color)
|
||||
|
||||
|
||||
# ─── BOMB ──────────────────────────────────────────────────────────────────────
|
||||
# Fuse rope path (logical coords), body-attachment → spark tip
|
||||
_FUSE = [(9, 6), (9, 5), (10, 4), (11, 3), (11, 2), (12, 1)]
|
||||
|
||||
|
||||
def make_bomb(frame: int) -> Image.Image:
|
||||
"""frame 0 = long fuse / tiny spark; frame 4 = no fuse / huge spark."""
|
||||
img = _img()
|
||||
|
||||
# Body: nested circles centered at logical (7.5, 10.0)
|
||||
cx, cy = 7.5, 10.0
|
||||
_circle(img, cx, cy, [
|
||||
(5.4, BK), # outline ring
|
||||
(4.9, BD), # dark body edge
|
||||
(4.0, BM), # mid body fill
|
||||
])
|
||||
# Highlight blob (upper-left of body)
|
||||
_circle(img, 5.5, 7.8, [(2.3, BH), (1.2, BG)])
|
||||
|
||||
# Fuse rope — show only the remaining segments
|
||||
segs = max(1, len(_FUSE) - frame)
|
||||
for i in range(segs):
|
||||
fx, fy = _FUSE[i]
|
||||
_put(img, fx, fy, FL if i % 2 == 0 else FD)
|
||||
|
||||
# Spark at the fuse tip — grows with frame
|
||||
ti = min(segs - 1, len(_FUSE) - 1)
|
||||
tx, ty = _FUSE[ti]
|
||||
if frame == 0:
|
||||
_put(img, tx, ty, SK)
|
||||
elif frame == 1:
|
||||
_put(img, tx, ty, EW)
|
||||
_put(img, tx, ty - 1, SK)
|
||||
elif frame == 2:
|
||||
_put(img, tx, ty, EW)
|
||||
_put(img, tx + 1, ty, SK)
|
||||
_put(img, tx, ty - 1, SK)
|
||||
elif frame == 3:
|
||||
for dx, dy, c in [(-1, 0, SK), (1, 0, SK), (0, -1, SK), (0, 1, SO), (0, 0, EW)]:
|
||||
_put(img, tx + dx, ty + dy, c)
|
||||
else: # frame 4 — about to detonate
|
||||
for dx, dy, c in [
|
||||
(-2, 0, SO), (-1, 0, SK), (-1, -1, SK), (-1, 1, SO),
|
||||
(0, -2, SK), (0, -1, EW), (0, 0, EW), (0, 1, SK),
|
||||
(1, 0, SK), (1, -1, SK), (2, 0, SO), (0, -3, SO),
|
||||
]:
|
||||
_put(img, tx + dx, ty + dy, c)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ─── GAS ───────────────────────────────────────────────────────────────────────
|
||||
_GAS_ROWS = [
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
'. . . . . K K K K . . . . . . .',
|
||||
'. . . K K D D D D K K . . . . .',
|
||||
'. . K D D M M M M D D K . . . .',
|
||||
'. K D M M M L L M M D D K . . .',
|
||||
'. K D M L G L L G L M D K . . .',
|
||||
'K D M M L L L L L L M D D K . .',
|
||||
'K D M L L L L L L L L M D K . .',
|
||||
'K D M M L L L L L L M M D K . .',
|
||||
'K D D M M L L L L M M D D K . .',
|
||||
'. K D D M M M M M M D D K . . .',
|
||||
'. . K D D D M M D D D K . . . .',
|
||||
'. . . K K D D D D K K . . . . .',
|
||||
'. . . . . K K K K . . . . . . .',
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
]
|
||||
_GAS_PAL = {'K': GK, 'D': GD, 'M': GM, 'L': GL, 'G': GH}
|
||||
|
||||
|
||||
def make_gas(direction=None) -> Image.Image:
|
||||
img = _img()
|
||||
_grid(img, _GAS_ROWS, _GAS_PAL)
|
||||
# Directional clip: erase the half the gas does NOT flow toward
|
||||
drw = ImageDraw.Draw(img)
|
||||
clips = {
|
||||
'LEFT': (32, 0, 63, 63),
|
||||
'RIGHT': (0, 0, 31, 63),
|
||||
'UP': (0, 32, 63, 63),
|
||||
'DOWN': (0, 0, 63, 31),
|
||||
}
|
||||
if direction in clips:
|
||||
drw.rectangle(list(clips[direction]), fill=(0, 0, 0, 0))
|
||||
return img
|
||||
|
||||
|
||||
# ─── EXPLOSION ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_explosion(direction=None) -> Image.Image:
|
||||
img = _img()
|
||||
cx, cy = 7.5, 7.5
|
||||
|
||||
# 8-way diagonal rays (thin)
|
||||
for deg in range(0, 360, 45):
|
||||
rad = math.radians(deg)
|
||||
for step in range(1, 110):
|
||||
d = step * 0.07
|
||||
if d > 7.8:
|
||||
break
|
||||
lx = round(cx + d * math.cos(rad))
|
||||
ly = round(cy + d * math.sin(rad))
|
||||
if 0 <= lx < LG and 0 <= ly < LG:
|
||||
c = EM if d > 6 else (EL if d > 3.5 else EW)
|
||||
_put(img, lx, ly, c)
|
||||
|
||||
# 4-way cardinal rays (thicker — 3 pixels wide)
|
||||
for deg in [0, 90, 180, 270]:
|
||||
rad = math.radians(deg)
|
||||
perp = math.radians(deg + 90)
|
||||
for step in range(1, 120):
|
||||
d = step * 0.07
|
||||
if d > 7.8:
|
||||
break
|
||||
for spread in (-0.35, 0.0, 0.35):
|
||||
lx = round(cx + d * math.cos(rad) + spread * math.cos(perp))
|
||||
ly = round(cy + d * math.sin(rad) + spread * math.sin(perp))
|
||||
if 0 <= lx < LG and 0 <= ly < LG:
|
||||
c = EM if d > 5.5 else (EL if d > 3.0 else EW)
|
||||
_put(img, lx, ly, c)
|
||||
|
||||
# Hot core
|
||||
_circle(img, cx, cy, [(2.5, EK), (1.8, EM), (1.0, EL), (0.5, EW)])
|
||||
|
||||
# Directional clip
|
||||
drw = ImageDraw.Draw(img)
|
||||
clips = {
|
||||
'LEFT': (32, 0, 63, 63),
|
||||
'RIGHT': (0, 0, 31, 63),
|
||||
'UP': (0, 32, 63, 63),
|
||||
'DOWN': (0, 0, 63, 31),
|
||||
}
|
||||
if direction in clips:
|
||||
drw.rectangle(list(clips[direction]), fill=(0, 0, 0, 0))
|
||||
return img
|
||||
|
||||
|
||||
# ─── NUCLEAR (mushroom cloud) ───────────────────────────────────────────────────
|
||||
_NUCLEAR_ROWS = [
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
'. . . . N L L L L L L N . . . .',
|
||||
'. . . N L H H H H H H L N . . .',
|
||||
'. . N L H H N N H H N H L N . .',
|
||||
'. . N L H N N N H N N H L N . .',
|
||||
'. . N L N N N H H N N N L N . .',
|
||||
'. . N L N N N H H N N N L N . .',
|
||||
'. . . N L H H H H H H L N . . .',
|
||||
'. . . . N L L L L L L N . . . .',
|
||||
'. . . . . . M Y Y M . . . . . .',
|
||||
'. . . . . . M Y Y M . . . . . .',
|
||||
'. . . . . Z M Y Y M Z . . . . .',
|
||||
'. . . . Z Z M Y Y M Z Z . . . .',
|
||||
'. . . Z Z Z M Y Y M Z Z Z . . .',
|
||||
'. . . Z Z Z Z Z Z Z Z Z Z . . .',
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
]
|
||||
_NUCLEAR_PAL = {'N': NC, 'L': NL, 'H': NH, 'M': NM, 'Y': NY, 'Z': NK}
|
||||
|
||||
|
||||
def make_nuclear() -> Image.Image:
|
||||
img = _img()
|
||||
_grid(img, _NUCLEAR_ROWS, _NUCLEAR_PAL)
|
||||
return img
|
||||
|
||||
|
||||
# ─── POISON VIAL ───────────────────────────────────────────────────────────────
|
||||
_POISON_ROWS = [
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
'. . . . . . . D D D . . . . . .',
|
||||
'. . . . . . D M M M D . . . . .',
|
||||
'. . . . . . W W W W W W . . . .',
|
||||
'. . . . . W B B B B B B W . . .',
|
||||
'. . . . . W B W . . B B W . . .',
|
||||
'. . . . . W B . W W . B W . . .',
|
||||
'. . . . . W B G G G B B W . . .',
|
||||
'. . . . . W B G H G B B W . . .',
|
||||
'. . . . . W B G G G B B W . . .',
|
||||
'. . . . . W B B B B B B W . . .',
|
||||
'. . . . . W B B B B B B W . . .',
|
||||
'. . . . . . W W W W W W . . . .',
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
'. . . . . . . . . . . . . . . .',
|
||||
]
|
||||
_POISON_PAL = {
|
||||
'D': PD, # dark purple stopper
|
||||
'M': PM, # mid purple cork
|
||||
'W': PW, # white glass
|
||||
'B': PBK, # skull / label black
|
||||
'G': PG, # green liquid
|
||||
'H': PGH, # green highlight
|
||||
}
|
||||
|
||||
|
||||
def make_poison() -> Image.Image:
|
||||
img = _img()
|
||||
_grid(img, _POISON_ROWS, _POISON_PAL)
|
||||
return img
|
||||
|
||||
|
||||
# ─── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Bombs (5 animation frames)
|
||||
for frame in range(5):
|
||||
p = OUT / f"BMP_BOMB{frame}.png"
|
||||
make_bomb(frame).save(p)
|
||||
print(f" wrote {p.name}")
|
||||
|
||||
# Gas
|
||||
for direction in [None, 'LEFT', 'RIGHT', 'UP', 'DOWN']:
|
||||
suffix = f"_{direction}" if direction else ""
|
||||
p = OUT / f"BMP_GAS{suffix}.png"
|
||||
make_gas(direction).save(p)
|
||||
print(f" wrote {p.name}")
|
||||
|
||||
# Explosion
|
||||
for direction in [None, 'LEFT', 'RIGHT', 'UP', 'DOWN']:
|
||||
suffix = f"_{direction}" if direction else ""
|
||||
p = OUT / f"BMP_EXPLOSION{suffix}.png"
|
||||
make_explosion(direction).save(p)
|
||||
print(f" wrote {p.name}")
|
||||
|
||||
# Nuclear & Poison
|
||||
make_nuclear().save(OUT / "BMP_NUCLEAR.png")
|
||||
print(" wrote BMP_NUCLEAR.png")
|
||||
make_poison().save(OUT / "BMP_POISON.png")
|
||||
print(" wrote BMP_POISON.png")
|
||||
|
||||
total = 5 + 5 + 5 + 2
|
||||
print(f"\nDone — {total} sprites saved to {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CHROMAKEY = [128, 128, 128, 255]
|
||||
GREEN_LIGHT = [0, 255, 0, 255]
|
||||
GREEN_DARK = [0, 128, 0, 255]
|
||||
|
||||
|
||||
def is_green(pixel):
|
||||
return pixel == GREEN_LIGHT or pixel == GREEN_DARK
|
||||
|
||||
|
||||
def is_background(pixel):
|
||||
return pixel == CHROMAKEY or is_green(pixel)
|
||||
|
||||
|
||||
def cave_reference_path(sprite_name):
|
||||
direction = sprite_name.rsplit("_", 1)[-1]
|
||||
return Path("assets/Rat") / f"BMP_1_CAVE_{direction}.png"
|
||||
|
||||
|
||||
def load_cave_grid(sprite_name):
|
||||
cave_path = cave_reference_path(sprite_name)
|
||||
cave_image = Image.open(cave_path).convert("RGBA")
|
||||
width, height = cave_image.size
|
||||
pixel_access = cave_image.load()
|
||||
return [
|
||||
[list(pixel_access[x, y]) for x in range(width)]
|
||||
for y in range(height)
|
||||
]
|
||||
|
||||
|
||||
def enhance_pixels(name, pixels):
|
||||
cave_pixels = load_cave_grid(name)
|
||||
enhanced = []
|
||||
for y, row in enumerate(pixels):
|
||||
enhanced_row = []
|
||||
for x, pixel in enumerate(row):
|
||||
if is_background(pixel):
|
||||
enhanced_row.append(cave_pixels[y][x][:])
|
||||
else:
|
||||
enhanced_row.append(pixel[:])
|
||||
enhanced.append(enhanced_row)
|
||||
return enhanced
|
||||
|
||||
|
||||
def process_file(src_path, dst_path):
|
||||
data = json.loads(src_path.read_text())
|
||||
pixels = data["pixels"]
|
||||
data["pixels"] = enhance_pixels(src_path.stem, pixels)
|
||||
dst_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: enhance_stage1_explosions.py <src_dir> <dst_dir>")
|
||||
|
||||
src_dir = Path(sys.argv[1])
|
||||
dst_dir = Path(sys.argv[2])
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for src_path in sorted(src_dir.glob("BMP_1_EXPLOSION_*.json")):
|
||||
dst_path = dst_dir / src_path.name
|
||||
process_file(src_path, dst_path)
|
||||
print(f"Wrote {dst_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 336 B |
|
After Width: | Height: | Size: 354 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |