Files
aee-arcade/docs/entities-system.md
T

350 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sistema d'entitats
Registre del disseny, els treballs fets i els pendents per al sistema d'entitats (sarcòfags, porta, momia, antorxa, trampes, roca) d'aee_2026. Material de referència per a decisions futures — no és documentació d'API.
Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
## Convenció de col·lisió i anchors (fase 3)
- **`sprite.size`** (YAML entitat): mida en píxels del frame de render.
- **`tile_size`** (YAML entitat): footprint lògic en tiles. Només el
consumeix Sarcophagus per al perímetre; a la resta és declaratiu.
- **`collision: { size: [w, h], offset: [x, y] }`** (opcional al YAML
entitat): sub-rect de col·lisió dins del sprite. Si `offset` falta, es
calcula automàticament bottom-center (falsa perspectiva típica).
Sense bloc, `Entity::getCollisionRect()` = `pixel_rect_` sencer.
- **`anchor`** (opcional al YAML entitat): `top_left` (per defecte) o
`feet`. `feet` vol dir que el `position: [col, row]` del map és el
tile dels peus; el sprite es centra horitzontalment sobre el tile i
s'alinea la vora inferior del sprite amb la vora inferior del tile.
Els sarcòfags tenen bottom-align hardcoded i no usen `anchor`.
---
## 1. Context i objectiu inicial
aee_2026 és un clon tipus Oh Mummy! amb estètica Amidar. Els sarcòfags eren fins ara rectangles purament geomètrics calculats a [Map::getSarcRect](../source/game/map/map.cpp#L67); no hi havia entitats de joc (momies, antorxes, trampes, portes).
**Objectiu inicial** (prompt de partida):
- Classe base `Entity` amb posició tile+pixel, sprite/animació opcional, `update(dt)`, `render()`, flags, `onPlayerContact`.
- Jerarquia: `Decoration` · `Interactable` · `Hazard` (→ `StaticHazard`, `DynamicHazard`).
- 7 classes concretes: `Torch`, `Sarcophagus`, `Door`, `Mummy`, `RollingRock`, `WallTrap`, `FloorTrap`.
- `Animation` com a **component** reutilitzable (composició, no herència).
- `EntityFactory`/`EntityLoader` amb registre per nom — afegir una entitat no ha de tocar el factory.
- Sarcòfag amb perímetre en tiles i estat CLOSED/OPENING/OPEN; emet `sarcophagus_opened`.
- Porta amb estat CLOSED/OPENING/OPEN; s'obri quan tots els sarcs oberts; contacte → `level_complete`.
- Momia amb IA `corridor_wanderer` (hook virtual).
- `EntityManager` + event bus minimalista (pub/sub).
- Placeholders gràfics (GIF paletitzats) + YAMLs de plantilla per cada entitat.
- Smoke tests stand-alone (sense frameworks externs).
---
## 2. Fase 1 — Andamiatge pur (DONE)
Codi, dades i tests aïllats del flux viu del joc: el sistema existeix i funciona, però `GameScene` encara no l'instancia.
### Fitxers creats
| Path | Paper |
|------|-------|
| [source/game/entities/entity.hpp](../source/game/entities/entity.hpp) / [.cpp](../source/game/entities/entity.cpp) | Classe base; `update`, `render`, `onPlayerContact(Player*)`, `notifyTileEntered` |
| [source/game/entities/decoration.hpp](../source/game/entities/decoration.hpp) | Header-only; `collides=false`, `interactable=false` |
| [source/game/entities/interactable.hpp](../source/game/entities/interactable.hpp) | Header-only; afegeix `interact(Player*)` |
| [source/game/entities/hazard.hpp](../source/game/entities/hazard.hpp) | `Hazard` + `StaticHazard` (cicle actiu/inactiu) + `DynamicHazard` (velocitat + `step`) |
| [source/game/entities/animation_component.hpp](../source/game/entities/animation_component.hpp) / [.cpp](../source/game/entities/animation_component.cpp) | Frames/fps/loop; `tick`, `currentFrame`. Completada d'inici si 1-frame no-loop |
| [source/game/entities/event_bus.hpp](../source/game/entities/event_bus.hpp) / [.cpp](../source/game/entities/event_bus.cpp) | Pub/sub amb `std::variant` payload |
| [source/game/entities/entity_factory.hpp](../source/game/entities/entity_factory.hpp) / [.cpp](../source/game/entities/entity_factory.cpp) | Macro `REGISTER_ENTITY` + registre estàtic per nom |
| [source/game/entities/entity_loader.hpp](../source/game/entities/entity_loader.hpp) / [.cpp](../source/game/entities/entity_loader.cpp) | Cache de plantilles YAML + `readSpawns` |
| [source/game/entities/entity_manager.hpp](../source/game/entities/entity_manager.hpp) / [.cpp](../source/game/entities/entity_manager.cpp) | Contenidor + comptador de sarcs + fan-out de tile |
| [source/game/entities/concrete/torch.*](../source/game/entities/concrete/torch.hpp) | Decoració animada |
| [source/game/entities/concrete/sarcophagus.*](../source/game/entities/concrete/sarcophagus.hpp) | Perímetre tiles ortogonal + estats |
| [source/game/entities/concrete/door.*](../source/game/entities/concrete/door.hpp) | Estats + `open()` + emissió level_complete |
| [source/game/entities/concrete/mummy.*](../source/game/entities/concrete/mummy.hpp) | `chooseDirection` virtual (aleatori per defecte) |
| [source/game/entities/concrete/rolling_rock.*](../source/game/entities/concrete/rolling_rock.hpp) | Línia recta, direcció inicial del YAML |
| [source/game/entities/concrete/wall_trap.*](../source/game/entities/concrete/wall_trap.hpp) | `StaticHazard` amb cicle |
| [source/game/entities/concrete/floor_trap.*](../source/game/entities/concrete/floor_trap.hpp) | Igual que wall_trap, semàntica distinta |
| [tools/entities/generate_placeholders.py](../tools/entities/generate_placeholders.py) | Genera 7 GIFs paletitzats amb Pillow |
| [data/entities/\<name\>/\<name\>.yaml](../data/entities/) + `.gif` | 7 plantilles d'entitat + 7 placeholders |
| [data/maps/test_entities.yaml](../data/maps/test_entities.yaml) | Nivell mínim per al smoke |
| [tests/test_entities_smoke.cpp](../tests/test_entities_smoke.cpp) | Executable stand-alone (T1–T8, ampliat a T9 a fase 2) |
### Mides i colors dels placeholders
| Entitat | Mida px | Color |
|---|---|---|
| torch | 16×16 | taronja |
| door | 16×16 | marró fosc |
| mummy | 12×24 | beige |
| sarcophagus | **48×16** (3×1 tiles) | daurat |
| rolling_rock | 16×16 | gris |
| wall_trap | 16×16 | vermell fosc |
| floor_trap | 16×16 | vermell clar |
### Decisions clau
- **`onPlayerContact(Player*)` amb punter nullable** en comptes de referència: permet smoke tests sense un Player real, i obre la porta a triggers d'àrea sense jugador específic.
- **`Sarcophagus` ha rebut dues variants de ctor**: amb i sense ancoratge. La sense ancoratge la crea el factory des d'una plantilla YAML; `place(col, row, predicate)` s'invoca després quan `EntityManager` coneix la posició.
- **Registre estàtic** via macro `REGISTER_ENTITY` — perquè funcioni, tots els `concrete/*.cpp` entren directament a `APP_SOURCES` (no a una STATIC lib sense `--whole-archive`).
- **Animació d'1 frame no-loop** marcada com a `completed()` des de la construcció: permet que sarcs i portes amb placeholder transitin OPENING → OPEN al següent `update`.
### Smoke tests fase 1
Target CMake `test_entities_smoke` al final de [CMakeLists.txt](../CMakeLists.txt). Cobria T1–T8:
- T1 factory: crea tots els 7 tipus per nom.
- T2 loader: llegeix spawns de `data/maps/test_entities.yaml`.
- T3 event bus: subscribe/publish/unsubscribe amb payloads variats.
- T4 perímetre de sarc: 3×1 tiles, 8 perimetrals, notificació → OPENING.
- T5 end-to-end: 2 sarcs + 1 door, obrir sarcs → door OPEN → contacte → level_complete.
- T6 animation: frames multi + loop + completed.
- T7 plantilles: totes les 7 es carreguen sense excepció.
- T8 decoration flags.
Tots passats al tancament de la fase 1. El binari del joc, sense canvis, continuava arrencant net sota `xvfb-run`.
---
## 3. Fase 2 — Integració al GameScene (DONE)
Cableja l'andamiatge al flux normal del joc. En arrencar amb `pyramid.yaml`, les entitats es carreguen, tenen sprite visible i poden reaccionar als moviments del Player.
### Diagnòstic clau
La primera proposta va suggerir refactor del sarcòfag a "4 costats en píxels" perquè l'origen del canvas (40, 64) no era múltiple de 16. **Diagnòstic erroni**: el tilemap està en grid 16×16 **relatiu al seu propi origen** (`origin: [-8, 0]` a `pyramid.yaml`), no al canvas. La conversió correcta és:
```
col = floor((player_center_x - tilemap.origin_x) / tile_size)
row = floor((player_center_y - tilemap.origin_y) / tile_size)
```
Amb això, el sarc (0,0) amb canvas rect (40, 64, 48, 16) → tile anchor (3, 4), span (3, 1). El model de tiles es manté intacte.
### Canvis aplicats
- **`Map` globals noves** ([map.hpp:36-40](../source/game/map/map.hpp#L36-L40)): `tile_size`, `tilemap_origin_x`, `tilemap_origin_y`. Poblats a `loadFrom` des de `yaml["tile_size"]` i `yaml["origin"]`.
- **`EventBus` payload** ampliat amb `struct TilePos { int col; int row; }` + overload `publish(name, TilePos)`.
- **`Entity`** amb `std::shared_ptr<Surface>`, `setSurface`/`getSurface`/`setPixelRect`. Per evitar arrossegar `Surface` al smoke, `Entity::render()` és no-op i el blit real el fa `EntityManager::renderAll`, centralitzat.
- **`Player`** amb `setEventBus(EventBus*)` + `getCurrentTile()` (fórmula correcta amb `tilemap_origin_*` + `tile_size`). Al final d'`update`, si el tile canvia, publica `on_tile_entered` amb payload `TilePos`.
- **`EntityManager::loadFromLevel`** (viu a [entity_manager_loadlevel.cpp](../source/game/entities/entity_manager_loadlevel.cpp), TU separat per aïllar la dependència a `Resource::Cache`):
- `sarcophagus` → `sarc_position` → `Map::getSarcRect` → tile anchor per al perímetre.
- resta → `position` (tile coords del tilemap) → píxels via `col*tile_size + origin`.
- **`EntityManager::renderAll`** (al mateix TU). Blit directe de `Surface::render` a `pixel_rect_`.
- **`EntityManager` subscriu `on_tile_entered`** al ctor: quan rep `TilePos`, fa fan-out via `notifyPlayerEnteredTile(col, row)` a totes les entitats.
- **`GameScene`** ([game_scene.cpp](../source/game/scenes/game_scene.cpp)):
- Membre `EntityManager entity_manager_`.
- Ctor: `loadFromLevel(config_.map_yaml)` després de carregar mapa; subscripcions `level_complete` i `player_hit` (log a `std::cout`); `players_.back()->setEventBus(&entity_manager_.getBus())`.
- `update`: `entity_manager_.update(DT)` després dels Players (així els sarcs reben els tiles del mateix frame).
- `render`: `entity_manager_.renderAll()` entre `tilemap_.render()` i els Players.
- **`data/maps/pyramid.yaml`** amb 5 spawns reals: torch (2,4), sarcs (0,0) i (3,4), door (10,14), mummy (10,7).
### Partició per aïllar dependències
El smoke test és stand-alone (no `Resource::Cache`, no `Surface`, no audio). Per no arrossegar tot el pipeline al smoke, `EntityManager` es divideix:
- [entity_manager.cpp](../source/game/entities/entity_manager.cpp): `ctor`/`dtor`/`add`/`setDoor`/`addSarcophagus`/`update`/`notifyPlayerEnteredTile` — **linkat al smoke**.
- [entity_manager_loadlevel.cpp](../source/game/entities/entity_manager_loadlevel.cpp): `loadFromLevel` + `renderAll` — **NOMÉS al binari principal**.
Al CMakeLists: `ENTITY_SOURCES` conté els fitxers core; `APP_SOURCES` afegeix `entity_manager_loadlevel.cpp` explícitament fora de `ENTITY_SOURCES`.
### T9 afegit al smoke
`testLoadFromLevel` — Map::loadFrom(pyramid), verifica `tile_size=16`, `origin=(-8, 0)`; EntityLoader::readSpawns retorna 5 spawns amb els tipus esperats; la conversió sarc-grid (0,0) → tile anchor (3,4) és correcta; fan-out via bus: visitar els 8 tiles perimetrals d'un sarc a (3,4) mida 3×1 fa passar l'estat a OPENING.
### Verificació de tancament
- `make build`: OK, 0 warnings nous.
- `./build/test_entities_smoke`: **9/9 OK**.
- `ctest --output-on-failure`: 1/1 suite passed.
- `timeout 5 xvfb-run ./build/aee_2026`: 5s sense crash, stdout inclou:
```
Surface : torch.gif ... LOADED
Surface : sarcophagus.gif ... LOADED (×2)
Surface : door.gif ... LOADED
Surface : mummy.gif ... LOADED
EntityManager: 5 entitats carregades des de data/maps/pyramid.yaml
```
---
## 4. Memòria durable registrada
Durant la fase 1 l'usuari va establir una regla permanent desada a [memory/feedback_smoke_test_mandatory.md](../../../.claude/projects/-home-sergio-gitea-aee-2026/memory/feedback_smoke_test_mandatory.md) (fora del repo):
> Sempre que s'afegeixi o modifiqui lògica no trivial, s'ha d'estendre un smoke existent o crear-ne un de nou que exerciti aquella lògica, i executar-lo abans de reportar la tasca com a acabada. Al missatge final cal indicar explícitament si els smoke tests passen.
Es respecta tant a la fase 1 com a la 2.
---
## 5. Pendent / DEFERRED
### Gameplay
| ID | Descripció | Estat |
|---|---|---|
| ENT-IA-01 | **Moviment viu de la momia**: `Mummy::step` camina en línia recta validant `Map::collides` + `Map::insidePlayfield` a cada frame. En topar, recull perpendiculars lliures (evitant U-turn llevat de cul-de-sac) i crida `chooseDirection`. Cobert per T13. | DONE |
| ENT-IA-02 | **IA de la momia amb bias cap al jugador** (weighted_towards_player). Hook `chooseDirection` deixat preparat; només cal una subclasse/override. | DEFERRED |
| ENT-HAZ-01 | **Trampes actives amb dany real al jugador**. `onPlayerContact` publica `player_hit` via `EntityManager::checkPlayerCollisions`, però encara no hi ha sistema de vides. Caldrà cablejar vides al Player. | DEFERRED |
| ENT-ROCK-01 | **Col·lisió de la roca amb sarcs/murs**. `RollingRock::step` valida `Map::collides`/`insidePlayfield` i s'atura quan bloqueja (setDirection 0,0). No rebota per disseny. | DONE |
| ENT-DOOR-01 | **Retorn a la pantalla de títol al `level_complete`**. Ara només log a stdout. Pendent cablejar la transició de Scene. | DEFERRED |
### Render i geometria
| ID | Descripció | Estat |
|---|---|---|
| REND-01 | **Z-order fi** entre entitats i Player: `GameScene::render` construeix una llista {Entity*, Player*, feetY}, la ordena amb `stable_sort` ascendent i dibuixa per ordre. Els sprites amb peus més amunt queden darrere dels que tenen peus més avall. | DONE |
| REND-02 | **Animacions multi-frame**. `renderAll` clipa el frame actual via `src_rect` calculat amb `Entity::getAnimation()->currentFrame()` sobre una graella left-to-right / top-to-bottom; columnes deduïdes de `surface->getWidth() / pixel_rect.w`. Así un strip 1×N, N×1 o N×M funciona igual. Torch primer consumidor real (4 frames @ 8 fps). | DONE |
| REND-03 | **Paleta compartida** amb el tilemap. Cada GIF placeholder té la seua paleta pròpia (Pillow); funciona perquè `Surface` desa paleta per surface. Amb l'art real pot caldre unificar per evitar mismatch cromàtic. | DEFERRED |
### Perímetre i col·lisió
| ID | Descripció | Estat |
|---|---|---|
| PER-01 | **`blocked_predicate` al `Sarcophagus`** per descartar tiles ocupats per altres sarcs. Actualment `loadFromLevel` passa `{}`. Els spawns de demo eviten sarcs veïns; al primer disseny amb 4×5 sarcs adjacents caldrà connectar-ho (amb `Map::collides` traduït a tiles). | DEFERRED |
| PER-02 | **Perímetre diagonal opcional**. Ara el perímetre és ortogonal (N/S/E/O). Si volem variants amb cantonades exigides, es pot afegir una flag al YAML de l'entitat. | WONTFIX (per ara — el comportament Oh Mummy! clàssic és ortogonal) |
### Infraestructura
| ID | Descripció | Estat |
|---|---|---|
| INF-01 | **Dead-code elimination del registre estàtic**. Funciona mentre `concrete/*.cpp` viuen a `APP_SOURCES`. Si algun dia es modularitza a STATIC libs caldrà `--whole-archive` o un `entity_registry_bootstrap.cpp` amb cridades explícites. | WONTFIX (vigilar) |
| INF-02 | **Multiplayer**: el bus del nivell és únic. Quan hi hagi múltiples Players, cal decidir si el `on_tile_entered` porta també el player_id. Ampliació trivial de `TilePos` o struct nou. | DEFERRED |
| INF-03 | **Integrar animacions multi-frame al YAML**: els camps `frames: [0]` actuals són placeholders. Quan arribi l'art, el loader ja és tolerant — només cal omplir els vectors. | WONTFIX (ja previst al codi) |
| INF-04 | **Hook `on_tile_entered` emet cada frame que el tile canvia**, no cada frame que el jugador es mou. Si algun dia es vol granularitat sub-tile (p.ex. "passar pel centre exacte"), caldrà un event separat. | WONTFIX |
### Tests
| ID | Descripció | Estat |
|---|---|---|
| TST-01 | **Test d'integració amb `loadFromLevel` real**: el smoke actual evita el Cache per no arrossegar dependències. Un test separat amb el Cache enllaçat podria verificar el blit, però demana instanciar Screen/Canvas. | WONTFIX (l'smoke d'arrencada sota `xvfb-run` ja cobreix el camí viu) |
| TST-02 | **Test de moviment de la momia**: T13 exercita step lliure (avança) i step contra paret (gira sense avançar). | DONE |
---
## 6. Fase 4 — Moviment i interacció (DONE)
Tanca els hooks d'IA i col·lisió que quedaven apuntalats a les fases 1–3.
- **`Mummy::step`** implementat: desplaçament recte validant playfield i
sarcs; gir a cul-de-sac o intersecció via `chooseDirection`.
- **`RollingRock::step`** igual, però s'atura en lloc de girar.
- **`Entity::getCollisionRect()` + `getFeetY()`**: caixa AABB encastada al
sprite (falsa perspectiva, opcional via bloc `collision:` del YAML).
- **Anchor `feet`** al loader: `position: [col, row]` és el tile dels peus;
el sprite es centra en X i bottom-aligned en Y.
- **Sarcòfag amb caixa lògica**: `placeSarcophagus` aplica un `CollisionBox`
que retalla la meitat superior del sprite (alçada) perquè no dispari
falsos contactes.
- **`Map::insidePlayfield(box)`**: valida la caixa candidata dels `step`.
- **`EntityManager::checkPlayerCollisions(players)`**: detecta solapament
base-box del player amb `getCollisionRect` de cada entitat amb
`collides()=true`, i crida `onPlayerContact`. Cablejat al `GameScene::update`.
- **Z-order**: `GameScene::render` ordena {entitats, players} per `getFeetY()`
ascendent i dibuixa en aquell ordre (interleaved).
- **`Player::getBaseBox()`** exposat com a API pública.
- **Smoke tests**: T12 (CollisionBox), T13 (Mummy::step), T11 (sarc YAML),
T10 (torch anim). Total 14/14.
## 7. Fase 5 — Convenció de caixa "peus" com el jugador + animacions direccionals (DONE)
Alinea la momia amb la convenció de col·lisió i animació del Player, i soluciona regressions de navegació derivades del canvi de caixa.
### Caixa concèntrica amb el rectangle-base
- **`collision.base_size: [bw, bh]`** (nou camp YAML, opcional). Si apareix, la caja no s'alinea a la vora inferior del sprite sinó que queda **concèntrica amb el rectangle-base `bw×bh` dels peus** (els `bh` píxels inferiors del sprite). Fórmula: `off_y = sprite_h − (base_h + H) / 2`. Sense `base_size`, comportament antic (alineada a la vora inferior del sprite).
- **`anchor: feet` ara alinea la caja, no el sprite**. Si l'entitat té `CollisionBox`, el loader col·loca `pixel_rect_` perquè la caja quedi exactament al tile. Sense caja, fallback a la convenció antiga (sprite centrat horitzontalment + vora inferior al tile). Per la momia: sprite 12×24 a (tile_x+2, tile_y−10); caja 16×16 a (tile_x, tile_y) — omple el tile sencer com fa el jugador.
- **`mummy.yaml`**: `collision: { size: [16, 16], base_size: [12, 12] }`. Abans era `[12, 12]` alineada a baix; visualment quedava "enganxada al mur inferior" dins dels corredors horitzontals.
### Snap-to-edge per girar al playfield
La caja 16×16 té exactament l'amplada del corredor (16 px). Amb el moviment contínu `speed × delta_time` (~0.66 px/frame), la momia s'aturava a una posició subpíxel dins de l'última cel·la (p. ex. `x=279.87` en lloc de `x=280`) i qualsevol gir perpendicular col·lidia amb el sarc adjacent al limit. [`Mummy::snapToPlayfieldEdge`](../source/game/entities/concrete/mummy.cpp) redueix el `dx/dy` fins a la vora exacta quan el candidat sortiria del playfield; el gir posterior ja parteix d'una posició alineada amb el lane.
### Animacions direccionals
- **`mummy.gif` a 12 frames** (graella 12×1) amb el mateix esquema que el jugador: 0–3 walk_down, 4–7 walk_up, 8–11 walk lateral. Sense variants `stand_*` (mai no para).
- **`Mummy` té tres `AnimationComponent`** privats + un helper `changeDirection(dx, dy)` (únic punt d'entrada per canviar direcció) que selecciona l'animació i aplica el flip: `dx ≠ 0` → lateral amb flip horitzontal segons signe; `dy < 0` → up; `dy > 0` → down.
- **`Entity::flip_` a la base** + `getFlip()`. `renderEntity` el passa a `Surface::render`, que ja suportava `SDL_FlipMode`. Les entitats estàtiques queden a `SDL_FLIP_NONE` automàticament.
### Pipeline de build: sincronització de `data/`
Canvi a [CMakeLists.txt](../CMakeLists.txt): la còpia de `data/` al build era un `add_custom_command(POST_BUILD)`, que només es dispara quan el binari es relinkeja. Si s'editava només un YAML o un GIF sense tocar codi, `build/data/` quedava obsolet. Ara és un `add_custom_target(sync_data ALL ...)` amb dependència del target principal — els custom targets sense BYPRODUCTS es consideren sempre out-of-date, així que la còpia s'executa a cada `cmake --build`.
### Refactors de suport
- `Mummy::step` partit: `snapToPlayfieldEdge`, `collectDirectionPools`, `applyChoice` — per mantenir la complexitat cognitiva sota el llindar de clang-tidy (< 25).
## 8. Fase 6 — Pintat de tiles (DONE)
Introdueix la mecànica central: el jugador deixa rastre al terra en trepitjar-lo, i els sarcòfags compten tiles **pintats** (no tiles *trepitjats*). Font única de veritat del pintat: una nova capa `PaintLayer` escolta el moviment del Player, filtra pintabilitat i re-publica `tile_painted`, que és el que ara observen els sarcs.
### Cadena d'events resultant
```
Player canvi de tile
→ bus: on_tile_entered { col, row, paint_tile_index }
→ PaintLayer: filtra Map::isPaintable + "primer pinta guanya"
→ buffer ARGB + bus: tile_painted { col, row }
→ EntityManager fan-out → Sarcophagus::notifyTilePainted
→ perímetre complet → OPENING → OPEN → sarcophagus_opened
→ tots oberts → Door::open
```
Dos events encadenats perquè el *pintat* no és la *presència*: es filtra per pintabilitat i per "ja pintat", i el re-publish garanteix que els sarcs no vegin falsos positius (p. ex. el jugador lliscant contra un mur, o un tile ja marcat per l'altre jugador).
### Canvis per fitxer
| Path | Canvi |
|---|---|
| [event_bus.hpp](../source/game/entities/event_bus.hpp) | Nou `struct PaintedTile { col, row, tile_index }` al `Payload` variant + overload `publish`. |
| [map.hpp](../source/game/map/map.hpp) / [.cpp](../source/game/map/map.cpp) | `Map::isPaintable(col, row)` → `true` si el tile cau dins del playfield i no s'encavalca cap sarc. |
| [map/paint_layer.hpp](../source/game/map/paint_layer.hpp) / [.cpp](../source/game/map/paint_layer.cpp) | Classe nova. Subscriu `on_tile_entered`, marca `painted_[row][col]`, blita el tile al buffer ARGB propi, publica `tile_painted`. `render()` anàleg a `Tilemap::render`. |
| [player.hpp](../source/game/entities/player.hpp) / [.cpp](../source/game/entities/player.cpp) | Camp `paint_tile_index_` al ctor (default -1). El Player publica `PaintedTile` amb el seu índex en lloc de `TilePos`. |
| [entity.hpp](../source/game/entities/entity.hpp) / [.cpp](../source/game/entities/entity.cpp) | Rename `notifyTileEntered` → `notifyTilePainted`. |
| [concrete/sarcophagus.hpp](../source/game/entities/concrete/sarcophagus.hpp) / [.cpp](../source/game/entities/concrete/sarcophagus.cpp) | Mateix rename. La lògica del `visited_` és idèntica — només canvia qui li truca el hook. |
| [entity_manager.hpp](../source/game/entities/entity_manager.hpp) / [.cpp](../source/game/entities/entity_manager.cpp) | La subscripció canvia d'`on_tile_entered` → `tile_painted`. Fan-out renombrat a `notifyPlayerTilePainted`. |
| [game_scene.hpp](../source/game/scenes/game_scene.hpp) / [.cpp](../source/game/scenes/game_scene.cpp) | Membre `std::unique_ptr<PaintLayer> paint_layer_` (declarat després d'`entity_manager_` per garantir ordre invers de destrucció). Helper local `readOptionalInt` llegeix `player1_paint_tile` del YAML del mapa i el passa al Player. `render()` crida `paint_layer_->render()` entre `tilemap_.render()` i els actors; s'amaga al mode editor. |
| [data/maps/pyramid.yaml](../data/maps/pyramid.yaml) | Camps nous `player1_paint_tile: 2` i `player2_paint_tile: 2` (índex provisional, canviable sense recompilar). |
| [tests/test_entities_smoke.cpp](../tests/test_entities_smoke.cpp) | T9 adaptat: publica `tile_painted` directament al bus per aïllar el fan-out d'EntityManager de la resta de la cadena. |
### Decisions de disseny
- **Centralitzat vs per-sarc**: l'estat "pintat" viu al `PaintLayer`, no dins cada sarc. Els sarcs només apunten els tiles del seu perímetre que han estat notificats pintats. Aquesta separació deixa preparat per a items-en-sarc (obrir quan `N` items trobats, no quan tots oberts) i per a altres productors de pintat futurs (enemics, trampes).
- **"Primer pinta guanya"**: `PaintLayer::tryPaint` retorna false si el tile ja està pintat. No se sobreescriu amb l'índex d'un altre jugador. Reduïm fonts de decisió inconsistent; el rastre visual és estable.
- **Paint tile per jugador**: l'índex l'aporta el YAML del mapa (`player1_paint_tile`, `player2_paint_tile`), no el YAML del jugador. Raó: depèn del tileset del mapa — cada nivell pot oferir rastres diferents; canviar de mapa canvia l'índex vàlid.
- **Render al mode editor**: desactivat. L'editor edita el tilemap base; la capa de pintat és d'estat de joc, no de dades del nivell. Quan `editor_.active()`, `SKIP_ACTORS=true` i el `paint_layer_->render()` se salta amb la resta d'actors.
- **Resolució**: 16×16 reutilitzant `Map::tile_size` i l'origen del tilemap. El `PaintLayer` emmagatzema `tile_size_` com a camp propi per facilitar una eventual migració a 8×8 (graella 2× més fina) sense tocar la interfície.
- **Persistència**: runtime only. No hi ha camp `painted:` al YAML. Entrar al `GameScene` reseteja el pintat.
- **No cal `is_blocked` al `sarc->place(...)`** per a aquest nivell: l'`outer_corridor` del `pyramid.yaml` és exactament 1 tile, cosa que garanteix que el perímetre de qualsevol sarc (fins i tot els de cantonades) cau íntegrament dins del playfield i és pintable. Quedaria rellevant si el disseny futur posa sarcs sense passadís al voltant — cobert per PER-01.
### Verificació
- `make debug` net, sense warnings ni de clang-tidy ni de cppcheck als fitxers nous.
- `./build/test_entities_smoke`: **ALL OK** (14/14). T9 adaptat (publica `tile_painted` en lloc d'`on_tile_entered`); la resta de tests del sistema d'entitats passen sense tocar.
- `timeout 5 xvfb-run ./build/aee_2026`: arrenca, log `PaintLayer: 21x15 tiles (336x240 px), tileset data/tilesets/tomb.gif` després de `EntityManager: 27 entitats carregades`, surt amb `Bye!` sense crash.
### Pendent derivat
| ID | Descripció | Estat |
|---|---|---|
| PAINT-01 | **Tile de pintat definitiu** quan l'art estigui madur. Ara `2` provisional al `pyramid.yaml`. Canviar l'índex no demana recompilar. | DEFERRED |
| PAINT-02 | **Resolució 8×8** si es vol més granularitat. El `PaintLayer` porta el seu propi `tile_size_`; cal propagar-lo al ctor per a no lligar-lo a `Tilemap::getTileSize()`. També cal decidir com es mapen els perímetres dels sarcs a sub-caselles. | DEFERRED |
| PAINT-03 | **Smoke unit del `PaintLayer`**: "primer pinta guanya", filtre d'`isPaintable`, publicació de `tile_painted`. Ara la cadena està coberta end-to-end via T9 (fan-out del bus) i via l'smoke d'arrencada, però el filtre i la deduplicació no tenen test directe. | DEFERRED |
| PAINT-04 | **Player 2 cablejat al `GameScene`**: la lectura del YAML i el ctor ja suporten un segon jugador amb `paint_tile_index_` propi; només cal instanciar-lo amb Controls distints (p. ex. WASD) i afegir-lo al vector. | DEFERRED |
### Memòria durable afectada
INF-02 queda parcialment cobert: el `PaintedTile` ja porta `tile_index` (per jugador), que és una forma d'identificar qui pinta sense haver de propagar un `player_id` explícit. Si en el futur cal saber *qui* (p. ex. per puntuació individual), caldrà afegir el camp; el canvi és local al struct.
---
## 9. Estat global
- **Andamiatge, integració, render animat, falsa perspectiva, moviment, animació direccional de momia, pintat de tiles**: DONE. El `pyramid.yaml` actual (4 torches, 20 sarcs, 1 door, 2 mummies) es carrega, anima, col·loca amb l'anchor correcte, col·lisiona segons caixa lògica, el z-order interleava per peus, la momia gira visualment a cada intersecció, i el jugador deixa rastre al terra.
- **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns), PAINT-03 (test unit del PaintLayer), PAINT-04 (Player 2 cablejat).
- **Smoke tests**: 14/14 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a totes les fases.
El document és viu: cada tasca nova hauria d'afegir-s'hi (entrada amb ID, descripció, estat) al fer-la o ajornar-la.