Compare commits

...
15 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
61 changed files with 2460 additions and 312 deletions

No files matched your search

+36 -4
View File
@@ -76,7 +76,12 @@ set(APP_SOURCES
source/core/resources/palette_loader.cpp
source/utils/easing.cpp
source/utils/utils.cpp
source/game/debug_config.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/entities/player.cpp
source/game/map/map.cpp
@@ -150,14 +155,19 @@ target_include_directories(${PROJECT_NAME} PRIVATE
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
# Copia recursius de la carpeta data/ al costat del binari per que les rutes
# relatives ("data/player/player.gif") funcionen sense desplegament.
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
# Còpia recursiva de data/ al costat del binari perquè les rutes relatives
# ("data/player/player.gif") funcionin sense desplegament. Es defineix com
# 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
"${CMAKE_SOURCE_DIR}/data"
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/data"
"${CMAKE_BINARY_DIR}/data"
COMMENT "Copying data/ next to the binary"
)
add_dependencies(${PROJECT_NAME} sync_data)
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
target_compile_definitions(${PROJECT_NAME} PRIVATE
@@ -255,6 +265,7 @@ option(BUILD_TESTS "Build smoke tests" ON)
if(BUILD_TESTS)
add_executable(test_entities_smoke
tests/test_entities_smoke.cpp
source/core/resources/resource_helper.cpp
source/game/map/map.cpp
${ENTITY_SOURCES}
)
@@ -268,4 +279,25 @@ if(BUILD_TESTS)
add_test(NAME entities_smoke
COMMAND test_entities_smoke
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()
+69 -18
View File
@@ -14,47 +14,66 @@ endif
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:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
build: configure
@cmake --build $(BUILDDIR) -j$(JOBS)
run: build
@./$(BUILDDIR)/$(PROJECT)
release:
all:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@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:
@echo "$(PROJECT) $(VERSION) ($(GIT_HASH))"
clean:
@rm -rf $(BUILDDIR)
rebuild: clean build
rebuild: clean all
# ==============================================================================
# CODE QUALITY (delegados a cmake)
# ==============================================================================
format: configure
format:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@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
tidy: configure
tidy:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@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
cppcheck: configure
cppcheck:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target cppcheck
# ==============================================================================
@@ -63,3 +82,35 @@ cppcheck: configure
hooks-install:
@git config core.hooksPath .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

+17 -8
View File
@@ -4,23 +4,32 @@ sprite:
path: mummy.gif
size: [12, 24]
tile_size: [1, 1]
# La momia usa falsa perspectiva: el sprite fa 24 px d'alt però la caixa
# AABB només ocupa els 12×12 de baix (els "peus"). L'spawn `position` del
# map és el tile dels peus: el sprite es centra horitzontalment i la
# seua vora inferior s'alinea amb la vora inferior del tile.
# 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: [12, 12]
size: [16, 16]
base_size: [12, 12]
flags:
collides: true
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:
idle: { frames: [0], fps: 1, loop: true }
walk: { frames: [0], fps: 8, loop: true }
walk_down: { frames: [0, 1, 2, 3], 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:
type: corridor_wanderer
direction_choice: random
initial_direction: right
stats:
speed: 40
speed: 30
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]
- type: mummy
position: [10, 7]
- type: mummy
position: [7, 9]
+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`.
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:
- `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-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 |
## 5. Inconsistències internes
| 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-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 |
@@ -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 |
|----|--------------|----------|-----------|-------|-------|
| 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-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.
+30 -2
View File
@@ -248,9 +248,37 @@ Tanca els hooks d'IA i col·lisió que quedaven apuntalats a les fases 1–3.
- **Smoke tests**: T12 (CollisionBox), T13 (Mummy::step), T11 (sarc YAML),
T10 (torch anim). Total 14/14.
## 7. Estat global
## 7. Fase 5 — Convenció de caixa "peus" com el jugador + animacions direccionals (DONE)
- **Andamiatge, integració, render animat, falsa perspectiva, moviment**: DONE. Els 26 actors del `pyramid.yaml` (4 torches, 20 sarcs, 1 door, 1 mummy) es carreguen, animen, es col·loquen amb l'anchor correcte, col·lisionen segons caixa lògica i el z-order interleava per peus.
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.
+13 -6
View File
@@ -52,12 +52,17 @@ impedeix avançar, l'animació `walk*` s'atura i es força la variant
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. `AnimatedSprite::update` integra la velocitat; la col·lisió
eix-separada reverteix cada eix si `Map::collides(baseBox())`.
4. Després de `clampToPlayfield`, es compara la posició final amb el
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_`.
5. Si hi ha hagut moviment real (incloent-hi snap), s'aplica la variant
`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
@@ -70,7 +75,9 @@ mirant amunt.
- DONE: sis animacions al YAML, `facing_` persistent, flip condicionat,
`stand` automàtic a la col·lisió.
- Build i smoke tests (14/14) verdes.
- 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
+52 -51
View File
@@ -42,83 +42,86 @@ namespace GlobalInputs {
}
void onDecZoom() {
if (Options::window.fullscreen) { return; }
if (Options::window.zoom <= MIN_ZOOM) { return; }
Options::window.zoom--;
Screen::get().setWindowZoom(Options::window.zoom);
Options::saveToFile();
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom));
if (Options::window().fullscreen) { return; }
if (Options::window().zoom <= MIN_ZOOM) { return; }
const int NEW_ZOOM = Options::window().zoom - 1;
Options::setWindowZoom(NEW_ZOOM);
Screen::get().setWindowZoom(NEW_ZOOM);
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
}
void onIncZoom() {
if (Options::window.fullscreen) { return; }
if (Options::window.zoom >= MAX_ZOOM) { return; }
Options::window.zoom++;
Screen::get().setWindowZoom(Options::window.zoom);
Options::saveToFile();
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom));
if (Options::window().fullscreen) { return; }
if (Options::window().zoom >= MAX_ZOOM) { return; }
const int NEW_ZOOM = Options::window().zoom + 1;
Options::setWindowZoom(NEW_ZOOM);
Screen::get().setWindowZoom(NEW_ZOOM);
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
}
void onFullscreen() {
Options::window.fullscreen = !Options::window.fullscreen;
Screen::get().setFullscreen(Options::window.fullscreen);
Options::saveToFile();
notifyLocale(Options::window.fullscreen ? "input.fullscreen_on" : "input.fullscreen_off");
const bool NEW_FS = !Options::window().fullscreen;
Options::setWindowFullscreen(NEW_FS);
Screen::get().setFullscreen(NEW_FS);
notifyLocale(NEW_FS ? "input.fullscreen_on" : "input.fullscreen_off");
}
void onToggleVSync() {
Options::video.vsync = !Options::video.vsync;
Screen::get().setVSync(Options::video.vsync);
Options::saveToFile();
notifyLocale(Options::video.vsync ? "input.vsync_on" : "input.vsync_off");
const bool NEW_VSYNC = !Options::video().vsync;
Options::setVideoVSync(NEW_VSYNC);
Screen::get().setVSync(NEW_VSYNC);
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
}
void onToggleShader() {
Options::video.shader.enabled = !Options::video.shader.enabled;
const bool NEW_ENABLED = !Options::video().shader.enabled;
Options::setShaderEnabled(NEW_ENABLED);
reapplyShader();
Options::saveToFile();
notifyLocale(Options::video.shader.enabled ? "input.shader_on" : "input.shader_off");
notifyLocale(NEW_ENABLED ? "input.shader_on" : "input.shader_off");
}
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();
Options::saveToFile();
Notification::notify(Locale::get().text("input.shader") + ": " + Options::video.shader.current_shader);
Notification::notify(Locale::get().text("input.shader") + ": " + NEW_NAME);
}
// 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.
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 (Options::crtpi_presets.empty()) { return; }
auto it = std::ranges::find_if(Options::crtpi_presets,
[](const auto& preset) { return preset.name == Options::video.shader.current_crtpi_preset; });
std::size_t idx = (it == Options::crtpi_presets.end()) ? 0 : std::distance(Options::crtpi_presets.begin(), it);
idx = (idx + 1) % Options::crtpi_presets.size();
Options::video.shader.current_crtpi_preset = Options::crtpi_presets[idx].name;
const auto& presets = Options::crtpiPresets();
if (presets.empty()) { return; }
auto it = std::ranges::find_if(presets,
[](const auto& preset) { return preset.name == Options::video().shader.current_crtpi_preset; });
std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
idx = (idx + 1) % presets.size();
new_preset = presets[idx].name;
Options::setCrtPiPreset(new_preset);
} else {
if (Options::postfx_presets.empty()) { return; }
auto it = std::ranges::find_if(Options::postfx_presets,
[](const auto& preset) { return preset.name == Options::video.shader.current_postfx_preset; });
std::size_t idx = (it == Options::postfx_presets.end()) ? 0 : std::distance(Options::postfx_presets.begin(), it);
idx = (idx + 1) % Options::postfx_presets.size();
Options::video.shader.current_postfx_preset = Options::postfx_presets[idx].name;
const auto& presets = Options::postfxPresets();
if (presets.empty()) { return; }
auto it = std::ranges::find_if(presets,
[](const auto& preset) { return preset.name == Options::video().shader.current_postfx_preset; });
std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
idx = (idx + 1) % presets.size();
new_preset = presets[idx].name;
Options::setPostFXPreset(new_preset);
}
reapplyShader();
Options::saveToFile();
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);
Notification::notify(Locale::get().text("input.preset") + ": " + new_preset);
}
void onNextScalingMode() {
using Mode = Defaults::ScalingMode;
Options::video.scaling_mode = static_cast<Mode>((static_cast<int>(Options::video.scaling_mode) + 1) % 5);
Screen::get().setScalingMode(Options::video.scaling_mode);
Options::saveToFile();
const auto NEW_MODE = static_cast<Mode>((static_cast<int>(Options::video().scaling_mode) + 1) % 5);
Options::setVideoScalingMode(NEW_MODE);
Screen::get().setScalingMode(NEW_MODE);
const char* mode_key = nullptr;
switch (Options::video.scaling_mode) {
switch (NEW_MODE) {
case Mode::DISABLED:
mode_key = "input.scaling_disabled";
break;
@@ -139,14 +142,12 @@ namespace GlobalInputs {
}
void onCycleRenderInfo() {
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::saveToFile();
const int NEXT = (static_cast<int>(Options::video().render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT;
Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
}
void onToggleRenderTime() {
Options::video.render_info_show_time = !Options::video.render_info_show_time;
Options::saveToFile();
Options::setVideoRenderInfoShowTime(!Options::video().render_info_show_time);
}
void onExit() {
+7 -5
View File
@@ -4,6 +4,7 @@
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
auto KeyConfig::get() -> KeyConfig& {
@@ -12,16 +13,17 @@ auto KeyConfig::get() -> KeyConfig& {
}
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()) {
std::cerr << "KeyConfig: no s'ha pogut obrir " << file_path << '\n';
std::cerr << "KeyConfig: no s'ha pogut obrir " << FULL << '\n';
return;
}
try {
auto yaml = fkyaml::node::deserialize(file);
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;
}
bindings_.clear();
@@ -36,9 +38,9 @@ void KeyConfig::load(const std::string& file_path) {
}
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) {
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);
[[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:
KeyConfig() = default;
+6 -4
View File
@@ -4,6 +4,7 @@
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
@@ -33,9 +34,10 @@ auto Locale::get() -> Locale& {
}
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()) {
std::cerr << "Locale: no s'ha pogut obrir " << file_path << '\n';
std::cerr << "Locale: no s'ha pogut obrir " << FULL << '\n';
return;
}
@@ -43,9 +45,9 @@ void Locale::load(const std::string& file_path) {
auto yaml = fkyaml::node::deserialize(file);
strings_.clear();
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) {
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));
std::vector<uint8_t> compressed_data = readSubBlocks(buffer);
int uncompressed_data_length = image_descriptor.image_width * image_descriptor.image_height;
std::vector<uint8_t> uncompressed_data(uncompressed_data_length);
const int IMG_W = image_descriptor.image_width;
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());
// 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;
}
@@ -72,3 +72,12 @@ void MovingSprite::setPosY(float value) {
y_ = value;
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 <functional>
#include <memory>
#include "core/rendering/sprite/sprite.hpp"
@@ -40,6 +41,15 @@ class MovingSprite : public Sprite {
auto getFlip() -> SDL_FlipMode { return flip_; }
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:
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
// actualitzar); les subclasses mòbils/animades fan override.
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();
// Renderitza substituint l'índex source_color per target_color (ús típic:
// la classe Text, que recolora glifs monocroms per paràmetre).
+24 -3
View File
@@ -2,20 +2,41 @@
#include <fstream>
#include <iostream>
#include <string>
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> {
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) {
std::cerr << "Resource::Helper: cannot open " << filepath << '\n';
std::cerr << "Resource::Helper: cannot open " << FULL << '\n';
return {};
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(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 data;
+16
View File
@@ -6,7 +6,23 @@
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).
// Les rutes relatives es resolen via `resolve()`; les absolutes s'obrin
// directament.
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
} // namespace Resource::Helper
+74 -8
View File
@@ -8,6 +8,7 @@
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include "core/audio/audio.hpp"
@@ -19,10 +20,15 @@
#include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/scene/scene.hpp"
#include "core/system/event_buffer.hpp"
#include "game/debug_config.hpp"
#include "game/options.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 "utils/defaults.hpp"
@@ -31,6 +37,15 @@ Director::Director() {
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
// les opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder();
@@ -61,16 +76,21 @@ Director::Director() {
// 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.
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.
KeyConfig::get().load("data/input/keys.yaml");
// Recursos compartits d'UI (paleta + font). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica.
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
// 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().loadSurface("8bithud.gif", "data/font/8bithud.gif");
Resource::Cache::get().getSurface("8bithud.gif")->setPalette(Resource::Cache::get().getPalette("ui"));
const std::string HUD_FONT_GIF_KEY = std::string(Defaults::UI::Font::HUD_NAME) + ".gif";
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í
// 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
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// d'ells hagi de conèixer els noms dels recursos concrets.
auto hud_font_surface = Resource::Cache::get().getSurface("8bithud.gif");
auto hud_text = std::make_shared<Text>(hud_font_surface, "data/font/8bithud.fnt");
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_text = std::make_shared<Text>(hud_font_surface, Defaults::UI::Font::HUD_FNT);
const auto& ui_palette = Resource::Cache::get().getPalette("ui");
RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text);
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();
}
@@ -143,6 +207,8 @@ void Director::processEvents() {
void Director::switchSceneIfRequested() {
if (!current_scene_) { return; }
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);
}
}
+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
+109 -33
View File
@@ -1,5 +1,6 @@
#include "game/entities/concrete/mummy.hpp"
#include <algorithm>
#include <array>
#include <random>
#include <string>
@@ -20,9 +21,36 @@ namespace {
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
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");
collides_ = true;
interactable_ = false;
@@ -43,16 +71,36 @@ Mummy::Mummy(const fkyaml::node& yaml) {
initial = yaml["ai"]["initial_direction"].get_value<std::string>();
}
if (initial == "right") {
setDirection(1.0F, 0.0F);
changeDirection(1.0F, 0.0F);
} else if (initial == "left") {
setDirection(-1.0F, 0.0F);
changeDirection(-1.0F, 0.0F);
} else if (initial == "up") {
setDirection(0.0F, -1.0F);
changeDirection(0.0F, -1.0F);
} else if (initial == "down") {
setDirection(0.0F, 1.0F);
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 {
if (allowed.empty()) { return Direction::NONE; }
std::uniform_int_distribution<std::size_t> dist(0, allowed.size() - 1);
@@ -66,6 +114,31 @@ auto Mummy::candidateBox(float dx, float dy) const -> SDL_FRect {
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;
@@ -84,60 +157,63 @@ void Mummy::step(float delta_time) {
return;
}
// Bloqueig: cal girar. Recollim les direccions perpendiculars a l'actual
// que tinguin un pròxim tram lliure. Evitem U-turn llevat que no hi hagi
// cap alternativa (comportament típic d'Oh Mummy!: no es recula sense
// motiu). Si la direcció perpendicular també està bloquejada, no s'hi
// inclou; si totes les perpendiculars estan bloquejades, afegim l'opcio
// oposada com a últim recurs (cul-de-sac).
const float STEP = speed_ * delta_time;
// 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},
{.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},
}};
std::vector<Direction> perp_allowed;
std::vector<Direction> uturn_allowed;
DirectionPools pools;
for (const auto& c : ALL) {
const bool IS_CURRENT = (c.dx > 0.0F && dir_x_ > 0.0F) || (c.dx < 0.0F && dir_x_ < 0.0F) || (c.dy > 0.0F && dir_y_ > 0.0F) || (c.dy < 0.0F && dir_y_ < 0.0F);
const bool IS_UTURN = (c.dx > 0.0F && dir_x_ < 0.0F) || (c.dx < 0.0F && dir_x_ > 0.0F) || (c.dy > 0.0F && dir_y_ < 0.0F) || (c.dy < 0.0F && dir_y_ > 0.0F);
if (IS_CURRENT) { continue; } // ja hem vist que està bloquejada
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) {
uturn_allowed.push_back(c.dir);
pools.uturn.push_back(c.dir);
} else {
perp_allowed.push_back(c.dir);
pools.perp.push_back(c.dir);
}
}
return pools;
}
const auto& pool = !perp_allowed.empty() ? perp_allowed : uturn_allowed;
const auto CHOICE = chooseDirection(pool);
switch (CHOICE) {
void Mummy::applyChoice(Direction dir) {
switch (dir) {
case Direction::UP:
setDirection(0.0F, -1.0F);
changeDirection(0.0F, -1.0F);
break;
case Direction::DOWN:
setDirection(0.0F, 1.0F);
changeDirection(0.0F, 1.0F);
break;
case Direction::LEFT:
setDirection(-1.0F, 0.0F);
changeDirection(-1.0F, 0.0F);
break;
case Direction::RIGHT:
setDirection(1.0F, 0.0F);
changeDirection(1.0F, 0.0F);
break;
case Direction::NONE:
setDirection(0.0F, 0.0F);
changeDirection(0.0F, 0.0F);
break;
}
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
}
void Mummy::onPlayerContact(Player* /*player*/) {
+33
View File
@@ -4,6 +4,7 @@
#include <vector>
#include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/hazard.hpp"
// Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció
@@ -44,4 +45,36 @@ class Mummy : public DynamicHazard {
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_;
};
+13
View File
@@ -128,6 +128,18 @@ class Entity {
// 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:
std::string name_;
TilePos tile_{};
@@ -137,5 +149,6 @@ class Entity {
std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false};
bool interactable_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr};
};
+7 -5
View File
@@ -4,6 +4,7 @@
#include <stdexcept>
#include <unordered_map>
#include "core/resources/resource_helper.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp"
@@ -30,10 +31,10 @@ namespace EntityLoader {
auto it = cache.find(name);
if (it != cache.end()) { return it->second; }
const std::string PATH = templatePath(name);
std::ifstream file(PATH);
const std::string FULL = Resource::Helper::resolve(templatePath(name));
std::ifstream file(FULL);
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);
cache.emplace(name, node);
@@ -41,9 +42,10 @@ namespace EntityLoader {
}
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()) {
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);
std::vector<fkyaml::node> spawns;
@@ -51,7 +51,7 @@ void EntityManager::renderEntity(const Entity& e) {
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC);
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC, e.getFlip());
}
void EntityManager::renderAll() {
@@ -105,9 +105,16 @@ namespace {
return info;
}
// Bloc `collision: { size: [w, h], offset: [x, y] }` de la plantilla
// d'entitat. `offset` és opcional: si no apareix, s'entén "la caixa
// queda bottom-center del sprite" (convenció de falsa perspectiva).
// 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> {
@@ -120,6 +127,16 @@ namespace {
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) {
@@ -258,17 +275,27 @@ namespace {
const auto& p = spawn["position"];
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
// Anchor "feet": el tile [col,row] és el tile dels peus. El sprite
// es centra horitzontalment sobre el tile i la seua vora inferior
// s'alinea amb la vora inferior del tile. Els píxels que sobresurten
// (alçada del sprite per damunt del tile dels peus) queden "per
// sobre" sense afectar cap col·lisió — falsa perspectiva.
// 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") {
+29 -36
View File
@@ -9,9 +9,9 @@
#include <utility>
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
#include "utils/defaults.hpp"
namespace {
@@ -22,9 +22,10 @@ namespace {
} // namespace
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()) {
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);
@@ -32,7 +33,7 @@ void Player::loadFrom(const std::string& yaml_path) {
sprite_h = yaml["frameHeight"].get_value<int>();
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"];
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;
}
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)),
controls_(controls) {
controls_(controls),
map_(map_info) {
setPos(x, y);
setCurrentAnimation("stand");
}
@@ -104,22 +106,13 @@ void Player::update(float delta_time) {
snapToVerticalLane(delta_time);
}
// Guardem la posició prèvia i deixem que AnimatedSprite::update aplique
// la velocitat com sempre; així no dupliquem la integració temporal ni
// l'animació. Després, axis-separated collision: revertim i re-apliquem
// cada eix comprovant el mapa de colisions, de manera que el jugador
// llisca contra els sarcòfags en compte de quedar-se bloquejat en sec.
const float OLD_X = getPosX();
const float OLD_Y = getPosY();
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); }
// Animació del frame + moviment col·lisió-aware. `tryMove` aplica els
// deltes per eix i reverteix l'eix que col·lisiona, així el jugador
// llisca contra sarcòfags i murs en compte de quedar-se bloquejat en
// sec. La caixa que s'envia al callback és `collisionBox()` (override
// a Player retornant `getBaseBox()`), no el sprite sencer.
animate(delta_time);
tryMove(vx * delta_time, vy * delta_time, [this](const SDL_FRect& box) { return map_.collides(box); });
clampToPlayfield();
@@ -157,28 +150,28 @@ auto Player::animationName(bool moving) const -> const char* {
}
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
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
const auto BOX = baseBox();
const float CX = BOX.x + (BOX.w * 0.5F);
const float CY = BOX.y + (BOX.h * 0.5F);
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(
(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};
}
void Player::clampToPlayfield() {
// 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.
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>(
Map::playfield_x + Map::playfield_w - collision_x_offset - collision_size);
const auto MIN_Y = static_cast<float>(Map::playfield_y - collision_y_offset);
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 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));
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
}
@@ -214,24 +207,24 @@ void Player::snapToLane(
const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
const float SAVED = (this->*get_pos)();
(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) {
snapToLane(delta_time,
static_cast<float>(Map::playfield_y - collision_y_offset),
static_cast<float>(Map::pitch_y),
Map::grid_rows,
static_cast<float>(map_.playfield_y - collision_y_offset),
static_cast<float>(map_.pitch_y),
map_.grid_rows,
&Player::getPosY,
&Player::setPosY);
}
void Player::snapToVerticalLane(float delta_time) {
snapToLane(delta_time,
static_cast<float>(Map::playfield_x - collision_x_offset),
static_cast<float>(Map::pitch_x),
Map::grid_cols,
static_cast<float>(map_.playfield_x - collision_x_offset),
static_cast<float>(map_.pitch_x),
map_.grid_cols,
&Player::getPosX,
&Player::setPosX);
}
+14 -1
View File
@@ -7,6 +7,7 @@
#include <utility>
#include "core/rendering/sprite/animated_sprite.hpp"
#include "game/map/map.hpp"
class EventBus;
@@ -40,7 +41,7 @@ class Player : public AnimatedSprite {
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
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;
@@ -66,6 +67,12 @@ class Player : public AnimatedSprite {
// 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:
// Alias privat històric. Mantingut perquè viu dins de l'implementació
// (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
@@ -95,6 +102,12 @@ class Player : public AnimatedSprite {
void (Player::*set_pos)(float));
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
+24 -4
View File
@@ -4,6 +4,7 @@
#include <iostream>
#include <stdexcept>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
@@ -21,13 +22,14 @@ namespace {
namespace Map {
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()) {
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);
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"];
@@ -76,7 +78,7 @@ namespace Map {
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
<< " sarcs " << sarc_w << "x" << sarc_h
<< " pitch " << pitch_x << "x" << pitch_y
<< " des de " << yaml_path << '\n';
<< " des de " << FULL << '\n';
}
auto getSarcRect(int col, int row) -> SDL_FRect {
@@ -104,4 +106,22 @@ namespace Map {
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
+31
View File
@@ -14,6 +14,14 @@
// els membres viuen com a globals del namespace i els callers hi accedeixen
// amb `Map::grid_cols`, `Map::collides(...)`, etc. El canvi respecte a
// 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 {
// Geometria de la graella (carregada de pyramid.yaml > geometry).
@@ -73,4 +81,27 @@ namespace Map {
// 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
+5 -3
View File
@@ -12,6 +12,7 @@
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
@@ -67,9 +68,10 @@ namespace {
} // namespace
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()) {
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);
@@ -111,7 +113,7 @@ Tilemap::Tilemap(const std::string& yaml_path) {
}
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 {
+157 -91
View File
@@ -14,6 +14,21 @@
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
// é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.
@@ -93,40 +108,40 @@ namespace {
void parseWindow(const fkyaml::node& yaml) {
if (!yaml.contains("window")) { return; }
const auto& node = yaml["window"];
readField(node, "zoom", Options::window.zoom);
readField(node, "fullscreen", Options::window.fullscreen);
readField(node, "zoom", g_window.zoom);
readField(node, "fullscreen", g_window.fullscreen);
}
void parseGpu(const fkyaml::node& video_node) {
if (!video_node.contains("gpu")) { return; }
const auto& node = video_node["gpu"];
readField(node, "acceleration", Options::video.gpu.acceleration);
readField(node, "preferred_driver", Options::video.gpu.preferred_driver);
readField(node, "acceleration", g_video.gpu.acceleration);
readField(node, "preferred_driver", g_video.gpu.preferred_driver);
}
void parseShader(const fkyaml::node& video_node) {
if (!video_node.contains("shader")) { return; }
const auto& node = video_node["shader"];
readField(node, "enabled", Options::video.shader.enabled);
readField(node, "current_shader", Options::video.shader.current_shader);
readField(node, "current_postfx_preset", Options::video.shader.current_postfx_preset);
readField(node, "current_crtpi_preset", Options::video.shader.current_crtpi_preset);
readField(node, "enabled", g_video.shader.enabled);
readField(node, "current_shader", g_video.shader.current_shader);
readField(node, "current_postfx_preset", g_video.shader.current_postfx_preset);
readField(node, "current_crtpi_preset", g_video.shader.current_crtpi_preset);
}
void parseVideo(const fkyaml::node& yaml) {
if (!yaml.contains("video")) { return; }
const auto& node = yaml["video"];
readField(node, "vsync", Options::video.vsync);
readField(node, "vsync", g_video.vsync);
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")) {
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")) {
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);
parseShader(node);
}
@@ -134,22 +149,22 @@ namespace {
void parseMusic(const fkyaml::node& audio_node) {
if (!audio_node.contains("music")) { return; }
const auto& node = audio_node["music"];
readField(node, "enabled", Options::audio.music.enabled);
readField(node, "volume", Options::audio.music.volume);
readField(node, "enabled", g_audio.music.enabled);
readField(node, "volume", g_audio.music.volume);
}
void parseSound(const fkyaml::node& audio_node) {
if (!audio_node.contains("sound")) { return; }
const auto& node = audio_node["sound"];
readField(node, "enabled", Options::audio.sound.enabled);
readField(node, "volume", Options::audio.sound.volume);
readField(node, "enabled", g_audio.sound.enabled);
readField(node, "volume", g_audio.sound.volume);
}
void parseAudio(const fkyaml::node& yaml) {
if (!yaml.contains("audio")) { return; }
const auto& node = yaml["audio"];
readField(node, "enabled", Options::audio.enabled);
readField(node, "volume", Options::audio.volume);
readField(node, "enabled", g_audio.enabled);
readField(node, "volume", g_audio.volume);
parseMusic(node);
parseSound(node);
}
@@ -423,14 +438,65 @@ namespace {
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) {
config_file_path = path;
g_config_file_path = path;
}
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()) {
// Primera execució: creem el fitxer amb els valors per defecte.
return saveToFile();
@@ -449,12 +515,12 @@ namespace Options {
if (file_version != Texts::VERSION) {
std::cout << "Options: config.yaml versió \"" << file_version
<< "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n";
backupFile(config_file_path);
backupFile(g_config_file_path);
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);
parseVideo(yaml);
parseAudio(yaml);
@@ -462,84 +528,84 @@ namespace Options {
} catch (const std::exception& e) {
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
std::cout << "Options: reescrivint el fitxer amb defaults.\n";
backupFile(config_file_path);
backupFile(g_config_file_path);
return saveToFile();
}
}
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;
out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n";
out << "version: \"" << config_version << "\"\n";
out << "locale: \"" << locale << "\"\n\n";
out << "version: \"" << g_config_version << "\"\n";
out << "locale: \"" << g_locale << "\"\n\n";
out << "window:\n";
out << " zoom: " << window.zoom << "\n";
out << " fullscreen: " << (window.fullscreen ? "true" : "false") << "\n\n";
out << " zoom: " << g_window.zoom << "\n";
out << " fullscreen: " << (g_window.fullscreen ? "true" : "false") << "\n\n";
out << "video:\n";
out << " vsync: " << (video.vsync ? "true" : "false") << "\n";
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, video.scaling_mode) << "\"\n";
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, video.texture_filter) << "\"\n";
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, video.render_info_position) << "\"\n";
out << " render_info_show_time: " << (video.render_info_show_time ? "true" : "false") << "\n";
out << " vsync: " << (g_video.vsync ? "true" : "false") << "\n";
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, g_video.scaling_mode) << "\"\n";
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, g_video.texture_filter) << "\"\n";
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, g_video.render_info_position) << "\"\n";
out << " render_info_show_time: " << (g_video.render_info_show_time ? "true" : "false") << "\n";
out << " gpu:\n";
out << " acceleration: " << (video.gpu.acceleration ? "true" : "false") << "\n";
out << " preferred_driver: \"" << video.gpu.preferred_driver << "\"\n";
out << " acceleration: " << (g_video.gpu.acceleration ? "true" : "false") << "\n";
out << " preferred_driver: \"" << g_video.gpu.preferred_driver << "\"\n";
out << " shader:\n";
out << " enabled: " << (video.shader.enabled ? "true" : "false") << "\n";
out << " current_shader: \"" << video.shader.current_shader << "\"\n";
out << " current_postfx_preset: \"" << video.shader.current_postfx_preset << "\"\n";
out << " current_crtpi_preset: \"" << video.shader.current_crtpi_preset << "\"\n\n";
out << " enabled: " << (g_video.shader.enabled ? "true" : "false") << "\n";
out << " current_shader: \"" << g_video.shader.current_shader << "\"\n";
out << " current_postfx_preset: \"" << g_video.shader.current_postfx_preset << "\"\n";
out << " current_crtpi_preset: \"" << g_video.shader.current_crtpi_preset << "\"\n\n";
out << "audio:\n";
out << " enabled: " << (audio.enabled ? "true" : "false") << "\n";
out << " volume: " << audio.volume << "\n";
out << " enabled: " << (g_audio.enabled ? "true" : "false") << "\n";
out << " volume: " << g_audio.volume << "\n";
out << " music:\n";
out << " enabled: " << (audio.music.enabled ? "true" : "false") << "\n";
out << " volume: " << audio.music.volume << "\n";
out << " enabled: " << (g_audio.music.enabled ? "true" : "false") << "\n";
out << " volume: " << g_audio.music.volume << "\n";
out << " sound:\n";
out << " enabled: " << (audio.sound.enabled ? "true" : "false") << "\n";
out << " volume: " << audio.sound.volume << "\n";
out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\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 {
return {
.zoom = window.zoom,
.fullscreen = window.fullscreen,
.vsync = video.vsync,
.scaling_mode = video.scaling_mode,
.texture_filter = video.texture_filter,
.gpu_acceleration = video.gpu.acceleration,
.preferred_driver = video.gpu.preferred_driver,
.zoom = g_window.zoom,
.fullscreen = g_window.fullscreen,
.vsync = g_video.vsync,
.scaling_mode = g_video.scaling_mode,
.texture_filter = g_video.texture_filter,
.gpu_acceleration = g_video.gpu.acceleration,
.preferred_driver = g_video.gpu.preferred_driver,
};
}
auto buildAudioConfig() -> ::Audio::Config {
return {
.enabled = audio.enabled,
.volume = audio.volume,
.music_enabled = audio.music.enabled,
.music_volume = audio.music.volume,
.sound_enabled = audio.sound.enabled,
.sound_volume = audio.sound.volume,
.enabled = g_audio.enabled,
.volume = g_audio.volume,
.music_enabled = g_audio.music.enabled,
.music_volume = g_audio.music.volume,
.sound_enabled = g_audio.sound.enabled,
.sound_volume = g_audio.sound.volume,
};
}
void setPostFXFile(const std::string& path) {
postfx_file_path = path;
g_postfx_file_path = path;
}
auto loadPostFXFromFile() -> bool {
return loadPresetsFromFile<PostFXPreset>(
postfx_file_path,
postfx_presets,
g_postfx_file_path,
g_postfx_presets,
&defaultPostFXPresets,
&parsePostFXPreset,
&savePostFXToFile,
@@ -548,21 +614,21 @@ namespace Options {
auto savePostFXToFile() -> bool {
return savePresetsToFile<PostFXPreset>(
postfx_file_path,
postfx_presets,
g_postfx_file_path,
g_postfx_presets,
&writePostFXPreset,
"Presets PostFX per a AEE 2026. Editable a mà.",
"Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset.");
}
void setCrtPiFile(const std::string& path) {
crtpi_file_path = path;
g_crtpi_file_path = path;
}
auto loadCrtPiFromFile() -> bool {
return loadPresetsFromFile<CrtPiPreset>(
crtpi_file_path,
crtpi_presets,
g_crtpi_file_path,
g_crtpi_presets,
&defaultCrtPiPresets,
&parseCrtPiPreset,
&saveCrtPiToFile,
@@ -571,29 +637,29 @@ namespace Options {
auto saveCrtPiToFile() -> bool {
return savePresetsToFile<CrtPiPreset>(
crtpi_file_path,
crtpi_presets,
g_crtpi_file_path,
g_crtpi_presets,
&writeCrtPiPreset,
"Presets CRT-Pi per a AEE 2026. Editable a mà.",
"Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset.");
}
auto buildActiveShader() -> Rendering::ShaderType {
if (video.shader.current_shader == "crtpi") {
if (g_video.shader.current_shader == "crtpi") {
return Rendering::ShaderType::CRTPI;
}
return Rendering::ShaderType::POSTFX;
}
auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame {
const std::string& active_preset = (video.shader.current_shader == "crtpi")
? video.shader.current_crtpi_preset
: video.shader.current_postfx_preset;
const std::string& active_preset = (g_video.shader.current_shader == "crtpi")
? g_video.shader.current_crtpi_preset
: g_video.shader.current_postfx_preset;
return {
.target_position = video.render_info_position,
.show_time = video.render_info_show_time,
.shader_enabled = video.shader.enabled,
.shader_name = video.shader.current_shader,
.target_position = g_video.render_info_position,
.show_time = g_video.render_info_show_time,
.shader_enabled = g_video.shader.enabled,
.shader_name = g_video.shader.current_shader,
.shader_preset = active_preset,
.text_color = text_color,
.shadow_color = shadow_color,
@@ -603,12 +669,12 @@ namespace Options {
auto buildCrtPiParams() -> Rendering::CrtPiParams {
// Si el shader està deshabilitat, retornem defaults (el dispatch anirà per
// 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 CrtPiPreset& preset) { return preset.name == video.shader.current_crtpi_preset; });
if (IT == crtpi_presets.end()) {
std::cout << "Options: preset CrtPi \"" << video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
const auto IT = std::ranges::find_if(g_crtpi_presets,
[](const CrtPiPreset& preset) { return preset.name == g_video.shader.current_crtpi_preset; });
if (IT == g_crtpi_presets.end()) {
std::cout << "Options: preset CrtPi \"" << g_video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
return {};
}
return {
@@ -631,12 +697,12 @@ namespace Options {
auto buildPostFXParams() -> Rendering::PostFXParams {
// 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 PostFXPreset& preset) { return preset.name == video.shader.current_postfx_preset; });
if (IT == postfx_presets.end()) {
std::cout << "Options: preset PostFX \"" << video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
const auto IT = std::ranges::find_if(g_postfx_presets,
[](const PostFXPreset& preset) { return preset.name == g_video.shader.current_postfx_preset; });
if (IT == g_postfx_presets.end()) {
std::cout << "Options: preset PostFX \"" << g_video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
return {};
}
return {
+32 -13
View File
@@ -13,6 +13,15 @@
// del config.yaml. Els subsistemes (Screen, Audio, ...) reben structs tipats
// a través dels build*Config(), de manera que no queden acoblats a aquest
// 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 {
struct Window {
@@ -95,20 +104,30 @@ namespace Options {
Sound sound{};
};
// --- Estat global (inline vars per a que una sola definició viva a tot
// el programa sense necessitar un .cpp extra per a cada una) ---
inline std::string config_version{};
inline std::string config_file_path{};
inline std::string postfx_file_path{};
inline std::string crtpi_file_path{};
inline std::string locale{Defaults::I18n::LOCALE};
inline Window window{};
inline Video video{};
inline Audio audio{};
inline std::vector<PostFXPreset> postfx_presets{};
inline std::vector<CrtPiPreset> crtpi_presets{};
// --- Getters de l'estat (read-only) ---
[[nodiscard]] auto window() -> const Window&;
[[nodiscard]] auto video() -> const Video&;
[[nodiscard]] auto audio() -> const Audio&;
[[nodiscard]] auto locale() -> const std::string&;
[[nodiscard]] auto configVersion() -> const std::string&;
[[nodiscard]] auto postfxPresets() -> const std::vector<PostFXPreset>&;
[[nodiscard]] auto crtpiPresets() -> const std::vector<CrtPiPreset>&;
// --- 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);
auto loadFromFile() -> bool;
auto saveToFile() -> bool;
+1 -1
View File
@@ -98,7 +98,7 @@ GameScene::GameScene(Config config)
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_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
// sarcòfags rebin les notificacions de pas per tile.
+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
// sense més impacte que dos accessors extra.
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).
constexpr Uint8 RENDER_INFO_TEXT = 47;
constexpr Uint8 RENDER_INFO_SHADOW = 34;
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.18.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 GameCanvas {
+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;
}