Compare commits

...
19 Commits
Author SHA1 Message Date
JailDesignerandClaude Opus 4.7 eb07e7e564 feat(fonts): deinterlace GIF i font ari-w9500 per placeholders
Fix: gif.cpp no desfeia l'interlace, trinxava qualsevol GIF entrellaçat
(tota la sortida de Pillow). Afegeix Defaults::UI::Font per centralitzar
noms/rutes i mou data/font → data/fonts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:45:06 +02:00
JailDesigner 03f1c5f926 fix(makefile): alinear targets amb ../aee i ../projecte_2026
Abans:
  make / make build -> Debug
  make release      -> Release (sense empaquetar)

Ara, mateixa convenció que els dos projectes germans:
  make              -> Release (binari d'ús normal)
  make debug        -> Debug (flux amb debug.yaml)
  make release      -> Release + empaquetat distribució (empaquetat TODO)

Afegit make run / make run-debug per conveniència i make help.
docs/convencions.md actualitzat amb la nova taula target -> comportament.
2026-04-22 11:48:14 +02:00
JailDesigner 6800786194 fix(resources): resoldre rutes de data/ respecte a SDL_GetBasePath
Abans el binari només arrencava si la CWD era la que contenia data/
(típicament build/). Executat des de qualsevol altra carpeta no trobava
keys.yaml, paletes, etc. i es moria al carregar el primer recurs.

Patró seguit de ../aee: Resource::Helper guarda un data root absolut
i resolve(rel) el prefixa davant de rutes no absolutes. Director fa
setDataRoot(SDL_GetBasePath()) com a primera acció del ctor, així tots
els loaders posteriors hi passen sense canvis al call site.

Migrats a Resource::Helper::resolve els loaders que obrien std::ifstream
directament amb el path rebut: Locale, KeyConfig, Player, Map, Tilemap
i EntityLoader. Els que ja usaven Resource::Helper::loadFile
(Resource::Cache, PaletteLoader) hereten el fix automàticament.

Els fitxers de system_folder_ (config.yaml, debug.yaml, postfx.yaml,
crtpi.yaml) es passen com a rutes absolutes i resolve() les deixa
intactes, així no es veuen afectats.

Test targets (test_entities_smoke, test_scene_flow) enllacen ara
resource_helper.cpp perquè el seu codi passa per Helper::resolve. Els
tests corren amb CWD = repo root, on les rutes "data/..." funcionen
amb data root buit sense canviar cap assert.

Verificat executant el binari des de build/, /tmp i / — tots tres
troben els recursos i arrenquen correctament, tant en debug com en
release (release sempre arranca a LOGO).
2026-04-22 11:29:51 +02:00
JailDesigner 9c9b6028e7 docs: convencions de commit i debug.yaml
Documenta dues peces que no estaven enlloc:

- Missatges de commit: valencià, frases curtes, sense firma automàtica
  (cap Co-Authored-By ni signatura de Claude Code).
- debug.yaml: ruta dins la carpeta de sistema, format, valors vàlids,
  i la diferència de comportament entre build release (sempre LOGO,
  ignora el fitxer) i build debug (llig el fitxer i el crea amb GAME
  si no existeix).

Entrada nova a l'índex de docs/README.md.
2026-04-22 10:35:19 +02:00
JailDesigner 2cb21211ef test(scenes): smoke del flux d'escenes i parser de debug.yaml
Cobreix les dues peces pures del flux:
- parseStartScene / resolveStartScene: tots els valors vàlids, fallback
  a GAME per valor invàlid, YAML corrupte o fitxer inexistent.
- createDefaultIfMissing: crea el fitxer amb start_scene=GAME si no
  existeix i és idempotent quan ja hi és.
- isReservedScancode: F1/F12/Escape reservats, SPACE/fletxes no, i
  UNKNOWN sempre fals.

La lògica de update/render dels placeholders no es testeja perquè
arrossegaria Screen/Notifier/RenderInfo (tot l'stack SDL). Aquesta
integració queda coberta pel smoke xvfb-run del binari.

Stand-alone target `test_scene_flow` que només enllaça els .cpp pures
necessaris, amb ctest afegit.
2026-04-22 10:35:07 +02:00
JailDesigner 87a9f098b5 feat(debug): selector d'escena inicial via debug.yaml
Director encadena LOGO → TITLE → INTRO → GAME amb factories injectades
al ctor de cada placeholder (sense que cap escena conegui el tipus de
la següent). L'escena d'arrencada surt de `debug.yaml` en debug build i
és sempre LOGO en release.

Comportament:
- Release: `debug.yaml` s'ignora; arranca sempre a LOGO.
- Debug: es llig `debug.yaml`. Si no existeix, es crea automàticament
  amb `start_scene: GAME` per no arrossegar 15 s de placeholders al dia
  a dia de gameplay. Valor invàlid o YAML corrupte → warning + GAME,
  però el fitxer no es reescriu (respectem l'edició manual).

`debug.yaml` viu a `$XDG_CONFIG_HOME/jailgames/aee_2026/`, al costat
de `config.yaml`, i queda fora del repo (ajust per màquina de dev).
2026-04-22 10:33:36 +02:00
JailDesigner d31483124e feat(scenes): afegir placeholders LogoScene/TitleScene/IntroScene
Tres escenes de 5 s cadascuna, activables al flux que precedeix GameScene.
Classes separades (sense base comuna) perquè divergiran quan es
desenvolupi contingut real.

Avancen per timeout o per qualsevol tecla no reservada. Les tecles
reservades (F1-F12, Escape) són les declarades a data/input/keys.yaml i
mantenen el seu comportament habitual via GlobalInputs. La
classificació viu a Placeholder::isReservedScancode, i KeyConfig exposa
bindings() per enumerar-les sense duplicar la llista.

Encara no integrats al Director: això es fa al següent commit quan
s'afegeix el selector d'escena inicial.
2026-04-22 10:31:48 +02:00
JailDesignerandClaude Opus 4.7 2883df57da docs: afegit README.md amb índex de docs/
Taula mínima per distingir d'un cop d'ull les auditories arxivades
(audit-core-*, audit-game) dels documents de disseny vius
(entities-system, player-animations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:24:31 +02:00
JailDesignerandClaude Opus 4.7 510ab55a33 docs: actualitza estat post-refactor/options-class
- audit-game.md cabecera: contadors actualitzats (32 DONE, 1 DONE
  parcial, 5 WONTFIX, 0 DEFERRED) i frase dels DEFERRED en pretèrit
  perfecte. INCONS-01 queda com l'únic DONE (parcial), intencionalment.
- player-animations.md secció 4: el patró antic
  "AnimatedSprite::update integra + col·lisió eix-separada reverteix
  cada eix" es substitueix per "animate(dt) + tryMove(vx*dt, vy*dt,
  collidesFn) amb collisionBox() virtual" — coincidint amb el codi
  post-PATCH-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:22:38 +02:00
JailDesigner a7e681a347 Merge branch 'refactor/options-class'
Tanca els 4 ítems DEFERRED de l'auditoria audit/game (COUP-02, COUP-03,
CONV-02 part Options, PATCH-01). Veure docs/audit-game.md → "Registro
de commits — refactor/options-class" per al desglossament dels commits
i les decisions preses durant el refactor.
2026-04-22 09:09:20 +02:00
JailDesignerandClaude Opus 4.7 b19a6e25da docs(audit-game): close refactor/options-class — final commit log
- Registre de commits omplert amb els hashes reals dels 3 commits de la
  rama (a3b03b8 / 64e0178 / d9f6fb4).
- player-animations.md: el recompte de smoke tests deia "14/14" però
  `tests/test_entities_smoke.cpp` només invoca 13 funcions `test*` des
  de `main()`. Nota corregida a "13/13" amb explicació.

Tanca els 4 ítems DEFERRED de `audit/game` (COUP-02, COUP-03,
CONV-02 part Options, PATCH-01).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:54:58 +02:00
JailDesignerandClaude Opus 4.7 d9f6fb45f8 refactor(sprite): PATCH-01 add tryMove + virtual collisionBox to base
- Sprite: nou hook virtual `collisionBox() -> SDL_FRect` (default
  `pos_`). Subclasses amb falsa perspectiva el sobreescriuen amb una
  sub-caixa més petita.
- MovingSprite: nou `tryMove(dx, dy, CollisionFn)` que aplica eix a
  eix i reverteix l'eix que col·lisiona; deixa lliscar el sprite
  contra obstacles.
- Player: override `collisionBox()` retornant `getBaseBox()` i
  `update()` reescrita per fer `animate(dt) + tryMove(vx*dt, vy*dt,
  collidesFn)` en lloc del patró antic `update + revertir + reaplicar`.

El comportament observable és idèntic: la integració `v*dt` parteix
de la mateixa posició (post-snap), els revertits per col·lisió són
els mateixos i el `MOVED` que controla l'animació stand/walk segueix
comparant amb `FRAME_START_X/Y`.

Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run idèntic al
baseline. clang-tidy i cppcheck nets sobre els fitxers modificats.

Refs: docs/audit-game.md (PATCH-01).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:53:11 +02:00
JailDesignerandClaude Opus 4.7 64e0178ee9 refactor(game): COUP-02 inject MapInfo snapshot into Player
Afegit `Map::Info` (POD trivialment copiable) que empaqueta playfield,
pitch, grid, tile_size, tilemap_origin + 2 punters a funció (collides,
inside_playfield). `Map::makeInfo()` el construeix a partir dels globals
del namespace.

`Player` ara rep `const Map::Info&` al ctor i en guarda una còpia plana
(`map_`), substituint totes les lectures de `Map::*` per `map_.*`. La
inclusió `#include "game/map/map.hpp"` puja al header per la dependència
de `Map::Info`; player.cpp deixa d'incloure'l (transitiu).

Només el Player es desacobla — momies, roques i `loadFromLevel`
continuen llegint `Map::*` directament, com diu la nota original del
audit (mentre hi hagi un Map global actiu, l'acoblament és acceptable).
Si quan s'acabi PATCH-01 algun consumidor extra chirría, COUP-02b en
una rama nova.

Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run idèntic al
baseline. clang-tidy i cppcheck nets sobre els fitxers modificats.

Refs: docs/audit-game.md (COUP-02).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:46:34 +02:00
JailDesignerandClaude Opus 4.7 a3b03b87be refactor(game): COUP-03 + CONV-02 encapsulate Options state behind setters
L'estat mutable d'Options (window, video, audio, locale, presets, paths)
es mou a un anonymous namespace dins options.cpp. L'API pública del
namespace passa a ser:

- Getters const-ref: window(), video(), audio(), locale(),
  configVersion(), postfxPresets(), crtpiPresets().
- Setters específics que muten i persisteixen via saveToFile():
  setWindowZoom, setWindowFullscreen, setVideoVSync,
  setVideoScalingMode, setVideoRenderInfoPosition,
  setVideoRenderInfoShowTime, setShaderEnabled, setActiveShaderName,
  setPostFXPreset, setCrtPiPreset.

Els setters NO notifiquen els subsistemes: el caller (global_inputs.cpp)
crida explícitament Screen::setWindowZoom, reapplyShader, etc. La part
"observers" del audit es deixa WONTFIX justificat — notificar des
d'Options invertiria la direcció Director→Screen/Audio i Options
aprenria del layout dels subsistemes.

CONV-02 tanca al pas: la part Options dels "globals sense observers"
queda resolta amb la nova encapsulació. Map::* (l'altra meitat de
CONV-02) ja s'havia tancat amb PATCH-07.

Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run +
SDL_VIDEODRIVER=dummy + SIGINT idèntic al baseline de main.
clang-tidy i cppcheck nets sobre els fitxers modificats.

Refs: docs/audit-game.md (COUP-03, CONV-02, INCONS-01).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:37:59 +02:00
JailDesigner 32dcf264c4 feat(entities): caja-peus concèntrica i animacions direccionals a la momia, còpia de data/ sempre sincronitzada; v0.19.0 2026-04-21 23:21:32 +02:00
JailDesigner a3592d6c73 feat(player): animacions direccionals i stand automàtic en col·lisió; v0.18.0 2026-04-21 20:41:49 +02:00
JailDesigner 13ae4a51a4 chore: ignora el lockfile intern del harness a .claude/ 2026-04-21 20:18:35 +02:00
JailDesigner ee8dc25181 feat(entities): fase 4 - moviment de la momia amb col·lisió, z-order per peus i contacte amb el jugador 2026-04-21 20:14:47 +02:00
JailDesigner e6b612d164 feat(entities): bloc collision i anchor:feet per entitats amb falsa perspectiva 2026-04-21 19:39:02 +02:00
70 changed files with 3080 additions and 338 deletions

No files matched your search

+1
View File
@@ -14,3 +14,4 @@ _aee_2026
.cache/ .cache/
compile_commands.json compile_commands.json
.DS_Store .DS_Store
.claude/
+36 -4
View File
@@ -76,7 +76,12 @@ set(APP_SOURCES
source/core/resources/palette_loader.cpp source/core/resources/palette_loader.cpp
source/utils/easing.cpp source/utils/easing.cpp
source/utils/utils.cpp source/utils/utils.cpp
source/game/debug_config.cpp
source/game/options.cpp source/game/options.cpp
source/game/scenes/placeholder_input.cpp
source/game/scenes/logo_scene.cpp
source/game/scenes/title_scene.cpp
source/game/scenes/intro_scene.cpp
source/game/scenes/game_scene.cpp source/game/scenes/game_scene.cpp
source/game/entities/player.cpp source/game/entities/player.cpp
source/game/map/map.cpp source/game/map/map.cpp
@@ -150,14 +155,19 @@ target_include_directories(${PROJECT_NAME} PRIVATE
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3) target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
# Copia recursius de la carpeta data/ al costat del binari per que les rutes # Còpia recursiva de data/ al costat del binari perquè les rutes relatives
# relatives ("data/player/player.gif") funcionen sense desplegament. # ("data/player/player.gif") funcionin sense desplegament. Es defineix com
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD # a custom target amb `ALL` — s'executa a cada `cmake --build` encara que
# el binari no hagi canviat, de manera que editar YAML/GIF sense tocar codi
# igualment sincronitza el build. Abans era un POST_BUILD, i si només es
# modificava una dada la còpia no es disparava.
add_custom_target(sync_data ALL
COMMAND ${CMAKE_COMMAND} -E copy_directory COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/data" "${CMAKE_SOURCE_DIR}/data"
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/data" "${CMAKE_BINARY_DIR}/data"
COMMENT "Copying data/ next to the binary" COMMENT "Copying data/ next to the binary"
) )
add_dependencies(${PROJECT_NAME} sync_data)
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
target_compile_definitions(${PROJECT_NAME} PRIVATE target_compile_definitions(${PROJECT_NAME} PRIVATE
@@ -255,6 +265,7 @@ option(BUILD_TESTS "Build smoke tests" ON)
if(BUILD_TESTS) if(BUILD_TESTS)
add_executable(test_entities_smoke add_executable(test_entities_smoke
tests/test_entities_smoke.cpp tests/test_entities_smoke.cpp
source/core/resources/resource_helper.cpp
source/game/map/map.cpp source/game/map/map.cpp
${ENTITY_SOURCES} ${ENTITY_SOURCES}
) )
@@ -268,4 +279,25 @@ if(BUILD_TESTS)
add_test(NAME entities_smoke add_test(NAME entities_smoke
COMMAND test_entities_smoke COMMAND test_entities_smoke
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke del flux d'escenes i del parser de debug.yaml. Stand-alone:
# només enllaça els helpers purs (debug_config, placeholder_input,
# key_config) perquè la integració completa dels placeholders arrossegaria
# tot l'stack SDL (Screen/Notifier/RenderInfo), coberta pel smoke xvfb.
add_executable(test_scene_flow
tests/test_scene_flow.cpp
source/game/debug_config.cpp
source/game/scenes/placeholder_input.cpp
source/core/input/key_config.cpp
source/core/resources/resource_helper.cpp
)
target_include_directories(test_scene_flow PRIVATE
"${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
)
target_link_libraries(test_scene_flow PRIVATE SDL3::SDL3)
target_compile_options(test_scene_flow PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME scene_flow
COMMAND test_scene_flow
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
endif() endif()
+69 -18
View File
@@ -14,47 +14,66 @@ endif
CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH) CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH)
.PHONY: all configure build run clean rebuild release show-version format format-check tidy tidy-fix cppcheck hooks-install .PHONY: all debug release run run-debug clean rebuild show-version format format-check tidy tidy-fix cppcheck hooks-install help
all: build # ==============================================================================
# COMPILACIÓ
# ==============================================================================
# Convenció alineada amb ../aee i ../projecte_2026:
# make → Release (binari d'ús normal)
# make debug → Debug (flux complet + `debug.yaml` per triar escena inicial)
# make release → Release + empaquetat per a distribució (TODO, pendent)
# ==============================================================================
configure: all:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
build: configure
@cmake --build $(BUILDDIR) -j$(JOBS)
run: build
@./$(BUILDDIR)/$(PROJECT)
release:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS) @cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) -j$(JOBS) @cmake --build $(BUILDDIR) -j$(JOBS)
debug:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) -j$(JOBS)
run: all
@./$(BUILDDIR)/$(PROJECT)
run-debug: debug
@./$(BUILDDIR)/$(PROJECT)
# Release + empaquetat per distribució (zip/dmg/tar.gz). De moment només
# compila amb CMAKE_BUILD_TYPE=Release; l'empaquetat OS-specific està
# pendent (veure Makefile de ../aee com a referència).
release: all
@echo "TODO: empaquetat per a distribució (zip/dmg/tar.gz) — pendent"
show-version: show-version:
@echo "$(PROJECT) $(VERSION) ($(GIT_HASH))" @echo "$(PROJECT) $(VERSION) ($(GIT_HASH))"
clean: clean:
@rm -rf $(BUILDDIR) @rm -rf $(BUILDDIR)
rebuild: clean build rebuild: clean all
# ============================================================================== # ==============================================================================
# CODE QUALITY (delegados a cmake) # CODE QUALITY (delegados a cmake)
# ============================================================================== # ==============================================================================
format: configure format:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target format @cmake --build $(BUILDDIR) --target format
format-check: configure format-check:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target format-check @cmake --build $(BUILDDIR) --target format-check
tidy: configure tidy:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target tidy @cmake --build $(BUILDDIR) --target tidy
tidy-fix: configure tidy-fix:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target tidy-fix @cmake --build $(BUILDDIR) --target tidy-fix
cppcheck: configure cppcheck:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target cppcheck @cmake --build $(BUILDDIR) --target cppcheck
# ============================================================================== # ==============================================================================
@@ -63,3 +82,35 @@ cppcheck: configure
hooks-install: hooks-install:
@git config core.hooksPath .githooks @git config core.hooksPath .githooks
@echo "Git hooks activats: $(shell pwd)/.githooks" @echo "Git hooks activats: $(shell pwd)/.githooks"
# ==============================================================================
# AJUDA
# ==============================================================================
help:
@echo "Makefile per a $(PROJECT)"
@echo ""
@echo " Compilació:"
@echo " make - Compilar amb cmake (Release)"
@echo " make debug - Compilar amb cmake (Debug)"
@echo ""
@echo " Execució:"
@echo " make run - Compilar (Release) i executar"
@echo " make run-debug - Compilar (Debug) i executar"
@echo ""
@echo " Release:"
@echo " make release - Release + empaquetat (TODO pendent)"
@echo ""
@echo " Qualitat de codi:"
@echo " make format - Formatar codi amb clang-format"
@echo " make format-check - Verificar format sense modificar"
@echo " make tidy - Anàlisi estàtic amb clang-tidy"
@echo " make tidy-fix - Anàlisi estàtic amb auto-fix"
@echo " make cppcheck - Anàlisi estàtic amb cppcheck"
@echo ""
@echo " Altres:"
@echo " make clean - Esborrar carpeta $(BUILDDIR)/"
@echo " make rebuild - clean + all"
@echo " make show-version - Mostrar versió"
@echo " make hooks-install - Activar git hooks del projecte"
@echo ""
@echo " Versió actual: $(VERSION) ($(GIT_HASH))"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 1.8 KiB

+20 -3
View File
@@ -4,15 +4,32 @@ sprite:
path: mummy.gif path: mummy.gif
size: [12, 24] size: [12, 24]
tile_size: [1, 1] tile_size: [1, 1]
# La momia usa falsa perspectiva: el sprite fa 12×24 (dos 12×12: cap i
# peus) però la caja de col·lisió amb el mapa és 16×16, concèntrica amb
# el rectangle-base 12×12 dels peus (igual que fa el jugador). Així la
# caixa s'estén 2 px a cada costat i 2 px per sota dels peus visuals,
# i amb `anchor: feet` s'alinea exactament amb el tile 16×16 on naix —
# la momia va pel centre del corredor i la caja ocupa la cel·la completa.
anchor: feet
collision:
size: [16, 16]
base_size: [12, 12]
flags: flags:
collides: true collides: true
interactable: false interactable: false
# Strip de 12 frames en graella 12×1 (mateix esquema que el jugador):
# 0..3 → walk_down (cara avall)
# 4..7 → walk_up (cara amunt)
# 8..11 → walk (lateral; flip horitzontal segons el sentit)
# La momia no té variants `stand_*` perquè mai no para.
animations: animations:
idle: { frames: [0], fps: 1, loop: true } walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true }
walk: { frames: [0], fps: 8, loop: true } walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk: { frames: [8, 9, 10, 11], fps: 8, loop: true }
ai: ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
initial_direction: right
stats: stats:
speed: 40 speed: 30
damage: 1 damage: 1
File renamed without changes.
File renamed without changes.
+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500-bold — generado desde ari-w9500-bold.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 13
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 3 # U+0020
33 3 # !
34 6 # "
35 9 # #
36 7 # $
37 12 # %
38 8 # &
39 3 # '
40 5 # (
41 5 # )
42 5 # *
43 7 # +
44 3 # ,
45 5 # -
46 3 # .
47 6 # /
48 7 # 0
49 6 # 1
50 7 # 2
51 7 # 3
52 7 # 4
53 7 # 5
54 7 # 6
55 7 # 7
56 7 # 8
57 7 # 9
58 3 # :
59 3 # ;
60 7 # <
61 7 # =
62 7 # >
63 7 # ?
64 13 # @
65 9 # A
66 8 # B
67 8 # C
68 8 # D
69 7 # E
70 7 # F
71 8 # G
72 8 # H
73 3 # I
74 6 # J
75 8 # K
76 7 # L
77 10 # M
78 9 # N
79 9 # O
80 8 # P
81 9 # Q
82 8 # R
83 8 # S
84 7 # T
85 8 # U
86 8 # V
87 11 # W
88 9 # X
89 9 # Y
90 9 # Z
91 5 # [
92 6 # \
93 5 # ]
94 6 # ^
95 7 # _
96 4 # `
97 7 # a
98 7 # b
99 7 # c
100 7 # d
101 7 # e
102 5 # f
103 7 # g
104 7 # h
105 3 # i
106 3 # j
107 7 # k
108 3 # l
109 9 # m
110 7 # n
111 7 # o
112 7 # p
113 7 # q
114 5 # r
115 6 # s
116 5 # t
117 7 # u
118 7 # v
119 9 # w
120 7 # x
121 7 # y
122 7 # z
123 6 # {
124 3 # |
125 6 # }
126 8 # ~
192 9 # À
193 9 # Á
200 7 # È
201 7 # É
204 3 # Ì
205 3 # Í
210 9 # Ò
211 9 # Ó
217 8 # Ù
218 8 # Ú
209 9 # Ñ
199 8 # Ç
224 7 # à
225 7 # á
232 7 # è
233 7 # é
236 3 # ì
237 4 # í
242 7 # ò
243 7 # ó
249 7 # ù
250 7 # ú
241 7 # ñ
231 7 # ç
161 3 # ¡
191 6 # ¿
171 9 # «
187 9 # »
183 3 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500-condensed-bold — generado desde ari-w9500-condensed-bold.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 12
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 2 # U+0020
33 3 # !
34 6 # "
35 9 # #
36 6 # $
37 10 # %
38 7 # &
39 3 # '
40 5 # (
41 5 # )
42 5 # *
43 7 # +
44 3 # ,
45 5 # -
46 3 # .
47 5 # /
48 6 # 0
49 5 # 1
50 6 # 2
51 6 # 3
52 6 # 4
53 6 # 5
54 6 # 6
55 6 # 7
56 6 # 8
57 6 # 9
58 3 # :
59 3 # ;
60 7 # <
61 7 # =
62 7 # >
63 6 # ?
64 12 # @
65 8 # A
66 7 # B
67 7 # C
68 7 # D
69 6 # E
70 6 # F
71 7 # G
72 7 # H
73 3 # I
74 6 # J
75 7 # K
76 7 # L
77 8 # M
78 8 # N
79 8 # O
80 7 # P
81 8 # Q
82 7 # R
83 7 # S
84 7 # T
85 7 # U
86 7 # V
87 9 # W
88 7 # X
89 7 # Y
90 7 # Z
91 5 # [
92 5 # \
93 5 # ]
94 6 # ^
95 6 # _
96 4 # `
97 6 # a
98 6 # b
99 6 # c
100 6 # d
101 6 # e
102 5 # f
103 6 # g
104 6 # h
105 3 # i
106 3 # j
107 6 # k
108 3 # l
109 8 # m
110 6 # n
111 6 # o
112 6 # p
113 6 # q
114 5 # r
115 6 # s
116 5 # t
117 6 # u
118 6 # v
119 8 # w
120 6 # x
121 6 # y
122 6 # z
123 6 # {
124 3 # |
125 6 # }
126 8 # ~
192 8 # À
193 8 # Á
200 6 # È
201 6 # É
204 3 # Ì
205 3 # Í
210 8 # Ò
211 8 # Ó
217 7 # Ù
218 7 # Ú
209 8 # Ñ
199 7 # Ç
224 6 # à
225 6 # á
232 6 # è
233 6 # é
236 3 # ì
237 4 # í
242 6 # ò
243 6 # ó
249 6 # ù
250 6 # ú
241 6 # ñ
231 6 # ç
161 3 # ¡
191 6 # ¿
171 9 # «
187 9 # »
183 3 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 12
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 3 # U+0020
33 3 # !
34 6 # "
35 9 # #
36 7 # $
37 11 # %
38 8 # &
39 3 # '
40 5 # (
41 5 # )
42 5 # *
43 7 # +
44 4 # ,
45 6 # -
46 3 # .
47 6 # /
48 7 # 0
49 6 # 1
50 7 # 2
51 7 # 3
52 8 # 4
53 7 # 5
54 7 # 6
55 7 # 7
56 7 # 8
57 7 # 9
58 3 # :
59 4 # ;
60 7 # <
61 7 # =
62 7 # >
63 7 # ?
64 12 # @
65 8 # A
66 7 # B
67 8 # C
68 8 # D
69 7 # E
70 7 # F
71 8 # G
72 7 # H
73 3 # I
74 6 # J
75 8 # K
76 7 # L
77 9 # M
78 8 # N
79 9 # O
80 7 # P
81 9 # Q
82 7 # R
83 7 # S
84 7 # T
85 7 # U
86 7 # V
87 10 # W
88 8 # X
89 7 # Y
90 8 # Z
91 5 # [
92 6 # \
93 5 # ]
94 6 # ^
95 7 # _
96 5 # `
97 7 # a
98 7 # b
99 7 # c
100 7 # d
101 7 # e
102 6 # f
103 7 # g
104 7 # h
105 3 # i
106 3 # j
107 7 # k
108 3 # l
109 9 # m
110 7 # n
111 7 # o
112 7 # p
113 7 # q
114 6 # r
115 6 # s
116 6 # t
117 7 # u
118 7 # v
119 8 # w
120 7 # x
121 7 # y
122 7 # z
123 6 # {
124 3 # |
125 6 # }
126 8 # ~
192 8 # À
193 8 # Á
200 7 # È
201 7 # É
204 3 # Ì
205 3 # Í
210 9 # Ò
211 9 # Ó
217 7 # Ù
218 7 # Ú
209 8 # Ñ
199 8 # Ç
224 7 # à
225 7 # á
232 7 # è
233 7 # é
236 4 # ì
237 3 # í
242 7 # ò
243 7 # ó
249 7 # ù
250 7 # ú
241 7 # ñ
231 7 # ç
161 3 # ¡
191 7 # ¿
171 10 # «
187 10 # »
183 3 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500-condensed — generado desde ari-w9500-condensed.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 9
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 2 # U+0020
33 2 # !
34 4 # "
35 8 # #
36 5 # $
37 8 # %
38 6 # &
39 2 # '
40 4 # (
41 4 # )
42 4 # *
43 6 # +
44 2 # ,
45 4 # -
46 2 # .
47 4 # /
48 5 # 0
49 4 # 1
50 5 # 2
51 5 # 3
52 5 # 4
53 5 # 5
54 5 # 6
55 5 # 7
56 5 # 8
57 5 # 9
58 2 # :
59 2 # ;
60 4 # <
61 6 # =
62 4 # >
63 5 # ?
64 9 # @
65 6 # A
66 6 # B
67 6 # C
68 6 # D
69 6 # E
70 6 # F
71 6 # G
72 6 # H
73 2 # I
74 4 # J
75 6 # K
76 6 # L
77 6 # M
78 6 # N
79 7 # O
80 6 # P
81 7 # Q
82 6 # R
83 6 # S
84 6 # T
85 6 # U
86 6 # V
87 8 # W
88 6 # X
89 6 # Y
90 6 # Z
91 4 # [
92 4 # \
93 4 # ]
94 6 # ^
95 5 # _
96 3 # `
97 5 # a
98 5 # b
99 5 # c
100 5 # d
101 5 # e
102 4 # f
103 5 # g
104 5 # h
105 2 # i
106 2 # j
107 5 # k
108 2 # l
109 6 # m
110 5 # n
111 5 # o
112 5 # p
113 5 # q
114 4 # r
115 5 # s
116 4 # t
117 5 # u
118 5 # v
119 6 # w
120 5 # x
121 5 # y
122 5 # z
123 5 # {
124 2 # |
125 5 # }
126 6 # ~
192 6 # À
193 6 # Á
200 6 # È
201 6 # É
204 2 # Ì
205 2 # Í
210 7 # Ò
211 7 # Ó
217 6 # Ù
218 6 # Ú
209 6 # Ñ
199 6 # Ç
224 5 # à
225 5 # á
232 5 # è
233 5 # é
236 2 # ì
237 3 # í
242 5 # ò
243 5 # ó
249 5 # ù
250 5 # ú
241 5 # ñ
231 5 # ç
161 2 # ¡
191 5 # ¿
171 7 # «
187 7 # »
183 2 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500-display — generado desde ari-w9500-display.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 12
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 3 # U+0020
33 3 # !
34 6 # "
35 9 # #
36 8 # $
37 12 # %
38 9 # &
39 3 # '
40 5 # (
41 5 # )
42 5 # *
43 7 # +
44 4 # ,
45 6 # -
46 3 # .
47 6 # /
48 8 # 0
49 6 # 1
50 8 # 2
51 8 # 3
52 8 # 4
53 8 # 5
54 8 # 6
55 8 # 7
56 8 # 8
57 8 # 9
58 3 # :
59 4 # ;
60 7 # <
61 7 # =
62 7 # >
63 8 # ?
64 12 # @
65 9 # A
66 8 # B
67 9 # C
68 9 # D
69 8 # E
70 8 # F
71 9 # G
72 8 # H
73 3 # I
74 7 # J
75 9 # K
76 8 # L
77 10 # M
78 9 # N
79 10 # O
80 8 # P
81 10 # Q
82 8 # R
83 8 # S
84 9 # T
85 8 # U
86 9 # V
87 12 # W
88 9 # X
89 9 # Y
90 9 # Z
91 5 # [
92 6 # \
93 5 # ]
94 7 # ^
95 7 # _
96 5 # `
97 8 # a
98 8 # b
99 8 # c
100 8 # d
101 8 # e
102 6 # f
103 8 # g
104 8 # h
105 3 # i
106 4 # j
107 8 # k
108 3 # l
109 11 # m
110 8 # n
111 8 # o
112 8 # p
113 8 # q
114 6 # r
115 7 # s
116 6 # t
117 8 # u
118 8 # v
119 9 # w
120 8 # x
121 8 # y
122 8 # z
123 6 # {
124 3 # |
125 6 # }
126 9 # ~
192 9 # À
193 9 # Á
200 8 # È
201 8 # É
204 3 # Ì
205 3 # Í
210 10 # Ò
211 10 # Ó
217 8 # Ù
218 8 # Ú
209 9 # Ñ
199 9 # Ç
224 8 # à
225 8 # á
232 8 # è
233 8 # é
236 4 # ì
237 3 # í
242 8 # ò
243 8 # ó
249 8 # ù
250 8 # ú
241 8 # ñ
231 8 # ç
161 3 # ¡
191 8 # ¿
171 10 # «
187 10 # »
183 3 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500 — generado desde ari-w9500.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 12
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 3 # U+0020
33 2 # !
34 4 # "
35 9 # #
36 6 # $
37 10 # %
38 7 # &
39 2 # '
40 4 # (
41 4 # )
42 4 # *
43 6 # +
44 2 # ,
45 4 # -
46 2 # .
47 5 # /
48 6 # 0
49 5 # 1
50 6 # 2
51 6 # 3
52 6 # 4
53 6 # 5
54 6 # 6
55 6 # 7
56 6 # 8
57 6 # 9
58 2 # :
59 2 # ;
60 6 # <
61 6 # =
62 6 # >
63 6 # ?
64 12 # @
65 8 # A
66 7 # B
67 8 # C
68 8 # D
69 7 # E
70 7 # F
71 8 # G
72 7 # H
73 2 # I
74 5 # J
75 7 # K
76 7 # L
77 8 # M
78 7 # N
79 8 # O
80 7 # P
81 8 # Q
82 7 # R
83 7 # S
84 6 # T
85 7 # U
86 7 # V
87 10 # W
88 8 # X
89 8 # Y
90 8 # Z
91 4 # [
92 5 # \
93 4 # ]
94 6 # ^
95 6 # _
96 3 # `
97 6 # a
98 6 # b
99 6 # c
100 6 # d
101 6 # e
102 4 # f
103 6 # g
104 6 # h
105 2 # i
106 2 # j
107 6 # k
108 2 # l
109 8 # m
110 6 # n
111 6 # o
112 6 # p
113 6 # q
114 4 # r
115 5 # s
116 4 # t
117 6 # u
118 6 # v
119 8 # w
120 6 # x
121 6 # y
122 6 # z
123 5 # {
124 2 # |
125 5 # }
126 6 # ~
192 8 # À
193 8 # Á
200 7 # È
201 7 # É
204 2 # Ì
205 2 # Í
210 8 # Ò
211 8 # Ó
217 7 # Ù
218 7 # Ú
209 7 # Ñ
199 8 # Ç
224 6 # à
225 6 # á
232 6 # è
233 6 # é
236 2 # ì
237 3 # í
242 6 # ò
243 6 # ó
249 6 # ù
250 6 # ú
241 6 # ñ
231 6 # ç
161 2 # ¡
191 6 # ¿
171 7 # «
187 7 # »
183 2 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+2
View File
@@ -130,3 +130,5 @@ entities:
position: [9, 1] position: [9, 1]
- type: mummy - type: mummy
position: [10, 7] position: [10, 7]
- type: mummy
position: [7, 9]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 1.8 KiB

+18 -13
View File
@@ -12,21 +12,26 @@ collision:
base_size: 12 base_size: 12
animations: animations:
- name: stand_down
frames: [0]
- name: stand_up
frames: [4]
- name: stand - name: stand
frames: [1] frames: [8]
- name: walk_down
speed: 0.07
loopFrom: 0
frames: [0, 1, 2, 3]
- name: walk_up
speed: 0.07
loopFrom: 0
frames: [4, 5, 6, 7]
- name: walk - name: walk
speed: 0.07 speed: 0.07
loopFrom: 0 loopFrom: 0
frames: [2, 3, 4, 1] frames: [8, 9, 10, 11]
- name: turn_walk
speed: [0.1, 0.07, 0.07, 0.07, 0.07]
loopFrom: 1
frames: [0, 2, 3, 4, 1]
- name: jump
frames: [6]
- name: jump_peak
frames: [5]
+10
View File
@@ -0,0 +1,10 @@
# `docs/`
| Fitxer | Tipus | Estat |
|--------|-------|-------|
| [audit-core-audio.md](audit-core-audio.md) | Auditoria de `source/core/audio/` | Arxivada (tancada amb `refactor/audio-engine-class`) |
| [audit-core-rendering.md](audit-core-rendering.md) | Auditoria de `source/core/rendering/` | Arxivada (tots els ítems DONE/WONTFIX/DEFERRED documentats) |
| [audit-game.md](audit-game.md) | Auditoria de `source/game/` | Arxivada (tancada amb `refactor/options-class`) |
| [convencions.md](convencions.md) | Convencions del repo (commits, `debug.yaml`) | Viu |
| [entities-system.md](entities-system.md) | Disseny del sistema d'entitats (factory, bus, EntityManager, hazards) | Viu |
| [player-animations.md](player-animations.md) | Notes d'animacions direccionals i `stand` automàtic del Player | Viu |
+14 -4
View File
@@ -3,7 +3,7 @@
Rama: `audit/game`. Rama: `audit/game`.
Cobertura: `source/game/{entities,map,scenes,ui}` + `source/game/options.{hpp,cpp}` (12 fitxers, ~1.486 línies). Cobertura: `source/game/{entities,map,scenes,ui}` + `source/game/options.{hpp,cpp}` (12 fitxers, ~1.486 línies).
Estado global: **28 DONE, 2 DONE (parcial), 5 WONTFIX, 4 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED (COUP-02, COUP-03, CONV-02 resta, PATCH-01) van a la futura branca `refactor/options-class`. Estado global: **32 DONE, 1 DONE (parcial), 5 WONTFIX, 0 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED inicials (COUP-02, COUP-03, CONV-02 part Options, PATCH-01) s'han tancat a la branca `refactor/options-class` (registre de commits al final del fitxer); l'únic ítem que queda en DONE (parcial) és INCONS-01, intencionadament — la nota n'explica el motiu.
Estados posibles: Estados posibles:
- `PENDING` — identificat, sense tocar - `PENDING` — identificat, sense tocar
@@ -69,14 +69,14 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer
|----|--------------|----------|-----------|-------|-------| |----|--------------|----------|-----------|-------|-------|
| COUP-01 | `core/input/global_inputs.cpp:15` | `core/` inclou `game/ui/notification_presets.hpp` | Crítico | DONE | `notification_presets.hpp` mogut a `core/rendering/` (al costat del `Notifier` que consumeix). `global_inputs.cpp` i el comentari de `notifier.hpp` actualitzats | | COUP-01 | `core/input/global_inputs.cpp:15` | `core/` inclou `game/ui/notification_presets.hpp` | Crítico | DONE | `notification_presets.hpp` mogut a `core/rendering/` (al costat del `Notifier` que consumeix). `global_inputs.cpp` i el comentari de `notifier.hpp` actualitzats |
| COUP-02 | `player.cpp` + `Map::PLAYFIELD_*`, `PITCH_*`, `GRID_*`, `collides` | `Player` coneix la geometria interna del mapa | Importante | DEFERRED | Player només llegeix globals de `Map::`, no muta res. Injectar `MapInfo&` té sentit quan hi haja múltiples mapes simultanis o un sistema tipus ECS. Mentre hi haja un Map actiu global, l'acoblament és acceptable. Es refacta amb COUP-03 a `refactor/options-class` | | COUP-02 | `player.cpp` + `Map::PLAYFIELD_*`, `PITCH_*`, `GRID_*`, `collides` | `Player` coneix la geometria interna del mapa | Importante | DEFERRED | Player només llegeix globals de `Map::`, no muta res. Injectar `MapInfo&` té sentit quan hi haja múltiples mapes simultanis o un sistema tipus ECS. Mentre hi haja un Map actiu global, l'acoblament és acceptable. Es refacta amb COUP-03 a `refactor/options-class` |
| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DEFERRED | Encapsular tots els globals darrere setters + observers per Screen/Audio/RenderInfo és un refactor ample (equivalent a COUP-01 de l'audit d'àudio, que va anar a una branca pròpia). Es tractarà a `refactor/options-class` perquè toca tots els callers de core/input. Es fa en branca separada | | COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DONE | Estat mutable mogut a anonymous namespace dins `options.cpp`; API pública són getters const-ref (`window()`, `video()`, `audio()`, `locale()`, `postfxPresets()`, `crtpiPresets()`) + setters específics (`setWindowZoom`, `setShaderEnabled`, `setActiveShaderName`, ...) que muten i persisteixen via `saveToFile()`. Els setters NO notifiquen els subsistemes: el caller (`global_inputs.cpp`) crida després `Screen::setWindowZoom`, `reapplyShader()`, etc. La part "observers" del audit es deixa WONTFIX (veure CONV-02): notificar des d'Options invertiria la direcció `Director→Screen/Audio` i Options aprenria del layout dels subsistemes |
| COUP-04 | `tilemap.cpp:117-138` | Clip+blit directe al canvas ARGB que podria viure a `Surface` | Menor | WONTFIX | Únic caller en tot el projecte. Promoure a `Surface::blitInto` ara afegiria una API genèrica per a un ús únic (YAGNI). Si un segon client apareix, es mourea en aquell moment | | COUP-04 | `tilemap.cpp:117-138` | Clip+blit directe al canvas ARGB que podria viure a `Surface` | Menor | WONTFIX | Únic caller en tot el projecte. Promoure a `Surface::blitInto` ara afegiria una API genèrica per a un ús únic (YAGNI). Si un segon client apareix, es mourea en aquell moment |
## 5. Inconsistències internes ## 5. Inconsistències internes
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes | | ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|----|--------------|----------|-----------|-------|-------| |----|--------------|----------|-----------|-------|-------|
| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta | | INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta. NOTA (post `refactor/options-class`): "encapsular" en COUP-03 vol dir ocultar l'estat mutable darrere de l'API del namespace (anonymous namespace + getters/setters), no convertir Options a classe — el patró `namespace per a estat compartit` queda preservat |
| INCONS-02 | `options.cpp:372,477,530,579,607` | `std::cerr` en comptes de `std::cout` (convenció del projecte) | Importante | DONE | Totes les ocurrències (10, incloent les afegides a PATCH-04) substituïdes per `std::cout`. Test manual: amb config.yaml trencat el missatge de backup va per stdout | | INCONS-02 | `options.cpp:372,477,530,579,607` | `std::cerr` en comptes de `std::cout` (convenció del projecte) | Importante | DONE | Totes les ocurrències (10, incloent les afegides a PATCH-04) substituïdes per `std::cout`. Test manual: amb config.yaml trencat el missatge de backup va per stdout |
| INCONS-03 | `options.cpp` vs `tilemap.cpp:77-78` | Diferent política en YAML absent: `contains()` vs accés directe que llança | Menor | WONTFIX | Les dues polítiques són correctes per al seu cas: `Tilemap`/`Map::loadFrom` tenen fitxers de definició (asset obligatori → llançar); `Options` parseja preferències de l'usuari (camp absent → manté default). No és inconsistència sinó dos contractes diferents | | INCONS-03 | `options.cpp` vs `tilemap.cpp:77-78` | Diferent política en YAML absent: `contains()` vs accés directe que llança | Menor | WONTFIX | Les dues polítiques són correctes per al seu cas: `Tilemap`/`Map::loadFrom` tenen fitxers de definició (asset obligatori → llançar); `Options` parseja preferències de l'usuari (camp absent → manté default). No és inconsistència sinó dos contractes diferents |
| INCONS-04 | `options.cpp`, `player.cpp` | Locals ALL-CAPS (`CONTENT`, `IT`, `DT`, `OLD_X`) barrejats amb `snake_case` | Menor | WONTFIX | El `.clang-tidy` del projecte exigeix `LocalConstantCase = UPPER_CASE` i `VariableCase = lower_case` — els ALL-CAPS són per a `const`/`constexpr` i els snake_case per a mutables. La barreja es correcta i està dictada pel linter | | INCONS-04 | `options.cpp`, `player.cpp` | Locals ALL-CAPS (`CONTENT`, `IT`, `DT`, `OLD_X`) barrejats amb `snake_case` | Menor | WONTFIX | El `.clang-tidy` del projecte exigeix `LocalConstantCase = UPPER_CASE` i `VariableCase = lower_case` — els ALL-CAPS són per a `const`/`constexpr` i els snake_case per a mutables. La barreja es correcta i està dictada pel linter |
@@ -97,6 +97,16 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes | | ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|----|--------------|----------|-----------|-------|-------| |----|--------------|----------|-----------|-------|-------|
| CONV-01 | `player.*`, `map.*`, `ui/*`, `game_scene.cpp` | Speed, caixa de col·lisió, geometria, índexs paleta, rutes d'assets, nom del track: tot cablejat | Crítico | DONE | Tapat per HC-01/02/03/04/05/06: speed a Defaults::Player, col·lisió a player.yaml, geometria a pyramid.yaml, índexs paleta a Defaults::UI, rutes a Defaults::Pyramid + Config | | CONV-01 | `player.*`, `map.*`, `ui/*`, `game_scene.cpp` | Speed, caixa de col·lisió, geometria, índexs paleta, rutes d'assets, nom del track: tot cablejat | Crítico | DONE | Tapat per HC-01/02/03/04/05/06: speed a Defaults::Player, col·lisió a player.yaml, geometria a pyramid.yaml, índexs paleta a Defaults::UI, rutes a Defaults::Pyramid + Config |
| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE (parcial) | Map resolt amb PATCH-07 (namespace). La part d'Options/observers va amb COUP-03 (DEFERRED a `refactor/options-class`) | | CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE | Map resolt amb PATCH-07 (namespace). Options resolt amb COUP-03: estat mutable encapsulat darrere de l'API del namespace (anonymous ns + getters/setters). La part "observers" (notificacions automàtiques a Screen/Audio) queda WONTFIX justificat: invertiria la direcció de dependència `Director→Screen/Audio` que el projecte manté intencionadament |
| CONV-03 | `options.cpp` | `std::cerr` viola la regla "std::cout only" | Importante | DONE | Resolt amb INCONS-02 | | CONV-03 | `options.cpp` | `std::cerr` viola la regla "std::cout only" | Importante | DONE | Resolt amb INCONS-02 |
| CONV-04 | `player.cpp:27` | `SDL_GetKeyboardState` directe en `Player::update` (fuga de SDL al gameplay) | Menor | WONTFIX | `SDL_GetKeyboardState` és l'accés correcte per a state-based polling a 60Hz; `EventBuffer` és per a esdeveniments discrets. La fuga és cosmètica — abstraure-la no aportaria valor | | CONV-04 | `player.cpp:27` | `SDL_GetKeyboardState` directe en `Player::update` (fuga de SDL al gameplay) | Menor | WONTFIX | `SDL_GetKeyboardState` és l'accés correcte per a state-based polling a 60Hz; `EventBuffer` és per a esdeveniments discrets. La fuga és cosmètica — abstraure-la no aportaria valor |
---
## Registro de commits — `refactor/options-class`
Branca per a tancar els 4 ítems DEFERRED de `audit/game`. Base: `main` (32dcf26).
- `a3b03b8` — COUP-03 + CONV-02: estat mutable d'`Options` mogut a anonymous ns + API de getters/setters; `global_inputs.cpp` i `director.cpp` adaptats. CONV-02 (part Options) tanca al pas. La part "observers" del audit es deixa WONTFIX (invertiria `Director→Screen/Audio`)
- `64e0178` — COUP-02: `Map::Info` plana injectada al `Player` (només Player; momia/roca/loadlevel resten amb `Map::*` global, com diu la nota original del audit)
- `d9f6fb4` — PATCH-01: `tryMove(dx, dy, CollisionFn)` a `MovingSprite` + hook virtual `collisionBox()` a `Sprite`; `Player::update` deixa de fer `update + revertir + reaplicar` i passa a `animate(dt) + tryMove`
+65
View File
@@ -0,0 +1,65 @@
# Convencions del repo
## Missatges de commit
- En **valencià**, frases curtes i compactes.
- **Sense firma**: res de `Co-Authored-By:`, `Generated with Claude Code`, ni cap altra firma automàtica.
- Un commit per unitat lògica. Si el canvi afecta àrees separades (feature + docs + test), separar en commits diferents.
- Prefix opcional estil convencional (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`) quan ajude a la llegibilitat.
Exemple correcte:
```
feat(scenes): afegir placeholders LogoScene/TitleScene/IntroScene
Tres escenes de 5 s cadascuna que precedeixen GameScene. Cada classe
té el seu fitxer perquè divergiran quan es desenvolupe el contingut
real (logo animat, menú de títol, cutscene).
```
## `debug.yaml` (ajust de dev, no versionat)
Fitxer personal de desenvolupament, **fora del repo**. Viu a la mateixa carpeta
de sistema que `config.yaml`:
- Linux (XDG): `$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml`
- Per defecte: `~/.config/jailgames/aee_2026/debug.yaml`
### Comportament segons build
Convenció alineada amb `../aee` i `../projecte_2026`:
- **Release** (`make` — binari d'ús normal): `debug.yaml` s'ignora completament.
El joc arranca sempre a `LOGO` i recorre el flux sencer
`LOGO → TITLE → INTRO → GAME`, tal com ho veurà el jugador final.
- **Debug** (`make debug`): es llig `debug.yaml`. Si no existeix, es crea
automàticament amb `start_scene: GAME` perquè el dia a dia de gameplay no
arrossegui els 15 s de placeholders. Editant-lo a mà, es pot triar el punt
d'entrada.
- `make release` fa el Release + empaquetat per a distribució (zip/dmg/tar.gz);
l'empaquetat concret està pendent d'implementar.
### Format
```yaml
start_scene: GAME # valors vàlids: LOGO, TITLE, INTRO, GAME
```
- `start_scene: LOGO` → arranca a `LogoScene` i recorre `LOGO → TITLE → INTRO → GAME`.
- `start_scene: TITLE` → arranca a `TitleScene` i recorre `TITLE → INTRO → GAME`.
- `start_scene: INTRO` → arranca a `IntroScene` i recorre `INTRO → GAME`.
- `start_scene: GAME` → arranca directament a `GameScene` (valor que s'escriu
per defecte en la creació automàtica).
### Comportament davant d'errors (build debug)
- Fitxer buit, valor desconegut o YAML invàlid → `GAME` amb warning a stderr.
El fitxer no es reescriu automàticament: es respecta l'edició manual per si
l'usuari ha teclejat alguna cosa intencionadament.
### Durada dels placeholders
Cada escena placeholder (LOGO, TITLE, INTRO) mostra el seu nom i un compte
regressiu de 5 segons al centre de la pantalla. Es pot saltar amb qualsevol
tecla excepte les registrades a `data/input/keys.yaml` (F1–F12 i Escape),
que mantenen el seu comportament de hotkey global.
+77 -9
View File
@@ -4,6 +4,22 @@ Registre del disseny, els treballs fets i els pendents per al sistema d'entitats
Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`. 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 ## 1. Context i objectiu inicial
@@ -169,17 +185,17 @@ Es respecta tant a la fase 1 com a la 2.
| ID | Descripció | Estat | | ID | Descripció | Estat |
|---|---|---| |---|---|---|
| ENT-IA-01 | **Moviment viu de la momia**: el `chooseDirection` està al seu lloc però el `step` no itera interseccions ni valida direcció contra el mapa. La momia es dibuixa estàtica. | DEFERRED | | 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-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` però encara no hi ha sistema de vides. Caldrà cablejar vides al Player. | 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**. `DynamicHazard::step` desplaça sense validar `Map::collides`. Una roca rodant a tota velocitat travessaria sarcs. | 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 | | 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 ### Render i geometria
| ID | Descripció | Estat | | ID | Descripció | Estat |
|---|---|---| |---|---|---|
| REND-01 | **Z-order fi** entre entitats i Player (interleaving per `feetY`). Ara les entitats sempre queden sota el Player. Veure comentari a [game_scene.cpp:109-110](../source/game/scenes/game_scene.cpp#L109-L110). | DEFERRED | | 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-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 | | 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 |
@@ -204,14 +220,66 @@ Es respecta tant a la fase 1 com a la 2.
| ID | Descripció | Estat | | 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-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** (quan ENT-IA-01 estigui fet). Pendent juntament amb la IA. | DEFERRED | | TST-02 | **Test de moviment de la momia**: T13 exercita step lliure (avança) i step contra paret (gira sense avançar). | DONE |
--- ---
## 6. Estat global ## 6. Fase 4 — Moviment i interacció (DONE)
- **Andamiatge i integració bàsica**: DONE. Els 5 actors del `pyramid.yaml` es carreguen, s'assignen surfaces i es renderitzen estàticament sobre el mapa. El bus connecta Player ↔ sarcs ↔ porta. Tanca els hooks d'IA i col·lisió que quedaven apuntalats a les fases 1–3.
- **Comportament dinàmic (IA, dany, transicions de scene)**: DEFERRED a iteracions posteriors. El hooks existeixen, falta omplir la lògica viva.
- **Smoke tests**: 9/9 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a les dues fases. - **`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. Estat global
- **Andamiatge, integració, render animat, falsa perspectiva, moviment, animació direccional de momia**: 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, i la momia gira visualment a cada intersecció.
- **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).
- **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. El document és viu: cada tasca nova hauria d'afegir-s'hi (entrada amb ID, descripció, estat) al fer-la o ajornar-la.
+85
View File
@@ -0,0 +1,85 @@
# Animacions i direccionalitat del jugador
Documenta els afegits d'aquesta sessió a `source/game/entities/player.*`: sis
animacions direccionals amb `facing_` persistent, regla de flip només per al
lateral i transició automàtica a `stand` quan la col·lisió bloqueja el
moviment. Tot el canvi és local al `Player`; no s'ha tocat l'EntityManager
ni el bus.
## 1. YAML — sis animacions
El fitxer `data/player/player.yaml` defineix ara sis animacions, agrupades
en tres files de 4 frames sobre `player.gif` (convenció habitual: d'esquerra
a dreta i de dalt a baix):
| Nom | Frames | Descripció |
|---------------|------------|----------------------------|
| `stand_down` | `[0]` | parat mirant cap avall |
| `walk_down` | `[0..3]` | caminant cap avall |
| `stand_up` | `[4]` | parat mirant cap amunt |
| `walk_up` | `[4..7]` | caminant cap amunt |
| `stand` | `[8]` | parat lateral |
| `walk` | `[8..11]` | caminant lateral |
Les laterals (`stand` i `walk`) ja existien; les variants `_up` i `_down`
són noves i permeten distingir visualment la direcció de moviment.
## 2. `Facing` persistent
S'ha afegit l'enum privat `Facing { SIDE, UP, DOWN }` amb el membre
`facing_{SIDE}`. La tria es fa a cada `update`, amb prevalença lateral
per si un dia es permetessin diagonals:
- `vx != 0` → `SIDE` (inclou diagonals).
- `vx == 0 && vy < 0` → `UP`.
- `vx == 0 && vy > 0` → `DOWN`.
- Cap input → es conserva l'últim `facing_` perquè l'animació `stand_*`
coincideixi amb l'última direcció caminada.
## 3. Regla de flip
- `SIDE`: el sprite es flipeja horitzontalment si `vx < 0`, i es restaura
a `FLIP_NONE` si `vx > 0`. El sprite original mira a la dreta.
- `UP` / `DOWN`: el flip es força explícitament a `FLIP_NONE`. Els sprites
frontals/dorsals no s'han d'espillar només perquè l'últim lateral havia
estat cap a l'esquerra.
## 4. `stand` automàtic a la col·lisió
Quan el jugador prem una tecla però la col·lisió (mapa o sarcòfag) li
impedeix avançar, l'animació `walk*` s'atura i es força la variant
`stand_*` corresponent al facing. L'algorisme:
1. Al principi del frame es captura `FRAME_START_X` / `FRAME_START_Y`.
2. S'aplica snap al passadís perpendicular si hi ha pur moviment H o V.
3. `animate(delta_time)` avança el frame de l'animació actual sense
tocar la posició.
4. `tryMove(vx*dt, vy*dt, collidesFn)` (a `MovingSprite`) aplica el
moviment per eix: si la `collisionBox()` resultant col·lisiona —
el Player la sobreescriu retornant `getBaseBox()` —, l'eix es
reverteix. Així el jugador llisca contra murs/sarcòfags en lloc de
bloquejar-se en sec.
5. Després de `clampToPlayfield`, es compara la posició final amb el
valor capturat al pas 1. Si és idèntica, `MOVED = false` i es tria
`stand` / `stand_up` / `stand_down` segons `facing_`. Si hi ha
hagut moviment real (incloent-hi snap), s'aplica la variant
`walk*` del mateix facing.
D'aquesta manera, xocar contra un mur o un sarcòfag fa que el jugador es
quede immòbil en compte d'animar el caminat "en el lloc". El facing
segueix reflectint la intenció (on vol anar), no el resultat, perquè
l'`stand_up` d'empentar-se contra un mur ha de mostrar el personatge
mirant amunt.
## 5. Estat
- DONE: sis animacions al YAML, `facing_` persistent, flip condicionat,
`stand` automàtic a la col·lisió.
- Build i smoke tests (13/13) verdes. (Aquesta nota deia "14/14" abans;
el comptatge real al `tests/test_entities_smoke.cpp` són 13 funcions
`test*` invocades des de `main()` — corregit a `refactor/options-class`.)
- Sense smoke test dedicat: la lògica està entrellaçada amb teclat i
`Map::collides`; l'smoke actual de `Player` no cobreix teclat simulat.
- Pendent (ajornat): si algun dia es vol feedback auditiu de wall-bump
(so en xocar), la condició natural és `!MOVED && (vx != 0 || vy != 0)`
al final d'`update`.
+52 -51
View File
@@ -42,83 +42,86 @@ namespace GlobalInputs {
} }
void onDecZoom() { void onDecZoom() {
if (Options::window.fullscreen) { return; } if (Options::window().fullscreen) { return; }
if (Options::window.zoom <= MIN_ZOOM) { return; } if (Options::window().zoom <= MIN_ZOOM) { return; }
Options::window.zoom--; const int NEW_ZOOM = Options::window().zoom - 1;
Screen::get().setWindowZoom(Options::window.zoom); Options::setWindowZoom(NEW_ZOOM);
Options::saveToFile(); Screen::get().setWindowZoom(NEW_ZOOM);
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom)); Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
} }
void onIncZoom() { void onIncZoom() {
if (Options::window.fullscreen) { return; } if (Options::window().fullscreen) { return; }
if (Options::window.zoom >= MAX_ZOOM) { return; } if (Options::window().zoom >= MAX_ZOOM) { return; }
Options::window.zoom++; const int NEW_ZOOM = Options::window().zoom + 1;
Screen::get().setWindowZoom(Options::window.zoom); Options::setWindowZoom(NEW_ZOOM);
Options::saveToFile(); Screen::get().setWindowZoom(NEW_ZOOM);
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom)); Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
} }
void onFullscreen() { void onFullscreen() {
Options::window.fullscreen = !Options::window.fullscreen; const bool NEW_FS = !Options::window().fullscreen;
Screen::get().setFullscreen(Options::window.fullscreen); Options::setWindowFullscreen(NEW_FS);
Options::saveToFile(); Screen::get().setFullscreen(NEW_FS);
notifyLocale(Options::window.fullscreen ? "input.fullscreen_on" : "input.fullscreen_off"); notifyLocale(NEW_FS ? "input.fullscreen_on" : "input.fullscreen_off");
} }
void onToggleVSync() { void onToggleVSync() {
Options::video.vsync = !Options::video.vsync; const bool NEW_VSYNC = !Options::video().vsync;
Screen::get().setVSync(Options::video.vsync); Options::setVideoVSync(NEW_VSYNC);
Options::saveToFile(); Screen::get().setVSync(NEW_VSYNC);
notifyLocale(Options::video.vsync ? "input.vsync_on" : "input.vsync_off"); notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
} }
void onToggleShader() { void onToggleShader() {
Options::video.shader.enabled = !Options::video.shader.enabled; const bool NEW_ENABLED = !Options::video().shader.enabled;
Options::setShaderEnabled(NEW_ENABLED);
reapplyShader(); reapplyShader();
Options::saveToFile(); notifyLocale(NEW_ENABLED ? "input.shader_on" : "input.shader_off");
notifyLocale(Options::video.shader.enabled ? "input.shader_on" : "input.shader_off");
} }
void onNextShader() { void onNextShader() {
Options::video.shader.current_shader = (Options::video.shader.current_shader == "crtpi") ? "postfx" : "crtpi"; const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi";
Options::setActiveShaderName(NEW_NAME);
reapplyShader(); reapplyShader();
Options::saveToFile(); Notification::notify(Locale::get().text("input.shader") + ": " + NEW_NAME);
Notification::notify(Locale::get().text("input.shader") + ": " + Options::video.shader.current_shader);
} }
// Busca l'entrada actual al vector de presets i passa a la següent // Busca l'entrada actual al vector de presets i passa a la següent
// (wrap-around). Si no en troba cap s'evita el canvi silenciosament. // (wrap-around). Si no en troba cap s'evita el canvi silenciosament.
void onNextPreset() { void onNextPreset() {
const bool IS_CRTPI = Options::video.shader.current_shader == "crtpi"; const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi";
std::string new_preset;
if (IS_CRTPI) { if (IS_CRTPI) {
if (Options::crtpi_presets.empty()) { return; } const auto& presets = Options::crtpiPresets();
auto it = std::ranges::find_if(Options::crtpi_presets, if (presets.empty()) { return; }
[](const auto& preset) { return preset.name == Options::video.shader.current_crtpi_preset; }); auto it = std::ranges::find_if(presets,
std::size_t idx = (it == Options::crtpi_presets.end()) ? 0 : std::distance(Options::crtpi_presets.begin(), it); [](const auto& preset) { return preset.name == Options::video().shader.current_crtpi_preset; });
idx = (idx + 1) % Options::crtpi_presets.size(); std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
Options::video.shader.current_crtpi_preset = Options::crtpi_presets[idx].name; idx = (idx + 1) % presets.size();
new_preset = presets[idx].name;
Options::setCrtPiPreset(new_preset);
} else { } else {
if (Options::postfx_presets.empty()) { return; } const auto& presets = Options::postfxPresets();
auto it = std::ranges::find_if(Options::postfx_presets, if (presets.empty()) { return; }
[](const auto& preset) { return preset.name == Options::video.shader.current_postfx_preset; }); auto it = std::ranges::find_if(presets,
std::size_t idx = (it == Options::postfx_presets.end()) ? 0 : std::distance(Options::postfx_presets.begin(), it); [](const auto& preset) { return preset.name == Options::video().shader.current_postfx_preset; });
idx = (idx + 1) % Options::postfx_presets.size(); std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
Options::video.shader.current_postfx_preset = Options::postfx_presets[idx].name; idx = (idx + 1) % presets.size();
new_preset = presets[idx].name;
Options::setPostFXPreset(new_preset);
} }
reapplyShader(); reapplyShader();
Options::saveToFile(); Notification::notify(Locale::get().text("input.preset") + ": " + new_preset);
const std::string& name = IS_CRTPI ? Options::video.shader.current_crtpi_preset : Options::video.shader.current_postfx_preset;
Notification::notify(Locale::get().text("input.preset") + ": " + name);
} }
void onNextScalingMode() { void onNextScalingMode() {
using Mode = Defaults::ScalingMode; using Mode = Defaults::ScalingMode;
Options::video.scaling_mode = static_cast<Mode>((static_cast<int>(Options::video.scaling_mode) + 1) % 5); const auto NEW_MODE = static_cast<Mode>((static_cast<int>(Options::video().scaling_mode) + 1) % 5);
Screen::get().setScalingMode(Options::video.scaling_mode); Options::setVideoScalingMode(NEW_MODE);
Options::saveToFile(); Screen::get().setScalingMode(NEW_MODE);
const char* mode_key = nullptr; const char* mode_key = nullptr;
switch (Options::video.scaling_mode) { switch (NEW_MODE) {
case Mode::DISABLED: case Mode::DISABLED:
mode_key = "input.scaling_disabled"; mode_key = "input.scaling_disabled";
break; break;
@@ -139,14 +142,12 @@ namespace GlobalInputs {
} }
void onCycleRenderInfo() { void onCycleRenderInfo() {
const int NEXT = (static_cast<int>(Options::video.render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT; const int NEXT = (static_cast<int>(Options::video().render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT;
Options::video.render_info_position = static_cast<Defaults::RenderInfoPosition>(NEXT); Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
Options::saveToFile();
} }
void onToggleRenderTime() { void onToggleRenderTime() {
Options::video.render_info_show_time = !Options::video.render_info_show_time; Options::setVideoRenderInfoShowTime(!Options::video().render_info_show_time);
Options::saveToFile();
} }
void onExit() { void onExit() {
+7 -5
View File
@@ -4,6 +4,7 @@
#include <iostream> #include <iostream>
#include <string> #include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
auto KeyConfig::get() -> KeyConfig& { auto KeyConfig::get() -> KeyConfig& {
@@ -12,16 +13,17 @@ auto KeyConfig::get() -> KeyConfig& {
} }
void KeyConfig::load(const std::string& file_path) { void KeyConfig::load(const std::string& file_path) {
std::ifstream file(file_path); const auto FULL = Resource::Helper::resolve(file_path);
std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
std::cerr << "KeyConfig: no s'ha pogut obrir " << file_path << '\n'; std::cerr << "KeyConfig: no s'ha pogut obrir " << FULL << '\n';
return; return;
} }
try { try {
auto yaml = fkyaml::node::deserialize(file); auto yaml = fkyaml::node::deserialize(file);
if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) { if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) {
std::cerr << "KeyConfig: falta la secció 'keys' a " << file_path << '\n'; std::cerr << "KeyConfig: falta la secció 'keys' a " << FULL << '\n';
return; return;
} }
bindings_.clear(); bindings_.clear();
@@ -36,9 +38,9 @@ void KeyConfig::load(const std::string& file_path) {
} }
bindings_[ID] = SC; bindings_[ID] = SC;
} }
std::cout << "KeyConfig: " << bindings_.size() << " bindings des de " << file_path << '\n'; std::cout << "KeyConfig: " << bindings_.size() << " bindings des de " << FULL << '\n';
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "KeyConfig: error parsejant " << file_path << ": " << e.what() << '\n'; std::cerr << "KeyConfig: error parsejant " << FULL << ": " << e.what() << '\n';
} }
} }
+1
View File
@@ -22,6 +22,7 @@ class KeyConfig {
void load(const std::string& file_path); void load(const std::string& file_path);
[[nodiscard]] auto scancode(const std::string& id) const -> SDL_Scancode; [[nodiscard]] auto scancode(const std::string& id) const -> SDL_Scancode;
[[nodiscard]] auto bindings() const -> const std::unordered_map<std::string, SDL_Scancode>& { return bindings_; }
private: private:
KeyConfig() = default; KeyConfig() = default;
+6 -4
View File
@@ -4,6 +4,7 @@
#include <iostream> #include <iostream>
#include <string> #include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
namespace { namespace {
@@ -33,9 +34,10 @@ auto Locale::get() -> Locale& {
} }
void Locale::load(const std::string& file_path) { void Locale::load(const std::string& file_path) {
std::ifstream file(file_path); const auto FULL = Resource::Helper::resolve(file_path);
std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
std::cerr << "Locale: no s'ha pogut obrir " << file_path << '\n'; std::cerr << "Locale: no s'ha pogut obrir " << FULL << '\n';
return; return;
} }
@@ -43,9 +45,9 @@ void Locale::load(const std::string& file_path) {
auto yaml = fkyaml::node::deserialize(file); auto yaml = fkyaml::node::deserialize(file);
strings_.clear(); strings_.clear();
flatten(yaml, "", strings_); flatten(yaml, "", strings_);
std::cout << "Locale: " << strings_.size() << " traduccions des de " << file_path << '\n'; std::cout << "Locale: " << strings_.size() << " traduccions des de " << FULL << '\n';
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "Locale: error parsejant " << file_path << ": " << e.what() << '\n'; std::cerr << "Locale: error parsejant " << FULL << ": " << e.what() << '\n';
} }
} }
+25 -2
View File
@@ -198,10 +198,33 @@ namespace GIF {
readBytes(buffer, &lzw_code_size, sizeof(uint8_t)); readBytes(buffer, &lzw_code_size, sizeof(uint8_t));
std::vector<uint8_t> compressed_data = readSubBlocks(buffer); std::vector<uint8_t> compressed_data = readSubBlocks(buffer);
int uncompressed_data_length = image_descriptor.image_width * image_descriptor.image_height; const int IMG_W = image_descriptor.image_width;
std::vector<uint8_t> uncompressed_data(uncompressed_data_length); const int IMG_H = image_descriptor.image_height;
std::vector<uint8_t> uncompressed_data(static_cast<std::size_t>(IMG_W) * IMG_H);
decompress(lzw_code_size, compressed_data.data(), static_cast<int>(compressed_data.size()), uncompressed_data.data()); decompress(lzw_code_size, compressed_data.data(), static_cast<int>(compressed_data.size()), uncompressed_data.data());
// Interlaced GIF (flag 0x40 al byte de fields): les files no arriben
// en ordre lineal, sinó en 4 passades (8:0, 8:4, 4:2, 2:1). Reordena
// al buffer lineal que espera Surface. Sense aquest pas, les fuentes
// i qualsevol GIF entrellaçat es veien completament trinxats.
constexpr uint8_t INTERLACE_FLAG = 0x40;
if ((image_descriptor.fields & INTERLACE_FLAG) != 0) {
constexpr int PASS_START[4] = {0, 4, 2, 1};
constexpr int PASS_STEP[4] = {8, 8, 4, 2};
std::vector<uint8_t> deinterlaced(static_cast<std::size_t>(IMG_W) * IMG_H);
int src_row = 0;
for (int pass = 0; pass < 4; ++pass) {
for (int dst_row = PASS_START[pass]; dst_row < IMG_H; dst_row += PASS_STEP[pass]) {
std::memcpy(deinterlaced.data() + (static_cast<std::size_t>(dst_row) * IMG_W),
uncompressed_data.data() + (static_cast<std::size_t>(src_row) * IMG_W),
IMG_W);
++src_row;
}
}
return deinterlaced;
}
return uncompressed_data; return uncompressed_data;
} }
@@ -72,3 +72,12 @@ void MovingSprite::setPosY(float value) {
y_ = value; y_ = value;
pos_.y = static_cast<int>(y_); pos_.y = static_cast<int>(y_);
} }
void MovingSprite::tryMove(float dx, float dy, const CollisionFn& collides) {
const float OLD_X = getPosX();
setPosX(OLD_X + dx);
if (collides(collisionBox())) { setPosX(OLD_X); }
const float OLD_Y = getPosY();
setPosY(OLD_Y + dy);
if (collides(collisionBox())) { setPosY(OLD_Y); }
}
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <functional>
#include <memory> #include <memory>
#include "core/rendering/sprite/sprite.hpp" #include "core/rendering/sprite/sprite.hpp"
@@ -40,6 +41,15 @@ class MovingSprite : public Sprite {
auto getFlip() -> SDL_FlipMode { return flip_; } auto getFlip() -> SDL_FlipMode { return flip_; }
void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; } void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; }
// Moviment eix-separat amb col·lisió: aplica `dx` (i si la nova
// `collisionBox()` col·lisiona, el reverteix); després aplica `dy`
// (idem). El caller calcula els deltes (típicament `vx_*dt`,
// `vy_*dt`) i passa la funció de detecció. Aquesta separació deixa
// que el sprite "llisqui" contra obstacles en compte de quedar-se
// bloquejat en sec quan només un eix col·lisiona.
using CollisionFn = std::function<bool(const SDL_FRect&)>;
void tryMove(float dx, float dy, const CollisionFn& collides);
protected: protected:
void move(float delta_time); void move(float delta_time);
+9
View File
@@ -19,6 +19,15 @@ class Sprite {
// Hook d'actualització per frame. Base buida (no hi ha Sprite pur a // Hook d'actualització per frame. Base buida (no hi ha Sprite pur a
// actualitzar); les subclasses mòbils/animades fan override. // actualitzar); les subclasses mòbils/animades fan override.
virtual void update(float /*delta_time*/) {} virtual void update(float /*delta_time*/) {}
// Caixa que `MovingSprite::tryMove` envia al callback de col·lisió per
// decidir si el moviment és vàlid. Per defecte coincideix amb `pos_`
// (el sprite sencer); les subclasses que usen falsa perspectiva (p.ex.
// `Player`) la sobreescriuen amb una sub-caixa més petita (només els
// peus) per a que el cap puga sobresortir sense bloquejar el
// moviment.
[[nodiscard]] virtual auto collisionBox() const -> SDL_FRect { return pos_; }
virtual void render(); virtual void render();
// Renderitza substituint l'índex source_color per target_color (ús típic: // Renderitza substituint l'índex source_color per target_color (ús típic:
// la classe Text, que recolora glifs monocroms per paràmetre). // la classe Text, que recolora glifs monocroms per paràmetre).
+24 -3
View File
@@ -2,20 +2,41 @@
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <string>
namespace Resource::Helper { namespace Resource::Helper {
namespace {
std::string g_data_root;
} // namespace
void setDataRoot(const std::string& absolute_path) {
g_data_root = absolute_path;
if (!g_data_root.empty() && g_data_root.back() != '/') {
g_data_root += '/';
}
}
auto resolve(const std::string& filepath) -> std::string {
if (filepath.empty() || filepath.front() == '/') { return filepath; }
if (g_data_root.empty()) { return filepath; }
return g_data_root + filepath;
}
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> { auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> {
std::ifstream file(filepath, std::ios::binary | std::ios::ate); const auto FULL = resolve(filepath);
std::ifstream file(FULL, std::ios::binary | std::ios::ate);
if (!file) { if (!file) {
std::cerr << "Resource::Helper: cannot open " << filepath << '\n'; std::cerr << "Resource::Helper: cannot open " << FULL << '\n';
return {}; return {};
} }
std::streamsize size = file.tellg(); std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg); file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(size)); std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
if (!file.read(reinterpret_cast<char*>(data.data()), size)) { if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
std::cerr << "Resource::Helper: failed to read " << filepath << '\n'; std::cerr << "Resource::Helper: failed to read " << FULL << '\n';
return {}; return {};
} }
return data; return data;
+16
View File
@@ -6,7 +6,23 @@
namespace Resource::Helper { namespace Resource::Helper {
// Estableix el prefix absolut des d'on es resolen totes les rutes
// relatives passades a `loadFile`/`resolve`. Pensat per a cridar-se una
// sola vegada al primer pas del Director amb `SDL_GetBasePath()`: així el
// binari troba `data/` sense dependre del CWD des d'on s'ha llançat (és
// obligatori per a .app de macOS i útil a Linux per no haver d'estar
// dins de `build/`).
void setDataRoot(const std::string& absolute_path);
// Resol una ruta relativa contra el data root. Si la ruta ja és absoluta
// (comença per '/') la retorna sense modificar — així els fitxers de
// config (system_folder_ + "config.yaml") segueixen funcionant sense
// canvis al call site.
[[nodiscard]] auto resolve(const std::string& filepath) -> std::string;
// Carga un fitxer del filesystem (a aquesta fase encara no hi ha pack). // Carga un fitxer del filesystem (a aquesta fase encara no hi ha pack).
// Les rutes relatives es resolen via `resolve()`; les absolutes s'obrin
// directament.
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>; auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
} // namespace Resource::Helper } // namespace Resource::Helper
+74 -8
View File
@@ -8,6 +8,7 @@
#include <filesystem> #include <filesystem>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <string>
#include <utility> #include <utility>
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
@@ -19,10 +20,15 @@
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/scene/scene.hpp" #include "core/scene/scene.hpp"
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "game/debug_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/game_scene.hpp" #include "game/scenes/game_scene.hpp"
#include "game/scenes/intro_scene.hpp"
#include "game/scenes/logo_scene.hpp"
#include "game/scenes/title_scene.hpp"
#include "game/ui/ui_palette.hpp" #include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -31,6 +37,15 @@ Director::Director() {
std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
} }
// Rutes dels recursos relatives a l'executable, no al CWD. SDL_GetBasePath
// retorna la carpeta del binari al desplegament portable i Contents/
// Resources/ dins d'un .app a macOS. Ha de fer-se abans de qualsevol
// loader que passi per Resource::Helper::resolve (Locale, KeyConfig,
// Resource::Cache, PaletteLoader, etc.).
if (const char* base = SDL_GetBasePath()) {
Resource::Helper::setDataRoot(base);
}
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega // Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
// les opcions; si no existeix config.yaml el crea amb els defaults. // les opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder(); createSystemFolder();
@@ -61,16 +76,21 @@ Director::Director() {
// Traduccions per a tots els textos visibles. Si el fitxer no existeix // Traduccions per a tots els textos visibles. Si el fitxer no existeix
// o hi ha un error de parse, Locale::text() caurà a retornar la clau. // o hi ha un error de parse, Locale::text() caurà a retornar la clau.
Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale + ".yaml"); Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale() + ".yaml");
// Hotkeys globals. GlobalInputs::update() les consumirà a cada frame. // Hotkeys globals. GlobalInputs::update() les consumirà a cada frame.
KeyConfig::get().load("data/input/keys.yaml"); KeyConfig::get().load("data/input/keys.yaml");
// Recursos compartits d'UI (paleta + font). Es carreguen abans que els // Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica. // overlays perquè tots els reaprofiten sense duplicar la lògica. Els
// noms de font viuen a Defaults::UI::Font (un únic lloc per canviar-los).
Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal"); Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal");
Resource::Cache::get().loadSurface("8bithud.gif", "data/font/8bithud.gif"); const std::string HUD_FONT_GIF_KEY = std::string(Defaults::UI::Font::HUD_NAME) + ".gif";
Resource::Cache::get().getSurface("8bithud.gif")->setPalette(Resource::Cache::get().getPalette("ui")); Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, Defaults::UI::Font::HUD_GIF);
Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
const std::string PLACEHOLDER_FONT_GIF_KEY = std::string(Defaults::UI::Font::PLACEHOLDER_NAME) + ".gif";
Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, Defaults::UI::Font::PLACEHOLDER_GIF);
Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Música de la piràmide 1 (primer i únic track de moment). Carreguem ací // Música de la piràmide 1 (primer i únic track de moment). Carreguem ací
// perquè forma part del preload d'assets, com la font i la paleta. // perquè forma part del preload d'assets, com la font i la paleta.
@@ -84,14 +104,58 @@ Director::Director() {
// Font compartida pels overlays: construïm una sola instància de Text // Font compartida pels overlays: construïm una sola instància de Text
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap // i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// d'ells hagi de conèixer els noms dels recursos concrets. // d'ells hagi de conèixer els noms dels recursos concrets.
auto hud_font_surface = Resource::Cache::get().getSurface("8bithud.gif"); auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_text = std::make_shared<Text>(hud_font_surface, "data/font/8bithud.fnt"); auto hud_text = std::make_shared<Text>(hud_font_surface, Defaults::UI::Font::HUD_FNT);
const auto& ui_palette = Resource::Cache::get().getPalette("ui"); const auto& ui_palette = Resource::Cache::get().getPalette("ui");
RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text); RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text);
Notifier::get().init(hud_text, ui_palette); Notifier::get().init(hud_text, ui_palette);
current_scene_ = std::make_unique<GameScene>(); // Font dels placeholders (LOGO / TITLE / INTRO). És una font distinta a
// la de HUD perquè els placeholders desapareixeran amb les seues escenes
// finals; mentrestant tenen estètica pròpia.
auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY);
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, Defaults::UI::Font::PLACEHOLDER_FNT);
// Flux d'escenes LOGO → TITLE → INTRO → GAME. Cada placeholder rep una
// factory que construeix la següent, construïda bottom-up des de GAME.
//
// Release: sempre s'arranca a LOGO (el flux complet, el que veu el
// jugador final). Debug: s'obri `debug.yaml`; si no existeix, el creem
// amb `start_scene: GAME` perquè el dia a dia de gameplay no es mengi
// els 15 s de placeholders. L'usuari pot editar-lo a mà per escollir
// un punt d'entrada intermedi.
#ifdef _DEBUG
const auto DEBUG_YAML = system_folder_ + "debug.yaml";
DebugConfig::createDefaultIfMissing(DEBUG_YAML);
const auto START = DebugConfig::resolveStartScene(DEBUG_YAML);
#else
const auto START = DebugConfig::StartScene::LOGO;
#endif
std::cout << "Director: start_scene=" << DebugConfig::toString(START) << '\n';
auto make_game = []() -> std::unique_ptr<Scene> { return std::make_unique<GameScene>(); };
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, make_game);
};
auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr<Scene> {
return std::make_unique<TitleScene>(placeholder_text, make_intro);
};
switch (START) {
case DebugConfig::StartScene::LOGO:
current_scene_ = std::make_unique<LogoScene>(placeholder_text, make_title);
break;
case DebugConfig::StartScene::TITLE:
current_scene_ = make_title();
break;
case DebugConfig::StartScene::INTRO:
current_scene_ = make_intro();
break;
case DebugConfig::StartScene::GAME:
current_scene_ = make_game();
break;
}
last_ticks_ms_ = SDL_GetTicks(); last_ticks_ms_ = SDL_GetTicks();
} }
@@ -143,6 +207,8 @@ void Director::processEvents() {
void Director::switchSceneIfRequested() { void Director::switchSceneIfRequested() {
if (!current_scene_) { return; } if (!current_scene_) { return; }
if (auto next = current_scene_->nextScene()) { if (auto next = current_scene_->nextScene()) {
// TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md).
std::cout << "Director: scene transition\n";
current_scene_ = std::move(next); current_scene_ = std::move(next);
} }
} }
+98
View File
@@ -0,0 +1,98 @@
#include "game/debug_config.hpp"
#include <exception>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include "external/fkyaml_node.hpp"
namespace DebugConfig {
namespace {
auto readFile(const std::string& path) -> std::string {
std::ifstream file(path);
if (!file) { return {}; }
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
auto fromName(const std::string& name) -> StartScene {
if (name == "LOGO") { return StartScene::LOGO; }
if (name == "TITLE") { return StartScene::TITLE; }
if (name == "INTRO") { return StartScene::INTRO; }
if (name == "GAME") { return StartScene::GAME; }
return StartScene::GAME;
}
auto isKnown(const std::string& name) -> bool {
return name == "LOGO" || name == "TITLE" || name == "INTRO" || name == "GAME";
}
} // namespace
auto parseStartScene(const std::string& yaml_content) -> StartScene {
if (yaml_content.empty()) { return StartScene::GAME; }
try {
auto yaml = fkyaml::node::deserialize(yaml_content);
if (!yaml.contains("start_scene")) { return StartScene::GAME; }
const auto RAW = yaml["start_scene"].get_value<std::string>();
if (!isKnown(RAW)) {
std::cerr << "debug.yaml: start_scene \"" << RAW << "\" invàlid, arrancant a GAME\n";
return StartScene::GAME;
}
return fromName(RAW);
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant: " << e.what() << " — arrancant a GAME\n";
return StartScene::GAME;
}
}
auto resolveStartScene(const std::string& file_path) -> StartScene {
std::error_code error;
if (!std::filesystem::exists(file_path, error)) {
// Silenciós: la no-existència és el cas per defecte en una màquina
// neta, no un error. L'usuari crea el fitxer quan el necessita.
return StartScene::GAME;
}
const auto CONTENT = readFile(file_path);
if (CONTENT.empty()) {
std::cerr << "debug.yaml: fitxer buit a " << file_path << ", arrancant a GAME\n";
return StartScene::GAME;
}
return parseStartScene(CONTENT);
}
auto toString(StartScene scene) -> const char* {
switch (scene) {
case StartScene::LOGO:
return "LOGO";
case StartScene::TITLE:
return "TITLE";
case StartScene::INTRO:
return "INTRO";
case StartScene::GAME:
return "GAME";
}
return "GAME";
}
void createDefaultIfMissing(const std::string& file_path) {
std::error_code error;
if (std::filesystem::exists(file_path, error)) { return; }
std::ofstream file(file_path);
if (!file) {
std::cerr << "debug.yaml: no s'ha pogut crear " << file_path << '\n';
return;
}
file << "# debug.yaml — ajust personal de dev (no versionat).\n";
file << "# Valors vàlids: LOGO, TITLE, INTRO, GAME.\n";
file << "start_scene: GAME\n";
std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME\n";
}
} // namespace DebugConfig
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include <string>
// Configuració personal de desenvolupament, carregada de `debug.yaml` dins la
// carpeta de sistema (`$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml`). No es
// versiona: cada dev el crea a la seua màquina. Si no existeix, s'aplica el
// valor per defecte (GAME) sense warning de nivell error.
namespace DebugConfig {
enum class StartScene : std::uint8_t { LOGO,
TITLE,
INTRO,
GAME };
// Parseja un contingut YAML en cru. Si no conté `start_scene`, el valor és
// desconegut o el YAML és invàlid, retorna GAME. Pura: no fa I/O.
[[nodiscard]] auto parseStartScene(const std::string& yaml_content) -> StartScene;
// Llig el fitxer i delega a parseStartScene. Si el fitxer no existeix
// retorna GAME sense warning (és el cas per defecte en una màquina neta);
// si existeix però el valor és invàlid, imprimeix un warning i retorna
// GAME.
[[nodiscard]] auto resolveStartScene(const std::string& file_path) -> StartScene;
// Nom canònic de l'enum (útil per a logs): "LOGO" / "TITLE" / "INTRO" / "GAME".
[[nodiscard]] auto toString(StartScene scene) -> const char*;
// Si el fitxer no existeix, el crea amb `start_scene: GAME` (el valor
// per defecte pensat per al dia a dia de dev). Si ja existeix, no el
// toca — preservem qualsevol edició manual de l'usuari. Usat només al
// path de debug del Director (el binari release ignora `debug.yaml`).
void createDefaultIfMissing(const std::string& file_path);
} // namespace DebugConfig
+179 -1
View File
@@ -1,10 +1,14 @@
#include "game/entities/concrete/mummy.hpp" #include "game/entities/concrete/mummy.hpp"
#include <algorithm>
#include <array>
#include <random> #include <random>
#include <string> #include <string>
#include <utility>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
namespace { namespace {
@@ -17,9 +21,36 @@ namespace {
return k_rng_; return k_rng_;
} }
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
// animació d'1 frame loopant, neutra — la momia es podrà moure però
// visualment es quedarà estàtica.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace } // namespace
Mummy::Mummy(const fkyaml::node& yaml) { Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_down_(buildAnimation(yaml, "walk_down")),
anim_walk_up_(buildAnimation(yaml, "walk_up")),
anim_walk_lateral_(buildAnimation(yaml, "walk")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true; collides_ = true;
interactable_ = false; interactable_ = false;
@@ -30,6 +61,44 @@ Mummy::Mummy(const fkyaml::node& yaml) {
if (s.contains("speed")) { setSpeed(s["speed"].get_value<float>()); } if (s.contains("speed")) { setSpeed(s["speed"].get_value<float>()); }
if (s.contains("damage")) { setDamage(s["damage"].get_value<int>()); } if (s.contains("damage")) { setDamage(s["damage"].get_value<int>()); }
} }
// Direcció inicial llegida del bloc `ai:`. Per defecte dreta, per
// coherència amb el que fa RollingRock. Valors acceptats: right, left,
// up, down. Qualsevol altre s'interpreta com a "sense direcció" (la
// momia no es mou fins que step() no triï una direcció).
std::string initial = "right";
if (yaml.contains("ai") && yaml["ai"].contains("initial_direction")) {
initial = yaml["ai"]["initial_direction"].get_value<std::string>();
}
if (initial == "right") {
changeDirection(1.0F, 0.0F);
} else if (initial == "left") {
changeDirection(-1.0F, 0.0F);
} else if (initial == "up") {
changeDirection(0.0F, -1.0F);
} else if (initial == "down") {
changeDirection(0.0F, 1.0F);
}
}
void Mummy::changeDirection(float dx, float dy) {
setDirection(dx, dy);
// Prevalença lateral: si hi ha component horitzontal s'usa l'anim
// lateral + flip segons el signe. Només direccions purament verticals
// disparen walk_up/walk_down. Quan la direcció queda (0,0) (cul-de-sac
// amb NONE) no toquem l'animació: la momia es quedarà al frame actual
// fins que step() li torni a assignar direcció.
if (dx != 0.0F) {
anim_ = anim_walk_lateral_;
flip_ = (dx < 0.0F) ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE;
} else if (dy < 0.0F) {
anim_ = anim_walk_up_;
flip_ = SDL_FLIP_NONE;
} else if (dy > 0.0F) {
anim_ = anim_walk_down_;
flip_ = SDL_FLIP_NONE;
}
if (anim_.has_value()) { anim_->reset(); }
} }
auto Mummy::chooseDirection(const std::vector<Direction>& allowed) -> Direction { auto Mummy::chooseDirection(const std::vector<Direction>& allowed) -> Direction {
@@ -38,6 +107,115 @@ auto Mummy::chooseDirection(const std::vector<Direction>& allowed) -> Direction
return allowed[dist(rng())]; return allowed[dist(rng())];
} }
auto Mummy::candidateBox(float dx, float dy) const -> SDL_FRect {
auto box = getCollisionRect();
box.x += dx;
box.y += dy;
return box;
}
void Mummy::snapToPlayfieldEdge(float dx, float dy) {
// La caja 16×16 té exactament l'amplada del corredor; si ens aturàvem
// a una posició subpíxel dins de l'última cel·la, qualsevol gir
// perpendicular col·lidiria amb el sarc adjacent al limit. Per això
// reduïm el dx/dy fins a la vora exacta del playfield en l'eix actiu
// (l'altre sempre és 0 perquè la momia només es mou en 1D per frame).
const auto BOX = getCollisionRect();
float snap_dx = 0.0F;
float snap_dy = 0.0F;
if (dx > 0.0F) {
snap_dx = std::max(0.0F, static_cast<float>(Map::playfield_x + Map::playfield_w) - BOX.x - BOX.w);
} else if (dx < 0.0F) {
snap_dx = std::min(0.0F, static_cast<float>(Map::playfield_x) - BOX.x);
}
if (dy > 0.0F) {
snap_dy = std::max(0.0F, static_cast<float>(Map::playfield_y + Map::playfield_h) - BOX.y - BOX.h);
} else if (dy < 0.0F) {
snap_dy = std::min(0.0F, static_cast<float>(Map::playfield_y) - BOX.y);
}
if (!Map::collides(candidateBox(snap_dx, snap_dy))) {
pixel_rect_.x += snap_dx;
pixel_rect_.y += snap_dy;
}
}
void Mummy::step(float delta_time) {
// Desplaçament nominal en aquest frame segons la direcció actual.
const float DX = dir_x_ * speed_ * delta_time;
const float DY = dir_y_ * speed_ * delta_time;
// Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és
// no-op fins que algú la injecte amb `setDirection`.
if (DX == 0.0F && DY == 0.0F) { return; }
// Prova d'avançar. Si la caixa candidata és vàlida (dins del playfield
// i sense trepitjar cap sarc), s'aplica el moviment i s'acaba el frame.
const auto CANDIDATE = candidateBox(DX, DY);
if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) {
pixel_rect_.x += DX;
pixel_rect_.y += DY;
return;
}
// Bloqueig per la vora del playfield: amarrem la caja a la vora exacta.
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
// Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap
// altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu).
const auto POOLS = collectDirectionPools(speed_ * delta_time);
const auto& pool = !POOLS.perp.empty() ? POOLS.perp : POOLS.uturn;
applyChoice(chooseDirection(pool));
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
}
auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
struct Candidate {
float dx;
float dy;
Direction dir;
};
const std::array<Candidate, 4> ALL{{
{.dx = step, .dy = 0.0F, .dir = Direction::RIGHT},
{.dx = -step, .dy = 0.0F, .dir = Direction::LEFT},
{.dx = 0.0F, .dy = -step, .dir = Direction::UP},
{.dx = 0.0F, .dy = step, .dir = Direction::DOWN},
}};
DirectionPools pools;
for (const auto& c : ALL) {
const bool IS_CURRENT = (c.dx * dir_x_ > 0.0F) || (c.dy * dir_y_ > 0.0F);
if (IS_CURRENT) { continue; } // ja sabem que està bloquejada
const auto TEST = candidateBox(c.dx, c.dy);
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F);
if (IS_UTURN) {
pools.uturn.push_back(c.dir);
} else {
pools.perp.push_back(c.dir);
}
}
return pools;
}
void Mummy::applyChoice(Direction dir) {
switch (dir) {
case Direction::UP:
changeDirection(0.0F, -1.0F);
break;
case Direction::DOWN:
changeDirection(0.0F, 1.0F);
break;
case Direction::LEFT:
changeDirection(-1.0F, 0.0F);
break;
case Direction::RIGHT:
changeDirection(1.0F, 0.0F);
break;
case Direction::NONE:
changeDirection(0.0F, 0.0F);
break;
}
}
void Mummy::onPlayerContact(Player* /*player*/) { void Mummy::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
} }
+44
View File
@@ -4,6 +4,7 @@
#include <vector> #include <vector>
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/hazard.hpp" #include "game/entities/hazard.hpp"
// Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció // Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció
@@ -31,6 +32,49 @@ class Mummy : public DynamicHazard {
// aleatoria; si `allowed` està buit, retorna `NONE`. // aleatoria; si `allowed` està buit, retorna `NONE`.
virtual auto chooseDirection(const std::vector<Direction>& allowed) -> Direction; virtual auto chooseDirection(const std::vector<Direction>& allowed) -> Direction;
// Política de moviment "corridor_wanderer": camina recte fins que el
// pròxim tram la bloquejaria (mur del playfield o sarc). Quan troba un
// obstacle, recull les direccions perpendiculars vàlides (evitant
// U-turn llevat que no hi hagi alternativa) i crida `chooseDirection`
// per decidir-ne una. No consumeix moviment el frame de gir.
void step(float delta_time) override;
// Contacte amb el jugador: publica `player_hit` amb el dany. // Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override; void onPlayerContact(Player* player) override;
private:
// Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy).
[[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect;
// Amarra la caja a la vora exacta del playfield quan el candidat hi
// sortiria — sense això, amb la caja 16×16 iguala el corredor, la
// posició subpíxel dins de l'última cel·la impediria girar a cap
// perpendicular (col·lisionaria amb el sarc adjacent). Es crida abans
// de la lògica de gir.
void snapToPlayfieldEdge(float dx, float dy);
// Recull les direccions alternatives viables des de la posició actual:
// `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou
// perquè ja sabem que està bloquejada. Cada candidata es valida amb
// `Map::insidePlayfield` + `Map::collides` sobre la caja resultant.
struct DirectionPools {
std::vector<Direction> perp;
std::vector<Direction> uturn;
};
[[nodiscard]] auto collectDirectionPools(float step) const -> DirectionPools;
// Canvi de direcció + aplicació de l'animació i flip corresponents.
// Tots els camins que trien nova direcció (ctor amb `initial_direction`,
// gir després d'un bloqueig) passen per ací per mantenir anim_ i
// flip_ en sincronia amb (dir_x_, dir_y_).
void changeDirection(float dx, float dy);
// Aplica la direcció triada (wrapper del switch en una sola crida).
void applyChoice(Direction dir);
// Animacions carregades del YAML, una per cada facing. Per a horitzontal
// només en cal una (`walk`) i el flip ja diferencia esquerra/dreta.
AnimationComponent anim_walk_down_;
AnimationComponent anim_walk_up_;
AnimationComponent anim_walk_lateral_;
}; };
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
namespace { namespace {
@@ -42,6 +43,23 @@ RollingRock::RollingRock(const fkyaml::node& yaml) {
applyInitialDirection(initial, *this); applyInitialDirection(initial, *this);
} }
void RollingRock::step(float delta_time) {
const float DX = dir_x_ * speed_ * delta_time;
const float DY = dir_y_ * speed_ * delta_time;
if (DX == 0.0F && DY == 0.0F) { return; }
auto candidate = getCollisionRect();
candidate.x += DX;
candidate.y += DY;
if (Map::insidePlayfield(candidate) && !Map::collides(candidate)) {
pixel_rect_.x += DX;
pixel_rect_.y += DY;
return;
}
// Bloqueig: aturada definitiva (la roca no rebota).
setDirection(0.0F, 0.0F);
}
void RollingRock::onPlayerContact(Player* /*player*/) { void RollingRock::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
} }
@@ -11,5 +11,12 @@ class RollingRock : public DynamicHazard {
public: public:
explicit RollingRock(const fkyaml::node& yaml); explicit RollingRock(const fkyaml::node& yaml);
// Avança en línia recta fins a la primera col·lisió: si la caixa
// candidata sortiria del playfield o trepitjaria un sarc, s'atura
// (direcció → 0,0) i l'actualització es manté en aquella posició fins
// que un agent extern la torni a empènyer. No rebota per decisió
// de disseny: la roca és trampa estàtica disfressada de dinàmica.
void step(float delta_time) override;
void onPlayerContact(Player* player) override; void onPlayerContact(Player* player) override;
}; };
+15
View File
@@ -26,3 +26,18 @@ void Entity::onPlayerContact(Player* /*player*/) {
void Entity::notifyTileEntered(int /*col*/, int /*row*/) { void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa. // No-op per defecte; només `Sarcophagus` l'usa.
} }
auto Entity::getCollisionRect() const -> SDL_FRect {
if (!collision_box_.has_value()) { return pixel_rect_; }
return {
.x = pixel_rect_.x + static_cast<float>(collision_box_->offset_x),
.y = pixel_rect_.y + static_cast<float>(collision_box_->offset_y),
.w = static_cast<float>(collision_box_->width),
.h = static_cast<float>(collision_box_->height),
};
}
auto Entity::getFeetY() const -> float {
const auto RECT = getCollisionRect();
return RECT.y + RECT.h;
}
+42
View File
@@ -37,6 +37,18 @@ class Entity {
int row{0}; int row{0};
}; };
// Sub-rectangle de col·lisió dins de `pixel_rect_`. Pensat per a
// personatges amb falsa perspectiva (sprite alt, caixa AABB petita
// encastada a baix) — mateixa convenció que el Player. `offset_x/y`
// són relatius a la cantonada superior-esquerra del sprite. Si l'entitat
// no té `CollisionBox`, `getCollisionRect()` retorna tot el `pixel_rect_`.
struct CollisionBox {
int offset_x{0};
int offset_y{0};
int width{0};
int height{0};
};
Entity() = default; Entity() = default;
Entity(std::string name, TilePos tile, SDL_FRect pixel_rect); Entity(std::string name, TilePos tile, SDL_FRect pixel_rect);
virtual ~Entity() = default; virtual ~Entity() = default;
@@ -100,13 +112,43 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte. // Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; } [[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats
// AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el
// sub-rect dins del sprite — vida útil: validar col·lisions amb el
// jugador o el mapa sense que la caixa inclogui "l'alçada" del sprite.
[[nodiscard]] auto getCollisionRect() const -> SDL_FRect;
// Y dels peus: la vora inferior de la caixa de col·lisió. La usarà el
// Z-order per ordenar entitats i jugador de menys a més profunditat
// (REND-01, quan s'implemente).
[[nodiscard]] auto getFeetY() const -> float;
// El loader li assigna una caixa explícita llegida del YAML; sense
// aquesta crida, l'entitat queda sense `CollisionBox` i la caixa de
// col·lisió coincideix amb el sprite.
void setCollisionBox(CollisionBox box) { collision_box_ = box; }
// Accés a la caixa crua (offsets + mida) — el loader la consulta per
// a col·locar el sprite quan `anchor: feet` ha d'alinear la caja (no
// el sprite) amb el tile. Nullopt si l'entitat no en té.
[[nodiscard]] auto getCollisionBox() const -> const std::optional<CollisionBox>& { return collision_box_; }
// Flip del sprite. Les entitats estàtiques mai no el toquen (queda a
// SDL_FLIP_NONE); les dinàmiques que miren cap als costats (p. ex.
// Mummy en direcció horitzontal) el canvien quan gira. El manager
// el passa a Surface::render.
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void setFlip(SDL_FlipMode flip) { flip_ = flip; }
protected: protected:
std::string name_; std::string name_;
TilePos tile_{}; TilePos tile_{};
SDL_FRect pixel_rect_{}; SDL_FRect pixel_rect_{};
std::optional<AnimationComponent> anim_; std::optional<AnimationComponent> anim_;
std::optional<CollisionBox> collision_box_;
std::shared_ptr<Surface> surface_{nullptr}; std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false}; bool collides_{false};
bool interactable_{false}; bool interactable_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
}; };
+7 -5
View File
@@ -4,6 +4,7 @@
#include <stdexcept> #include <stdexcept>
#include <unordered_map> #include <unordered_map>
#include "core/resources/resource_helper.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
@@ -30,10 +31,10 @@ namespace EntityLoader {
auto it = cache.find(name); auto it = cache.find(name);
if (it != cache.end()) { return it->second; } if (it != cache.end()) { return it->second; }
const std::string PATH = templatePath(name); const std::string FULL = Resource::Helper::resolve(templatePath(name));
std::ifstream file(PATH); std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + PATH); throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + FULL);
} }
fkyaml::node node = fkyaml::node::deserialize(file); fkyaml::node node = fkyaml::node::deserialize(file);
cache.emplace(name, node); cache.emplace(name, node);
@@ -41,9 +42,10 @@ namespace EntityLoader {
} }
auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node> { auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node> {
std::ifstream file(level_yaml_path); const auto FULL = Resource::Helper::resolve(level_yaml_path);
std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + level_yaml_path); throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + FULL);
} }
fkyaml::node root = fkyaml::node::deserialize(file); fkyaml::node root = fkyaml::node::deserialize(file);
std::vector<fkyaml::node> spawns; std::vector<fkyaml::node> spawns;
+9
View File
@@ -1,5 +1,7 @@
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include <algorithm>
#include <iterator>
#include <utility> #include <utility>
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
@@ -68,6 +70,13 @@ void EntityManager::notifyPlayerEnteredTile(int col, int row) {
for (auto& e : entities_) { e->notifyTileEntered(col, row); } for (auto& e : entities_) { e->notifyTileEntered(col, row); }
} }
auto EntityManager::getEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out;
out.reserve(entities_.size());
std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
return out;
}
// NOTA: `renderAll` es defineix a `entity_manager_loadlevel.cpp`. Conté // NOTA: `renderAll` es defineix a `entity_manager_loadlevel.cpp`. Conté
// blits via `Surface::render`, que dragaria el pipeline complet de render // blits via `Surface::render`, que dragaria el pipeline complet de render
// (GIF, Canvas, Screen, àudio...) al smoke test. Separar-lo manté el smoke // (GIF, Canvas, Screen, àudio...) al smoke test. Separar-lo manté el smoke
+23 -2
View File
@@ -10,6 +10,7 @@
class Entity; class Entity;
class Sarcophagus; class Sarcophagus;
class Door; class Door;
class Player;
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats: // Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
// 1. `add` registra una entitat, la connecta al bus i manté propietat. // 1. `add` registra una entitat, la connecta al bus i manté propietat.
@@ -49,10 +50,30 @@ class EntityManager {
void update(float delta_time); void update(float delta_time);
// Dibuix de totes les entitats. Sense ordenació per z: la crida dura // Dibuix de totes les entitats. Sense ordenació per z: la crida dura
// l'ordre d'inserció. El z-order fi (interleaving amb el Player per // l'ordre d'inserció. GameScene fa el z-order interleaving amb els
// feetY) queda per a una tasca posterior. // players directament via `renderEntity`; `renderAll` queda com a
// dreçera quan no hi ha players al render (editor, tests).
void renderAll(); void renderAll();
// Render d'una única entitat (mateixa lògica de clipping per frame
// que renderAll). Exposat perquè GameScene pugui interleavar entitats
// i players per `getFeetY()` (REND-01). Estàtic perquè la lògica de
// render no depèn de cap estat del manager — ara com ara, l'entitat
// porta la seua surface, animació i pixel_rect.
static void renderEntity(const Entity& entity);
// Accés als punters observacionals a les entitats per a consumidors
// externs que necessiten construir una llista ordenada (z-order).
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
// Si la caixa base del jugador solapa amb la caixa de col·lisió de
// l'entitat, crida `entity->onPlayerContact(player)`. No fa res si
// la llista està buida. Es crida des de GameScene cada frame, després
// de `update`, perquè la resposta (p.ex. `player_hit`, `level_complete`)
// reflecteixi l'estat del frame actual.
void checkPlayerCollisions(const std::vector<Player*>& players);
// Fan-out del hook de moviment de jugador: per a cada sarcòfag, // Fan-out del hook de moviment de jugador: per a cada sarcòfag,
// cridar notifyTileEntered. Les entitats no-sarcòfag ho ignoren per // cridar notifyTileEntered. Les entitats no-sarcòfag ho ignoren per
// defecte (veure Entity::notifyTileEntered). // defecte (veure Entity::notifyTileEntered).
+134 -24
View File
@@ -19,6 +19,7 @@
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp" #include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
// Render centralitzat al manager per confinar la dependència cap a // Render centralitzat al manager per confinar la dependència cap a
@@ -32,26 +33,42 @@
// surface per l'amplada del frame — així un GIF de 4 frames pot venir com // surface per l'amplada del frame — així un GIF de 4 frames pot venir com
// a 1×4, 4×1 o 2×2 indistintament. `renderAll` clipa la cel·la del frame // a 1×4, 4×1 o 2×2 indistintament. `renderAll` clipa la cel·la del frame
// actual amb un `src_rect`. Entitats sense animació es dibuixen al frame 0. // actual amb un `src_rect`. Entitats sense animació es dibuixen al frame 0.
void EntityManager::renderEntity(const Entity& e) {
const auto& surface = e.getSurface();
if (surface == nullptr) { return; }
const auto RECT = e.getPixelRect();
const auto& anim = e.getAnimation();
const int FRAME = anim.has_value() ? anim->currentFrame() : 0;
const int FRAME_W = static_cast<int>(RECT.w);
const int FRAME_H = static_cast<int>(RECT.h);
const int COLS = (FRAME_W > 0) ? (surface->getWidth() / FRAME_W) : 1;
const int COLS_SAFE = (COLS > 0) ? COLS : 1;
const int COL = FRAME % COLS_SAFE;
const int ROW = FRAME / COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(COL * FRAME_W),
.y = static_cast<float>(ROW * FRAME_H),
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC, e.getFlip());
}
void EntityManager::renderAll() { void EntityManager::renderAll() {
for (const auto& e : entities_) { for (const auto& e : entities_) { renderEntity(*e); }
const auto& surface = e->getSurface(); }
if (surface == nullptr) { continue; }
const auto RECT = e->getPixelRect(); void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
const auto& anim = e->getAnimation(); if (players.empty()) { return; }
const int FRAME = anim.has_value() ? anim->currentFrame() : 0; for (const auto& entity : entities_) {
const int FRAME_W = static_cast<int>(RECT.w); if (!entity->collides()) { continue; }
const int FRAME_H = static_cast<int>(RECT.h); const auto BOX = entity->getCollisionRect();
const int COLS = (FRAME_W > 0) ? (surface->getWidth() / FRAME_W) : 1; for (auto* p : players) {
const int COLS_SAFE = (COLS > 0) ? COLS : 1; if (p == nullptr) { continue; }
const int COL = FRAME % COLS_SAFE; const auto PBOX = p->getBaseBox();
const int ROW = FRAME / COLS_SAFE; const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
const SDL_FRect SRC{ if (OVERLAP) { entity->onPlayerContact(p); }
.x = static_cast<float>(COL * FRAME_W), }
.y = static_cast<float>(ROW * FRAME_H),
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC);
} }
} }
@@ -88,6 +105,58 @@ namespace {
return info; return info;
} }
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora
// inferior del sprite i centrada horitzontalment (convenció antiga).
// - Amb `base_size: [bw, bh]`: la caja queda concèntrica amb el
// rectangle inferior bw×bh del sprite (els "peus" visuals), igual
// que fa el Player. Pot sobresortir per sobre/sota/costats del
// rectangle-base simètricament — així la caja representa el volum
// sòlid al voltant dels peus, no la silueta de tot el sprite.
// - `offset: [x, y]`: si apareix, sobreescriu el càlcul anterior.
// Retorna nullopt si no hi ha bloc, i l'entitat es queda sense caixa
// explícita (getCollisionRect torna tot el sprite).
auto readCollisionBox(const fkyaml::node& tmpl, float sprite_w, float sprite_h) -> std::optional<Entity::CollisionBox> {
if (!tmpl.contains("collision")) { return std::nullopt; }
const auto& c = tmpl["collision"];
if (!c.contains("size")) { return std::nullopt; }
const auto& sz = c["size"];
if (!sz.is_sequence() || sz.size() != 2) { return std::nullopt; }
const int W = sz[0].get_value<int>();
const int H = sz[1].get_value<int>();
int off_x = static_cast<int>((sprite_w - static_cast<float>(W)) / 2.0F);
int off_y = static_cast<int>(sprite_h - static_cast<float>(H));
if (c.contains("base_size")) {
const auto& bs = c["base_size"];
if (bs.is_sequence() && bs.size() == 2) {
const int BH = bs[1].get_value<int>();
// Centre vertical del rectangle-base (alineat a la vora
// inferior del sprite): sprite_h − bh/2. Caja centrada hi:
// off_y = sprite_h − bh/2 − H/2 = sprite_h − (bh + H)/2.
off_y = static_cast<int>(sprite_h) - ((BH + H) / 2);
}
}
if (c.contains("offset")) {
const auto& o = c["offset"];
if (o.is_sequence() && o.size() == 2) {
off_x = o[0].get_value<int>();
off_y = o[1].get_value<int>();
}
}
return Entity::CollisionBox{.offset_x = off_x, .offset_y = off_y, .width = W, .height = H};
}
// Ancoratge d'spawn. "feet" vol dir que el `position: [col, row]` del
// map és el tile dels peus: el sprite es centra horitzontalment dins
// del tile i s'alinea la seua vora inferior amb la vora inferior del
// tile. "top_left" (per defecte) posa el sprite amb el cantonal
// superior-esquerre a la cantonada superior-esquerra del tile.
auto readAnchor(const fkyaml::node& tmpl) -> std::string {
if (!tmpl.contains("anchor")) { return "top_left"; }
return tmpl["anchor"].get_value<std::string>();
}
// Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El // Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que // grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
// pot ser negatiu per overscan CPC. // pot ser negatiu per overscan CPC.
@@ -115,7 +184,10 @@ namespace {
// Crea l'entitat per factory i li adjunta la surface si n'hi ha. Retorna // Crea l'entitat per factory i li adjunta la surface si n'hi ha. Retorna
// nullptr si la plantilla no es pot carregar o si el tipus no està // nullptr si la plantilla no es pot carregar o si el tipus no està
// registrat al factory — el loop principal ja imprimeix el missatge. // registrat al factory — el loop principal ja imprimeix el missatge.
auto buildEntity(const std::string& type, SpriteInfo& sprite_out) -> std::unique_ptr<Entity> { // També llegeix el bloc `collision:` de la plantilla (si existeix) i
// el bota a l'entitat, i retorna l'`anchor` per a que el loader sàpiga
// com col·locar el sprite a l'spawn ("feet" o "top_left").
auto buildEntity(const std::string& type, SpriteInfo& sprite_out, std::string& anchor_out) -> std::unique_ptr<Entity> {
fkyaml::node tmpl; fkyaml::node tmpl;
try { try {
tmpl = EntityLoader::loadTemplate(type); tmpl = EntityLoader::loadTemplate(type);
@@ -133,6 +205,10 @@ namespace {
Resource::Cache::get().loadSurface(sprite_out.key, sprite_out.full_path); Resource::Cache::get().loadSurface(sprite_out.key, sprite_out.full_path);
entity->setSurface(Resource::Cache::get().getSurface(sprite_out.key)); entity->setSurface(Resource::Cache::get().getSurface(sprite_out.key));
} }
if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) {
entity->setCollisionBox(*BOX);
}
anchor_out = readAnchor(tmpl);
return entity; return entity;
} }
@@ -167,6 +243,17 @@ namespace {
}; };
entity->setPixelRect(RENDER); entity->setPixelRect(RENDER);
// La caixa de col·lisió del sarc és la lògica (48×16 inferior), no el
// rect del sprite sencer: si un jugador passa just per damunt (pels
// 16 px superiors del sprite, que són "alçada" sense cos), no s'ha
// de disparar cap contacte. Offset relatiu al render rect.
entity->setCollisionBox({
.offset_x = 0,
.offset_y = static_cast<int>(LOGICAL.y - RENDER.y),
.width = static_cast<int>(LOGICAL.w),
.height = static_cast<int>(LOGICAL.h),
});
// Downcast segur: la factory ens garanteix que "sarcophagus" crea // Downcast segur: la factory ens garanteix que "sarcophagus" crea
// un `Sarcophagus`. Usant el retorn de `release` al ctor del nou // un `Sarcophagus`. Usant el retorn de `release` al ctor del nou
// `unique_ptr` evitem el lint `bugprone-unused-return-value`. // `unique_ptr` evitem el lint `bugprone-unused-return-value`.
@@ -180,14 +267,36 @@ namespace {
return true; return true;
} }
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const fkyaml::node& spawn) -> bool { auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
if (!spawn.contains("position")) { if (!spawn.contains("position")) {
std::cerr << "EntityManager: " << type << " sense position\n"; std::cerr << "EntityManager: " << type << " sense position\n";
return false; return false;
} }
const auto& p = spawn["position"]; const auto& p = spawn["position"];
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>()); const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
entity->setPixelRect({.x = PX.x, .y = PX.y, .w = sprite.width, .h = sprite.height});
// Anchor "feet": el tile [col,row] és el tile dels peus de l'actor.
// Si l'entitat té caixa de col·lisió explícita, la caixa s'alinea
// exactament amb el tile (el sprite queda col·locat per als offsets
// que la caja defineix — típicament sobresurt cap amunt per falsa
// perspectiva). Això és coherent amb el jugador, que també centra
// la seua 16×16 sobre el rectangle-base; la caja del tile i la del
// mapeig són la mateixa. Sense caixa, fallback històric: sprite
// centrat horitzontalment i amb la vora inferior a la vora inferior
// del tile.
float rect_x = PX.x;
float rect_y = PX.y;
if (anchor == "feet") {
const auto& box_opt = entity->getCollisionBox();
if (box_opt.has_value()) {
rect_x = PX.x - static_cast<float>(box_opt->offset_x);
rect_y = PX.y - static_cast<float>(box_opt->offset_y);
} else {
rect_x = PX.x + (static_cast<float>(Map::tile_size) - sprite.width) / 2.0F;
rect_y = PX.y + static_cast<float>(Map::tile_size) - sprite.height;
}
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
if (type == "door") { if (type == "door") {
auto* door_ptr = dynamic_cast<Door*>(entity.get()); auto* door_ptr = dynamic_cast<Door*>(entity.get());
@@ -209,12 +318,13 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto TYPE = spawn["type"].get_value<std::string>(); const auto TYPE = spawn["type"].get_value<std::string>();
SpriteInfo sprite; SpriteInfo sprite;
auto entity = buildEntity(TYPE, sprite); std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { continue; } if (entity == nullptr) { continue; }
const bool OK = (TYPE == "sarcophagus") const bool OK = (TYPE == "sarcophagus")
? placeSarcophagus(*this, std::move(entity), sprite, spawn) ? placeSarcophagus(*this, std::move(entity), sprite, spawn)
: placeGeneric(*this, std::move(entity), TYPE, sprite, spawn); : placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
if (OK) { ++loaded; } if (OK) { ++loaded; }
} }
+71 -46
View File
@@ -9,9 +9,9 @@
#include <utility> #include <utility>
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
namespace { namespace {
@@ -22,9 +22,10 @@ namespace {
} // namespace } // namespace
void Player::loadFrom(const std::string& yaml_path) { void Player::loadFrom(const std::string& yaml_path) {
std::ifstream file(yaml_path); const auto FULL = Resource::Helper::resolve(yaml_path);
std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path); throw std::runtime_error("Player: no s'ha pogut obrir " + FULL);
} }
auto yaml = fkyaml::node::deserialize(file); auto yaml = fkyaml::node::deserialize(file);
@@ -32,7 +33,7 @@ void Player::loadFrom(const std::string& yaml_path) {
sprite_h = yaml["frameHeight"].get_value<int>(); sprite_h = yaml["frameHeight"].get_value<int>();
if (!yaml.contains("collision")) { if (!yaml.contains("collision")) {
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); throw std::runtime_error("Player: manca bloc `collision:` a " + FULL);
} }
const auto& c = yaml["collision"]; const auto& c = yaml["collision"];
collision_size = c["size"].get_value<int>(); collision_size = c["size"].get_value<int>();
@@ -47,9 +48,10 @@ void Player::loadFrom(const std::string& yaml_path) {
collision_y_offset = (sprite_h - base_size) - collision_padding; collision_y_offset = (sprite_h - base_size) - collision_padding;
} }
Player::Player(const std::string& anim_key, float x, float y, const Controls& controls) Player::Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info)
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)), : AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
controls_(controls) { controls_(controls),
map_(map_info) {
setPos(x, y); setPos(x, y);
setCurrentAnimation("stand"); setCurrentAnimation("stand");
} }
@@ -66,49 +68,60 @@ void Player::update(float delta_time) {
setVelX(vx); setVelX(vx);
setVelY(vy); setVelY(vy);
// El sprite original mira a la dreta; flipem només si es mou a l'esquerra // Tria de facing amb prevalença lateral: si hi ha component horitzontal
// i restaurem si torna a la dreta. Moviments purament verticals conserven // (inclòs el diagonal), l'animació és lateral i el flip segueix el signe
// l'últim flip perquè el jugador no "gire" sense motiu. // de `vx`. Només en moviment purament vertical passem a UP/DOWN, i en
if (vx < 0.0F) { // aquest cas el flip es força a NONE perquè els sprites frontals no
setFlip(SDL_FLIP_HORIZONTAL); // s'han d'espillar segons l'últim lateral. En aturar-se conservem
} else if (vx > 0.0F) { // `facing_` per a l'animació stand corresponent.
if (vx != 0.0F) {
facing_ = Facing::SIDE;
setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);
} else if (vy < 0.0F) {
facing_ = Facing::UP;
setFlip(SDL_FLIP_NONE);
} else if (vy > 0.0F) {
facing_ = Facing::DOWN;
setFlip(SDL_FLIP_NONE); setFlip(SDL_FLIP_NONE);
} }
const bool MOVING = vx != 0.0F || vy != 0.0F;
setCurrentAnimation(MOVING ? "walk" : "stand");
// Snap al passadís: si només hi ha moviment horitzontal (o vertical) i // Snap al passadís: si només hi ha moviment horitzontal (o vertical) i
// la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la // la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la
// Y (o X) es corregeix gradualment per enfilar el passadís. Amb // Y (o X) es corregeix gradualment per enfilar el passadís. Amb
// moviment diagonal no es fa snap — l'usuari pilota manualment. // moviment diagonal no es fa snap — l'usuari pilota manualment.
const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F); const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F);
const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F); const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F);
// Pos d'inici de frame (abans de snap i integració): si en acabar el
// frame coincideix amb la posició final, el jugador no s'ha mogut
// realment (ni per input, ni per snap, ni ha superat la col·lisió) i cal
// forçar l'animació `stand` corresponent al facing — així s'atura el
// caminat quan xoca contra un mur o un sarcòfag.
const float FRAME_START_X = getPosX();
const float FRAME_START_Y = getPosY();
if (ONLY_H) { if (ONLY_H) {
snapToHorizontalLane(delta_time); snapToHorizontalLane(delta_time);
} else if (ONLY_V) { } else if (ONLY_V) {
snapToVerticalLane(delta_time); snapToVerticalLane(delta_time);
} }
// Guardem la posició prèvia i deixem que AnimatedSprite::update aplique // Animació del frame + moviment col·lisió-aware. `tryMove` aplica els
// la velocitat com sempre; així no dupliquem la integració temporal ni // deltes per eix i reverteix l'eix que col·lisiona, així el jugador
// l'animació. Després, axis-separated collision: revertim i re-apliquem // llisca contra sarcòfags i murs en compte de quedar-se bloquejat en
// cada eix comprovant el mapa de colisions, de manera que el jugador // sec. La caixa que s'envia al callback és `collisionBox()` (override
// llisca contra els sarcòfags en compte de quedar-se bloquejat en sec. // a Player retornant `getBaseBox()`), no el sprite sencer.
const float OLD_X = getPosX(); animate(delta_time);
const float OLD_Y = getPosY(); tryMove(vx * delta_time, vy * delta_time, [this](const SDL_FRect& box) { return map_.collides(box); });
AnimatedSprite::update(delta_time);
const float NEW_X = getPosX();
const float NEW_Y = getPosY();
setPos(OLD_X, OLD_Y);
setPosX(NEW_X);
if (Map::collides(baseBox())) { setPosX(OLD_X); }
setPosY(NEW_Y);
if (Map::collides(baseBox())) { setPosY(OLD_Y); }
clampToPlayfield(); clampToPlayfield();
// Animació basada en moviment real: si la col·lisió (o l'absència
// d'input) ha deixat el jugador al mateix lloc que al principi del
// frame, queda en `stand` del facing actual.
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
setCurrentAnimation(animationName(MOVED));
// Detecció de canvi de tile. Es calcula *després* del clamp i de les // Detecció de canvi de tile. Es calcula *després* del clamp i de les
// col·lisions perquè la posició aquí ja és la definitiva del frame; // col·lisions perquè la posició aquí ja és la definitiva del frame;
// així els sarcòfags no reben falsos positius mentre el Player rellisca // així els sarcòfags no reben falsos positius mentre el Player rellisca
@@ -124,34 +137,46 @@ void Player::update(float delta_time) {
} }
} }
auto Player::animationName(bool moving) const -> const char* {
switch (facing_) {
case Facing::UP:
return moving ? "walk_up" : "stand_up";
case Facing::DOWN:
return moving ? "walk_down" : "stand_down";
case Facing::SIDE:
break;
}
return moving ? "walk" : "stand";
}
auto Player::getCurrentTile() const -> std::pair<int, int> { auto Player::getCurrentTile() const -> std::pair<int, int> {
// El grid del tilemap és de `Map::tile_size` píxels i està 16-alineat // El grid del tilemap és de `tile_size` píxels i està 16-alineat
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El // respecte a l'origen del tilemap (pot ser negatiu per overscan). El
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player. // centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
const auto BOX = baseBox(); const auto BOX = baseBox();
const float CX = BOX.x + (BOX.w * 0.5F); const float CX = BOX.x + (BOX.w * 0.5F);
const float CY = BOX.y + (BOX.h * 0.5F); const float CY = BOX.y + (BOX.h * 0.5F);
const int COL = static_cast<int>(std::floor( const int COL = static_cast<int>(std::floor(
(CX - static_cast<float>(Map::tilemap_origin_x)) / static_cast<float>(Map::tile_size))); (CX - static_cast<float>(map_.tilemap_origin_x)) / static_cast<float>(map_.tile_size)));
const int ROW = static_cast<int>(std::floor( const int ROW = static_cast<int>(std::floor(
(CY - static_cast<float>(Map::tilemap_origin_y)) / static_cast<float>(Map::tile_size))); (CY - static_cast<float>(map_.tilemap_origin_y)) / static_cast<float>(map_.tile_size)));
return {COL, ROW}; return {COL, ROW};
} }
void Player::clampToPlayfield() { void Player::clampToPlayfield() {
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del // Límits de la zona jugable: la caixa de col·lisió no pot eixir del
// playfield (muros). El cap del sprite sí pot sobresortir per damunt. // playfield (muros). El cap del sprite sí pot sobresortir per damunt.
const auto MIN_X = static_cast<float>(Map::playfield_x - collision_x_offset); const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
const auto MAX_X = static_cast<float>( const auto MAX_X = static_cast<float>(
Map::playfield_x + Map::playfield_w - collision_x_offset - collision_size); map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
const auto MIN_Y = static_cast<float>(Map::playfield_y - collision_y_offset); const auto MIN_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
const auto MAX_Y = static_cast<float>( const auto MAX_Y = static_cast<float>(
Map::playfield_y + Map::playfield_h - collision_y_offset - collision_size); map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
setPosX(std::clamp(getPosX(), MIN_X, MAX_X)); setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y)); setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
} }
auto Player::baseBox() const -> SDL_FRect { auto Player::getBaseBox() const -> SDL_FRect {
return { return {
.x = getPosX() + static_cast<float>(collision_x_offset), .x = getPosX() + static_cast<float>(collision_x_offset),
.y = getPosY() + static_cast<float>(collision_y_offset), .y = getPosY() + static_cast<float>(collision_y_offset),
@@ -182,24 +207,24 @@ void Player::snapToLane(
const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time); const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
const float SAVED = (this->*get_pos)(); const float SAVED = (this->*get_pos)();
(this->*set_pos)(SAVED + std::copysign(STEP, DELTA)); (this->*set_pos)(SAVED + std::copysign(STEP, DELTA));
if (Map::collides(baseBox())) { (this->*set_pos)(SAVED); } if (map_.collides(baseBox())) { (this->*set_pos)(SAVED); }
} }
} }
void Player::snapToHorizontalLane(float delta_time) { void Player::snapToHorizontalLane(float delta_time) {
snapToLane(delta_time, snapToLane(delta_time,
static_cast<float>(Map::playfield_y - collision_y_offset), static_cast<float>(map_.playfield_y - collision_y_offset),
static_cast<float>(Map::pitch_y), static_cast<float>(map_.pitch_y),
Map::grid_rows, map_.grid_rows,
&Player::getPosY, &Player::getPosY,
&Player::setPosY); &Player::setPosY);
} }
void Player::snapToVerticalLane(float delta_time) { void Player::snapToVerticalLane(float delta_time) {
snapToLane(delta_time, snapToLane(delta_time,
static_cast<float>(Map::playfield_x - collision_x_offset), static_cast<float>(map_.playfield_x - collision_x_offset),
static_cast<float>(Map::pitch_x), static_cast<float>(map_.pitch_x),
Map::grid_cols, map_.grid_cols,
&Player::getPosX, &Player::getPosX,
&Player::setPosX); &Player::setPosX);
} }
+37 -4
View File
@@ -2,10 +2,12 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstdint>
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include "game/map/map.hpp"
class EventBus; class EventBus;
@@ -39,7 +41,7 @@ class Player : public AnimatedSprite {
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path); static void loadFrom(const std::string& yaml_path);
Player(const std::string& anim_key, float x, float y, const Controls& controls); Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info);
void update(float delta_time) override; void update(float delta_time) override;
@@ -60,10 +62,21 @@ class Player : public AnimatedSprite {
// Map::tilemap_origin_*). Retorna (col, row). // Map::tilemap_origin_*). Retorna (col, row).
[[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>; [[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>;
// Rectangle de col·lisió del jugador, en coordenades del canvas. Usat
// pel mapa de col·lisions i per les entitats que comproven contacte
// amb el jugador (momies, trampes, porta).
[[nodiscard]] auto getBaseBox() const -> SDL_FRect;
// Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta
// caixa al callback de col·lisió. La base del Player és més petita
// que el sprite (el cap pot sobresortir per murs/sarcòfags sense
// bloquejar el moviment).
[[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); }
private: private:
// Rectangle de col·lisió del jugador, usat pel mapa de colisions i com // Alias privat històric. Mantingut perquè viu dins de l'implementació
// a referència per al Z-order. // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
[[nodiscard]] auto baseBox() const -> SDL_FRect; [[nodiscard]] auto baseBox() const -> SDL_FRect { return getBaseBox(); }
// Clamp de la caixa de col·lisió al playfield (no al sprite sencer: // Clamp de la caixa de col·lisió al playfield (no al sprite sencer:
// el cap pot sobresortir pel mur superior sense afectar el joc). // el cap pot sobresortir pel mur superior sense afectar el joc).
@@ -76,6 +89,10 @@ class Player : public AnimatedSprite {
// wrappers deleguen a `snapToLane` només canviant l'eix actiu. // wrappers deleguen a `snapToLane` només canviant l'eix actiu.
void snapToHorizontalLane(float delta_time); void snapToHorizontalLane(float delta_time);
void snapToVerticalLane(float delta_time); void snapToVerticalLane(float delta_time);
// Nom d'animació per al `facing_` actual. Si `moving` és cert s'usa
// la variant `walk*`, altrament la variant `stand*`.
[[nodiscard]] auto animationName(bool moving) const -> const char*;
void snapToLane( void snapToLane(
float delta_time, float delta_time,
float base, float base,
@@ -85,6 +102,22 @@ class Player : public AnimatedSprite {
void (Player::*set_pos)(float)); void (Player::*set_pos)(float));
Controls controls_; Controls controls_;
// Còpia plana de la geometria del mapa + punters a `collides`/
// `inside_playfield`. POD trivial; copiada al ctor i no muta durant
// la vida del Player. Vol dir que aquest Player només és vàlid per
// a un mapa concret — si el mapa canvia, la Scene ha de
// reinstanciar-lo.
Map::Info map_;
// Direcció "cap on mira" el jugador. Governa quin conjunt d'animacions
// s'aplica (lateral, amunt, avall) i si el sprite es flipeja o no. Els
// moviments purament horitzontals o diagonals prenen `SIDE` (prevalença
// lateral), els purament verticals fan `UP`/`DOWN`. En aturar-se es
// preserva l'última orientació perquè la versió stand coincideixi.
enum class Facing : std::uint8_t { SIDE,
UP,
DOWN };
Facing facing_{Facing::SIDE};
// Bus del nivell (opcional). Propietat externa (EntityManager); el // Bus del nivell (opcional). Propietat externa (EntityManager); el
// Player només en guarda un punter per publicar events. // Player només en guarda un punter per publicar events.
+28 -4
View File
@@ -4,6 +4,7 @@
#include <iostream> #include <iostream>
#include <stdexcept> #include <stdexcept>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
namespace { namespace {
@@ -21,13 +22,14 @@ namespace {
namespace Map { namespace Map {
void loadFrom(const std::string& yaml_path) { void loadFrom(const std::string& yaml_path) {
std::ifstream file(yaml_path); const auto FULL = Resource::Helper::resolve(yaml_path);
std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
throw std::runtime_error("Map: no s'ha pogut obrir " + yaml_path); throw std::runtime_error("Map: no s'ha pogut obrir " + FULL);
} }
auto yaml = fkyaml::node::deserialize(file); auto yaml = fkyaml::node::deserialize(file);
if (!yaml.contains("geometry")) { if (!yaml.contains("geometry")) {
throw std::runtime_error("Map: manca bloc `geometry:` a " + yaml_path); throw std::runtime_error("Map: manca bloc `geometry:` a " + FULL);
} }
const auto& g = yaml["geometry"]; const auto& g = yaml["geometry"];
@@ -76,7 +78,7 @@ namespace Map {
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
<< " sarcs " << sarc_w << "x" << sarc_h << " sarcs " << sarc_w << "x" << sarc_h
<< " pitch " << pitch_x << "x" << pitch_y << " pitch " << pitch_x << "x" << pitch_y
<< " des de " << yaml_path << '\n'; << " des de " << FULL << '\n';
} }
auto getSarcRect(int col, int row) -> SDL_FRect { auto getSarcRect(int col, int row) -> SDL_FRect {
@@ -100,4 +102,26 @@ namespace Map {
return false; return false;
} }
auto insidePlayfield(const SDL_FRect& box) -> bool {
return box.x >= static_cast<float>(playfield_x) && box.y >= static_cast<float>(playfield_y) && box.x + box.w <= static_cast<float>(playfield_x + playfield_w) && box.y + box.h <= static_cast<float>(playfield_y + playfield_h);
}
auto makeInfo() -> Info {
return {
.playfield_x = playfield_x,
.playfield_y = playfield_y,
.playfield_w = playfield_w,
.playfield_h = playfield_h,
.pitch_x = pitch_x,
.pitch_y = pitch_y,
.grid_cols = grid_cols,
.grid_rows = grid_rows,
.tile_size = tile_size,
.tilemap_origin_x = tilemap_origin_x,
.tilemap_origin_y = tilemap_origin_y,
.collides = &collides,
.inside_playfield = &insidePlayfield,
};
}
} // namespace Map } // namespace Map
+37
View File
@@ -14,6 +14,14 @@
// els membres viuen com a globals del namespace i els callers hi accedeixen // els membres viuen com a globals del namespace i els callers hi accedeixen
// amb `Map::grid_cols`, `Map::collides(...)`, etc. El canvi respecte a // amb `Map::grid_cols`, `Map::collides(...)`, etc. El canvi respecte a
// l'anterior `class Map { static ... };` és purament sintàctic. // l'anterior `class Map { static ... };` és purament sintàctic.
//
// `MapInfo` (POD trivialment copiable) empaqueta la geometria + dos
// punters a funció de col·lisió per injectar-la a entitats que han
// d'evitar conèixer el namespace `Map::` directament. Construïda via
// `Map::makeInfo()` després de `loadFrom`. Avui només l'usa el Player
// (COUP-02 a docs/audit-game.md); momies, roques i `loadFromLevel`
// continuen llegint `Map::*` directament — la nota original justifica
// que mentre hi hagi un Map global actiu, l'acoblament és acceptable.
namespace Map { namespace Map {
// Geometria de la graella (carregada de pyramid.yaml > geometry). // Geometria de la graella (carregada de pyramid.yaml > geometry).
@@ -67,4 +75,33 @@ namespace Map {
// amb cap sarcòfag. // amb cap sarcòfag.
[[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool; [[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool;
// true si la caixa `box` queda completament dins del playfield (zona
// interior, sense tocar els murs exteriors). El Player ja té clamp
// propi que aplica el mateix constrenyiment; això serveix a les
// entitats dinàmiques per validar posicions candidates del seu step.
[[nodiscard]] auto insidePlayfield(const SDL_FRect& box) -> bool;
// Snapshot plà de la geometria del mapa + punters a `collides` /
// `insidePlayfield`. Pensat per ser passat per valor (POD petit,
// trivialment copiable). Un cop construïda, llegeix els valors
// congelats — si `Map::loadFrom` es torna a cridar amb un mapa
// diferent, cal demanar un MapInfo nou.
struct Info {
int playfield_x{0};
int playfield_y{0};
int playfield_w{0};
int playfield_h{0};
int pitch_x{0};
int pitch_y{0};
int grid_cols{0};
int grid_rows{0};
int tile_size{0};
int tilemap_origin_x{0};
int tilemap_origin_y{0};
bool (*collides)(const SDL_FRect&){nullptr};
bool (*inside_playfield)(const SDL_FRect&){nullptr};
};
[[nodiscard]] auto makeInfo() -> Info;
} // namespace Map } // namespace Map
+5 -3
View File
@@ -12,6 +12,7 @@
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
namespace { namespace {
@@ -67,9 +68,10 @@ namespace {
} // namespace } // namespace
Tilemap::Tilemap(const std::string& yaml_path) { Tilemap::Tilemap(const std::string& yaml_path) {
std::ifstream file(yaml_path); const auto FULL = Resource::Helper::resolve(yaml_path);
std::ifstream file(FULL);
if (!file.is_open()) { if (!file.is_open()) {
throw std::runtime_error("Tilemap: no s'ha pogut obrir " + yaml_path); throw std::runtime_error("Tilemap: no s'ha pogut obrir " + FULL);
} }
auto yaml = fkyaml::node::deserialize(file); auto yaml = fkyaml::node::deserialize(file);
@@ -111,7 +113,7 @@ Tilemap::Tilemap(const std::string& yaml_path) {
} }
std::cout << "Tilemap: " << COLS << "x" << ROWS << " tiles (" std::cout << "Tilemap: " << COLS << "x" << ROWS << " tiles ("
<< width_px_ << "x" << height_px_ << " px) des de " << yaml_path << '\n'; << width_px_ << "x" << height_px_ << " px) des de " << FULL << '\n';
} }
void Tilemap::render() const { void Tilemap::render() const {
+157 -91
View File
@@ -14,6 +14,21 @@
namespace { namespace {
// --- Estat mutable (privat al TU) ---
// Encapsulat ací per impedir mutacions directes des de fora del namespace
// Options:: (decisió COUP-03 / docs/audit-game.md). Els callers llegeixen
// via getters const-ref i muten via setters específics que persisteixen.
std::string g_config_version;
std::string g_config_file_path;
std::string g_postfx_file_path;
std::string g_crtpi_file_path;
std::string g_locale{Defaults::I18n::LOCALE};
Options::Window g_window{};
Options::Video g_video{};
Options::Audio g_audio{};
std::vector<Options::PostFXPreset> g_postfx_presets{};
std::vector<Options::CrtPiPreset> g_crtpi_presets{};
// Taules enum ↔ string per a la serialització a YAML. La primera entrada // Taules enum ↔ string per a la serialització a YAML. La primera entrada
// és el fallback quan l'enum no té nom conegut o el text no casa amb // és el fallback quan l'enum no té nom conegut o el text no casa amb
// cap entrada coneguda — per això `LETTERBOX`/`NEAREST`/`OFF` encapçalen. // cap entrada coneguda — per això `LETTERBOX`/`NEAREST`/`OFF` encapçalen.
@@ -93,40 +108,40 @@ namespace {
void parseWindow(const fkyaml::node& yaml) { void parseWindow(const fkyaml::node& yaml) {
if (!yaml.contains("window")) { return; } if (!yaml.contains("window")) { return; }
const auto& node = yaml["window"]; const auto& node = yaml["window"];
readField(node, "zoom", Options::window.zoom); readField(node, "zoom", g_window.zoom);
readField(node, "fullscreen", Options::window.fullscreen); readField(node, "fullscreen", g_window.fullscreen);
} }
void parseGpu(const fkyaml::node& video_node) { void parseGpu(const fkyaml::node& video_node) {
if (!video_node.contains("gpu")) { return; } if (!video_node.contains("gpu")) { return; }
const auto& node = video_node["gpu"]; const auto& node = video_node["gpu"];
readField(node, "acceleration", Options::video.gpu.acceleration); readField(node, "acceleration", g_video.gpu.acceleration);
readField(node, "preferred_driver", Options::video.gpu.preferred_driver); readField(node, "preferred_driver", g_video.gpu.preferred_driver);
} }
void parseShader(const fkyaml::node& video_node) { void parseShader(const fkyaml::node& video_node) {
if (!video_node.contains("shader")) { return; } if (!video_node.contains("shader")) { return; }
const auto& node = video_node["shader"]; const auto& node = video_node["shader"];
readField(node, "enabled", Options::video.shader.enabled); readField(node, "enabled", g_video.shader.enabled);
readField(node, "current_shader", Options::video.shader.current_shader); readField(node, "current_shader", g_video.shader.current_shader);
readField(node, "current_postfx_preset", Options::video.shader.current_postfx_preset); readField(node, "current_postfx_preset", g_video.shader.current_postfx_preset);
readField(node, "current_crtpi_preset", Options::video.shader.current_crtpi_preset); readField(node, "current_crtpi_preset", g_video.shader.current_crtpi_preset);
} }
void parseVideo(const fkyaml::node& yaml) { void parseVideo(const fkyaml::node& yaml) {
if (!yaml.contains("video")) { return; } if (!yaml.contains("video")) { return; }
const auto& node = yaml["video"]; const auto& node = yaml["video"];
readField(node, "vsync", Options::video.vsync); readField(node, "vsync", g_video.vsync);
if (node.contains("scaling_mode")) { if (node.contains("scaling_mode")) {
Options::video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value<std::string>()); g_video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value<std::string>());
} }
if (node.contains("texture_filter")) { if (node.contains("texture_filter")) {
Options::video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value<std::string>()); g_video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value<std::string>());
} }
if (node.contains("render_info_position")) { if (node.contains("render_info_position")) {
Options::video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value<std::string>()); g_video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value<std::string>());
} }
readField(node, "render_info_show_time", Options::video.render_info_show_time); readField(node, "render_info_show_time", g_video.render_info_show_time);
parseGpu(node); parseGpu(node);
parseShader(node); parseShader(node);
} }
@@ -134,22 +149,22 @@ namespace {
void parseMusic(const fkyaml::node& audio_node) { void parseMusic(const fkyaml::node& audio_node) {
if (!audio_node.contains("music")) { return; } if (!audio_node.contains("music")) { return; }
const auto& node = audio_node["music"]; const auto& node = audio_node["music"];
readField(node, "enabled", Options::audio.music.enabled); readField(node, "enabled", g_audio.music.enabled);
readField(node, "volume", Options::audio.music.volume); readField(node, "volume", g_audio.music.volume);
} }
void parseSound(const fkyaml::node& audio_node) { void parseSound(const fkyaml::node& audio_node) {
if (!audio_node.contains("sound")) { return; } if (!audio_node.contains("sound")) { return; }
const auto& node = audio_node["sound"]; const auto& node = audio_node["sound"];
readField(node, "enabled", Options::audio.sound.enabled); readField(node, "enabled", g_audio.sound.enabled);
readField(node, "volume", Options::audio.sound.volume); readField(node, "volume", g_audio.sound.volume);
} }
void parseAudio(const fkyaml::node& yaml) { void parseAudio(const fkyaml::node& yaml) {
if (!yaml.contains("audio")) { return; } if (!yaml.contains("audio")) { return; }
const auto& node = yaml["audio"]; const auto& node = yaml["audio"];
readField(node, "enabled", Options::audio.enabled); readField(node, "enabled", g_audio.enabled);
readField(node, "volume", Options::audio.volume); readField(node, "volume", g_audio.volume);
parseMusic(node); parseMusic(node);
parseSound(node); parseSound(node);
} }
@@ -423,14 +438,65 @@ namespace {
namespace Options { namespace Options {
// --- Getters de l'estat ---
auto window() -> const Window& { return g_window; }
auto video() -> const Video& { return g_video; }
auto audio() -> const Audio& { return g_audio; }
auto locale() -> const std::string& { return g_locale; }
auto configVersion() -> const std::string& { return g_config_version; }
auto postfxPresets() -> const std::vector<PostFXPreset>& { return g_postfx_presets; }
auto crtpiPresets() -> const std::vector<CrtPiPreset>& { return g_crtpi_presets; }
// --- Setters: muten + persisteixen ---
void setWindowZoom(int value) {
g_window.zoom = value;
saveToFile();
}
void setWindowFullscreen(bool value) {
g_window.fullscreen = value;
saveToFile();
}
void setVideoVSync(bool value) {
g_video.vsync = value;
saveToFile();
}
void setVideoScalingMode(Defaults::ScalingMode value) {
g_video.scaling_mode = value;
saveToFile();
}
void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value) {
g_video.render_info_position = value;
saveToFile();
}
void setVideoRenderInfoShowTime(bool value) {
g_video.render_info_show_time = value;
saveToFile();
}
void setShaderEnabled(bool value) {
g_video.shader.enabled = value;
saveToFile();
}
void setActiveShaderName(const std::string& value) {
g_video.shader.current_shader = value;
saveToFile();
}
void setPostFXPreset(const std::string& value) {
g_video.shader.current_postfx_preset = value;
saveToFile();
}
void setCrtPiPreset(const std::string& value) {
g_video.shader.current_crtpi_preset = value;
saveToFile();
}
void setConfigFile(const std::string& path) { void setConfigFile(const std::string& path) {
config_file_path = path; g_config_file_path = path;
} }
auto loadFromFile() -> bool { auto loadFromFile() -> bool {
if (config_file_path.empty()) { return false; } if (g_config_file_path.empty()) { return false; }
const std::string CONTENT = readFileOrEmpty(config_file_path); const std::string CONTENT = readFileOrEmpty(g_config_file_path);
if (CONTENT.empty()) { if (CONTENT.empty()) {
// Primera execució: creem el fitxer amb els valors per defecte. // Primera execució: creem el fitxer amb els valors per defecte.
return saveToFile(); return saveToFile();
@@ -449,12 +515,12 @@ namespace Options {
if (file_version != Texts::VERSION) { if (file_version != Texts::VERSION) {
std::cout << "Options: config.yaml versió \"" << file_version std::cout << "Options: config.yaml versió \"" << file_version
<< "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n"; << "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n";
backupFile(config_file_path); backupFile(g_config_file_path);
return saveToFile(); return saveToFile();
} }
config_version = file_version; g_config_version = file_version;
if (yaml.contains("locale")) { Options::locale = yaml["locale"].get_value<std::string>(); } if (yaml.contains("locale")) { g_locale = yaml["locale"].get_value<std::string>(); }
parseWindow(yaml); parseWindow(yaml);
parseVideo(yaml); parseVideo(yaml);
parseAudio(yaml); parseAudio(yaml);
@@ -462,84 +528,84 @@ namespace Options {
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n'; std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
std::cout << "Options: reescrivint el fitxer amb defaults.\n"; std::cout << "Options: reescrivint el fitxer amb defaults.\n";
backupFile(config_file_path); backupFile(g_config_file_path);
return saveToFile(); return saveToFile();
} }
} }
auto saveToFile() -> bool { auto saveToFile() -> bool {
if (config_file_path.empty()) { return false; } if (g_config_file_path.empty()) { return false; }
config_version = Texts::VERSION; g_config_version = Texts::VERSION;
std::ostringstream out; std::ostringstream out;
out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n"; out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n";
out << "version: \"" << config_version << "\"\n"; out << "version: \"" << g_config_version << "\"\n";
out << "locale: \"" << locale << "\"\n\n"; out << "locale: \"" << g_locale << "\"\n\n";
out << "window:\n"; out << "window:\n";
out << " zoom: " << window.zoom << "\n"; out << " zoom: " << g_window.zoom << "\n";
out << " fullscreen: " << (window.fullscreen ? "true" : "false") << "\n\n"; out << " fullscreen: " << (g_window.fullscreen ? "true" : "false") << "\n\n";
out << "video:\n"; out << "video:\n";
out << " vsync: " << (video.vsync ? "true" : "false") << "\n"; out << " vsync: " << (g_video.vsync ? "true" : "false") << "\n";
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, video.scaling_mode) << "\"\n"; out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, g_video.scaling_mode) << "\"\n";
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, video.texture_filter) << "\"\n"; out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, g_video.texture_filter) << "\"\n";
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, video.render_info_position) << "\"\n"; out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, g_video.render_info_position) << "\"\n";
out << " render_info_show_time: " << (video.render_info_show_time ? "true" : "false") << "\n"; out << " render_info_show_time: " << (g_video.render_info_show_time ? "true" : "false") << "\n";
out << " gpu:\n"; out << " gpu:\n";
out << " acceleration: " << (video.gpu.acceleration ? "true" : "false") << "\n"; out << " acceleration: " << (g_video.gpu.acceleration ? "true" : "false") << "\n";
out << " preferred_driver: \"" << video.gpu.preferred_driver << "\"\n"; out << " preferred_driver: \"" << g_video.gpu.preferred_driver << "\"\n";
out << " shader:\n"; out << " shader:\n";
out << " enabled: " << (video.shader.enabled ? "true" : "false") << "\n"; out << " enabled: " << (g_video.shader.enabled ? "true" : "false") << "\n";
out << " current_shader: \"" << video.shader.current_shader << "\"\n"; out << " current_shader: \"" << g_video.shader.current_shader << "\"\n";
out << " current_postfx_preset: \"" << video.shader.current_postfx_preset << "\"\n"; out << " current_postfx_preset: \"" << g_video.shader.current_postfx_preset << "\"\n";
out << " current_crtpi_preset: \"" << video.shader.current_crtpi_preset << "\"\n\n"; out << " current_crtpi_preset: \"" << g_video.shader.current_crtpi_preset << "\"\n\n";
out << "audio:\n"; out << "audio:\n";
out << " enabled: " << (audio.enabled ? "true" : "false") << "\n"; out << " enabled: " << (g_audio.enabled ? "true" : "false") << "\n";
out << " volume: " << audio.volume << "\n"; out << " volume: " << g_audio.volume << "\n";
out << " music:\n"; out << " music:\n";
out << " enabled: " << (audio.music.enabled ? "true" : "false") << "\n"; out << " enabled: " << (g_audio.music.enabled ? "true" : "false") << "\n";
out << " volume: " << audio.music.volume << "\n"; out << " volume: " << g_audio.music.volume << "\n";
out << " sound:\n"; out << " sound:\n";
out << " enabled: " << (audio.sound.enabled ? "true" : "false") << "\n"; out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\n";
out << " volume: " << audio.sound.volume << "\n"; out << " volume: " << g_audio.sound.volume << "\n";
return writeFile(config_file_path, out.str()); return writeFile(g_config_file_path, out.str());
} }
auto buildScreenConfig() -> Screen::Config { auto buildScreenConfig() -> Screen::Config {
return { return {
.zoom = window.zoom, .zoom = g_window.zoom,
.fullscreen = window.fullscreen, .fullscreen = g_window.fullscreen,
.vsync = video.vsync, .vsync = g_video.vsync,
.scaling_mode = video.scaling_mode, .scaling_mode = g_video.scaling_mode,
.texture_filter = video.texture_filter, .texture_filter = g_video.texture_filter,
.gpu_acceleration = video.gpu.acceleration, .gpu_acceleration = g_video.gpu.acceleration,
.preferred_driver = video.gpu.preferred_driver, .preferred_driver = g_video.gpu.preferred_driver,
}; };
} }
auto buildAudioConfig() -> ::Audio::Config { auto buildAudioConfig() -> ::Audio::Config {
return { return {
.enabled = audio.enabled, .enabled = g_audio.enabled,
.volume = audio.volume, .volume = g_audio.volume,
.music_enabled = audio.music.enabled, .music_enabled = g_audio.music.enabled,
.music_volume = audio.music.volume, .music_volume = g_audio.music.volume,
.sound_enabled = audio.sound.enabled, .sound_enabled = g_audio.sound.enabled,
.sound_volume = audio.sound.volume, .sound_volume = g_audio.sound.volume,
}; };
} }
void setPostFXFile(const std::string& path) { void setPostFXFile(const std::string& path) {
postfx_file_path = path; g_postfx_file_path = path;
} }
auto loadPostFXFromFile() -> bool { auto loadPostFXFromFile() -> bool {
return loadPresetsFromFile<PostFXPreset>( return loadPresetsFromFile<PostFXPreset>(
postfx_file_path, g_postfx_file_path,
postfx_presets, g_postfx_presets,
&defaultPostFXPresets, &defaultPostFXPresets,
&parsePostFXPreset, &parsePostFXPreset,
&savePostFXToFile, &savePostFXToFile,
@@ -548,21 +614,21 @@ namespace Options {
auto savePostFXToFile() -> bool { auto savePostFXToFile() -> bool {
return savePresetsToFile<PostFXPreset>( return savePresetsToFile<PostFXPreset>(
postfx_file_path, g_postfx_file_path,
postfx_presets, g_postfx_presets,
&writePostFXPreset, &writePostFXPreset,
"Presets PostFX per a AEE 2026. Editable a mà.", "Presets PostFX per a AEE 2026. Editable a mà.",
"Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset."); "Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset.");
} }
void setCrtPiFile(const std::string& path) { void setCrtPiFile(const std::string& path) {
crtpi_file_path = path; g_crtpi_file_path = path;
} }
auto loadCrtPiFromFile() -> bool { auto loadCrtPiFromFile() -> bool {
return loadPresetsFromFile<CrtPiPreset>( return loadPresetsFromFile<CrtPiPreset>(
crtpi_file_path, g_crtpi_file_path,
crtpi_presets, g_crtpi_presets,
&defaultCrtPiPresets, &defaultCrtPiPresets,
&parseCrtPiPreset, &parseCrtPiPreset,
&saveCrtPiToFile, &saveCrtPiToFile,
@@ -571,29 +637,29 @@ namespace Options {
auto saveCrtPiToFile() -> bool { auto saveCrtPiToFile() -> bool {
return savePresetsToFile<CrtPiPreset>( return savePresetsToFile<CrtPiPreset>(
crtpi_file_path, g_crtpi_file_path,
crtpi_presets, g_crtpi_presets,
&writeCrtPiPreset, &writeCrtPiPreset,
"Presets CRT-Pi per a AEE 2026. Editable a mà.", "Presets CRT-Pi per a AEE 2026. Editable a mà.",
"Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset."); "Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset.");
} }
auto buildActiveShader() -> Rendering::ShaderType { auto buildActiveShader() -> Rendering::ShaderType {
if (video.shader.current_shader == "crtpi") { if (g_video.shader.current_shader == "crtpi") {
return Rendering::ShaderType::CRTPI; return Rendering::ShaderType::CRTPI;
} }
return Rendering::ShaderType::POSTFX; return Rendering::ShaderType::POSTFX;
} }
auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame { auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame {
const std::string& active_preset = (video.shader.current_shader == "crtpi") const std::string& active_preset = (g_video.shader.current_shader == "crtpi")
? video.shader.current_crtpi_preset ? g_video.shader.current_crtpi_preset
: video.shader.current_postfx_preset; : g_video.shader.current_postfx_preset;
return { return {
.target_position = video.render_info_position, .target_position = g_video.render_info_position,
.show_time = video.render_info_show_time, .show_time = g_video.render_info_show_time,
.shader_enabled = video.shader.enabled, .shader_enabled = g_video.shader.enabled,
.shader_name = video.shader.current_shader, .shader_name = g_video.shader.current_shader,
.shader_preset = active_preset, .shader_preset = active_preset,
.text_color = text_color, .text_color = text_color,
.shadow_color = shadow_color, .shadow_color = shadow_color,
@@ -603,12 +669,12 @@ namespace Options {
auto buildCrtPiParams() -> Rendering::CrtPiParams { auto buildCrtPiParams() -> Rendering::CrtPiParams {
// Si el shader està deshabilitat, retornem defaults (el dispatch anirà per // Si el shader està deshabilitat, retornem defaults (el dispatch anirà per
// PostFX passthrough, però sent nets per si algú consulta aquests params). // PostFX passthrough, però sent nets per si algú consulta aquests params).
if (!video.shader.enabled) { return {}; } if (!g_video.shader.enabled) { return {}; }
const auto IT = std::ranges::find_if(crtpi_presets, const auto IT = std::ranges::find_if(g_crtpi_presets,
[](const CrtPiPreset& preset) { return preset.name == video.shader.current_crtpi_preset; }); [](const CrtPiPreset& preset) { return preset.name == g_video.shader.current_crtpi_preset; });
if (IT == crtpi_presets.end()) { if (IT == g_crtpi_presets.end()) {
std::cout << "Options: preset CrtPi \"" << video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n"; std::cout << "Options: preset CrtPi \"" << g_video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
return {}; return {};
} }
return { return {
@@ -631,12 +697,12 @@ namespace Options {
auto buildPostFXParams() -> Rendering::PostFXParams { auto buildPostFXParams() -> Rendering::PostFXParams {
// Si el shader està deshabilitat, retornem params a zero → passthrough. // Si el shader està deshabilitat, retornem params a zero → passthrough.
if (!video.shader.enabled) { return {}; } if (!g_video.shader.enabled) { return {}; }
const auto IT = std::ranges::find_if(postfx_presets, const auto IT = std::ranges::find_if(g_postfx_presets,
[](const PostFXPreset& preset) { return preset.name == video.shader.current_postfx_preset; }); [](const PostFXPreset& preset) { return preset.name == g_video.shader.current_postfx_preset; });
if (IT == postfx_presets.end()) { if (IT == g_postfx_presets.end()) {
std::cout << "Options: preset PostFX \"" << video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n"; std::cout << "Options: preset PostFX \"" << g_video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
return {}; return {};
} }
return { return {
+32 -13
View File
@@ -13,6 +13,15 @@
// del config.yaml. Els subsistemes (Screen, Audio, ...) reben structs tipats // del config.yaml. Els subsistemes (Screen, Audio, ...) reben structs tipats
// a través dels build*Config(), de manera que no queden acoblats a aquest // a través dels build*Config(), de manera que no queden acoblats a aquest
// namespace ni al format concret del fitxer. // namespace ni al format concret del fitxer.
//
// L'estat mutable viu en un anonymous namespace dins d'`options.cpp`: els
// callers el llegeixen via getters const-ref (`window()`, `video()`, ...) i
// el muten via setters específics que persisteixen a config.yaml. Els
// setters NO notifiquen els subsistemes (Screen/Audio): el caller (típic.
// `core/input/global_inputs.cpp`) crida després el setter del subsistema.
// Aquesta asimetria preserva la direcció `Director → Screen/Audio` i evita
// que Options aprenga del layout dels subsistemes (decisió WONTFIX d'aquest
// refactor: veure docs/audit-game.md → COUP-03).
namespace Options { namespace Options {
struct Window { struct Window {
@@ -95,20 +104,30 @@ namespace Options {
Sound sound{}; Sound sound{};
}; };
// --- Estat global (inline vars per a que una sola definició viva a tot // --- Getters de l'estat (read-only) ---
// el programa sense necessitar un .cpp extra per a cada una) --- [[nodiscard]] auto window() -> const Window&;
inline std::string config_version{}; [[nodiscard]] auto video() -> const Video&;
inline std::string config_file_path{}; [[nodiscard]] auto audio() -> const Audio&;
inline std::string postfx_file_path{}; [[nodiscard]] auto locale() -> const std::string&;
inline std::string crtpi_file_path{}; [[nodiscard]] auto configVersion() -> const std::string&;
inline std::string locale{Defaults::I18n::LOCALE}; [[nodiscard]] auto postfxPresets() -> const std::vector<PostFXPreset>&;
inline Window window{}; [[nodiscard]] auto crtpiPresets() -> const std::vector<CrtPiPreset>&;
inline Video video{};
inline Audio audio{};
inline std::vector<PostFXPreset> postfx_presets{};
inline std::vector<CrtPiPreset> crtpi_presets{};
// --- API pública --- // --- Setters: muten + persisteixen a config.yaml ---
// No notifiquen els subsistemes; el caller ha de propagar el canvi al
// subsistema corresponent (p.ex. Screen::setWindowZoom) per separat.
void setWindowZoom(int value);
void setWindowFullscreen(bool value);
void setVideoVSync(bool value);
void setVideoScalingMode(Defaults::ScalingMode value);
void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value);
void setVideoRenderInfoShowTime(bool value);
void setShaderEnabled(bool value);
void setActiveShaderName(const std::string& value); // "postfx" | "crtpi"
void setPostFXPreset(const std::string& value);
void setCrtPiPreset(const std::string& value);
// --- API pública: càrrega/desament de fitxers ---
void setConfigFile(const std::string& path); void setConfigFile(const std::string& path);
auto loadFromFile() -> bool; auto loadFromFile() -> bool;
auto saveToFile() -> bool; auto saveToFile() -> bool;
+40 -20
View File
@@ -17,6 +17,7 @@
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
@@ -97,7 +98,7 @@ GameScene::GameScene(Config config)
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F); static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F); const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F); const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
players_.emplace_back(std::make_unique<Player>(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1)); players_.emplace_back(std::make_unique<Player>(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo()));
// Connectem el Player al bus perquè emeti `on_tile_entered` i els // Connectem el Player al bus perquè emeti `on_tile_entered` i els
// sarcòfags rebin les notificacions de pas per tile. // sarcòfags rebin les notificacions de pas per tile.
@@ -123,6 +124,17 @@ void GameScene::update(int delta_ms) {
// als sarcòfags via la subscripció del bus, i aquest `update` pot // als sarcòfags via la subscripció del bus, i aquest `update` pot
// aplicar la transició OPENING → OPEN en el mateix frame. // aplicar la transició OPENING → OPEN en el mateix frame.
entity_manager_.update(DT); entity_manager_.update(DT);
// Detecció de contacte player↔entitat després que totes les entitats
// hagin mogut aquest frame. La porta (si està oberta) emet
// `level_complete`; la momia/trampa emet `player_hit`. Els sarcs, encara
// que tinguen collides=true, tenen la caixa lògica fora del camí del
// jugador (gràcies a Map::collides al Player::update), així que aquí
// no generen falsos contactes.
std::vector<Player*> player_ptrs;
player_ptrs.reserve(players_.size());
std::ranges::transform(players_, std::back_inserter(player_ptrs), [](const auto& p) { return p.get(); });
entity_manager_.checkPlayerCollisions(player_ptrs);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW)); RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
Notifier::get().update(delta_ms); Notifier::get().update(delta_ms);
} }
@@ -137,25 +149,33 @@ void GameScene::render() {
// col·lisions del jugador. // col·lisions del jugador.
tilemap_.render(); tilemap_.render();
// Entitats (sarcòfags, porta, momia, antorxa...) dibuixades per sobre // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
// dels tiles però sota dels players. Z-order fi (interleaving per feetY // els actors amb els peus més amunt queden dibuixats primer (al fons),
// amb els sarcs) queda per a una tasca posterior; "sin pulido". // els més avall al davant. Així un sarc situat per darrere d'un player
entity_manager_.renderAll(); // queda tapat per ell, i un sarc a davant el tapa a ell.
struct Renderable {
// Amb un sol jugador, l'ordre és irrellevant i ens estalviem el vector float feet_y;
// temporal. Quan n'hi haja més d'un (o quan els sarcs siguen sprites) Entity* entity; // nullable; si és nullptr, s'usa `player`.
// caldrà ordenar per `getFeetY()` ascendent i interleaveing amb les Player* player; // nullable; si és nullptr, s'usa `entity`.
// files de sarcs — l'ordre ascendent manté els més al fons darrere. };
if (players_.size() <= 1) { std::vector<Renderable> order;
for (auto& player : players_) { player->render(); } order.reserve(entity_manager_.entityCount() + players_.size());
} else { const auto ENTITIES = entity_manager_.getEntities();
std::vector<Player*> sorted; std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
sorted.reserve(players_.size()); return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
std::ranges::transform(players_, std::back_inserter(sorted), [](const auto& p) { return p.get(); }); });
std::ranges::sort(sorted, [](const Player* a, const Player* b) { std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return a->getFeetY() < b->getFeetY(); return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
}); });
for (auto* player : sorted) { player->render(); } std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
return a.feet_y < b.feet_y;
});
for (const auto& r : order) {
if (r.entity != nullptr) {
EntityManager::renderEntity(*r.entity);
} else if (r.player != nullptr) {
r.player->render();
}
} }
Notifier::get().render(); Notifier::get().render();
+85
View File
@@ -0,0 +1,85 @@
#include "game/scenes/intro_scene.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <string>
#include <utility>
#include "core/input/global_inputs.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
namespace {
constexpr int NAME_Y_OFFSET = -8;
constexpr int TIMER_Y_OFFSET = 8;
// TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a
// docs/audit-core-rendering.md).
const char* const SCENE_NAME = "INTRO";
} // namespace
IntroScene::IntroScene(std::shared_ptr<Text> font, SceneFactory next_factory)
: font_(std::move(font)),
next_factory_(std::move(next_factory)) {
std::cout << "[scene] " << SCENE_NAME << " start\n";
}
IntroScene::~IntroScene() = default;
void IntroScene::update(int delta_ms) {
GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) {
wants_quit_ = true;
return;
}
timer_ms_ -= delta_ms;
const bool SKIPPED = std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) {
next_scene_ = next_factory_();
}
Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
}
void IntroScene::render() {
constexpr std::uint32_t BLACK_ARGB = 0xFF000000U;
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB);
if (font_) {
const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
SCENE_NAME,
1,
Defaults::UI::NOTIFICATION_TEXT);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
std::to_string(SECS_LEFT),
1,
Defaults::UI::NOTIFICATION_TEXT);
}
Notifier::get().render();
RenderInfo::get().render();
Screen::get().present();
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <functional>
#include <memory>
#include "core/scene/scene.hpp"
class Text;
// Escena placeholder de la introducció. Com la resta de placeholders, durada
// fixa i skip amb tecla no reservada; quan expira encadena a la factory
// injectada (en producció: GameScene).
class IntroScene : public Scene {
public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>;
static constexpr int DURATION_MS = 5000;
IntroScene(std::shared_ptr<Text> font, SceneFactory next_factory);
~IntroScene() override;
void update(int delta_ms) override;
void render() override;
private:
std::shared_ptr<Text> font_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
};
+86
View File
@@ -0,0 +1,86 @@
#include "game/scenes/logo_scene.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <string>
#include <utility>
#include "core/input/global_inputs.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
namespace {
constexpr int NAME_Y_OFFSET = -8;
constexpr int TIMER_Y_OFFSET = 8;
// TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a
// docs/audit-core-rendering.md). De moment std::cout per rastrejar el
// flux d'escenes als smoke runs.
const char* const SCENE_NAME = "LOGO";
} // namespace
LogoScene::LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory)
: font_(std::move(font)),
next_factory_(std::move(next_factory)) {
std::cout << "[scene] " << SCENE_NAME << " start\n";
}
LogoScene::~LogoScene() = default;
void LogoScene::update(int delta_ms) {
GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) {
wants_quit_ = true;
return;
}
timer_ms_ -= delta_ms;
const bool SKIPPED = std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) {
next_scene_ = next_factory_();
}
Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
}
void LogoScene::render() {
constexpr std::uint32_t BLACK_ARGB = 0xFF000000U;
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB);
if (font_) {
const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
SCENE_NAME,
1,
Defaults::UI::NOTIFICATION_TEXT);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
std::to_string(SECS_LEFT),
1,
Defaults::UI::NOTIFICATION_TEXT);
}
Notifier::get().render();
RenderInfo::get().render();
Screen::get().present();
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <functional>
#include <memory>
#include "core/scene/scene.hpp"
class Text;
// Escena placeholder del logotip. Durada fixa (5 s) o skip amb qualsevol tecla
// no reservada. Quan expira, encadena amb la `SceneFactory` que se li injecta
// al ctor — així l'escena no coneix el tipus concret de la següent, i es pot
// exercir en tests sense arrossegar GameScene.
class LogoScene : public Scene {
public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>;
static constexpr int DURATION_MS = 5000;
LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory);
~LogoScene() override;
void update(int delta_ms) override;
void render() override;
private:
std::shared_ptr<Text> font_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
};
+15
View File
@@ -0,0 +1,15 @@
#include "game/scenes/placeholder_input.hpp"
#include <algorithm>
#include "core/input/key_config.hpp"
namespace Placeholder {
auto isReservedScancode(SDL_Scancode sc) -> bool {
if (sc == SDL_SCANCODE_UNKNOWN) { return false; }
const auto& bindings = KeyConfig::get().bindings();
return std::ranges::any_of(bindings, [sc](const auto& kv) { return kv.second == sc; });
}
} // namespace Placeholder
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <SDL3/SDL.h>
// Helper per als placeholders del flux LOGO → TITLE → INTRO → GAME. Les escenes
// placeholder avancen amb qualsevol tecla excepte les registrades a KeyConfig
// (F1–F12, Escape), que mantenen el seu comportament habitual via GlobalInputs.
namespace Placeholder {
// Retorna true si el scancode coincideix amb alguna tecla declarada al
// KeyConfig::get() (les carregades de data/input/keys.yaml). Si KeyConfig
// no s'ha carregat, totes les consultes retornaran SDL_SCANCODE_UNKNOWN i
// aquesta funció tornarà false, de manera que el test/smoke no depèn de
// l'estat global de la configuració.
[[nodiscard]] auto isReservedScancode(SDL_Scancode sc) -> bool;
} // namespace Placeholder
+85
View File
@@ -0,0 +1,85 @@
#include "game/scenes/title_scene.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <string>
#include <utility>
#include "core/input/global_inputs.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
namespace {
constexpr int NAME_Y_OFFSET = -8;
constexpr int TIMER_Y_OFFSET = 8;
// TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a
// docs/audit-core-rendering.md).
const char* const SCENE_NAME = "TITLE";
} // namespace
TitleScene::TitleScene(std::shared_ptr<Text> font, SceneFactory next_factory)
: font_(std::move(font)),
next_factory_(std::move(next_factory)) {
std::cout << "[scene] " << SCENE_NAME << " start\n";
}
TitleScene::~TitleScene() = default;
void TitleScene::update(int delta_ms) {
GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) {
wants_quit_ = true;
return;
}
timer_ms_ -= delta_ms;
const bool SKIPPED = std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) {
next_scene_ = next_factory_();
}
Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
}
void TitleScene::render() {
constexpr std::uint32_t BLACK_ARGB = 0xFF000000U;
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB);
if (font_) {
const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
SCENE_NAME,
1,
Defaults::UI::NOTIFICATION_TEXT);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
std::to_string(SECS_LEFT),
1,
Defaults::UI::NOTIFICATION_TEXT);
}
Notifier::get().render();
RenderInfo::get().render();
Screen::get().present();
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <functional>
#include <memory>
#include "core/scene/scene.hpp"
class Text;
// Escena placeholder del títol. Mateix comportament que LogoScene — duració
// fixa de 5 s i skip amb tecla no reservada —, però classe separada perquè
// quan divergeixi en contingut real (menú, seleccions, animacions) creixi en
// el seu propi fitxer sense haver de refactoritzar cap base comuna.
class TitleScene : public Scene {
public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>;
static constexpr int DURATION_MS = 5000;
TitleScene(std::shared_ptr<Text> font, SceneFactory next_factory);
~TitleScene() override;
void update(int delta_ms) override;
void render() override;
private:
std::shared_ptr<Text> font_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
};
+18
View File
@@ -90,6 +90,24 @@ namespace Defaults {
// pel codi. Si algun dia es volen temes múltiples, es pot moure a YAML // pel codi. Si algun dia es volen temes múltiples, es pot moure a YAML
// sense més impacte que dos accessors extra. // sense més impacte que dos accessors extra.
namespace UI { namespace UI {
// Fonts d'UI centralitzades: un únic lloc per conèixer la carpeta
// (data/fonts/), el nom-clau al Resource::Cache i els fitxers .gif /
// .fnt de cada font. Afegir-ne una nova és una constant ací i ja.
namespace Font {
constexpr const char* DIR = "data/fonts/";
// HUD/Notifier/RenderInfo: font principal de UI del joc.
constexpr const char* HUD_NAME = "8bithud";
constexpr const char* HUD_GIF = "data/fonts/8bithud.gif";
constexpr const char* HUD_FNT = "data/fonts/8bithud.fnt";
// Placeholders (LOGO / TITLE / INTRO): font temporal fins que els
// placeholders es substitueixin per les seues escenes finals.
constexpr const char* PLACEHOLDER_NAME = "ari-w9500";
constexpr const char* PLACEHOLDER_GIF = "data/fonts/ari-w9500.gif";
constexpr const char* PLACEHOLDER_FNT = "data/fonts/ari-w9500.fnt";
} // namespace Font
// Overlay de RenderInfo (F12). // Overlay de RenderInfo (F12).
constexpr Uint8 RENDER_INFO_TEXT = 47; constexpr Uint8 RENDER_INFO_TEXT = 47;
constexpr Uint8 RENDER_INFO_SHADOW = 34; constexpr Uint8 RENDER_INFO_SHADOW = 34;
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.15.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.19.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {
+85
View File
@@ -14,6 +14,12 @@
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame. // T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva: // T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16. // el sprite.height no canvia la caixa lògica 48×16.
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
// retornada coincideix amb les dimensions del bloc
// `collision:` del YAML; sense caixa, coincideix amb el
// sprite sencer (pixel_rect).
// T13 mummy step — camina en línia recta pel playfield i gira en topar
// un mur (insidePlayfield=false) o sarc (Map::collides).
// //
// Execució (des de la ruta del repo): // Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure // make build && cd build && ctest --output-on-failure
@@ -342,6 +348,83 @@ namespace {
REQUIRE(sarc->getPerimeterSize() == 8); REQUIRE(sarc->getPerimeterSize() == 8);
} }
void testCollisionBox() {
std::cout << "[T12] collision box encastada al sprite\n";
// Torch sense bloc `collision:` → getCollisionRect == pixel_rect.
fkyaml::node torch_tmpl = EntityLoader::loadTemplate("torch");
Torch torch(torch_tmpl);
torch.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 16.0F, .h = 16.0F});
const auto TRECT = torch.getCollisionRect();
REQUIRE(static_cast<int>(TRECT.w) == 16);
REQUIRE(static_cast<int>(TRECT.h) == 16);
REQUIRE(static_cast<int>(TRECT.x) == 100);
REQUIRE(static_cast<int>(TRECT.y) == 200);
// Mummy amb `collision: { size: [12, 12] }` i sprite 12×24 → caixa
// auto-centrada bottom dels peus: offset_x = (12-12)/2 = 0,
// offset_y = 24 - 12 = 12. Amb pixel_rect a (100, 200, 12, 24),
// la caixa ha de sortir a (100, 212, 12, 12) i feet_y = 224.
fkyaml::node mummy_tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(mummy_tmpl);
// Apliquem el CollisionBox com fa el loader (readCollisionBox)
// amb sprite 12×24, offset calculat per defecte.
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
mummy.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 12.0F, .h = 24.0F});
const auto MRECT = mummy.getCollisionRect();
REQUIRE(static_cast<int>(MRECT.x) == 100);
REQUIRE(static_cast<int>(MRECT.y) == 212);
REQUIRE(static_cast<int>(MRECT.w) == 12);
REQUIRE(static_cast<int>(MRECT.h) == 12);
REQUIRE(static_cast<int>(mummy.getFeetY()) == 224);
}
void testMummyStep() {
std::cout << "[T13] momia step amb col·lisió i gir\n";
// Cal Map::loadFrom (T9 ja el carrega, però el rerrumigo ací per
// aïllar la intenció del test).
Map::loadFrom("data/maps/pyramid.yaml");
// Momia al corredor exterior superior: playfield_y=48, alçada del
// corredor exterior = outer_corridor (16). Així el base box 12×12
// queda a y∈[52, 64] aprox. Collision box: offset_y=12 (sprite
// 12×24 amb peus 12×12 a baix). pixel_rect.x/y triat perquè els
// peus quedin al tile centrat (4, 3) del tilemap → peus a
// (col*16-8, row*16+0) i alineats bottom del tile.
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Posem els peus ben dins d'un corredor horitzontal lliure. El
// corredor superior exterior va de y=48 a y=64 (alçada 16). Amb
// collision_y=12, sprite 12×24 → pixel_rect.y perquè feetY=64.
// feetY = pixel_rect.y + offset_y + h = pixel_rect.y + 24 → pixel_rect.y = 40.
// X: fora d'un sarc; (x=60) és al corredor extern abans dels sarcs.
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
const auto BEFORE = mummy.getPixelRect();
// Velocitat llegida del YAML és 40 px/s. A 0.1s hauria d'avançar
// 4 px en X (direcció inicial right=+1,0). Com que està en un
// corredor lliure, el step s'aplica.
mummy.step(0.1F);
const auto AFTER = mummy.getPixelRect();
REQUIRE(AFTER.x > BEFORE.x);
REQUIRE(AFTER.y == BEFORE.y);
// Posem la momia contra la paret dreta del playfield: avançar un
// frame la trauria fora → el step ha de girar. No assumim cap nova
// direcció concreta (perpendiculars lliures poden ser vàlides); el
// contracte és "ja no continua cap a la dreta".
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
const auto POS_BEFORE = mummy.getPixelRect();
mummy.step(0.1F);
const auto POS_AFTER = mummy.getPixelRect();
// No ha avançat cap a la dreta en el frame de gir.
REQUIRE(POS_AFTER.x == POS_BEFORE.x);
REQUIRE(POS_AFTER.y == POS_BEFORE.y);
// I ja no apunta cap a la dreta (ha triat perpendicular o U-turn).
REQUIRE(mummy.getDirX() <= 0.0F);
}
void testTorchAnimation() { void testTorchAnimation() {
std::cout << "[T10] torch animation wired from yaml\n"; std::cout << "[T10] torch animation wired from yaml\n";
// Regressió: el torch.yaml declarava `frames: [0]` i el render no // Regressió: el torch.yaml declarava `frames: [0]` i el render no
@@ -379,6 +462,8 @@ auto main() -> int {
testDecorationFlags(); testDecorationFlags();
testLoadFromLevel(); testLoadFromLevel();
testSarcophagusYaml(); testSarcophagusYaml();
testCollisionBox();
testMummyStep();
testTorchAnimation(); testTorchAnimation();
if (g_failures == 0) { if (g_failures == 0) {
+158
View File
@@ -0,0 +1,158 @@
// Smoke tests dels helpers purs del flux d'escenes: parser de debug.yaml i
// classificació de tecles reservades. Stand-alone, sense frameworks externs.
//
// Tests coberts:
// T1 parseStartScene — "LOGO"/"TITLE"/"INTRO"/"GAME" donen l'enum correcte.
// T2 parseStartScene — valor desconegut o fitxer buit → GAME (fallback).
// T3 parseStartScene — YAML invàlid no llança, cau a GAME.
// T4 resolveStartScene — fitxer inexistent → GAME sense crash.
// T5 resolveStartScene — fitxer real amb valor vàlid és respectat.
// T6 resolveStartScene — fitxer real amb valor invàlid → GAME.
// T7 isReservedScancode — F1/F12/Escape reservats després de KeyConfig::load.
// T8 isReservedScancode — SPACE i fletxes no són reservades.
// T9 isReservedScancode — SDL_SCANCODE_UNKNOWN sempre fals.
//
// La lògica d'update/render dels placeholders (transició per timeout o
// keypress) no es testeja ací perquè `update()` crida GlobalInputs, que
// arrossega Screen/Notifier/RenderInfo i tot l'stack SDL. Aquesta integració
// la cobreix el smoke run del binari sota xvfb-run.
//
// Execució:
// make build && cd build && ctest --output-on-failure
// O bé:
// ./build/test_scene_flow
#include <SDL3/SDL.h>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include "core/input/key_config.hpp"
#include "game/debug_config.hpp"
#include "game/scenes/placeholder_input.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
void writeTemp(const std::string& path, const std::string& content) {
std::ofstream file(path);
file << content;
}
void testParseStartSceneValid() {
std::cout << "T1 parseStartScene valid\n";
CHECK(DebugConfig::parseStartScene("start_scene: LOGO") == DebugConfig::StartScene::LOGO);
CHECK(DebugConfig::parseStartScene("start_scene: TITLE") == DebugConfig::StartScene::TITLE);
CHECK(DebugConfig::parseStartScene("start_scene: INTRO") == DebugConfig::StartScene::INTRO);
CHECK(DebugConfig::parseStartScene("start_scene: GAME") == DebugConfig::StartScene::GAME);
}
void testParseStartSceneFallback() {
std::cout << "T2 parseStartScene fallback\n";
CHECK(DebugConfig::parseStartScene("") == DebugConfig::StartScene::GAME);
CHECK(DebugConfig::parseStartScene("start_scene: FOO") == DebugConfig::StartScene::GAME);
CHECK(DebugConfig::parseStartScene("other_field: 42") == DebugConfig::StartScene::GAME);
}
void testParseStartSceneInvalidYaml() {
std::cout << "T3 parseStartScene YAML invàlid\n";
// Text amb caràcters que fan petar el parser: indentació incoherent +
// claus desbalancejades. Si el nostre try/catch no està bé, això
// propagaria una excepció i trencaria el test.
CHECK(DebugConfig::parseStartScene("{{{unclosed: [") == DebugConfig::StartScene::GAME);
}
void testResolveStartSceneMissing() {
std::cout << "T4 resolveStartScene fitxer inexistent\n";
CHECK(DebugConfig::resolveStartScene("/tmp/aee_2026_nonexistent_XYZ.yaml") == DebugConfig::StartScene::GAME);
}
void testResolveStartSceneValid() {
std::cout << "T5 resolveStartScene fitxer vàlid\n";
const auto PATH = std::filesystem::temp_directory_path() / "aee_2026_test_debug_valid.yaml";
writeTemp(PATH.string(), "start_scene: TITLE\n");
CHECK(DebugConfig::resolveStartScene(PATH.string()) == DebugConfig::StartScene::TITLE);
std::filesystem::remove(PATH);
}
void testResolveStartSceneInvalid() {
std::cout << "T6 resolveStartScene valor invàlid\n";
const auto PATH = std::filesystem::temp_directory_path() / "aee_2026_test_debug_invalid.yaml";
writeTemp(PATH.string(), "start_scene: WUT\n");
CHECK(DebugConfig::resolveStartScene(PATH.string()) == DebugConfig::StartScene::GAME);
std::filesystem::remove(PATH);
}
void testCreateDefaultIfMissing() {
std::cout << "T7 createDefaultIfMissing\n";
const auto PATH = std::filesystem::temp_directory_path() / "aee_2026_test_debug_create.yaml";
std::filesystem::remove(PATH);
CHECK(!std::filesystem::exists(PATH));
DebugConfig::createDefaultIfMissing(PATH.string());
CHECK(std::filesystem::exists(PATH));
CHECK(DebugConfig::resolveStartScene(PATH.string()) == DebugConfig::StartScene::GAME);
// Segona invocació: ha de ser idempotent (no reescriu si ja existeix).
writeTemp(PATH.string(), "start_scene: LOGO\n");
DebugConfig::createDefaultIfMissing(PATH.string());
CHECK(DebugConfig::resolveStartScene(PATH.string()) == DebugConfig::StartScene::LOGO);
std::filesystem::remove(PATH);
}
void testIsReservedScancodeAfterLoad() {
std::cout << "T8 isReservedScancode després de KeyConfig::load\n";
KeyConfig::get().load("data/input/keys.yaml");
CHECK(Placeholder::isReservedScancode(SDL_SCANCODE_F1));
CHECK(Placeholder::isReservedScancode(SDL_SCANCODE_F12));
CHECK(Placeholder::isReservedScancode(SDL_SCANCODE_ESCAPE));
}
void testIsReservedScancodeNonReserved() {
std::cout << "T9 isReservedScancode per tecles no reservades\n";
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_SPACE));
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_RETURN));
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_UP));
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_DOWN));
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_LEFT));
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_RIGHT));
}
void testIsReservedScancodeUnknown() {
std::cout << "T10 isReservedScancode amb UNKNOWN\n";
CHECK(!Placeholder::isReservedScancode(SDL_SCANCODE_UNKNOWN));
}
} // namespace
auto main() -> int {
testParseStartSceneValid();
testParseStartSceneFallback();
testParseStartSceneInvalidYaml();
testResolveStartSceneMissing();
testResolveStartSceneValid();
testResolveStartSceneInvalid();
testCreateDefaultIfMissing();
testIsReservedScancodeAfterLoad();
testIsReservedScancodeNonReserved();
testIsReservedScancodeUnknown();
if (g_failures == 0) {
std::cout << "OK: tots els tests de scene flow passen\n";
return 0;
}
std::cerr << g_failures << " tests han fallat\n";
return 1;
}