Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5312abc761 | ||
|
|
4dd2b6d2a9 | ||
|
|
d5abc6343e | ||
|
|
a2b01ba9d5 | ||
|
|
a230351b79 | ||
|
|
7c35ffaa74 | ||
|
|
be9309d342 | ||
|
|
591b4b58e5 | ||
|
|
fb40d0effb | ||
|
|
350625dec0 | ||
|
|
e4a8b4cd3e | ||
|
|
eb07e7e564 | ||
|
|
03f1c5f926 | ||
|
|
6800786194 | ||
|
|
9c9b6028e7 | ||
|
|
2cb21211ef | ||
|
|
87a9f098b5 | ||
|
|
d31483124e | ||
|
|
2883df57da | ||
|
|
510ab55a33 | ||
|
|
a7e681a347 | ||
|
|
b19a6e25da | ||
|
|
d9f6fb45f8 | ||
|
|
64e0178ee9 | ||
|
|
a3b03b87be | ||
|
|
32dcf264c4 | ||
|
|
a3592d6c73 |
No files matched your search
@@ -58,6 +58,9 @@ set(APP_SOURCES
|
|||||||
source/core/locale/locale.cpp
|
source/core/locale/locale.cpp
|
||||||
source/core/input/key_config.cpp
|
source/core/input/key_config.cpp
|
||||||
source/core/input/global_inputs.cpp
|
source/core/input/global_inputs.cpp
|
||||||
|
source/core/input/input_state.cpp
|
||||||
|
source/core/input/gamepad_manager.cpp
|
||||||
|
source/core/input/mouse.cpp
|
||||||
source/core/rendering/screen.cpp
|
source/core/rendering/screen.cpp
|
||||||
source/core/rendering/canvas.cpp
|
source/core/rendering/canvas.cpp
|
||||||
source/core/rendering/notifier.cpp
|
source/core/rendering/notifier.cpp
|
||||||
@@ -76,7 +79,14 @@ set(APP_SOURCES
|
|||||||
source/core/resources/palette_loader.cpp
|
source/core/resources/palette_loader.cpp
|
||||||
source/utils/easing.cpp
|
source/utils/easing.cpp
|
||||||
source/utils/utils.cpp
|
source/utils/utils.cpp
|
||||||
|
source/game/debug_config.cpp
|
||||||
source/game/options.cpp
|
source/game/options.cpp
|
||||||
|
source/game/options_apply.cpp
|
||||||
|
source/game/ui/service_menu.cpp
|
||||||
|
source/game/scenes/placeholder_input.cpp
|
||||||
|
source/game/scenes/logo_scene.cpp
|
||||||
|
source/game/scenes/title_scene.cpp
|
||||||
|
source/game/scenes/intro_scene.cpp
|
||||||
source/game/scenes/game_scene.cpp
|
source/game/scenes/game_scene.cpp
|
||||||
source/game/entities/player.cpp
|
source/game/entities/player.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
@@ -150,14 +160,19 @@ target_include_directories(${PROJECT_NAME} PRIVATE
|
|||||||
|
|
||||||
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
|
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
|
||||||
|
|
||||||
# Copia recursius de la carpeta data/ al costat del binari per que les rutes
|
# Còpia recursiva de data/ al costat del binari perquè les rutes relatives
|
||||||
# relatives ("data/player/player.gif") funcionen sense desplegament.
|
# ("data/player/player.gif") funcionin sense desplegament. Es defineix com
|
||||||
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
|
# a custom target amb `ALL` — s'executa a cada `cmake --build` encara que
|
||||||
|
# el binari no hagi canviat, de manera que editar YAML/GIF sense tocar codi
|
||||||
|
# igualment sincronitza el build. Abans era un POST_BUILD, i si només es
|
||||||
|
# modificava una dada la còpia no es disparava.
|
||||||
|
add_custom_target(sync_data ALL
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||||
"${CMAKE_SOURCE_DIR}/data"
|
"${CMAKE_SOURCE_DIR}/data"
|
||||||
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/data"
|
"${CMAKE_BINARY_DIR}/data"
|
||||||
COMMENT "Copying data/ next to the binary"
|
COMMENT "Copying data/ next to the binary"
|
||||||
)
|
)
|
||||||
|
add_dependencies(${PROJECT_NAME} sync_data)
|
||||||
|
|
||||||
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
target_compile_definitions(${PROJECT_NAME} PRIVATE
|
target_compile_definitions(${PROJECT_NAME} PRIVATE
|
||||||
@@ -255,6 +270,7 @@ option(BUILD_TESTS "Build smoke tests" ON)
|
|||||||
if(BUILD_TESTS)
|
if(BUILD_TESTS)
|
||||||
add_executable(test_entities_smoke
|
add_executable(test_entities_smoke
|
||||||
tests/test_entities_smoke.cpp
|
tests/test_entities_smoke.cpp
|
||||||
|
source/core/resources/resource_helper.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
${ENTITY_SOURCES}
|
${ENTITY_SOURCES}
|
||||||
)
|
)
|
||||||
@@ -268,4 +284,25 @@ if(BUILD_TESTS)
|
|||||||
add_test(NAME entities_smoke
|
add_test(NAME entities_smoke
|
||||||
COMMAND test_entities_smoke
|
COMMAND test_entities_smoke
|
||||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||||
|
|
||||||
|
# Smoke del flux d'escenes i del parser de debug.yaml. Stand-alone:
|
||||||
|
# només enllaça els helpers purs (debug_config, placeholder_input,
|
||||||
|
# key_config) perquè la integració completa dels placeholders arrossegaria
|
||||||
|
# tot l'stack SDL (Screen/Notifier/RenderInfo), coberta pel smoke xvfb.
|
||||||
|
add_executable(test_scene_flow
|
||||||
|
tests/test_scene_flow.cpp
|
||||||
|
source/game/debug_config.cpp
|
||||||
|
source/game/scenes/placeholder_input.cpp
|
||||||
|
source/core/input/key_config.cpp
|
||||||
|
source/core/resources/resource_helper.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_scene_flow PRIVATE
|
||||||
|
"${CMAKE_SOURCE_DIR}/source"
|
||||||
|
"${CMAKE_BINARY_DIR}"
|
||||||
|
)
|
||||||
|
target_link_libraries(test_scene_flow PRIVATE SDL3::SDL3)
|
||||||
|
target_compile_options(test_scene_flow PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
add_test(NAME scene_flow
|
||||||
|
COMMAND test_scene_flow
|
||||||
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||||
endif()
|
endif()
|
||||||
@@ -14,47 +14,66 @@ endif
|
|||||||
|
|
||||||
CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH)
|
CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH)
|
||||||
|
|
||||||
.PHONY: all configure build run clean rebuild release show-version format format-check tidy tidy-fix cppcheck hooks-install
|
.PHONY: all debug release run run-debug clean rebuild show-version format format-check tidy tidy-fix cppcheck hooks-install help
|
||||||
|
|
||||||
all: build
|
# ==============================================================================
|
||||||
|
# COMPILACIÓ
|
||||||
|
# ==============================================================================
|
||||||
|
# Convenció alineada amb ../aee i ../projecte_2026:
|
||||||
|
# make → Release (binari d'ús normal)
|
||||||
|
# make debug → Debug (flux complet + `debug.yaml` per triar escena inicial)
|
||||||
|
# make release → Release + empaquetat per a distribució (TODO, pendent)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
configure:
|
all:
|
||||||
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
|
|
||||||
|
|
||||||
build: configure
|
|
||||||
@cmake --build $(BUILDDIR) -j$(JOBS)
|
|
||||||
|
|
||||||
run: build
|
|
||||||
@./$(BUILDDIR)/$(PROJECT)
|
|
||||||
|
|
||||||
release:
|
|
||||||
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||||
@cmake --build $(BUILDDIR) -j$(JOBS)
|
@cmake --build $(BUILDDIR) -j$(JOBS)
|
||||||
|
|
||||||
|
debug:
|
||||||
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
|
||||||
|
@cmake --build $(BUILDDIR) -j$(JOBS)
|
||||||
|
|
||||||
|
run: all
|
||||||
|
@./$(BUILDDIR)/$(PROJECT)
|
||||||
|
|
||||||
|
run-debug: debug
|
||||||
|
@./$(BUILDDIR)/$(PROJECT)
|
||||||
|
|
||||||
|
# Release + empaquetat per distribució (zip/dmg/tar.gz). De moment només
|
||||||
|
# compila amb CMAKE_BUILD_TYPE=Release; l'empaquetat OS-specific està
|
||||||
|
# pendent (veure Makefile de ../aee com a referència).
|
||||||
|
release: all
|
||||||
|
@echo "TODO: empaquetat per a distribució (zip/dmg/tar.gz) — pendent"
|
||||||
|
|
||||||
show-version:
|
show-version:
|
||||||
@echo "$(PROJECT) $(VERSION) ($(GIT_HASH))"
|
@echo "$(PROJECT) $(VERSION) ($(GIT_HASH))"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@rm -rf $(BUILDDIR)
|
@rm -rf $(BUILDDIR)
|
||||||
|
|
||||||
rebuild: clean build
|
rebuild: clean all
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# CODE QUALITY (delegados a cmake)
|
# CODE QUALITY (delegados a cmake)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
format: configure
|
format:
|
||||||
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||||
@cmake --build $(BUILDDIR) --target format
|
@cmake --build $(BUILDDIR) --target format
|
||||||
|
|
||||||
format-check: configure
|
format-check:
|
||||||
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||||
@cmake --build $(BUILDDIR) --target format-check
|
@cmake --build $(BUILDDIR) --target format-check
|
||||||
|
|
||||||
tidy: configure
|
tidy:
|
||||||
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||||
@cmake --build $(BUILDDIR) --target tidy
|
@cmake --build $(BUILDDIR) --target tidy
|
||||||
|
|
||||||
tidy-fix: configure
|
tidy-fix:
|
||||||
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||||
@cmake --build $(BUILDDIR) --target tidy-fix
|
@cmake --build $(BUILDDIR) --target tidy-fix
|
||||||
|
|
||||||
cppcheck: configure
|
cppcheck:
|
||||||
|
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||||
@cmake --build $(BUILDDIR) --target cppcheck
|
@cmake --build $(BUILDDIR) --target cppcheck
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@@ -63,3 +82,35 @@ cppcheck: configure
|
|||||||
hooks-install:
|
hooks-install:
|
||||||
@git config core.hooksPath .githooks
|
@git config core.hooksPath .githooks
|
||||||
@echo "Git hooks activats: $(shell pwd)/.githooks"
|
@echo "Git hooks activats: $(shell pwd)/.githooks"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# AJUDA
|
||||||
|
# ==============================================================================
|
||||||
|
help:
|
||||||
|
@echo "Makefile per a $(PROJECT)"
|
||||||
|
@echo ""
|
||||||
|
@echo " Compilació:"
|
||||||
|
@echo " make - Compilar amb cmake (Release)"
|
||||||
|
@echo " make debug - Compilar amb cmake (Debug)"
|
||||||
|
@echo ""
|
||||||
|
@echo " Execució:"
|
||||||
|
@echo " make run - Compilar (Release) i executar"
|
||||||
|
@echo " make run-debug - Compilar (Debug) i executar"
|
||||||
|
@echo ""
|
||||||
|
@echo " Release:"
|
||||||
|
@echo " make release - Release + empaquetat (TODO pendent)"
|
||||||
|
@echo ""
|
||||||
|
@echo " Qualitat de codi:"
|
||||||
|
@echo " make format - Formatar codi amb clang-format"
|
||||||
|
@echo " make format-check - Verificar format sense modificar"
|
||||||
|
@echo " make tidy - Anàlisi estàtic amb clang-tidy"
|
||||||
|
@echo " make tidy-fix - Anàlisi estàtic amb auto-fix"
|
||||||
|
@echo " make cppcheck - Anàlisi estàtic amb cppcheck"
|
||||||
|
@echo ""
|
||||||
|
@echo " Altres:"
|
||||||
|
@echo " make clean - Esborrar carpeta $(BUILDDIR)/"
|
||||||
|
@echo " make rebuild - clean + all"
|
||||||
|
@echo " make show-version - Mostrar versió"
|
||||||
|
@echo " make hooks-install - Activar git hooks del projecte"
|
||||||
|
@echo ""
|
||||||
|
@echo " Versió actual: $(VERSION) ($(GIT_HASH))"
|
||||||
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 1.8 KiB |
@@ -4,23 +4,32 @@ sprite:
|
|||||||
path: mummy.gif
|
path: mummy.gif
|
||||||
size: [12, 24]
|
size: [12, 24]
|
||||||
tile_size: [1, 1]
|
tile_size: [1, 1]
|
||||||
# La momia usa falsa perspectiva: el sprite fa 24 px d'alt però la caixa
|
# La momia usa falsa perspectiva: el sprite fa 12×24 (dos 12×12: cap i
|
||||||
# AABB només ocupa els 12×12 de baix (els "peus"). L'spawn `position` del
|
# peus) però la caja de col·lisió amb el mapa és 16×16, concèntrica amb
|
||||||
# map és el tile dels peus: el sprite es centra horitzontalment i la
|
# el rectangle-base 12×12 dels peus (igual que fa el jugador). Així la
|
||||||
# seua vora inferior s'alinea amb la vora inferior del tile.
|
# 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
|
anchor: feet
|
||||||
collision:
|
collision:
|
||||||
size: [12, 12]
|
size: [16, 16]
|
||||||
|
base_size: [12, 12]
|
||||||
flags:
|
flags:
|
||||||
collides: true
|
collides: true
|
||||||
interactable: false
|
interactable: false
|
||||||
|
# Strip de 12 frames en graella 12×1 (mateix esquema que el jugador):
|
||||||
|
# 0..3 → walk_down (cara avall)
|
||||||
|
# 4..7 → walk_up (cara amunt)
|
||||||
|
# 8..11 → walk (lateral; flip horitzontal segons el sentit)
|
||||||
|
# La momia no té variants `stand_*` perquè mai no para.
|
||||||
animations:
|
animations:
|
||||||
idle: { frames: [0], fps: 1, loop: true }
|
walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true }
|
||||||
walk: { frames: [0], fps: 8, loop: true }
|
walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true }
|
||||||
|
walk: { frames: [8, 9, 10, 11], fps: 8, loop: true }
|
||||||
ai:
|
ai:
|
||||||
type: corridor_wanderer
|
type: corridor_wanderer
|
||||||
direction_choice: random
|
direction_choice: random
|
||||||
initial_direction: right
|
initial_direction: right
|
||||||
stats:
|
stats:
|
||||||
speed: 40
|
speed: 30
|
||||||
damage: 1
|
damage: 1
|
||||||
@@ -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 # ·
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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 # ·
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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 # ·
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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 # ·
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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 # ·
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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 # ·
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -29,6 +29,9 @@ keys:
|
|||||||
- id: toggle_vsync
|
- id: toggle_vsync
|
||||||
code: "F8"
|
code: "F8"
|
||||||
desc: "Activa / desactiva VSync"
|
desc: "Activa / desactiva VSync"
|
||||||
|
- id: menu_toggle
|
||||||
|
code: "F9"
|
||||||
|
desc: "Obri / tanca el menú de servei"
|
||||||
- id: toggle_render_info
|
- id: toggle_render_info
|
||||||
code: "F12"
|
code: "F12"
|
||||||
desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
|
desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
|
||||||
|
|||||||
@@ -3,20 +3,75 @@
|
|||||||
|
|
||||||
notification:
|
notification:
|
||||||
test: "notificacio de prova"
|
test: "notificacio de prova"
|
||||||
|
gamepad_connected: "connectat"
|
||||||
|
gamepad_disconnected: "desconnectat"
|
||||||
|
|
||||||
input:
|
input:
|
||||||
zoom: "zoom"
|
zoom: "Zoom"
|
||||||
fullscreen_on: "pantalla completa"
|
fullscreen_on: "Pantalla completa"
|
||||||
fullscreen_off: "mode finestra"
|
fullscreen_off: "Mode finestra"
|
||||||
shader_on: "shaders activats"
|
shader_on: "Shaders activats"
|
||||||
shader_off: "shaders desactivats"
|
shader_off: "Shaders desactivats"
|
||||||
vsync_on: "vsync activat"
|
vsync_on: "VSync activat"
|
||||||
vsync_off: "vsync desactivat"
|
vsync_off: "VSync desactivat"
|
||||||
shader: "shader"
|
shader: "Shader"
|
||||||
preset: "preset"
|
preset: "Preset"
|
||||||
scaling_disabled: "escalat: sense"
|
scaling_disabled: "Escalat sense"
|
||||||
scaling_stretch: "escalat: stretch"
|
scaling_stretch: "Escalat stretch"
|
||||||
scaling_letterbox: "escalat: letterbox"
|
scaling_letterbox: "Escalat letterbox"
|
||||||
scaling_overscan: "escalat: overscan"
|
scaling_overscan: "Escalat overscan"
|
||||||
scaling_integer: "escalat: enter"
|
scaling_integer: "Escalat enter"
|
||||||
press_again_exit: "prem una altra volta per eixir"
|
press_again_exit: "Prem una altra volta per eixir"
|
||||||
|
|
||||||
|
menu:
|
||||||
|
titles:
|
||||||
|
root: "Menú de servei"
|
||||||
|
video: "Vídeo"
|
||||||
|
audio: "Àudio"
|
||||||
|
controls: "Controls"
|
||||||
|
keyboard: "Teclat"
|
||||||
|
gamepad: "Comandament"
|
||||||
|
system: "Sistema"
|
||||||
|
labels:
|
||||||
|
player_1: "Jugador 1"
|
||||||
|
gamepad_unavailable: "No disponible"
|
||||||
|
items:
|
||||||
|
video_submenu: "Vídeo"
|
||||||
|
audio_submenu: "Àudio"
|
||||||
|
controls_submenu: "Controls"
|
||||||
|
keyboard_submenu: "Teclat"
|
||||||
|
gamepad_submenu: "Comandament"
|
||||||
|
system_submenu: "Sistema"
|
||||||
|
move_up: "Amunt"
|
||||||
|
move_down: "Avall"
|
||||||
|
move_left: "Esquerra"
|
||||||
|
move_right: "Dreta"
|
||||||
|
zoom: "Zoom"
|
||||||
|
screen: "Pantalla completa"
|
||||||
|
vsync: "VSync"
|
||||||
|
scaling_mode: "Escalat"
|
||||||
|
shader: "Shader"
|
||||||
|
shader_type: "Tipus de shader"
|
||||||
|
preset: "Preset"
|
||||||
|
render_info: "Info de render"
|
||||||
|
uptime: "Mostra temps"
|
||||||
|
master_enable: "Àudio"
|
||||||
|
master_volume: "Volum general"
|
||||||
|
music: "Música"
|
||||||
|
music_volume: "Volum música"
|
||||||
|
sounds: "Sons"
|
||||||
|
sounds_volume: "Volum sons"
|
||||||
|
restart: "Reiniciar"
|
||||||
|
exit_game: "Eixir del joc"
|
||||||
|
values:
|
||||||
|
on: "Actiu"
|
||||||
|
off: "Inactiu"
|
||||||
|
submenu_arrow: ">"
|
||||||
|
scaling_disabled: "Sense"
|
||||||
|
scaling_stretch: "Stretch"
|
||||||
|
scaling_letterbox: "Letterbox"
|
||||||
|
scaling_overscan: "Overscan"
|
||||||
|
scaling_integer: "Enter"
|
||||||
|
render_info_off: "Apagat"
|
||||||
|
render_info_top: "Dalt"
|
||||||
|
render_info_bottom: "Baix"
|
||||||
@@ -3,20 +3,75 @@
|
|||||||
|
|
||||||
notification:
|
notification:
|
||||||
test: "test notification"
|
test: "test notification"
|
||||||
|
gamepad_connected: "connected"
|
||||||
|
gamepad_disconnected: "disconnected"
|
||||||
|
|
||||||
input:
|
input:
|
||||||
zoom: "zoom"
|
zoom: "Zoom"
|
||||||
fullscreen_on: "fullscreen"
|
fullscreen_on: "Fullscreen"
|
||||||
fullscreen_off: "windowed"
|
fullscreen_off: "Windowed"
|
||||||
shader_on: "shaders on"
|
shader_on: "Shaders on"
|
||||||
shader_off: "shaders off"
|
shader_off: "Shaders off"
|
||||||
vsync_on: "vsync on"
|
vsync_on: "VSync on"
|
||||||
vsync_off: "vsync off"
|
vsync_off: "VSync off"
|
||||||
shader: "shader"
|
shader: "Shader"
|
||||||
preset: "preset"
|
preset: "Preset"
|
||||||
scaling_disabled: "scaling: off"
|
scaling_disabled: "Scaling off"
|
||||||
scaling_stretch: "scaling: stretch"
|
scaling_stretch: "Scaling stretch"
|
||||||
scaling_letterbox: "scaling: letterbox"
|
scaling_letterbox: "Scaling letterbox"
|
||||||
scaling_overscan: "scaling: overscan"
|
scaling_overscan: "Scaling overscan"
|
||||||
scaling_integer: "scaling: integer"
|
scaling_integer: "Scaling integer"
|
||||||
press_again_exit: "press again to exit"
|
press_again_exit: "Press again to exit"
|
||||||
|
|
||||||
|
menu:
|
||||||
|
titles:
|
||||||
|
root: "Service menu"
|
||||||
|
video: "Video"
|
||||||
|
audio: "Audio"
|
||||||
|
controls: "Controls"
|
||||||
|
keyboard: "Keyboard"
|
||||||
|
gamepad: "Gamepad"
|
||||||
|
system: "System"
|
||||||
|
labels:
|
||||||
|
player_1: "Player 1"
|
||||||
|
gamepad_unavailable: "Not available"
|
||||||
|
items:
|
||||||
|
video_submenu: "Video"
|
||||||
|
audio_submenu: "Audio"
|
||||||
|
controls_submenu: "Controls"
|
||||||
|
keyboard_submenu: "Keyboard"
|
||||||
|
gamepad_submenu: "Gamepad"
|
||||||
|
system_submenu: "System"
|
||||||
|
move_up: "Up"
|
||||||
|
move_down: "Down"
|
||||||
|
move_left: "Left"
|
||||||
|
move_right: "Right"
|
||||||
|
zoom: "Zoom"
|
||||||
|
screen: "Fullscreen"
|
||||||
|
vsync: "VSync"
|
||||||
|
scaling_mode: "Scaling"
|
||||||
|
shader: "Shader"
|
||||||
|
shader_type: "Shader type"
|
||||||
|
preset: "Preset"
|
||||||
|
render_info: "Render info"
|
||||||
|
uptime: "Show time"
|
||||||
|
master_enable: "Audio"
|
||||||
|
master_volume: "Master volume"
|
||||||
|
music: "Music"
|
||||||
|
music_volume: "Music volume"
|
||||||
|
sounds: "Sounds"
|
||||||
|
sounds_volume: "Sound volume"
|
||||||
|
restart: "Restart"
|
||||||
|
exit_game: "Exit game"
|
||||||
|
values:
|
||||||
|
on: "On"
|
||||||
|
off: "Off"
|
||||||
|
submenu_arrow: ">"
|
||||||
|
scaling_disabled: "Off"
|
||||||
|
scaling_stretch: "Stretch"
|
||||||
|
scaling_letterbox: "Letterbox"
|
||||||
|
scaling_overscan: "Overscan"
|
||||||
|
scaling_integer: "Integer"
|
||||||
|
render_info_off: "Off"
|
||||||
|
render_info_top: "Top"
|
||||||
|
render_info_bottom: "Bottom"
|
||||||
@@ -130,3 +130,5 @@ entities:
|
|||||||
position: [9, 1]
|
position: [9, 1]
|
||||||
- type: mummy
|
- type: mummy
|
||||||
position: [10, 7]
|
position: [10, 7]
|
||||||
|
- type: mummy
|
||||||
|
position: [7, 9]
|
||||||
@@ -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 |
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Rama: `audit/game`.
|
Rama: `audit/game`.
|
||||||
Cobertura: `source/game/{entities,map,scenes,ui}` + `source/game/options.{hpp,cpp}` (12 fitxers, ~1.486 línies).
|
Cobertura: `source/game/{entities,map,scenes,ui}` + `source/game/options.{hpp,cpp}` (12 fitxers, ~1.486 línies).
|
||||||
|
|
||||||
Estado global: **28 DONE, 2 DONE (parcial), 5 WONTFIX, 4 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED (COUP-02, COUP-03, CONV-02 resta, PATCH-01) van a la futura branca `refactor/options-class`.
|
Estado global: **32 DONE, 1 DONE (parcial), 5 WONTFIX, 0 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED inicials (COUP-02, COUP-03, CONV-02 part Options, PATCH-01) s'han tancat a la branca `refactor/options-class` (registre de commits al final del fitxer); l'únic ítem que queda en DONE (parcial) és INCONS-01, intencionadament — la nota n'explica el motiu.
|
||||||
|
|
||||||
Estados posibles:
|
Estados posibles:
|
||||||
- `PENDING` — identificat, sense tocar
|
- `PENDING` — identificat, sense tocar
|
||||||
@@ -69,14 +69,14 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer
|
|||||||
|----|--------------|----------|-----------|-------|-------|
|
|----|--------------|----------|-----------|-------|-------|
|
||||||
| COUP-01 | `core/input/global_inputs.cpp:15` | `core/` inclou `game/ui/notification_presets.hpp` | Crítico | DONE | `notification_presets.hpp` mogut a `core/rendering/` (al costat del `Notifier` que consumeix). `global_inputs.cpp` i el comentari de `notifier.hpp` actualitzats |
|
| COUP-01 | `core/input/global_inputs.cpp:15` | `core/` inclou `game/ui/notification_presets.hpp` | Crítico | DONE | `notification_presets.hpp` mogut a `core/rendering/` (al costat del `Notifier` que consumeix). `global_inputs.cpp` i el comentari de `notifier.hpp` actualitzats |
|
||||||
| COUP-02 | `player.cpp` + `Map::PLAYFIELD_*`, `PITCH_*`, `GRID_*`, `collides` | `Player` coneix la geometria interna del mapa | Importante | DEFERRED | Player només llegeix globals de `Map::`, no muta res. Injectar `MapInfo&` té sentit quan hi haja múltiples mapes simultanis o un sistema tipus ECS. Mentre hi haja un Map actiu global, l'acoblament és acceptable. Es refacta amb COUP-03 a `refactor/options-class` |
|
| COUP-02 | `player.cpp` + `Map::PLAYFIELD_*`, `PITCH_*`, `GRID_*`, `collides` | `Player` coneix la geometria interna del mapa | Importante | DEFERRED | Player només llegeix globals de `Map::`, no muta res. Injectar `MapInfo&` té sentit quan hi haja múltiples mapes simultanis o un sistema tipus ECS. Mentre hi haja un Map actiu global, l'acoblament és acceptable. Es refacta amb COUP-03 a `refactor/options-class` |
|
||||||
| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DEFERRED | Encapsular tots els globals darrere setters + observers per Screen/Audio/RenderInfo és un refactor ample (equivalent a COUP-01 de l'audit d'àudio, que va anar a una branca pròpia). Es tractarà a `refactor/options-class` perquè toca tots els callers de core/input. Es fa en branca separada |
|
| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DONE | Estat mutable mogut a anonymous namespace dins `options.cpp`; API pública són getters const-ref (`window()`, `video()`, `audio()`, `locale()`, `postfxPresets()`, `crtpiPresets()`) + setters específics (`setWindowZoom`, `setShaderEnabled`, `setActiveShaderName`, ...) que muten i persisteixen via `saveToFile()`. Els setters NO notifiquen els subsistemes: el caller (`global_inputs.cpp`) crida després `Screen::setWindowZoom`, `reapplyShader()`, etc. La part "observers" del audit es deixa WONTFIX (veure CONV-02): notificar des d'Options invertiria la direcció `Director→Screen/Audio` i Options aprenria del layout dels subsistemes |
|
||||||
| COUP-04 | `tilemap.cpp:117-138` | Clip+blit directe al canvas ARGB que podria viure a `Surface` | Menor | WONTFIX | Únic caller en tot el projecte. Promoure a `Surface::blitInto` ara afegiria una API genèrica per a un ús únic (YAGNI). Si un segon client apareix, es mourea en aquell moment |
|
| COUP-04 | `tilemap.cpp:117-138` | Clip+blit directe al canvas ARGB que podria viure a `Surface` | Menor | WONTFIX | Únic caller en tot el projecte. Promoure a `Surface::blitInto` ara afegiria una API genèrica per a un ús únic (YAGNI). Si un segon client apareix, es mourea en aquell moment |
|
||||||
|
|
||||||
## 5. Inconsistències internes
|
## 5. Inconsistències internes
|
||||||
|
|
||||||
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|
||||||
|----|--------------|----------|-----------|-------|-------|
|
|----|--------------|----------|-----------|-------|-------|
|
||||||
| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta |
|
| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta. NOTA (post `refactor/options-class`): "encapsular" en COUP-03 vol dir ocultar l'estat mutable darrere de l'API del namespace (anonymous namespace + getters/setters), no convertir Options a classe — el patró `namespace per a estat compartit` queda preservat |
|
||||||
| INCONS-02 | `options.cpp:372,477,530,579,607` | `std::cerr` en comptes de `std::cout` (convenció del projecte) | Importante | DONE | Totes les ocurrències (10, incloent les afegides a PATCH-04) substituïdes per `std::cout`. Test manual: amb config.yaml trencat el missatge de backup va per stdout |
|
| INCONS-02 | `options.cpp:372,477,530,579,607` | `std::cerr` en comptes de `std::cout` (convenció del projecte) | Importante | DONE | Totes les ocurrències (10, incloent les afegides a PATCH-04) substituïdes per `std::cout`. Test manual: amb config.yaml trencat el missatge de backup va per stdout |
|
||||||
| INCONS-03 | `options.cpp` vs `tilemap.cpp:77-78` | Diferent política en YAML absent: `contains()` vs accés directe que llança | Menor | WONTFIX | Les dues polítiques són correctes per al seu cas: `Tilemap`/`Map::loadFrom` tenen fitxers de definició (asset obligatori → llançar); `Options` parseja preferències de l'usuari (camp absent → manté default). No és inconsistència sinó dos contractes diferents |
|
| INCONS-03 | `options.cpp` vs `tilemap.cpp:77-78` | Diferent política en YAML absent: `contains()` vs accés directe que llança | Menor | WONTFIX | Les dues polítiques són correctes per al seu cas: `Tilemap`/`Map::loadFrom` tenen fitxers de definició (asset obligatori → llançar); `Options` parseja preferències de l'usuari (camp absent → manté default). No és inconsistència sinó dos contractes diferents |
|
||||||
| INCONS-04 | `options.cpp`, `player.cpp` | Locals ALL-CAPS (`CONTENT`, `IT`, `DT`, `OLD_X`) barrejats amb `snake_case` | Menor | WONTFIX | El `.clang-tidy` del projecte exigeix `LocalConstantCase = UPPER_CASE` i `VariableCase = lower_case` — els ALL-CAPS són per a `const`/`constexpr` i els snake_case per a mutables. La barreja es correcta i està dictada pel linter |
|
| INCONS-04 | `options.cpp`, `player.cpp` | Locals ALL-CAPS (`CONTENT`, `IT`, `DT`, `OLD_X`) barrejats amb `snake_case` | Menor | WONTFIX | El `.clang-tidy` del projecte exigeix `LocalConstantCase = UPPER_CASE` i `VariableCase = lower_case` — els ALL-CAPS són per a `const`/`constexpr` i els snake_case per a mutables. La barreja es correcta i està dictada pel linter |
|
||||||
@@ -97,6 +97,16 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer
|
|||||||
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|
||||||
|----|--------------|----------|-----------|-------|-------|
|
|----|--------------|----------|-----------|-------|-------|
|
||||||
| CONV-01 | `player.*`, `map.*`, `ui/*`, `game_scene.cpp` | Speed, caixa de col·lisió, geometria, índexs paleta, rutes d'assets, nom del track: tot cablejat | Crítico | DONE | Tapat per HC-01/02/03/04/05/06: speed a Defaults::Player, col·lisió a player.yaml, geometria a pyramid.yaml, índexs paleta a Defaults::UI, rutes a Defaults::Pyramid + Config |
|
| CONV-01 | `player.*`, `map.*`, `ui/*`, `game_scene.cpp` | Speed, caixa de col·lisió, geometria, índexs paleta, rutes d'assets, nom del track: tot cablejat | Crítico | DONE | Tapat per HC-01/02/03/04/05/06: speed a Defaults::Player, col·lisió a player.yaml, geometria a pyramid.yaml, índexs paleta a Defaults::UI, rutes a Defaults::Pyramid + Config |
|
||||||
| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE (parcial) | Map resolt amb PATCH-07 (namespace). La part d'Options/observers va amb COUP-03 (DEFERRED a `refactor/options-class`) |
|
| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE | Map resolt amb PATCH-07 (namespace). Options resolt amb COUP-03: estat mutable encapsulat darrere de l'API del namespace (anonymous ns + getters/setters). La part "observers" (notificacions automàtiques a Screen/Audio) queda WONTFIX justificat: invertiria la direcció de dependència `Director→Screen/Audio` que el projecte manté intencionadament |
|
||||||
| CONV-03 | `options.cpp` | `std::cerr` viola la regla "std::cout only" | Importante | DONE | Resolt amb INCONS-02 |
|
| CONV-03 | `options.cpp` | `std::cerr` viola la regla "std::cout only" | Importante | DONE | Resolt amb INCONS-02 |
|
||||||
| CONV-04 | `player.cpp:27` | `SDL_GetKeyboardState` directe en `Player::update` (fuga de SDL al gameplay) | Menor | WONTFIX | `SDL_GetKeyboardState` és l'accés correcte per a state-based polling a 60Hz; `EventBuffer` és per a esdeveniments discrets. La fuga és cosmètica — abstraure-la no aportaria valor |
|
| CONV-04 | `player.cpp:27` | `SDL_GetKeyboardState` directe en `Player::update` (fuga de SDL al gameplay) | Menor | WONTFIX | `SDL_GetKeyboardState` és l'accés correcte per a state-based polling a 60Hz; `EventBuffer` és per a esdeveniments discrets. La fuga és cosmètica — abstraure-la no aportaria valor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registro de commits — `refactor/options-class`
|
||||||
|
|
||||||
|
Branca per a tancar els 4 ítems DEFERRED de `audit/game`. Base: `main` (32dcf26).
|
||||||
|
|
||||||
|
- `a3b03b8` — COUP-03 + CONV-02: estat mutable d'`Options` mogut a anonymous ns + API de getters/setters; `global_inputs.cpp` i `director.cpp` adaptats. CONV-02 (part Options) tanca al pas. La part "observers" del audit es deixa WONTFIX (invertiria `Director→Screen/Audio`)
|
||||||
|
- `64e0178` — COUP-02: `Map::Info` plana injectada al `Player` (només Player; momia/roca/loadlevel resten amb `Map::*` global, com diu la nota original del audit)
|
||||||
|
- `d9f6fb4` — PATCH-01: `tryMove(dx, dy, CollisionFn)` a `MovingSprite` + hook virtual `collisionBox()` a `Sprite`; `Player::update` deixa de fer `update + revertir + reaplicar` i passa a `animate(dt) + tryMove`
|
||||||
@@ -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.
|
||||||
@@ -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),
|
- **Smoke tests**: T12 (CollisionBox), T13 (Mummy::step), T11 (sarc YAML),
|
||||||
T10 (torch anim). Total 14/14.
|
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).
|
- **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.
|
- **Smoke tests**: 14/14 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a totes les fases.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Animacions i direccionalitat del jugador
|
||||||
|
|
||||||
|
Documenta els afegits d'aquesta sessió a `source/game/entities/player.*`: sis
|
||||||
|
animacions direccionals amb `facing_` persistent, regla de flip només per al
|
||||||
|
lateral i transició automàtica a `stand` quan la col·lisió bloqueja el
|
||||||
|
moviment. Tot el canvi és local al `Player`; no s'ha tocat l'EntityManager
|
||||||
|
ni el bus.
|
||||||
|
|
||||||
|
## 1. YAML — sis animacions
|
||||||
|
|
||||||
|
El fitxer `data/player/player.yaml` defineix ara sis animacions, agrupades
|
||||||
|
en tres files de 4 frames sobre `player.gif` (convenció habitual: d'esquerra
|
||||||
|
a dreta i de dalt a baix):
|
||||||
|
|
||||||
|
| Nom | Frames | Descripció |
|
||||||
|
|---------------|------------|----------------------------|
|
||||||
|
| `stand_down` | `[0]` | parat mirant cap avall |
|
||||||
|
| `walk_down` | `[0..3]` | caminant cap avall |
|
||||||
|
| `stand_up` | `[4]` | parat mirant cap amunt |
|
||||||
|
| `walk_up` | `[4..7]` | caminant cap amunt |
|
||||||
|
| `stand` | `[8]` | parat lateral |
|
||||||
|
| `walk` | `[8..11]` | caminant lateral |
|
||||||
|
|
||||||
|
Les laterals (`stand` i `walk`) ja existien; les variants `_up` i `_down`
|
||||||
|
són noves i permeten distingir visualment la direcció de moviment.
|
||||||
|
|
||||||
|
## 2. `Facing` persistent
|
||||||
|
|
||||||
|
S'ha afegit l'enum privat `Facing { SIDE, UP, DOWN }` amb el membre
|
||||||
|
`facing_{SIDE}`. La tria es fa a cada `update`, amb prevalença lateral
|
||||||
|
per si un dia es permetessin diagonals:
|
||||||
|
|
||||||
|
- `vx != 0` → `SIDE` (inclou diagonals).
|
||||||
|
- `vx == 0 && vy < 0` → `UP`.
|
||||||
|
- `vx == 0 && vy > 0` → `DOWN`.
|
||||||
|
- Cap input → es conserva l'últim `facing_` perquè l'animació `stand_*`
|
||||||
|
coincideixi amb l'última direcció caminada.
|
||||||
|
|
||||||
|
## 3. Regla de flip
|
||||||
|
|
||||||
|
- `SIDE`: el sprite es flipeja horitzontalment si `vx < 0`, i es restaura
|
||||||
|
a `FLIP_NONE` si `vx > 0`. El sprite original mira a la dreta.
|
||||||
|
- `UP` / `DOWN`: el flip es força explícitament a `FLIP_NONE`. Els sprites
|
||||||
|
frontals/dorsals no s'han d'espillar només perquè l'últim lateral havia
|
||||||
|
estat cap a l'esquerra.
|
||||||
|
|
||||||
|
## 4. `stand` automàtic a la col·lisió
|
||||||
|
|
||||||
|
Quan el jugador prem una tecla però la col·lisió (mapa o sarcòfag) li
|
||||||
|
impedeix avançar, l'animació `walk*` s'atura i es força la variant
|
||||||
|
`stand_*` corresponent al facing. L'algorisme:
|
||||||
|
|
||||||
|
1. Al principi del frame es captura `FRAME_START_X` / `FRAME_START_Y`.
|
||||||
|
2. S'aplica snap al passadís perpendicular si hi ha pur moviment H o V.
|
||||||
|
3. `animate(delta_time)` avança el frame de l'animació actual sense
|
||||||
|
tocar la posició.
|
||||||
|
4. `tryMove(vx*dt, vy*dt, collidesFn)` (a `MovingSprite`) aplica el
|
||||||
|
moviment per eix: si la `collisionBox()` resultant col·lisiona —
|
||||||
|
el Player la sobreescriu retornant `getBaseBox()` —, l'eix es
|
||||||
|
reverteix. Així el jugador llisca contra murs/sarcòfags en lloc de
|
||||||
|
bloquejar-se en sec.
|
||||||
|
5. Després de `clampToPlayfield`, es compara la posició final amb el
|
||||||
|
valor capturat al pas 1. Si és idèntica, `MOVED = false` i es tria
|
||||||
|
`stand` / `stand_up` / `stand_down` segons `facing_`. Si hi ha
|
||||||
|
hagut moviment real (incloent-hi snap), s'aplica la variant
|
||||||
|
`walk*` del mateix facing.
|
||||||
|
|
||||||
|
D'aquesta manera, xocar contra un mur o un sarcòfag fa que el jugador es
|
||||||
|
quede immòbil en compte d'animar el caminat "en el lloc". El facing
|
||||||
|
segueix reflectint la intenció (on vol anar), no el resultat, perquè
|
||||||
|
l'`stand_up` d'empentar-se contra un mur ha de mostrar el personatge
|
||||||
|
mirant amunt.
|
||||||
|
|
||||||
|
## 5. Estat
|
||||||
|
|
||||||
|
- DONE: sis animacions al YAML, `facing_` persistent, flip condicionat,
|
||||||
|
`stand` automàtic a la col·lisió.
|
||||||
|
- Build i smoke tests (13/13) verdes. (Aquesta nota deia "14/14" abans;
|
||||||
|
el comptatge real al `tests/test_entities_smoke.cpp` són 13 funcions
|
||||||
|
`test*` invocades des de `main()` — corregit a `refactor/options-class`.)
|
||||||
|
- Sense smoke test dedicat: la lògica està entrellaçada amb teclat i
|
||||||
|
`Map::collides`; l'smoke actual de `Player` no cobreix teclat simulat.
|
||||||
|
- Pendent (ajornat): si algun dia es vol feedback auditiu de wall-bump
|
||||||
|
(so en xocar), la condició natural és `!MOVED && (vx != 0 || vy != 0)`
|
||||||
|
al final d'`update`.
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
# Pla d'implementació — Menú de servei
|
||||||
|
|
||||||
|
Pla per a portar el menú de servei de `../aee` (`source/core/rendering/menu.cpp`)
|
||||||
|
a `aee_2026`, adaptat a l'arquitectura actual (Scenes + Director instanciat +
|
||||||
|
`Options` amb setters persistents + `Screen` ARGB + `Text` bitmap). Document de
|
||||||
|
referència per a implementar-ho en una sessió futura; no s'ha escrit cap codi
|
||||||
|
encara.
|
||||||
|
|
||||||
|
Objectiu visual: caixa centrada amb vora i títol, navegable amb fletxes +
|
||||||
|
Retorn + Retrocés, oberta i tancada amb F12 (hotkey `menu_toggle`), retolada amb
|
||||||
|
la font `ari-w9500-display`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Referència a `../aee`
|
||||||
|
|
||||||
|
Tot el que segueix és la base; la còpia mental serveix per a no reinventar res:
|
||||||
|
|
||||||
|
- [source/core/rendering/menu.hpp](../../aee/source/core/rendering/menu.hpp) — API
|
||||||
|
pública (`init/destroy/toggle/close/isOpen/isVisible/render/handleKey`).
|
||||||
|
- [source/core/rendering/menu.cpp](../../aee/source/core/rendering/menu.cpp) —
|
||||||
|
implementació completa (676 línies). Conté:
|
||||||
|
- Struct `Item` i `Page` amb `ItemKind::{Toggle, Cycle, IntRange, Submenu,
|
||||||
|
KeyBind, Action}`.
|
||||||
|
- Animació d'obertura/tancament (`OPEN_SPEED`, `CLOSE_SPEED`,
|
||||||
|
`HEIGHT_RATE`) i transició entre pàgines (`pushPage/popPage`).
|
||||||
|
- Dibuix manual sobre `Uint32* pixel_data` amb `blendRect/fillRect/drawBorder`.
|
||||||
|
- `buildRoot/buildVideo/buildAudio/buildControls/buildGame/buildSystem`.
|
||||||
|
- Claus de `locale/ca.yaml` sota `menu.{titles, items, values}` — es poden
|
||||||
|
reutilitzar gairebé literalment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Diferències d'arquitectura rellevants
|
||||||
|
|
||||||
|
| Punt | `../aee` | `aee_2026` |
|
||||||
|
| ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
|
| Overlay | `Overlay::render()` dibuixa Menú + Notifier al buffer | No hi ha Overlay. Cada Scene crida `Notifier::render()` i `RenderInfo::render()` |
|
||||||
|
| Director | Singleton `Director::get()` amb `requestQuit/requestRestart` | Classe instanciada; sortida via `SDL_EVENT_QUIT` a `EventBuffer` |
|
||||||
|
| `Options` | Membres `inline` mutats directament | Getters const-ref + setters que persisteixen automàticament |
|
||||||
|
| Setter → subsistema | Dins del mètode (ex. `Screen::toggleVSync`) | El caller crida `Options::setX(v)` **i** `Screen::setX(v)` per separat |
|
||||||
|
| Remapping tecles | `KeyConfig::scancodePtr(id)` retorna mutable | `KeyConfig` actual no té `scancodePtr` — veure limitació a §5 |
|
||||||
|
| Render | Pixel-level (blend manual sobre `Uint32*`) | `Screen::fillRect(argb)` + `Text::writeDX`; ARGB cru via `getCanvasArgb()` |
|
||||||
|
| Font | `8bithud.fnt` via `Text` | `Text` via `Resource::Cache` (paletat sobre `ui`) |
|
||||||
|
|
||||||
|
Conseqüències per al disseny:
|
||||||
|
|
||||||
|
1. El menú ha d'integrar-se en el pipeline de render de cada Scene, no via
|
||||||
|
Overlay. Pot ser tan simple com una línia al final de cada `render()`
|
||||||
|
abans de `Screen::get().present()`, al costat de Notifier/RenderInfo.
|
||||||
|
2. El menú intercepta input **abans** que `GlobalInputs::update()` processi
|
||||||
|
les hotkeys (si és obert, només F12 i les fletxes arriben al menú; la
|
||||||
|
resta d'esdeveniments es consumeixen perquè no activin F1–F11).
|
||||||
|
3. Els setters de `Options::` ja persisteixen. El menú crida
|
||||||
|
`Options::setX(v); Screen::setX(v);` com fa `global_inputs.cpp`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ubicació i mòduls nous
|
||||||
|
|
||||||
|
```
|
||||||
|
source/game/ui/service_menu.hpp (nou)
|
||||||
|
source/game/ui/service_menu.cpp (nou)
|
||||||
|
data/fonts/ari-w9500-display.{fnt,gif} (ja existeixen)
|
||||||
|
data/locale/ca.yaml (afegir clau arrel `menu:`)
|
||||||
|
data/locale/en.yaml (afegir clau arrel `menu:`)
|
||||||
|
data/input/keys.yaml (canviar `toggle_render_info` → `menu_toggle` en F12, o moure F12 a menu i reubicar render_info)
|
||||||
|
```
|
||||||
|
|
||||||
|
Raonament:
|
||||||
|
|
||||||
|
- Viu a `game/ui/` perquè el contingut de les pàgines toca `Options::` i
|
||||||
|
`Screen::` del joc; no és un primitiu de renderització com `Text` o
|
||||||
|
`Notifier`.
|
||||||
|
- Exposa l'API com a **singleton** (`ServiceMenu::get()`) per coherència amb
|
||||||
|
`Notifier` i `RenderInfo`.
|
||||||
|
- `init()` rep `std::shared_ptr<Text>` + `Palette ui_palette`, igual que
|
||||||
|
`Notifier::init()`. El Director construeix la font display i la injecta.
|
||||||
|
|
||||||
|
Esquelet de l'API:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// source/game/ui/service_menu.hpp
|
||||||
|
class ServiceMenu {
|
||||||
|
public:
|
||||||
|
static auto get() -> ServiceMenu&;
|
||||||
|
void init(std::shared_ptr<Text> font, const Palette& ui_palette);
|
||||||
|
|
||||||
|
[[nodiscard]] auto isOpen() const -> bool; // accepta input
|
||||||
|
[[nodiscard]] auto isVisible() const -> bool; // dibuixa caixa (també tancant-se)
|
||||||
|
|
||||||
|
void toggle();
|
||||||
|
void close();
|
||||||
|
|
||||||
|
void update(int delta_ms); // avança animacions
|
||||||
|
void render(); // dibuixa sobre el canvas de Screen
|
||||||
|
|
||||||
|
// Retorna true si el menú ha consumit l'esdeveniment (tecla empassada).
|
||||||
|
auto handleEvent(const SDL_Event& ev) -> bool;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hotkey i integració amb `GlobalInputs`
|
||||||
|
|
||||||
|
Decisió: F12 passa a ser `menu_toggle` i el cicle de l'overlay d'info es mou
|
||||||
|
a una altra tecla (per ex. F10) o es manté a F12 i s'obre el menú amb una
|
||||||
|
altra (per ex. F9). Opció recomanada:
|
||||||
|
|
||||||
|
- `menu_toggle` → **F9** (tecla nova, no conflictiva).
|
||||||
|
- `toggle_render_info` es queda a F12 (hàbit actual).
|
||||||
|
|
||||||
|
Motius: evita trencar la memòria muscular existent i deixa F12 — una tecla
|
||||||
|
familiar per a debug — lliure per a la seua funció actual.
|
||||||
|
|
||||||
|
Afegir a `data/input/keys.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: menu_toggle
|
||||||
|
code: "F9"
|
||||||
|
desc: "Obri/tanca el menú de servei"
|
||||||
|
```
|
||||||
|
|
||||||
|
Flux d'input (escena GameScene::update):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Abans de GlobalInputs::update, donar prioritat al menú.
|
||||||
|
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
|
||||||
|
if (ServiceMenu::get().isOpen() && ServiceMenu::get().handleEvent(*it)) {
|
||||||
|
it = EventBuffer::events.erase(it);
|
||||||
|
} else if (it->type == SDL_EVENT_KEY_DOWN &&
|
||||||
|
it->key.scancode == KeyConfig::get().scancode("menu_toggle")) {
|
||||||
|
ServiceMenu::get().toggle();
|
||||||
|
it = EventBuffer::events.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlobalInputs::update(delta_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mentre el menú està obert, les hotkeys (F1–F8, F11, F12) queden bloquejades
|
||||||
|
perquè els KEY_DOWN els consumeix el menú.
|
||||||
|
- Escape dins del menú ↔ close (o cancel·lació de captura de tecla, si es
|
||||||
|
porta el KeyBind — veure §5).
|
||||||
|
- F9 sempre passa el toggle (encara que el menú estiga obert).
|
||||||
|
|
||||||
|
Alternativa més neta a mig termini: un `InputRouter` centralitzat al Director
|
||||||
|
amb prioritats (Menu → GlobalInputs → Scene). De moment, cadascuna de les
|
||||||
|
Scenes (`LogoScene`, `TitleScene`, `IntroScene`, `GameScene`) crida el bloc
|
||||||
|
d'input-del-menú al principi de `update()`, igual que ja fan amb
|
||||||
|
`GlobalInputs::update`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Contingut de les pàgines (adaptat a `Options` actuals)
|
||||||
|
|
||||||
|
### Root
|
||||||
|
|
||||||
|
- Vídeo → submenú
|
||||||
|
- Àudio → submenú
|
||||||
|
- Sistema → submenú
|
||||||
|
|
||||||
|
_No hi ha Controls ni Game:_ `Options::` actual no exposa remapping de tecles
|
||||||
|
de joc (les `KeysGame` són constants de `Defaults::`), ni opcions "de joc"
|
||||||
|
equivalents a `use_new_logo` / `show_preload`. Si en el futur apareixen, es
|
||||||
|
pot afegir sense reescriure res.
|
||||||
|
|
||||||
|
### Vídeo
|
||||||
|
|
||||||
|
Items, cadascun mapa el setter+notificador com `global_inputs.cpp`:
|
||||||
|
|
||||||
|
| Label | Tipus | Origen | Handler |
|
||||||
|
| --------------------- | -------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `menu.items.zoom` | IntRange | `Options::window().zoom` | `Options::setWindowZoom(v); Screen::get().setWindowZoom(v)` (rang 1–6, clamp) |
|
||||||
|
| `menu.items.screen` | Toggle | `Options::window().fullscreen` | `Options::setWindowFullscreen(v); Screen::get().setFullscreen(v)` |
|
||||||
|
| `menu.items.vsync` | Toggle | `Options::video().vsync` | `Options::setVideoVSync(v); Screen::get().setVSync(v)` |
|
||||||
|
| `menu.items.scaling_mode` | Cycle | `Options::video().scaling_mode` | `Options::setVideoScalingMode(m); Screen::get().setScalingMode(m)` |
|
||||||
|
| `menu.items.shader` | Toggle | `Options::video().shader.enabled` | `Options::setShaderEnabled(v)` + `reapplyShader()` (copiar helper de `global_inputs.cpp`)|
|
||||||
|
| `menu.items.shader_type` | Cycle | `Options::video().shader.current_shader` | `Options::setActiveShaderName("postfx"/"crtpi")` + `reapplyShader()` |
|
||||||
|
| `menu.items.preset` | Cycle | preset actiu segons shader | `Options::setPostFXPreset(s)` o `setCrtPiPreset(s)` + `reapplyShader()` |
|
||||||
|
| `menu.items.render_info` | Cycle | `Options::video().render_info_position` | `Options::setVideoRenderInfoPosition(p)` |
|
||||||
|
| `menu.items.uptime` | Toggle | `Options::video().render_info_show_time` | `Options::setVideoRenderInfoShowTime(v)` (sols visible si render_info ≠ OFF) |
|
||||||
|
|
||||||
|
Claus `visible` (oculta quan no apliquen):
|
||||||
|
|
||||||
|
- `shader_type`, `preset` → només si `shader.enabled == true`.
|
||||||
|
- `uptime` → només si `render_info_position != OFF`.
|
||||||
|
|
||||||
|
### Àudio
|
||||||
|
|
||||||
|
| Label | Tipus | Origen | Handler |
|
||||||
|
| ---------------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------ |
|
||||||
|
| `menu.items.master_enable` | Toggle | `Options::audio().enabled` | Caldrà afegir `Options::setAudioEnabled(v)` (no hi és avui) + `Audio::applyConfig()` |
|
||||||
|
| `menu.items.master_volume` | IntRange | `Options::audio().volume` | `Options::setAudioVolume(v)` (afegir) + `Audio::applyConfig()` |
|
||||||
|
| `menu.items.music` | Toggle | `Options::audio().music.enabled` | `Options::setAudioMusicEnabled(v)` (afegir) + `Audio::applyConfig()` |
|
||||||
|
| `menu.items.music_volume` | IntRange | `Options::audio().music.volume` | `Options::setAudioMusicVolume(v)` (afegir) + `Audio::applyConfig()` |
|
||||||
|
| `menu.items.sounds` | Toggle | `Options::audio().sound.enabled` | `Options::setAudioSoundEnabled(v)` (afegir) + `Audio::applyConfig()` |
|
||||||
|
| `menu.items.sounds_volume` | IntRange | `Options::audio().sound.volume` | `Options::setAudioSoundVolume(v)` (afegir) + `Audio::applyConfig()` |
|
||||||
|
|
||||||
|
**Dependència prèvia:** `Options::` no té setters d'àudio. Cal afegir-los
|
||||||
|
seguint el mateix patró que `setVideoVSync` (muten namespace intern +
|
||||||
|
`saveToFile()`) i exposar `Audio::applyConfig(Audio::Config)` o
|
||||||
|
`Audio::setVolumes(...)` — depenent del que ja existeixi a `core/audio/`.
|
||||||
|
|
||||||
|
Les escales de volum són 0.0–1.0 amb pas 0.05 (5 %), com a `../aee`.
|
||||||
|
|
||||||
|
### Sistema
|
||||||
|
|
||||||
|
Subtítol: `v<VERSION> (<GIT_HASH>)` — usar el `version.h` generat per CMake
|
||||||
|
(ja existeix a `source/version.h.in`).
|
||||||
|
|
||||||
|
| Label | Tipus | Handler |
|
||||||
|
| --------------------- | ------ | -------------------------------------------------------------------------------- |
|
||||||
|
| `menu.items.exit_game`| Action | Injectar un `SDL_EVENT_QUIT` a `EventBuffer::events` perquè `Director::processEvents` el vegi |
|
||||||
|
|
||||||
|
_Sense `Restart`_ de moment: Director no té un pas de reinici en calent, i
|
||||||
|
simular-lo a partir del director actual implica destruir l'escena i
|
||||||
|
reconstruir el flux — fora d'abast d'aquest menú. Deixar-ho com a TODO si
|
||||||
|
es necessita.
|
||||||
|
|
||||||
|
### Limitació: pàgina Controls diferida
|
||||||
|
|
||||||
|
El menú de `../aee` té una pàgina Controls (`buildControls`) que permet
|
||||||
|
remappar `up/down/left/right` i la tecla de menú. A `aee_2026`:
|
||||||
|
|
||||||
|
- Les tecles de joc són `Defaults::KeysGame::{UP,DOWN,LEFT,RIGHT}` — constants
|
||||||
|
`constexpr`, no mutables.
|
||||||
|
- `KeyConfig` actual no exposa `scancodePtr(id)`.
|
||||||
|
|
||||||
|
Opcions, de menys a més invasiu:
|
||||||
|
|
||||||
|
1. **No portar Controls** en aquesta iteració (recomanat per a la primera
|
||||||
|
versió).
|
||||||
|
2. Migrar `KeysGame` a `Options::` + afegir `KeyConfig::scancodePtr(id)` per
|
||||||
|
a hotkeys. Requereix tocar `Player::Controls`, el config.yaml i keys.yaml.
|
||||||
|
|
||||||
|
Si s'opta per (2), la pàgina Controls s'afegeix exactament igual que a
|
||||||
|
`../aee`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Renderitzat
|
||||||
|
|
||||||
|
Dos camins segons nivell d'ambició:
|
||||||
|
|
||||||
|
### A. Simple — sòlid opac (MVP)
|
||||||
|
|
||||||
|
- Caixa centrada: `Screen::get().fillRect(x, y, w, h, ui_palette_[idx_fons])`.
|
||||||
|
- Vora: 4 `fillRect` d'1 px amb color `ui_palette_[idx_vora]`.
|
||||||
|
- Text: `Text::writeDX(CENTER_FLAG | COLOR_FLAG | SHADOW_FLAG, ...)`.
|
||||||
|
- Cap blend; el joc queda totalment ocult darrere de la caixa (equivalent a
|
||||||
|
`../aee` però amb `BG_ALPHA = 255`).
|
||||||
|
|
||||||
|
Afegir dos índexs nous a `utils/defaults.hpp → UI::`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr Uint8 MENU_BG = /* triar paleta sega-cube */;
|
||||||
|
constexpr Uint8 MENU_BORDER = /* triar */;
|
||||||
|
constexpr Uint8 MENU_TITLE = /* blanc */;
|
||||||
|
constexpr Uint8 MENU_LABEL = /* gris clar */;
|
||||||
|
constexpr Uint8 MENU_VALUE = /* cian/groc */;
|
||||||
|
constexpr Uint8 MENU_CURSOR = /* destacat */;
|
||||||
|
constexpr Uint8 MENU_EMPTY = /* gris */;
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Semi-transparent (opcional, iteració 2)
|
||||||
|
|
||||||
|
Calca el `blendRect` de `../aee`: expandir `Screen::` amb un `blendRect(x,
|
||||||
|
y, w, h, argb, alpha)` que opere sobre `canvas_.data()`. El canvas és
|
||||||
|
`std::uint32_t*` format ABGR (equivalent a ARGB little-endian). Amb això, el
|
||||||
|
fons del menú pot ser `alpha=220` i es veu el joc apagat per darrere.
|
||||||
|
|
||||||
|
Mida fixa recomanada per al MVP: `BOX_W = 220`, altura dinàmica (igual que
|
||||||
|
`../aee`). Canvas lògic d'`aee_2026` és `GameCanvas::WIDTH`/`HEIGHT` —
|
||||||
|
confirmar mida real (mateixos 320×200?) a `utils/defines.hpp` abans de
|
||||||
|
hardcoded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Font `ari-w9500-display`
|
||||||
|
|
||||||
|
Ja és al disc i ja té el metadatfnt corresponent
|
||||||
|
([data/fonts/ari-w9500-display.fnt](../data/fonts/ari-w9500-display.fnt),
|
||||||
|
11 px d'altura, 12 px de box). És paletable sobre `ui` com les altres
|
||||||
|
fonts bitmap.
|
||||||
|
|
||||||
|
Al `Director::Director()`, afegir després del bloc de fonts HUD/Placeholder:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr const char* MENU_FONT_NAME = "ari-w9500-display";
|
||||||
|
constexpr const char* MENU_FONT_GIF = "data/fonts/ari-w9500-display.gif";
|
||||||
|
constexpr const char* MENU_FONT_FNT = "data/fonts/ari-w9500-display.fnt";
|
||||||
|
|
||||||
|
const std::string MENU_FONT_GIF_KEY = std::string(MENU_FONT_NAME) + ".gif";
|
||||||
|
Resource::Cache::get().loadSurface(MENU_FONT_GIF_KEY, MENU_FONT_GIF);
|
||||||
|
Resource::Cache::get().getSurface(MENU_FONT_GIF_KEY)->setPalette(
|
||||||
|
Resource::Cache::get().getPalette("ui"));
|
||||||
|
auto menu_font_surface = Resource::Cache::get().getSurface(MENU_FONT_GIF_KEY);
|
||||||
|
auto menu_text = std::make_shared<Text>(menu_font_surface, MENU_FONT_FNT);
|
||||||
|
|
||||||
|
ServiceMenu::get().init(menu_text, ui_palette);
|
||||||
|
```
|
||||||
|
|
||||||
|
Centralitzar les constants a `Defaults::UI::Font::MENU_{NAME,GIF,FNT}` perquè
|
||||||
|
encaixi amb el memo `project_ui_fonts.md` ("tot a `Defaults::UI::Font`").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Locale
|
||||||
|
|
||||||
|
Copiar les claus de `../aee/data/locale/ca.yaml` sota `menu:` (titles, items,
|
||||||
|
values) a **ca.yaml** i **en.yaml**. El primer commit pot incloure només `ca`;
|
||||||
|
`en` val la pena fer-lo a la vegada perquè és traducció directa.
|
||||||
|
|
||||||
|
Valors a eliminar de la còpia (no apliquen ací):
|
||||||
|
|
||||||
|
- `menu.items.{use_new_logo, show_title_credits, show_preload}` (no hi ha
|
||||||
|
pàgina Game).
|
||||||
|
- `menu.items.{move_up, move_down, move_left, move_right, menu_key,
|
||||||
|
restart, aspect_4_3, texture_filter, internal_resolution, supersampling}`
|
||||||
|
(no portats a l'MVP — veure §5 i les decisions de pàgina Vídeo).
|
||||||
|
|
||||||
|
Claus noves que no tenia `../aee` — cap per a l'MVP; si s'afegeixen pàgines
|
||||||
|
en futures iteracions, traduir a mesura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fases d'implementació
|
||||||
|
|
||||||
|
Cada fase és un commit. Ordre perquè cada pas compila i s'executa.
|
||||||
|
|
||||||
|
### Fase 1 — setters d'àudio a `Options::`
|
||||||
|
|
||||||
|
_Preparatori; sense això la pàgina Àudio no pot mutar res._
|
||||||
|
|
||||||
|
- Afegir `setAudioEnabled`, `setAudioVolume`, `setAudioMusicEnabled`,
|
||||||
|
`setAudioMusicVolume`, `setAudioSoundEnabled`, `setAudioSoundVolume` a
|
||||||
|
`options.hpp/.cpp`. Patró exacte de `setVideoVSync`: muten estat intern +
|
||||||
|
`saveToFile()`.
|
||||||
|
- Exposar un mètode de reconfiguració en calent a `core/audio/`
|
||||||
|
(`Audio::applyConfig` o similar) si no hi és ja.
|
||||||
|
- Smoke test: `xvfb-run` 5 s, joc arrenca i música sona.
|
||||||
|
|
||||||
|
### Fase 2 — hotkey i esquelet `ServiceMenu`
|
||||||
|
|
||||||
|
- Afegir `menu_toggle` a `data/input/keys.yaml` amb F9.
|
||||||
|
- Crear `service_menu.{hpp,cpp}` amb API mínima: `init`, `toggle`, `close`,
|
||||||
|
`isOpen`, `isVisible`, `update`, `render` (render de caixa buida amb
|
||||||
|
títol fix "Opcions"), `handleEvent` (només F9, Escape, Backspace).
|
||||||
|
- Al Director: construir font `ari-w9500-display`, inicialitzar
|
||||||
|
`ServiceMenu::get()`.
|
||||||
|
- A `GameScene::update()`: bloc d'input abans de `GlobalInputs::update`
|
||||||
|
(veure §4). A `GameScene::render()`: `ServiceMenu::get().render()` abans
|
||||||
|
de `Screen::present()`. **No** replicar encara a Logo/Title/Intro — el
|
||||||
|
menú només té sentit quan l'usuari juga.
|
||||||
|
- Smoke test: F9 obri caixa centrada, Escape/F9 la tanca.
|
||||||
|
|
||||||
|
### Fase 3 — framework de pàgines
|
||||||
|
|
||||||
|
- Portar `Item`, `Page`, `ItemKind`, `stack_`, `pushPage`/`popPage`,
|
||||||
|
`nextVisibleCursor`, `isVisible(Item)`, `handleKey` de `../aee` (quasi
|
||||||
|
literalment).
|
||||||
|
- Portar `renderPageContent` simplificat (sense transicions ni clipping
|
||||||
|
avançat al principi): render de títol, línia, items amb cursor i valor.
|
||||||
|
- Afegir les claus `menu.titles.root` i `menu.values.*` a `ca.yaml`/`en.yaml`.
|
||||||
|
- Implementar `buildRoot()` amb tres entrades Submenu buides (es naveguen
|
||||||
|
però totes porten a pàgines sense items visibles, text "(Buit)").
|
||||||
|
- Smoke test: navegació amunt/avall, enter a submenú, backspace torna.
|
||||||
|
|
||||||
|
### Fase 4 — Pàgina Vídeo
|
||||||
|
|
||||||
|
- Implementar `buildVideo()` amb els items de §5.
|
||||||
|
- Copiar el helper `reapplyShader()` de `global_inputs.cpp` a un lloc
|
||||||
|
compartit (p. ex. `game/options_apply.{hpp,cpp}`) perquè el cridin
|
||||||
|
tant `global_inputs` com el menú, sense duplicar la lògica.
|
||||||
|
- Afegir claus `menu.items.{zoom,screen,vsync,scaling_mode,shader,shader_type,preset,render_info,uptime}`.
|
||||||
|
- Smoke test: obrir menú, toggle vsync, canvi d'escala, comprovar que la
|
||||||
|
notificació NO apareix (és el menú qui fa el canvi, no les hotkeys).
|
||||||
|
|
||||||
|
### Fase 5 — Pàgina Àudio
|
||||||
|
|
||||||
|
- `buildAudio()` amb els 6 items. Helpers `volPct`/`stepVolume` copiats de
|
||||||
|
`../aee`.
|
||||||
|
- Claus `menu.items.{master_enable,master_volume,music,music_volume,sounds,sounds_volume}`.
|
||||||
|
- Smoke test: baixar volum de música, pujar volum de sons.
|
||||||
|
|
||||||
|
### Fase 6 — Pàgina Sistema
|
||||||
|
|
||||||
|
- `buildSystem()` amb subtítol de versió i `exit_game` que injecta
|
||||||
|
`SDL_EVENT_QUIT`.
|
||||||
|
- Smoke test: obrir sistema, exit_game tanca el joc net.
|
||||||
|
|
||||||
|
### Fase 7 (opcional) — polit
|
||||||
|
|
||||||
|
- Animació d'obertura/tancament (porta les constants `OPEN_SPEED`,
|
||||||
|
`CLOSE_SPEED`, `HEIGHT_RATE`, `TRANSITION_SPEED` de `../aee`).
|
||||||
|
- `blendRect` a `Screen::` per a fons semi-transparent.
|
||||||
|
- Replicar el bloc d'input del menú a `Title/Intro/Logo` si s'acorda que el
|
||||||
|
menú hi és accessible també.
|
||||||
|
- Traduccions EN si no es van fer abans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tests
|
||||||
|
|
||||||
|
- **Smoke (xvfb-run 5 s)** després de cada fase — memòria `feedback_smoke_test_mandatory`.
|
||||||
|
- Unitaris amb Catch2 si el framework està muntat (veure `tests/` actual):
|
||||||
|
- `ServiceMenu::toggle()` idempotent després de `close()`.
|
||||||
|
- `nextVisibleCursor` salta items ocults.
|
||||||
|
- `handleEvent` retorna `true` per a KEY_DOWN quan està obert, `false`
|
||||||
|
quan està tancat.
|
||||||
|
- Manual:
|
||||||
|
- Obrir F9 → tancar F9 → obrir de nou (no leak d'estat).
|
||||||
|
- Entrar a Vídeo → Shader off → verificar que `shader_type` i `preset`
|
||||||
|
desapareixen (anim o salt).
|
||||||
|
- Tancar el joc, re-obrir, verificar que `config.yaml` conserva els
|
||||||
|
canvis (els fa `Options::setX`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Riscos i punts oberts
|
||||||
|
|
||||||
|
- **Input routing als altres Scenes.** Si l'usuari vol obrir el menú
|
||||||
|
durant el LOGO/TITLE/INTRO, cal replicar el bloc d'input. Decisió: no.
|
||||||
|
Motiu: són placeholders amb 5 s de durada; obrir un menú allà és
|
||||||
|
overkill. Anotar-ho a la capçalera de `service_menu.hpp`.
|
||||||
|
- **`EventBuffer::events.erase(it)`** invalida els iteradors — mà amb el
|
||||||
|
loop. Patró amb `it = events.erase(it)` ↔ `++it` és segur.
|
||||||
|
- **Blend rect vs. GPU pipeline.** Si el shader PostFX reprocessa el
|
||||||
|
canvas, el menú ha d'estar pintat *abans* del shader (i ja ho està,
|
||||||
|
perquè el canvas ARGB va al shader). Res a fer; tot passa per
|
||||||
|
`Screen::present()` al final.
|
||||||
|
- **Captura de tecla (`KeyBind`).** No s'implementa aquesta iteració
|
||||||
|
(§5). Si es porta, l'event router ha de distingir "menú obert i
|
||||||
|
capturant" — el menú consumeix **tota** tecla, no només Enter/Backspace/etc.
|
||||||
|
- **`ari-w9500-display` és paletada.** Ha de compartir la paleta `ui` amb
|
||||||
|
la resta d'overlays. Verificar que el GIF només té la tinta a l'índex
|
||||||
|
`Text::INK_COLOR_INDEX = 1`; si no, el font_gen l'ha de regenerar (veure
|
||||||
|
memòria `reference_font_generator`).
|
||||||
|
- **F9 lliure?** Abans d'agafar F9 per al `menu_toggle`, confirmar que
|
||||||
|
cap altra hotkey hi és mapejada en builds futures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Resum d'arxius a tocar
|
||||||
|
|
||||||
|
**Nous:**
|
||||||
|
|
||||||
|
- `source/game/ui/service_menu.hpp`
|
||||||
|
- `source/game/ui/service_menu.cpp`
|
||||||
|
- (opcional) `source/game/options_apply.{hpp,cpp}` per compartir
|
||||||
|
`reapplyShader` amb `global_inputs.cpp`.
|
||||||
|
|
||||||
|
**Modificats:**
|
||||||
|
|
||||||
|
- `source/utils/defaults.hpp` — afegir índexs `UI::MENU_*` + `UI::Font::MENU_*`.
|
||||||
|
- `source/game/options.hpp/.cpp` — setters d'àudio (fase 1).
|
||||||
|
- `source/core/system/director.cpp` — carregar font menú + `ServiceMenu::get().init(...)`.
|
||||||
|
- `source/game/scenes/game_scene.cpp` — bloc d'input del menú a `update`,
|
||||||
|
render del menú a `render` abans de `present`.
|
||||||
|
- `data/input/keys.yaml` — afegir `menu_toggle: F9`.
|
||||||
|
- `data/locale/ca.yaml`, `data/locale/en.yaml` — secció `menu:`.
|
||||||
|
- `CMakeLists.txt` o `source/CMakeLists.txt` — afegir el nou `service_menu.cpp`.
|
||||||
|
|
||||||
|
**Sense tocar per a l'MVP:**
|
||||||
|
|
||||||
|
- `source/core/input/key_config.*` (no cal `scancodePtr`).
|
||||||
|
- `source/core/audio/*` si ja exposa configuració en calent; si no, mínima
|
||||||
|
adició.
|
||||||
|
- LogoScene/TitleScene/IntroScene.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#include "core/input/gamepad_manager.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "core/locale/locale.hpp"
|
||||||
|
#include "core/rendering/notification_presets.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
// A Emscripten, SDL 3.4+ no casa el GUID dels mandos web: Chrome/Android
|
||||||
|
// no aporten Vendor/Product ID i el parser de SDL extreu dades brutes.
|
||||||
|
// El W3C Gamepad API, però, garanteix un layout estàndard quan el mapping
|
||||||
|
// reporta "standard", així que injectem un mapping SDL explícit amb eixe
|
||||||
|
// layout per al GUID del joystick abans que SDL intenti obrir-lo com a
|
||||||
|
// gamepad. Fora d'Emscripten és un no-op.
|
||||||
|
void installWebStandardMapping(SDL_JoystickID jid) {
|
||||||
|
SDL_GUID guid = SDL_GetJoystickGUIDForID(jid);
|
||||||
|
char guid_str[33];
|
||||||
|
SDL_GUIDToString(guid, guid_str, sizeof(guid_str));
|
||||||
|
const char* name = SDL_GetJoystickNameForID(jid);
|
||||||
|
if (name == nullptr || *name == '\0') { name = "Standard Gamepad"; }
|
||||||
|
|
||||||
|
char mapping[512];
|
||||||
|
SDL_snprintf(mapping, sizeof(mapping),
|
||||||
|
"%s,%s,"
|
||||||
|
"a:b0,b:b1,x:b2,y:b3,"
|
||||||
|
"leftshoulder:b4,rightshoulder:b5,"
|
||||||
|
"lefttrigger:b6,righttrigger:b7,"
|
||||||
|
"back:b8,start:b9,"
|
||||||
|
"leftstick:b10,rightstick:b11,"
|
||||||
|
"dpup:b12,dpdown:b13,dpleft:b14,dpright:b15,"
|
||||||
|
"guide:b16,"
|
||||||
|
"leftx:a0,lefty:a1,rightx:a2,righty:a3,"
|
||||||
|
"platform:Emscripten",
|
||||||
|
guid_str,
|
||||||
|
name);
|
||||||
|
SDL_AddGamepadMapping(mapping);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
void installWebStandardMapping(SDL_JoystickID /*jid*/) {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
auto GamepadManager::get() -> GamepadManager& {
|
||||||
|
static GamepadManager instance_;
|
||||||
|
return instance_;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GamepadManager::prettyName(const char* raw) -> std::string {
|
||||||
|
std::string name = (raw != nullptr && *raw != '\0') ? std::string(raw) : std::string("Gamepad");
|
||||||
|
const auto POS = name.find_first_of("([");
|
||||||
|
if (POS != std::string::npos) { name.erase(POS); }
|
||||||
|
while (!name.empty() && name.back() == ' ') { name.pop_back(); }
|
||||||
|
if (name.empty()) { name = "Gamepad"; }
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GamepadManager::uniqueName(const std::string& base) const -> std::string {
|
||||||
|
const bool COLLISION = std::ranges::any_of(
|
||||||
|
entries_,
|
||||||
|
[&](const Entry& e) { return e.pretty_name == base; });
|
||||||
|
if (!COLLISION) { return base; }
|
||||||
|
|
||||||
|
// Format " #N" començant a #2 — la primera ocurrència manté el nom net.
|
||||||
|
for (int i = 2; i < 100; ++i) {
|
||||||
|
std::string candidate = base + " #" + std::to_string(i);
|
||||||
|
const bool MATCH = std::ranges::any_of(
|
||||||
|
entries_,
|
||||||
|
[&](const Entry& e) { return e.pretty_name == candidate; });
|
||||||
|
if (!MATCH) { return candidate; }
|
||||||
|
}
|
||||||
|
return base; // cas ultra-improbable: 99 mandos idèntics connectats
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
|
||||||
|
installWebStandardMapping(jid);
|
||||||
|
if (!SDL_IsGamepad(jid)) { return; }
|
||||||
|
SDL_Gamepad* pad = SDL_OpenGamepad(jid);
|
||||||
|
if (pad == nullptr) {
|
||||||
|
std::cout << "GamepadManager: SDL_OpenGamepad(" << jid
|
||||||
|
<< ") ha fallat: " << SDL_GetError() << '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad)));
|
||||||
|
std::cout << "GamepadManager: connectat " << name << '\n';
|
||||||
|
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name});
|
||||||
|
if (notify) {
|
||||||
|
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_connected"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::removeGamepad(SDL_JoystickID jid) {
|
||||||
|
auto it = std::ranges::find_if(
|
||||||
|
entries_,
|
||||||
|
[jid](const Entry& e) { return e.instance_id == jid; });
|
||||||
|
if (it == entries_.end()) { return; }
|
||||||
|
std::string name = std::move(it->pretty_name);
|
||||||
|
if (it->pad != nullptr) { SDL_CloseGamepad(it->pad); }
|
||||||
|
std::cout << "GamepadManager: desconnectat " << name << '\n';
|
||||||
|
entries_.erase(it);
|
||||||
|
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_disconnected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::init() {
|
||||||
|
if (initialized_) { return; }
|
||||||
|
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
|
||||||
|
std::cout << "GamepadManager: SDL_INIT_GAMEPAD ha fallat: "
|
||||||
|
<< SDL_GetError() << '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `gamecontrollerdb.txt` és opcional. Si no existeix, SDL retorna -1 i
|
||||||
|
// continuem amb les definicions internes del sistema.
|
||||||
|
const int ADDED = SDL_AddGamepadMappingsFromFile("data/input/gamecontrollerdb.txt");
|
||||||
|
if (ADDED > 0) {
|
||||||
|
std::cout << "GamepadManager: " << ADDED << " mappings de gamecontrollerdb.txt\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
|
||||||
|
if (ids != nullptr) {
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
addGamepad(ids[i], /*notify=*/false);
|
||||||
|
}
|
||||||
|
SDL_free(ids);
|
||||||
|
}
|
||||||
|
initialized_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::shutdown() {
|
||||||
|
for (auto& entry : entries_) {
|
||||||
|
if (entry.pad != nullptr) { SDL_CloseGamepad(entry.pad); }
|
||||||
|
}
|
||||||
|
entries_.clear();
|
||||||
|
if (initialized_) {
|
||||||
|
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
|
||||||
|
initialized_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GamepadManager::handleEvent(const SDL_Event& ev) {
|
||||||
|
if (ev.type == SDL_EVENT_GAMEPAD_ADDED) {
|
||||||
|
addGamepad(ev.gdevice.which, /*notify=*/true);
|
||||||
|
} else if (ev.type == SDL_EVENT_GAMEPAD_REMOVED) {
|
||||||
|
removeGamepad(ev.gdevice.which);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GamepadManager::getPrimary() const -> SDL_Gamepad* {
|
||||||
|
return entries_.empty() ? nullptr : entries_.front().pad;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GamepadManager::getCount() const -> std::size_t {
|
||||||
|
return entries_.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GamepadManager::getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad* {
|
||||||
|
return idx < entries_.size() ? entries_[idx].pad : nullptr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Gestor global de gamepads. Descobreix els mandos presents a `init()`
|
||||||
|
// sense emetre notificacions (el jugador ja els té posats al llançar el
|
||||||
|
// joc). Durant el runtime, `handleEvent()` processa els events de hot-plug
|
||||||
|
// i emet notificacions visibles via `Notification::notify`.
|
||||||
|
//
|
||||||
|
// El polling real de botons i eixos NO es fa a través d'aquesta classe:
|
||||||
|
// els consumidors obtenen el `SDL_Gamepad*` primari (o l'enèsim per a
|
||||||
|
// multi-jugador futur) i el passen a `InputState::getGamepadButton/Axis`,
|
||||||
|
// que ho filtra pel flag de bloqueig de la UI (mateixa estratègia que
|
||||||
|
// amb el teclat).
|
||||||
|
class GamepadManager {
|
||||||
|
public:
|
||||||
|
static auto get() -> GamepadManager&;
|
||||||
|
|
||||||
|
GamepadManager(const GamepadManager&) = delete;
|
||||||
|
GamepadManager(GamepadManager&&) = delete;
|
||||||
|
auto operator=(const GamepadManager&) -> GamepadManager& = delete;
|
||||||
|
auto operator=(GamepadManager&&) -> GamepadManager& = delete;
|
||||||
|
|
||||||
|
// Inicialitza el subsistema SDL de gamepad, intenta carregar un
|
||||||
|
// `gamecontrollerdb.txt` opcional i obri silenciosament tots els
|
||||||
|
// mandos ja connectats. Cridat una sola vegada des de Director.
|
||||||
|
void init();
|
||||||
|
|
||||||
|
// Tanca tots els gamepads oberts i treu el subsistema SDL. Cridat
|
||||||
|
// des de `~Director` abans de `SDL_Quit` per evitar handles orfes.
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
// Processa SDL_EVENT_GAMEPAD_ADDED i SDL_EVENT_GAMEPAD_REMOVED
|
||||||
|
// emesos durant el runtime. Emet notificacions "<nom> connectat" o
|
||||||
|
// "<nom> desconnectat". El Director decideix quins events li arriben
|
||||||
|
// via `processGamepadEvents()`.
|
||||||
|
void handleEvent(const SDL_Event& ev);
|
||||||
|
|
||||||
|
// Primary = primer gamepad al vector. `nullptr` si no n'hi ha cap.
|
||||||
|
// Ús típic: Player 1 i navegació del ServiceMenu.
|
||||||
|
[[nodiscard]] auto getPrimary() const -> SDL_Gamepad*;
|
||||||
|
|
||||||
|
[[nodiscard]] auto getCount() const -> std::size_t;
|
||||||
|
|
||||||
|
// Retorna el mand del jugador `idx`-èsim (0-indexed) o `nullptr` si
|
||||||
|
// encara no hi ha prou mandos connectats. Reservat per a multi-jugador.
|
||||||
|
[[nodiscard]] auto getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad*;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Entry {
|
||||||
|
SDL_Gamepad* pad{nullptr};
|
||||||
|
SDL_JoystickID instance_id{0};
|
||||||
|
std::string pretty_name;
|
||||||
|
};
|
||||||
|
|
||||||
|
GamepadManager() = default;
|
||||||
|
~GamepadManager() = default;
|
||||||
|
|
||||||
|
// Retalla `raw` al primer '(' o '[' i elimina espais finals per a
|
||||||
|
// llevar metadata verbosa ("Retroid Controller (vendor: 1001) ...").
|
||||||
|
// Fallback "Gamepad" si queda buit. No limita l'amplada final — el
|
||||||
|
// Notifier multilinia ja gestiona noms llargs.
|
||||||
|
[[nodiscard]] static auto prettyName(const char* raw) -> std::string;
|
||||||
|
|
||||||
|
// Si `base` ja existeix al vector, afegeix " #2", " #3", ... fins que
|
||||||
|
// trobi un nom lliure. Així dos mandos idèntics es distingeixen a les
|
||||||
|
// notificacions.
|
||||||
|
[[nodiscard]] auto uniqueName(const std::string& base) const -> std::string;
|
||||||
|
|
||||||
|
void addGamepad(SDL_JoystickID jid, bool notify);
|
||||||
|
void removeGamepad(SDL_JoystickID jid);
|
||||||
|
|
||||||
|
std::vector<Entry> entries_;
|
||||||
|
bool initialized_{false};
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "core/rendering/screen.hpp"
|
#include "core/rendering/screen.hpp"
|
||||||
#include "core/system/event_buffer.hpp"
|
#include "core/system/event_buffer.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
|
#include "game/options_apply.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
|
|
||||||
namespace GlobalInputs {
|
namespace GlobalInputs {
|
||||||
@@ -30,95 +31,91 @@ namespace GlobalInputs {
|
|||||||
float exit_window_s = 0.0F;
|
float exit_window_s = 0.0F;
|
||||||
bool quit_requested = false;
|
bool quit_requested = false;
|
||||||
|
|
||||||
// Canvia uniforms i shader actiu sense recrear la finestra.
|
|
||||||
void reapplyShader() {
|
|
||||||
Screen::get().setActiveShader(Options::buildActiveShader());
|
|
||||||
Screen::get().applyPostFXParams(Options::buildPostFXParams());
|
|
||||||
Screen::get().applyCrtPiParams(Options::buildCrtPiParams());
|
|
||||||
}
|
|
||||||
|
|
||||||
void notifyLocale(const std::string& key) {
|
void notifyLocale(const std::string& key) {
|
||||||
Notification::notify(Locale::get().text(key));
|
Notification::notify(Locale::get().text(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDecZoom() {
|
void onDecZoom() {
|
||||||
if (Options::window.fullscreen) { return; }
|
if (Options::window().fullscreen) { return; }
|
||||||
if (Options::window.zoom <= MIN_ZOOM) { return; }
|
if (Options::window().zoom <= MIN_ZOOM) { return; }
|
||||||
Options::window.zoom--;
|
const int NEW_ZOOM = Options::window().zoom - 1;
|
||||||
Screen::get().setWindowZoom(Options::window.zoom);
|
Options::setWindowZoom(NEW_ZOOM);
|
||||||
Options::saveToFile();
|
Screen::get().setWindowZoom(NEW_ZOOM);
|
||||||
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom));
|
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
|
||||||
}
|
}
|
||||||
|
|
||||||
void onIncZoom() {
|
void onIncZoom() {
|
||||||
if (Options::window.fullscreen) { return; }
|
if (Options::window().fullscreen) { return; }
|
||||||
if (Options::window.zoom >= MAX_ZOOM) { return; }
|
if (Options::window().zoom >= MAX_ZOOM) { return; }
|
||||||
Options::window.zoom++;
|
const int NEW_ZOOM = Options::window().zoom + 1;
|
||||||
Screen::get().setWindowZoom(Options::window.zoom);
|
Options::setWindowZoom(NEW_ZOOM);
|
||||||
Options::saveToFile();
|
Screen::get().setWindowZoom(NEW_ZOOM);
|
||||||
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom));
|
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
|
||||||
}
|
}
|
||||||
|
|
||||||
void onFullscreen() {
|
void onFullscreen() {
|
||||||
Options::window.fullscreen = !Options::window.fullscreen;
|
const bool NEW_FS = !Options::window().fullscreen;
|
||||||
Screen::get().setFullscreen(Options::window.fullscreen);
|
Options::setWindowFullscreen(NEW_FS);
|
||||||
Options::saveToFile();
|
Screen::get().setFullscreen(NEW_FS);
|
||||||
notifyLocale(Options::window.fullscreen ? "input.fullscreen_on" : "input.fullscreen_off");
|
notifyLocale(NEW_FS ? "input.fullscreen_on" : "input.fullscreen_off");
|
||||||
}
|
}
|
||||||
|
|
||||||
void onToggleVSync() {
|
void onToggleVSync() {
|
||||||
Options::video.vsync = !Options::video.vsync;
|
const bool NEW_VSYNC = !Options::video().vsync;
|
||||||
Screen::get().setVSync(Options::video.vsync);
|
Options::setVideoVSync(NEW_VSYNC);
|
||||||
Options::saveToFile();
|
Screen::get().setVSync(NEW_VSYNC);
|
||||||
notifyLocale(Options::video.vsync ? "input.vsync_on" : "input.vsync_off");
|
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
|
||||||
}
|
}
|
||||||
|
|
||||||
void onToggleShader() {
|
void onToggleShader() {
|
||||||
Options::video.shader.enabled = !Options::video.shader.enabled;
|
const bool NEW_ENABLED = !Options::video().shader.enabled;
|
||||||
reapplyShader();
|
Options::setShaderEnabled(NEW_ENABLED);
|
||||||
Options::saveToFile();
|
OptionsApply::reapplyShader();
|
||||||
notifyLocale(Options::video.shader.enabled ? "input.shader_on" : "input.shader_off");
|
notifyLocale(NEW_ENABLED ? "input.shader_on" : "input.shader_off");
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNextShader() {
|
void onNextShader() {
|
||||||
Options::video.shader.current_shader = (Options::video.shader.current_shader == "crtpi") ? "postfx" : "crtpi";
|
const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi";
|
||||||
reapplyShader();
|
Options::setActiveShaderName(NEW_NAME);
|
||||||
Options::saveToFile();
|
OptionsApply::reapplyShader();
|
||||||
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
|
// Busca l'entrada actual al vector de presets i passa a la següent
|
||||||
// (wrap-around). Si no en troba cap s'evita el canvi silenciosament.
|
// (wrap-around). Si no en troba cap s'evita el canvi silenciosament.
|
||||||
void onNextPreset() {
|
void onNextPreset() {
|
||||||
const bool IS_CRTPI = Options::video.shader.current_shader == "crtpi";
|
const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi";
|
||||||
|
std::string new_preset;
|
||||||
if (IS_CRTPI) {
|
if (IS_CRTPI) {
|
||||||
if (Options::crtpi_presets.empty()) { return; }
|
const auto& presets = Options::crtpiPresets();
|
||||||
auto it = std::ranges::find_if(Options::crtpi_presets,
|
if (presets.empty()) { return; }
|
||||||
[](const auto& preset) { return preset.name == Options::video.shader.current_crtpi_preset; });
|
auto it = std::ranges::find_if(presets,
|
||||||
std::size_t idx = (it == Options::crtpi_presets.end()) ? 0 : std::distance(Options::crtpi_presets.begin(), it);
|
[](const auto& preset) { return preset.name == Options::video().shader.current_crtpi_preset; });
|
||||||
idx = (idx + 1) % Options::crtpi_presets.size();
|
std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
|
||||||
Options::video.shader.current_crtpi_preset = Options::crtpi_presets[idx].name;
|
idx = (idx + 1) % presets.size();
|
||||||
|
new_preset = presets[idx].name;
|
||||||
|
Options::setCrtPiPreset(new_preset);
|
||||||
} else {
|
} else {
|
||||||
if (Options::postfx_presets.empty()) { return; }
|
const auto& presets = Options::postfxPresets();
|
||||||
auto it = std::ranges::find_if(Options::postfx_presets,
|
if (presets.empty()) { return; }
|
||||||
[](const auto& preset) { return preset.name == Options::video.shader.current_postfx_preset; });
|
auto it = std::ranges::find_if(presets,
|
||||||
std::size_t idx = (it == Options::postfx_presets.end()) ? 0 : std::distance(Options::postfx_presets.begin(), it);
|
[](const auto& preset) { return preset.name == Options::video().shader.current_postfx_preset; });
|
||||||
idx = (idx + 1) % Options::postfx_presets.size();
|
std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
|
||||||
Options::video.shader.current_postfx_preset = Options::postfx_presets[idx].name;
|
idx = (idx + 1) % presets.size();
|
||||||
|
new_preset = presets[idx].name;
|
||||||
|
Options::setPostFXPreset(new_preset);
|
||||||
}
|
}
|
||||||
reapplyShader();
|
OptionsApply::reapplyShader();
|
||||||
Options::saveToFile();
|
Notification::notify(Locale::get().text("input.preset") + ' ' + new_preset);
|
||||||
const std::string& name = IS_CRTPI ? Options::video.shader.current_crtpi_preset : Options::video.shader.current_postfx_preset;
|
|
||||||
Notification::notify(Locale::get().text("input.preset") + ": " + name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNextScalingMode() {
|
void onNextScalingMode() {
|
||||||
using Mode = Defaults::ScalingMode;
|
using Mode = Defaults::ScalingMode;
|
||||||
Options::video.scaling_mode = static_cast<Mode>((static_cast<int>(Options::video.scaling_mode) + 1) % 5);
|
const auto NEW_MODE = static_cast<Mode>((static_cast<int>(Options::video().scaling_mode) + 1) % 5);
|
||||||
Screen::get().setScalingMode(Options::video.scaling_mode);
|
Options::setVideoScalingMode(NEW_MODE);
|
||||||
Options::saveToFile();
|
Screen::get().setScalingMode(NEW_MODE);
|
||||||
const char* mode_key = nullptr;
|
const char* mode_key = nullptr;
|
||||||
switch (Options::video.scaling_mode) {
|
switch (NEW_MODE) {
|
||||||
case Mode::DISABLED:
|
case Mode::DISABLED:
|
||||||
mode_key = "input.scaling_disabled";
|
mode_key = "input.scaling_disabled";
|
||||||
break;
|
break;
|
||||||
@@ -139,14 +136,12 @@ namespace GlobalInputs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onCycleRenderInfo() {
|
void onCycleRenderInfo() {
|
||||||
const int NEXT = (static_cast<int>(Options::video.render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT;
|
const int NEXT = (static_cast<int>(Options::video().render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT;
|
||||||
Options::video.render_info_position = static_cast<Defaults::RenderInfoPosition>(NEXT);
|
Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
|
||||||
Options::saveToFile();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onToggleRenderTime() {
|
void onToggleRenderTime() {
|
||||||
Options::video.render_info_show_time = !Options::video.render_info_show_time;
|
Options::setVideoRenderInfoShowTime(!Options::video().render_info_show_time);
|
||||||
Options::saveToFile();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onExit() {
|
void onExit() {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include "core/input/input_state.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
namespace InputState {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool blocked = false;
|
||||||
|
|
||||||
|
// Buffer retornat quan el bloqueig és actiu. `SDL_GetKeyboardState`
|
||||||
|
// retorna un pointer d'una mida igual a `SDL_SCANCODE_COUNT`; el
|
||||||
|
// nostre buffer ha de ser com a mínim igual de gran perquè els
|
||||||
|
// consumidors puguin indexar-lo amb qualsevol scancode sense
|
||||||
|
// llegir fora de rang.
|
||||||
|
auto zeroKeyboard() -> const bool* {
|
||||||
|
static const std::array<bool, SDL_SCANCODE_COUNT> ZEROS{};
|
||||||
|
return ZEROS.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void setBlocked(bool value) { blocked = value; }
|
||||||
|
|
||||||
|
auto isBlocked() -> bool { return blocked; }
|
||||||
|
|
||||||
|
auto getKeyboardState() -> const bool* {
|
||||||
|
return blocked ? zeroKeyboard() : SDL_GetKeyboardState(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool {
|
||||||
|
return (blocked || gp == nullptr) ? false : SDL_GetGamepadButton(gp, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16 {
|
||||||
|
return (blocked || gp == nullptr) ? static_cast<Sint16>(0) : SDL_GetGamepadAxis(gp, ax);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace InputState
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Wrapper de l'estat d'input (polling) amb gating de bloqueig.
|
||||||
|
//
|
||||||
|
// El service menu (i qualsevol UI modal futura) ha de poder "amagar" l'estat
|
||||||
|
// d'entrada als consumidors de polling — el jugador, entitats controlades
|
||||||
|
// per l'usuari, etc. — mentre la UI captura l'atenció, sense interrompre la
|
||||||
|
// resta del joc. Per això tots els consumidors de polling han de llegir a
|
||||||
|
// través d'aquest mòdul i no cridar `SDL_GetKeyboardState` directament. Si
|
||||||
|
// `setBlocked(true)` està actiu, el wrapper retorna un buffer tot-a-zero;
|
||||||
|
// altrament, la crida és transparent (mateix pointer que SDL).
|
||||||
|
//
|
||||||
|
// El menú NO passa per aquí: consumeix events (`SDL_EVENT_KEY_DOWN`) abans
|
||||||
|
// que l'escena els vegi, així que la seva navegació segueix funcionant per
|
||||||
|
// molt que el flag estigui posat.
|
||||||
|
//
|
||||||
|
// El mateix patró aplica al gamepad: `getGamepadButton`/`getGamepadAxis`
|
||||||
|
// retornen zero/false quan el bloqueig és actiu. Els mandos concrets els
|
||||||
|
// obtén el caller via `GamepadManager::get().getPrimary()` o similars.
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
namespace InputState {
|
||||||
|
|
||||||
|
// Activa/desactiva el bloqueig global de polling. El ServiceMenu el
|
||||||
|
// crida a `toggle()`/`close()`. No es toca des de cap altre lloc.
|
||||||
|
void setBlocked(bool value);
|
||||||
|
|
||||||
|
[[nodiscard]] auto isBlocked() -> bool;
|
||||||
|
|
||||||
|
// Equivalent a `SDL_GetKeyboardState(nullptr)`: retorna un buffer de
|
||||||
|
// `SDL_SCANCODE_COUNT` bools. Si l'input està bloquejat, tots zero —
|
||||||
|
// el consumidor veu com si cap tecla estigués premuda, sense branques
|
||||||
|
// explícites. El buffer és estable mentre dura la sessió; no cal
|
||||||
|
// guardar-lo frame a frame.
|
||||||
|
[[nodiscard]] auto getKeyboardState() -> const bool*;
|
||||||
|
|
||||||
|
// Versió filtrada de `SDL_GetGamepadButton`. Retorna false si el bloqueig
|
||||||
|
// és actiu o si `gp` és nullptr (cas habitual: no hi ha cap mand).
|
||||||
|
[[nodiscard]] auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool;
|
||||||
|
|
||||||
|
// Versió filtrada de `SDL_GetGamepadAxis`. Retorna 0 si el bloqueig
|
||||||
|
// és actiu o si `gp` és nullptr.
|
||||||
|
[[nodiscard]] auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16;
|
||||||
|
|
||||||
|
} // namespace InputState
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
|
|
||||||
auto KeyConfig::get() -> KeyConfig& {
|
auto KeyConfig::get() -> KeyConfig& {
|
||||||
@@ -12,16 +13,17 @@ auto KeyConfig::get() -> KeyConfig& {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void KeyConfig::load(const std::string& file_path) {
|
void KeyConfig::load(const std::string& file_path) {
|
||||||
std::ifstream file(file_path);
|
const auto FULL = Resource::Helper::resolve(file_path);
|
||||||
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
std::cerr << "KeyConfig: no s'ha pogut obrir " << file_path << '\n';
|
std::cerr << "KeyConfig: no s'ha pogut obrir " << FULL << '\n';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
auto yaml = fkyaml::node::deserialize(file);
|
auto yaml = fkyaml::node::deserialize(file);
|
||||||
if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) {
|
if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) {
|
||||||
std::cerr << "KeyConfig: falta la secció 'keys' a " << file_path << '\n';
|
std::cerr << "KeyConfig: falta la secció 'keys' a " << FULL << '\n';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bindings_.clear();
|
bindings_.clear();
|
||||||
@@ -36,9 +38,9 @@ void KeyConfig::load(const std::string& file_path) {
|
|||||||
}
|
}
|
||||||
bindings_[ID] = SC;
|
bindings_[ID] = SC;
|
||||||
}
|
}
|
||||||
std::cout << "KeyConfig: " << bindings_.size() << " bindings des de " << file_path << '\n';
|
std::cout << "KeyConfig: " << bindings_.size() << " bindings des de " << FULL << '\n';
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "KeyConfig: error parsejant " << file_path << ": " << e.what() << '\n';
|
std::cerr << "KeyConfig: error parsejant " << FULL << ": " << e.what() << '\n';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class KeyConfig {
|
|||||||
void load(const std::string& file_path);
|
void load(const std::string& file_path);
|
||||||
|
|
||||||
[[nodiscard]] auto scancode(const std::string& id) const -> SDL_Scancode;
|
[[nodiscard]] auto scancode(const std::string& id) const -> SDL_Scancode;
|
||||||
|
[[nodiscard]] auto bindings() const -> const std::unordered_map<std::string, SDL_Scancode>& { return bindings_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
KeyConfig() = default;
|
KeyConfig() = default;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#include "core/input/mouse.hpp"
|
||||||
|
|
||||||
|
namespace Mouse {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Temps (ms) sense moviment de ratolí abans d'amagar el cursor.
|
||||||
|
constexpr Uint32 HIDE_DELAY_MS = 3000;
|
||||||
|
|
||||||
|
Uint32 last_move_time = 0;
|
||||||
|
bool cursor_visible = true;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void handleEvent(const SDL_Event& ev) {
|
||||||
|
if (ev.type == SDL_EVENT_MOUSE_MOTION) {
|
||||||
|
last_move_time = SDL_GetTicks();
|
||||||
|
if (!cursor_visible) {
|
||||||
|
SDL_ShowCursor();
|
||||||
|
cursor_visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateCursorVisibility() {
|
||||||
|
if (cursor_visible && (SDL_GetTicks() - last_move_time > HIDE_DELAY_MS)) {
|
||||||
|
SDL_HideCursor();
|
||||||
|
cursor_visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Mouse
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
// Auto-ocultació del cursor del ratolí. El joc no usa el ratolí com a
|
||||||
|
// entrada, així que el cursor del sistema només molesta sobre el canvas.
|
||||||
|
// El patró és senzill: si l'usuari mou el ratolí, es mostra; si passa
|
||||||
|
// més de `HIDE_DELAY_MS` sense moviment, s'amaga.
|
||||||
|
//
|
||||||
|
// El Director crida `handleEvent()` per cada event i `updateCursorVisibility()`
|
||||||
|
// una vegada per frame.
|
||||||
|
namespace Mouse {
|
||||||
|
|
||||||
|
void handleEvent(const SDL_Event& ev);
|
||||||
|
void updateCursorVisibility();
|
||||||
|
|
||||||
|
} // namespace Mouse
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -33,9 +34,10 @@ auto Locale::get() -> Locale& {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Locale::load(const std::string& file_path) {
|
void Locale::load(const std::string& file_path) {
|
||||||
std::ifstream file(file_path);
|
const auto FULL = Resource::Helper::resolve(file_path);
|
||||||
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
std::cerr << "Locale: no s'ha pogut obrir " << file_path << '\n';
|
std::cerr << "Locale: no s'ha pogut obrir " << FULL << '\n';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +45,9 @@ void Locale::load(const std::string& file_path) {
|
|||||||
auto yaml = fkyaml::node::deserialize(file);
|
auto yaml = fkyaml::node::deserialize(file);
|
||||||
strings_.clear();
|
strings_.clear();
|
||||||
flatten(yaml, "", strings_);
|
flatten(yaml, "", strings_);
|
||||||
std::cout << "Locale: " << strings_.size() << " traduccions des de " << file_path << '\n';
|
std::cout << "Locale: " << strings_.size() << " traduccions des de " << FULL << '\n';
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "Locale: error parsejant " << file_path << ": " << e.what() << '\n';
|
std::cerr << "Locale: error parsejant " << FULL << ": " << e.what() << '\n';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
#include "core/rendering/canvas.hpp"
|
#include "core/rendering/canvas.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "core/rendering/screen.hpp"
|
#include "core/rendering/screen.hpp"
|
||||||
|
|
||||||
namespace Rendering {
|
namespace Rendering {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Pila de clips en un namespace anònim perquè no siga accessible des
|
||||||
|
// de fora. L'app és single-threaded; si algun dia hi ha threads de
|
||||||
|
// render, s'ha de fer thread_local o passar-la explícitament.
|
||||||
|
auto clipStack() -> std::vector<Clip>& {
|
||||||
|
static std::vector<Clip> stack_;
|
||||||
|
return stack_;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
auto activeCanvas() -> Canvas {
|
auto activeCanvas() -> Canvas {
|
||||||
auto& screen = Screen::get();
|
auto& screen = Screen::get();
|
||||||
return {
|
return {
|
||||||
@@ -13,4 +28,32 @@ namespace Rendering {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto activeClip() -> Clip {
|
||||||
|
const auto& stack = clipStack();
|
||||||
|
if (!stack.empty()) { return stack.back(); }
|
||||||
|
const auto CANVAS = activeCanvas();
|
||||||
|
return {.x = 0, .y = 0, .w = CANVAS.width, .h = CANVAS.height};
|
||||||
|
}
|
||||||
|
|
||||||
|
void pushClip(Clip rect) {
|
||||||
|
// Retall contra el canvas per garantir que els blitters no s'han
|
||||||
|
// de preocupar de coordenades negatives o més grans que el canvas.
|
||||||
|
const auto CANVAS = activeCanvas();
|
||||||
|
const int X0 = std::max(rect.x, 0);
|
||||||
|
const int Y0 = std::max(rect.y, 0);
|
||||||
|
const int X1 = std::min(rect.x + rect.w, CANVAS.width);
|
||||||
|
const int Y1 = std::min(rect.y + rect.h, CANVAS.height);
|
||||||
|
clipStack().push_back({
|
||||||
|
.x = X0,
|
||||||
|
.y = Y0,
|
||||||
|
.w = std::max(0, X1 - X0),
|
||||||
|
.h = std::max(0, Y1 - Y0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void popClip() {
|
||||||
|
auto& stack = clipStack();
|
||||||
|
if (!stack.empty()) { stack.pop_back(); }
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Rendering
|
} // namespace Rendering
|
||||||
@@ -20,9 +20,33 @@ namespace Rendering {
|
|||||||
int height{0};
|
int height{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rectangle de clip (en píxels de canvas). Els blitters han de comprovar
|
||||||
|
// `clip` abans d'escriure cada píxel; fora del rectangle no es dibuixa.
|
||||||
|
// `push/pop` permet niar clips (tot i que de moment només en tenim un
|
||||||
|
// nivell d'ús, el menú). Si la pila és buida, `activeClip` retorna el
|
||||||
|
// canvas complet — comportament "sense clip".
|
||||||
|
struct Clip {
|
||||||
|
int x{0};
|
||||||
|
int y{0};
|
||||||
|
int w{0};
|
||||||
|
int h{0};
|
||||||
|
};
|
||||||
|
|
||||||
// Retorna el canvas actiu del joc. En aquesta fase del projecte n'hi ha
|
// Retorna el canvas actiu del joc. En aquesta fase del projecte n'hi ha
|
||||||
// sempre un i només un (el de Screen); en el futur, si cal renderitzar
|
// sempre un i només un (el de Screen); en el futur, si cal renderitzar
|
||||||
// off-screen (miniatures, efectes), es podrà pushejar/popar per ací.
|
// off-screen (miniatures, efectes), es podrà pushejar/popar per ací.
|
||||||
[[nodiscard]] auto activeCanvas() -> Canvas;
|
[[nodiscard]] auto activeCanvas() -> Canvas;
|
||||||
|
|
||||||
|
// Clip actiu. Si ningú ha fet push, és tot el canvas.
|
||||||
|
[[nodiscard]] auto activeClip() -> Clip;
|
||||||
|
|
||||||
|
// Restringeix el dibuix al rectangle donat fins al proper popClip().
|
||||||
|
// El rectangle es retalla contra el canvas (cap clip pot excedir els
|
||||||
|
// seus límits, així els blitters poden assumir-los sans).
|
||||||
|
void pushClip(Clip rect);
|
||||||
|
|
||||||
|
// Desapila el clip actiu i restaura l'anterior. Si no hi havia cap
|
||||||
|
// push previ, l'operació és idempotent i queda a "canvas complet".
|
||||||
|
void popClip();
|
||||||
|
|
||||||
} // namespace Rendering
|
} // namespace Rendering
|
||||||
@@ -198,10 +198,33 @@ namespace GIF {
|
|||||||
readBytes(buffer, &lzw_code_size, sizeof(uint8_t));
|
readBytes(buffer, &lzw_code_size, sizeof(uint8_t));
|
||||||
|
|
||||||
std::vector<uint8_t> compressed_data = readSubBlocks(buffer);
|
std::vector<uint8_t> compressed_data = readSubBlocks(buffer);
|
||||||
int uncompressed_data_length = image_descriptor.image_width * image_descriptor.image_height;
|
const int IMG_W = image_descriptor.image_width;
|
||||||
std::vector<uint8_t> uncompressed_data(uncompressed_data_length);
|
const int IMG_H = image_descriptor.image_height;
|
||||||
|
std::vector<uint8_t> uncompressed_data(static_cast<std::size_t>(IMG_W) * IMG_H);
|
||||||
|
|
||||||
decompress(lzw_code_size, compressed_data.data(), static_cast<int>(compressed_data.size()), uncompressed_data.data());
|
decompress(lzw_code_size, compressed_data.data(), static_cast<int>(compressed_data.size()), uncompressed_data.data());
|
||||||
|
|
||||||
|
// Interlaced GIF (flag 0x40 al byte de fields): les files no arriben
|
||||||
|
// en ordre lineal, sinó en 4 passades (8:0, 8:4, 4:2, 2:1). Reordena
|
||||||
|
// al buffer lineal que espera Surface. Sense aquest pas, les fuentes
|
||||||
|
// i qualsevol GIF entrellaçat es veien completament trinxats.
|
||||||
|
constexpr uint8_t INTERLACE_FLAG = 0x40;
|
||||||
|
if ((image_descriptor.fields & INTERLACE_FLAG) != 0) {
|
||||||
|
constexpr int PASS_START[4] = {0, 4, 2, 1};
|
||||||
|
constexpr int PASS_STEP[4] = {8, 8, 4, 2};
|
||||||
|
std::vector<uint8_t> deinterlaced(static_cast<std::size_t>(IMG_W) * IMG_H);
|
||||||
|
int src_row = 0;
|
||||||
|
for (int pass = 0; pass < 4; ++pass) {
|
||||||
|
for (int dst_row = PASS_START[pass]; dst_row < IMG_H; dst_row += PASS_STEP[pass]) {
|
||||||
|
std::memcpy(deinterlaced.data() + (static_cast<std::size_t>(dst_row) * IMG_W),
|
||||||
|
uncompressed_data.data() + (static_cast<std::size_t>(src_row) * IMG_W),
|
||||||
|
IMG_W);
|
||||||
|
++src_row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deinterlaced;
|
||||||
|
}
|
||||||
|
|
||||||
return uncompressed_data;
|
return uncompressed_data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,59 @@
|
|||||||
#include "core/rendering/notifier.hpp"
|
#include "core/rendering/notifier.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <numeric>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/rendering/screen.hpp"
|
#include "core/rendering/screen.hpp"
|
||||||
#include "core/rendering/text.hpp"
|
#include "core/rendering/text.hpp"
|
||||||
#include "utils/defines.hpp"
|
#include "utils/defines.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Parteix `text` en una llista de línies que caben cadascuna dins
|
||||||
|
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula
|
||||||
|
// que no cap passa a la línia següent. Les paraules aïllades més
|
||||||
|
// llargues que `max_width` es queden a la seva pròpia línia sense
|
||||||
|
// trencar-les (cas improbable amb els pretty_names de gamepad retallats
|
||||||
|
// a ~25 caràcters).
|
||||||
|
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
if (text.empty()) {
|
||||||
|
lines.emplace_back();
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string current;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < text.size()) {
|
||||||
|
const std::size_t SPACE_POS = text.find(' ', i);
|
||||||
|
const bool LAST = (SPACE_POS == std::string::npos);
|
||||||
|
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
|
||||||
|
|
||||||
|
if (current.empty()) {
|
||||||
|
current = WORD;
|
||||||
|
} else {
|
||||||
|
std::string candidate = current;
|
||||||
|
candidate += ' ';
|
||||||
|
candidate += WORD;
|
||||||
|
if (font.length(candidate, kerning) <= max_width) {
|
||||||
|
current = std::move(candidate);
|
||||||
|
} else {
|
||||||
|
lines.push_back(current);
|
||||||
|
current = WORD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LAST) { break; }
|
||||||
|
i = SPACE_POS + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push_back(current);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
auto Notifier::get() -> Notifier& {
|
auto Notifier::get() -> Notifier& {
|
||||||
static Notifier instance_;
|
static Notifier instance_;
|
||||||
return instance_;
|
return instance_;
|
||||||
@@ -34,8 +82,8 @@ void Notifier::update(int delta_ms) {
|
|||||||
break;
|
break;
|
||||||
case Status::VANISHING:
|
case Status::VANISHING:
|
||||||
y_ -= SLIDE_SPEED * DT;
|
y_ -= SLIDE_SPEED * DT;
|
||||||
if (y_ <= static_cast<float>(-HEIGHT)) {
|
if (y_ <= static_cast<float>(-current_box_h_)) {
|
||||||
y_ = static_cast<float>(-HEIGHT - MARGIN_TOP);
|
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
|
||||||
status_ = Status::HIDDEN;
|
status_ = Status::HIDDEN;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -45,31 +93,45 @@ void Notifier::update(int delta_ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Notifier::render() {
|
void Notifier::render() {
|
||||||
if (status_ == Status::HIDDEN || text_ == nullptr || current_text_.empty()) { return; }
|
if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; }
|
||||||
|
|
||||||
const int TEXT_W = text_->length(current_text_, 1);
|
const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2;
|
||||||
const int BOX_W = TEXT_W + (2 * PADDING_H);
|
|
||||||
const int BOX_X = (GameCanvas::WIDTH - BOX_W) / 2;
|
|
||||||
const int BOX_Y = static_cast<int>(y_);
|
const int BOX_Y = static_cast<int>(y_);
|
||||||
|
|
||||||
// Fons sòlid amb la paleta UI que ens ha injectat el caller a init()
|
// Fons sòlid amb la paleta UI que ens ha injectat el caller a init()
|
||||||
// (ARGB directe al canvas, sense tocar cap surface paletada).
|
// (ARGB directe al canvas, sense tocar cap surface paletada).
|
||||||
Screen::get().fillRect(BOX_X, BOX_Y, BOX_W, HEIGHT, ui_palette_[current_bg_color_]);
|
Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]);
|
||||||
|
|
||||||
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + LINE_GAP;
|
||||||
GameCanvas::CENTER_X,
|
int line_y = BOX_Y + PADDING_V;
|
||||||
BOX_Y + PADDING_V,
|
for (const auto& line : current_lines_) {
|
||||||
current_text_,
|
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
||||||
1,
|
GameCanvas::CENTER_X,
|
||||||
current_text_color_);
|
line_y,
|
||||||
|
line,
|
||||||
|
1,
|
||||||
|
current_text_color_);
|
||||||
|
line_y += LINE_STEP;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms) {
|
void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms) {
|
||||||
// Política simple: la nova notificació reemplaça la que hi havia. Per
|
// Política simple: la nova notificació reemplaça la que hi havia. Per
|
||||||
// gestionar-ne una cua, canviar ací per una FIFO quan calga.
|
// gestionar-ne una cua, canviar ací per una FIFO quan calga.
|
||||||
current_text_ = text;
|
if (text_ == nullptr) { return; }
|
||||||
|
|
||||||
|
current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1);
|
||||||
|
|
||||||
|
// Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding.
|
||||||
|
const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); });
|
||||||
|
current_box_w_ = MAX_LINE_W + (2 * PADDING_H);
|
||||||
|
|
||||||
|
const int N = static_cast<int>(current_lines_.size());
|
||||||
|
current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * LINE_GAP) + (2 * PADDING_V);
|
||||||
|
|
||||||
current_text_color_ = text_color;
|
current_text_color_ = text_color;
|
||||||
current_bg_color_ = bg_color;
|
current_bg_color_ = bg_color;
|
||||||
remaining_ms_ = time_ms;
|
remaining_ms_ = time_ms;
|
||||||
|
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
|
||||||
status_ = Status::RISING;
|
status_ = Status::RISING;
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "core/rendering/hud_constants.hpp"
|
#include "core/rendering/hud_constants.hpp"
|
||||||
#include "core/rendering/surface.hpp"
|
#include "core/rendering/surface.hpp"
|
||||||
|
#include "utils/defines.hpp"
|
||||||
|
|
||||||
class Text;
|
class Text;
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ class Text;
|
|||||||
// API crua: Notifier::get().notify(text, text_color, bg_color, time_ms).
|
// API crua: Notifier::get().notify(text, text_color, bg_color, time_ms).
|
||||||
// Per a treballar amb presets pre-definits, vegeu Notification::notify() a
|
// Per a treballar amb presets pre-definits, vegeu Notification::notify() a
|
||||||
// core/rendering/notification_presets.hpp.
|
// core/rendering/notification_presets.hpp.
|
||||||
|
//
|
||||||
|
// Multilinia: si el text passa de l'amplada màxima (`MAX_TEXT_WIDTH`), es
|
||||||
|
// parteix per espais i la caixa creix en alçada automàticament. Els textos
|
||||||
|
// curts (cas habitual) ocupen una sola línia i no canvien d'aspecte.
|
||||||
class Notifier {
|
class Notifier {
|
||||||
public:
|
public:
|
||||||
static auto get() -> Notifier&;
|
static auto get() -> Notifier&;
|
||||||
@@ -39,9 +45,13 @@ class Notifier {
|
|||||||
|
|
||||||
static constexpr int PADDING_H = 6;
|
static constexpr int PADDING_H = 6;
|
||||||
static constexpr int PADDING_V = 3;
|
static constexpr int PADDING_V = 3;
|
||||||
static constexpr int HEIGHT = Rendering::Hud::FONT_SIZE_PX + (2 * PADDING_V);
|
static constexpr int LINE_GAP = 1; // Separació vertical entre línies multilinia.
|
||||||
static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas.
|
static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas.
|
||||||
static constexpr float SLIDE_SPEED = 120.0F;
|
static constexpr float SLIDE_SPEED = 120.0F;
|
||||||
|
// Amplada màxima (sense comptar padding) que pot ocupar el text abans que
|
||||||
|
// `wrapToWidth` el parteixi en línies. Deixem marge respecte a la vora del
|
||||||
|
// canvas perquè la caixa no toqui els laterals.
|
||||||
|
static constexpr int MAX_TEXT_WIDTH = GameCanvas::WIDTH - 20 - (2 * PADDING_H);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum class Status : std::uint8_t { HIDDEN,
|
enum class Status : std::uint8_t { HIDDEN,
|
||||||
@@ -55,8 +65,10 @@ class Notifier {
|
|||||||
std::shared_ptr<Text> text_;
|
std::shared_ptr<Text> text_;
|
||||||
Palette ui_palette_{};
|
Palette ui_palette_{};
|
||||||
Status status_{Status::HIDDEN};
|
Status status_{Status::HIDDEN};
|
||||||
float y_{static_cast<float>(-HEIGHT - MARGIN_TOP)};
|
float y_{0.0F};
|
||||||
std::string current_text_;
|
std::vector<std::string> current_lines_;
|
||||||
|
int current_box_w_{0};
|
||||||
|
int current_box_h_{0};
|
||||||
Uint8 current_text_color_{0};
|
Uint8 current_text_color_{0};
|
||||||
Uint8 current_bg_color_{0};
|
Uint8 current_bg_color_{0};
|
||||||
int remaining_ms_{0};
|
int remaining_ms_{0};
|
||||||
|
|||||||
@@ -220,17 +220,61 @@ void Screen::clear(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
|
void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
|
||||||
const int X0 = std::max(x, 0);
|
const auto CLIP = Rendering::activeClip();
|
||||||
const int Y0 = std::max(y, 0);
|
const int X0 = std::max(x, CLIP.x);
|
||||||
const int X1 = std::min(x + w, canvas_width_);
|
const int Y0 = std::max(y, CLIP.y);
|
||||||
const int Y1 = std::min(y + h, canvas_height_);
|
const int X1 = std::min(x + w, CLIP.x + CLIP.w);
|
||||||
|
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
|
||||||
for (int iy = Y0; iy < Y1; ++iy) {
|
for (int iy = Y0; iy < Y1; ++iy) {
|
||||||
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
|
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
|
||||||
std::fill(row + X0, row + X1, argb);
|
std::fill(row + X0, row + X1, argb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Screen::blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha) {
|
||||||
|
if (alpha == 0) { return; }
|
||||||
|
if (alpha == 0xFF) {
|
||||||
|
fillRect(x, y, w, h, argb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mescla linealment src (argb, alpha) sobre dst (el canvas). El canvas
|
||||||
|
// és ARGB amb R/G/B als bytes baixos i A fix a 0xFF; fem la mescla per
|
||||||
|
// canal i deixem A intacte perquè el backend espera opacitat total.
|
||||||
|
const std::uint8_t SA = alpha;
|
||||||
|
const std::uint8_t INV = 255 - alpha;
|
||||||
|
const std::uint8_t SR = (argb >> 16) & 0xFF;
|
||||||
|
const std::uint8_t SG = (argb >> 8) & 0xFF;
|
||||||
|
const std::uint8_t SB = argb & 0xFF;
|
||||||
|
const auto CLIP = Rendering::activeClip();
|
||||||
|
const int X0 = std::max(x, CLIP.x);
|
||||||
|
const int Y0 = std::max(y, CLIP.y);
|
||||||
|
const int X1 = std::min(x + w, CLIP.x + CLIP.w);
|
||||||
|
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
|
||||||
|
for (int iy = Y0; iy < Y1; ++iy) {
|
||||||
|
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
|
||||||
|
for (int ix = X0; ix < X1; ++ix) {
|
||||||
|
const std::uint32_t DST = row[ix];
|
||||||
|
const std::uint8_t DR = (DST >> 16) & 0xFF;
|
||||||
|
const std::uint8_t DG = (DST >> 8) & 0xFF;
|
||||||
|
const std::uint8_t DB = DST & 0xFF;
|
||||||
|
const std::uint8_t R = ((SR * SA) + (DR * INV)) / 255;
|
||||||
|
const std::uint8_t G = ((SG * SA) + (DG * INV)) / 255;
|
||||||
|
const std::uint8_t B = ((SB * SA) + (DB * INV)) / 255;
|
||||||
|
row[ix] = 0xFF000000U | (static_cast<std::uint32_t>(R) << 16) | (static_cast<std::uint32_t>(G) << 8) | static_cast<std::uint32_t>(B);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Screen::setOverlayRenderer(std::function<void()> callback) {
|
||||||
|
overlay_renderer_ = std::move(callback);
|
||||||
|
}
|
||||||
|
|
||||||
void Screen::present() {
|
void Screen::present() {
|
||||||
|
// Overlays (menú de servei, etc.) s'han de pintar al canvas ABANS que
|
||||||
|
// shader_backend llegisca els píxels per pujar-los a GPU; si no, queden
|
||||||
|
// per sota del postprocés o no apareixen al frame presentat.
|
||||||
|
if (overlay_renderer_) { overlay_renderer_(); }
|
||||||
|
|
||||||
fps_.increment();
|
fps_.increment();
|
||||||
fps_.calculate(SDL_GetTicks());
|
fps_.calculate(SDL_GetTicks());
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -68,6 +69,16 @@ class Screen {
|
|||||||
// Pinta un rectangle opac al canvas ARGB del joc. Pensat per a HUDs
|
// Pinta un rectangle opac al canvas ARGB del joc. Pensat per a HUDs
|
||||||
// simples (caixes de notificacions, barres d'estat); no gasta paleta.
|
// simples (caixes de notificacions, barres d'estat); no gasta paleta.
|
||||||
void fillRect(int x, int y, int w, int h, std::uint32_t argb);
|
void fillRect(int x, int y, int w, int h, std::uint32_t argb);
|
||||||
|
// Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival
|
||||||
|
// a fillRect. El color entra com a ARGB little-endian; `alpha` és el
|
||||||
|
// factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays
|
||||||
|
// (menú, pauses) que volen veure's a través el joc atenuat.
|
||||||
|
void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha);
|
||||||
|
// Hook invocat al principi de `present()`, abans de pujar els píxels al
|
||||||
|
// backend. Serveix per a pintar overlays que han de quedar per damunt
|
||||||
|
// de qualsevol escena (p.ex. el menú de servei). El Director el registra
|
||||||
|
// un sol cop; Screen no coneix el tipus concret del callback.
|
||||||
|
void setOverlayRenderer(std::function<void()> callback);
|
||||||
void present();
|
void present();
|
||||||
|
|
||||||
[[nodiscard]] auto getRenderer() const -> SDL_Renderer* { return renderer_; }
|
[[nodiscard]] auto getRenderer() const -> SDL_Renderer* { return renderer_; }
|
||||||
@@ -123,4 +134,10 @@ class Screen {
|
|||||||
|
|
||||||
FPS fps_{};
|
FPS fps_{};
|
||||||
std::string gpu_driver_;
|
std::string gpu_driver_;
|
||||||
|
|
||||||
|
// Callback cridat al principi de present() per pintar overlays per
|
||||||
|
// damunt de l'escena. Viu en Screen perquè la seqüència correcta
|
||||||
|
// (dibuix al canvas → overlay → upload al backend → present) queda
|
||||||
|
// centralitzada en un sol lloc, siga quina siga l'escena activa.
|
||||||
|
std::function<void()> overlay_renderer_;
|
||||||
};
|
};
|
||||||
@@ -72,3 +72,12 @@ void MovingSprite::setPosY(float value) {
|
|||||||
y_ = value;
|
y_ = value;
|
||||||
pos_.y = static_cast<int>(y_);
|
pos_.y = static_cast<int>(y_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MovingSprite::tryMove(float dx, float dy, const CollisionFn& collides) {
|
||||||
|
const float OLD_X = getPosX();
|
||||||
|
setPosX(OLD_X + dx);
|
||||||
|
if (collides(collisionBox())) { setPosX(OLD_X); }
|
||||||
|
const float OLD_Y = getPosY();
|
||||||
|
setPosY(OLD_Y + dy);
|
||||||
|
if (collides(collisionBox())) { setPosY(OLD_Y); }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
#include "core/rendering/sprite/sprite.hpp"
|
#include "core/rendering/sprite/sprite.hpp"
|
||||||
@@ -40,6 +41,15 @@ class MovingSprite : public Sprite {
|
|||||||
auto getFlip() -> SDL_FlipMode { return flip_; }
|
auto getFlip() -> SDL_FlipMode { return flip_; }
|
||||||
void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; }
|
void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; }
|
||||||
|
|
||||||
|
// Moviment eix-separat amb col·lisió: aplica `dx` (i si la nova
|
||||||
|
// `collisionBox()` col·lisiona, el reverteix); després aplica `dy`
|
||||||
|
// (idem). El caller calcula els deltes (típicament `vx_*dt`,
|
||||||
|
// `vy_*dt`) i passa la funció de detecció. Aquesta separació deixa
|
||||||
|
// que el sprite "llisqui" contra obstacles en compte de quedar-se
|
||||||
|
// bloquejat en sec quan només un eix col·lisiona.
|
||||||
|
using CollisionFn = std::function<bool(const SDL_FRect&)>;
|
||||||
|
void tryMove(float dx, float dy, const CollisionFn& collides);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void move(float delta_time);
|
void move(float delta_time);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ class Sprite {
|
|||||||
// Hook d'actualització per frame. Base buida (no hi ha Sprite pur a
|
// Hook d'actualització per frame. Base buida (no hi ha Sprite pur a
|
||||||
// actualitzar); les subclasses mòbils/animades fan override.
|
// actualitzar); les subclasses mòbils/animades fan override.
|
||||||
virtual void update(float /*delta_time*/) {}
|
virtual void update(float /*delta_time*/) {}
|
||||||
|
|
||||||
|
// Caixa que `MovingSprite::tryMove` envia al callback de col·lisió per
|
||||||
|
// decidir si el moviment és vàlid. Per defecte coincideix amb `pos_`
|
||||||
|
// (el sprite sencer); les subclasses que usen falsa perspectiva (p.ex.
|
||||||
|
// `Player`) la sobreescriuen amb una sub-caixa més petita (només els
|
||||||
|
// peus) per a que el cap puga sobresortir sense bloquejar el
|
||||||
|
// moviment.
|
||||||
|
[[nodiscard]] virtual auto collisionBox() const -> SDL_FRect { return pos_; }
|
||||||
|
|
||||||
virtual void render();
|
virtual void render();
|
||||||
// Renderitza substituint l'índex source_color per target_color (ús típic:
|
// Renderitza substituint l'índex source_color per target_color (ús típic:
|
||||||
// la classe Text, que recolora glifs monocroms per paràmetre).
|
// la classe Text, que recolora glifs monocroms per paràmetre).
|
||||||
|
|||||||
@@ -90,9 +90,13 @@ void Surface::render(int x, int y, const SDL_FRect* src_rect, SDL_FlipMode flip)
|
|||||||
|
|
||||||
void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 target_color, const SDL_FRect* src_rect, SDL_FlipMode flip) const {
|
void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 target_color, const SDL_FRect* src_rect, SDL_FlipMode flip) const {
|
||||||
const auto CANVAS = Rendering::activeCanvas();
|
const auto CANVAS = Rendering::activeCanvas();
|
||||||
|
const auto CLIP = Rendering::activeClip();
|
||||||
Uint32* dst = CANVAS.pixels;
|
Uint32* dst = CANVAS.pixels;
|
||||||
const int DST_W = CANVAS.width;
|
const int DST_W = CANVAS.width;
|
||||||
const int DST_H = CANVAS.height;
|
const int CLIP_X0 = CLIP.x;
|
||||||
|
const int CLIP_Y0 = CLIP.y;
|
||||||
|
const int CLIP_X1 = CLIP.x + CLIP.w;
|
||||||
|
const int CLIP_Y1 = CLIP.y + CLIP.h;
|
||||||
|
|
||||||
const int SX = (src_rect != nullptr) ? static_cast<int>(src_rect->x) : 0;
|
const int SX = (src_rect != nullptr) ? static_cast<int>(src_rect->x) : 0;
|
||||||
const int SY = (src_rect != nullptr) ? static_cast<int>(src_rect->y) : 0;
|
const int SY = (src_rect != nullptr) ? static_cast<int>(src_rect->y) : 0;
|
||||||
@@ -109,7 +113,7 @@ void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 tar
|
|||||||
const int SRC_Y = (flip == SDL_FLIP_VERTICAL) ? (SY + sh - 1 - iy) : (SY + iy);
|
const int SRC_Y = (flip == SDL_FLIP_VERTICAL) ? (SY + sh - 1 - iy) : (SY + iy);
|
||||||
const int DST_X = x + ix;
|
const int DST_X = x + ix;
|
||||||
const int DST_Y = y + iy;
|
const int DST_Y = y + iy;
|
||||||
if (DST_X < 0 || DST_X >= DST_W || DST_Y < 0 || DST_Y >= DST_H) { continue; }
|
if (DST_X < CLIP_X0 || DST_X >= CLIP_X1 || DST_Y < CLIP_Y0 || DST_Y >= CLIP_Y1) { continue; }
|
||||||
|
|
||||||
const Uint8 COLOR = src[SRC_X + (SRC_Y * surface_data_->width)];
|
const Uint8 COLOR = src[SRC_X + (SRC_Y * surface_data_->width)];
|
||||||
if (COLOR == transparent_color_) { continue; }
|
if (COLOR == transparent_color_) { continue; }
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ class Text {
|
|||||||
Text(const std::shared_ptr<Surface>& surface, const std::shared_ptr<File>& text_file);
|
Text(const std::shared_ptr<Surface>& surface, const std::shared_ptr<File>& text_file);
|
||||||
~Text() = default;
|
~Text() = default;
|
||||||
|
|
||||||
|
// Kerning per defecte de la font (separació entre glifs en píxels, sumat
|
||||||
|
// a l'amplada natural). Els callers poden passar un kerning explícit a
|
||||||
|
// write/length, o llegir aquest valor i reenviar-lo; així cada font pot
|
||||||
|
// tenir un kerning pròpi (p. ex. negatiu per a displays amb sidebearings
|
||||||
|
// amples) sense obligar els callers a saber-ho.
|
||||||
|
void setDefaultKerning(int kerning) { default_kerning_ = kerning; }
|
||||||
|
[[nodiscard]] auto getDefaultKerning() const -> int { return default_kerning_; }
|
||||||
|
|
||||||
Text(const Text&) = delete;
|
Text(const Text&) = delete;
|
||||||
Text(Text&&) = delete;
|
Text(Text&&) = delete;
|
||||||
auto operator=(const Text&) -> Text& = delete;
|
auto operator=(const Text&) -> Text& = delete;
|
||||||
@@ -64,6 +72,7 @@ class Text {
|
|||||||
[[nodiscard]] auto length(const std::string& text, int kerning = 1) const -> int;
|
[[nodiscard]] auto length(const std::string& text, int kerning = 1) const -> int;
|
||||||
[[nodiscard]] auto lengthMonoDigits(const std::string& text, int digit_cell_w, int kerning = 1) const -> int;
|
[[nodiscard]] auto lengthMonoDigits(const std::string& text, int digit_cell_w, int kerning = 1) const -> int;
|
||||||
[[nodiscard]] auto getCharacterSize() const -> int { return box_width_; }
|
[[nodiscard]] auto getCharacterSize() const -> int { return box_width_; }
|
||||||
|
[[nodiscard]] auto getLineHeight() const -> int { return box_height_; }
|
||||||
|
|
||||||
static auto loadTextFile(const std::string& file_path) -> std::shared_ptr<File>;
|
static auto loadTextFile(const std::string& file_path) -> std::shared_ptr<File>;
|
||||||
static auto nextCodepoint(const std::string& s, std::size_t& pos) -> std::uint32_t;
|
static auto nextCodepoint(const std::string& s, std::size_t& pos) -> std::uint32_t;
|
||||||
@@ -76,5 +85,6 @@ class Text {
|
|||||||
std::unique_ptr<Sprite> sprite_;
|
std::unique_ptr<Sprite> sprite_;
|
||||||
int box_width_{0};
|
int box_width_{0};
|
||||||
int box_height_{0};
|
int box_height_{0};
|
||||||
|
int default_kerning_{1};
|
||||||
std::unordered_map<std::uint32_t, Offset> offset_;
|
std::unordered_map<std::uint32_t, Offset> offset_;
|
||||||
};
|
};
|
||||||
@@ -2,20 +2,41 @@
|
|||||||
|
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace Resource::Helper {
|
namespace Resource::Helper {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string g_data_root;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void setDataRoot(const std::string& absolute_path) {
|
||||||
|
g_data_root = absolute_path;
|
||||||
|
if (!g_data_root.empty() && g_data_root.back() != '/') {
|
||||||
|
g_data_root += '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto resolve(const std::string& filepath) -> std::string {
|
||||||
|
if (filepath.empty() || filepath.front() == '/') { return filepath; }
|
||||||
|
if (g_data_root.empty()) { return filepath; }
|
||||||
|
return g_data_root + filepath;
|
||||||
|
}
|
||||||
|
|
||||||
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> {
|
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> {
|
||||||
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
|
const auto FULL = resolve(filepath);
|
||||||
|
std::ifstream file(FULL, std::ios::binary | std::ios::ate);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
std::cerr << "Resource::Helper: cannot open " << filepath << '\n';
|
std::cerr << "Resource::Helper: cannot open " << FULL << '\n';
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
std::streamsize size = file.tellg();
|
std::streamsize size = file.tellg();
|
||||||
file.seekg(0, std::ios::beg);
|
file.seekg(0, std::ios::beg);
|
||||||
std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
|
std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
|
||||||
if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
|
if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
|
||||||
std::cerr << "Resource::Helper: failed to read " << filepath << '\n';
|
std::cerr << "Resource::Helper: failed to read " << FULL << '\n';
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -6,7 +6,23 @@
|
|||||||
|
|
||||||
namespace Resource::Helper {
|
namespace Resource::Helper {
|
||||||
|
|
||||||
|
// Estableix el prefix absolut des d'on es resolen totes les rutes
|
||||||
|
// relatives passades a `loadFile`/`resolve`. Pensat per a cridar-se una
|
||||||
|
// sola vegada al primer pas del Director amb `SDL_GetBasePath()`: així el
|
||||||
|
// binari troba `data/` sense dependre del CWD des d'on s'ha llançat (és
|
||||||
|
// obligatori per a .app de macOS i útil a Linux per no haver d'estar
|
||||||
|
// dins de `build/`).
|
||||||
|
void setDataRoot(const std::string& absolute_path);
|
||||||
|
|
||||||
|
// Resol una ruta relativa contra el data root. Si la ruta ja és absoluta
|
||||||
|
// (comença per '/') la retorna sense modificar — així els fitxers de
|
||||||
|
// config (system_folder_ + "config.yaml") segueixen funcionant sense
|
||||||
|
// canvis al call site.
|
||||||
|
[[nodiscard]] auto resolve(const std::string& filepath) -> std::string;
|
||||||
|
|
||||||
// Carga un fitxer del filesystem (a aquesta fase encara no hi ha pack).
|
// Carga un fitxer del filesystem (a aquesta fase encara no hi ha pack).
|
||||||
|
// Les rutes relatives es resolen via `resolve()`; les absolutes s'obrin
|
||||||
|
// directament.
|
||||||
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
|
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
|
||||||
|
|
||||||
} // namespace Resource::Helper
|
} // namespace Resource::Helper
|
||||||
@@ -8,10 +8,13 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/audio/audio.hpp"
|
#include "core/audio/audio.hpp"
|
||||||
|
#include "core/input/gamepad_manager.hpp"
|
||||||
#include "core/input/key_config.hpp"
|
#include "core/input/key_config.hpp"
|
||||||
|
#include "core/input/mouse.hpp"
|
||||||
#include "core/locale/locale.hpp"
|
#include "core/locale/locale.hpp"
|
||||||
#include "core/rendering/notifier.hpp"
|
#include "core/rendering/notifier.hpp"
|
||||||
#include "core/rendering/render_info.hpp"
|
#include "core/rendering/render_info.hpp"
|
||||||
@@ -19,10 +22,16 @@
|
|||||||
#include "core/rendering/surface.hpp"
|
#include "core/rendering/surface.hpp"
|
||||||
#include "core/rendering/text.hpp"
|
#include "core/rendering/text.hpp"
|
||||||
#include "core/resources/resource_cache.hpp"
|
#include "core/resources/resource_cache.hpp"
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "core/scene/scene.hpp"
|
#include "core/scene/scene.hpp"
|
||||||
#include "core/system/event_buffer.hpp"
|
#include "core/system/event_buffer.hpp"
|
||||||
|
#include "game/debug_config.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
#include "game/scenes/game_scene.hpp"
|
#include "game/scenes/game_scene.hpp"
|
||||||
|
#include "game/scenes/intro_scene.hpp"
|
||||||
|
#include "game/scenes/logo_scene.hpp"
|
||||||
|
#include "game/scenes/title_scene.hpp"
|
||||||
|
#include "game/ui/service_menu.hpp"
|
||||||
#include "game/ui/ui_palette.hpp"
|
#include "game/ui/ui_palette.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
|
|
||||||
@@ -31,6 +40,15 @@ Director::Director() {
|
|||||||
std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
|
std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rutes dels recursos relatives a l'executable, no al CWD. SDL_GetBasePath
|
||||||
|
// retorna la carpeta del binari al desplegament portable i Contents/
|
||||||
|
// Resources/ dins d'un .app a macOS. Ha de fer-se abans de qualsevol
|
||||||
|
// loader que passi per Resource::Helper::resolve (Locale, KeyConfig,
|
||||||
|
// Resource::Cache, PaletteLoader, etc.).
|
||||||
|
if (const char* base = SDL_GetBasePath()) {
|
||||||
|
Resource::Helper::setDataRoot(base);
|
||||||
|
}
|
||||||
|
|
||||||
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
|
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
|
||||||
// les opcions; si no existeix config.yaml el crea amb els defaults.
|
// les opcions; si no existeix config.yaml el crea amb els defaults.
|
||||||
createSystemFolder();
|
createSystemFolder();
|
||||||
@@ -61,16 +79,21 @@ Director::Director() {
|
|||||||
|
|
||||||
// Traduccions per a tots els textos visibles. Si el fitxer no existeix
|
// Traduccions per a tots els textos visibles. Si el fitxer no existeix
|
||||||
// o hi ha un error de parse, Locale::text() caurà a retornar la clau.
|
// o hi ha un error de parse, Locale::text() caurà a retornar la clau.
|
||||||
Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale + ".yaml");
|
Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale() + ".yaml");
|
||||||
|
|
||||||
// Hotkeys globals. GlobalInputs::update() les consumirà a cada frame.
|
// Hotkeys globals. GlobalInputs::update() les consumirà a cada frame.
|
||||||
KeyConfig::get().load("data/input/keys.yaml");
|
KeyConfig::get().load("data/input/keys.yaml");
|
||||||
|
|
||||||
// Recursos compartits d'UI (paleta + font). Es carreguen abans que els
|
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
|
||||||
// overlays perquè tots els reaprofiten sense duplicar la lògica.
|
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els
|
||||||
|
// noms de font viuen a Defaults::UI::Font (un únic lloc per canviar-los).
|
||||||
Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal");
|
Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal");
|
||||||
Resource::Cache::get().loadSurface("8bithud.gif", "data/font/8bithud.gif");
|
const std::string HUD_FONT_GIF_KEY = std::string(Defaults::UI::Font::HUD_NAME) + ".gif";
|
||||||
Resource::Cache::get().getSurface("8bithud.gif")->setPalette(Resource::Cache::get().getPalette("ui"));
|
Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, Defaults::UI::Font::HUD_GIF);
|
||||||
|
Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
|
||||||
|
const std::string PLACEHOLDER_FONT_GIF_KEY = std::string(Defaults::UI::Font::PLACEHOLDER_NAME) + ".gif";
|
||||||
|
Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, Defaults::UI::Font::PLACEHOLDER_GIF);
|
||||||
|
Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
|
||||||
|
|
||||||
// Música de la piràmide 1 (primer i únic track de moment). Carreguem ací
|
// Música de la piràmide 1 (primer i únic track de moment). Carreguem ací
|
||||||
// perquè forma part del preload d'assets, com la font i la paleta.
|
// perquè forma part del preload d'assets, com la font i la paleta.
|
||||||
@@ -81,17 +104,96 @@ Director::Director() {
|
|||||||
// JA_Music_t que continga, així que no queden streams orfes.
|
// JA_Music_t que continga, així que no queden streams orfes.
|
||||||
Audio::init(Options::buildAudioConfig());
|
Audio::init(Options::buildAudioConfig());
|
||||||
|
|
||||||
|
// Gamepad: descobreix mandos presents silenciosament. A partir d'ara
|
||||||
|
// els events SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via
|
||||||
|
// `processGamepadEvents()` i dispararan notificacions.
|
||||||
|
GamepadManager::get().init();
|
||||||
|
|
||||||
// Font compartida pels overlays: construïm una sola instància de Text
|
// Font compartida pels overlays: construïm una sola instància de Text
|
||||||
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap
|
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap
|
||||||
// d'ells hagi de conèixer els noms dels recursos concrets.
|
// d'ells hagi de conèixer els noms dels recursos concrets.
|
||||||
auto hud_font_surface = Resource::Cache::get().getSurface("8bithud.gif");
|
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
|
||||||
auto hud_text = std::make_shared<Text>(hud_font_surface, "data/font/8bithud.fnt");
|
auto hud_text = std::make_shared<Text>(hud_font_surface, Defaults::UI::Font::HUD_FNT);
|
||||||
|
hud_text->setDefaultKerning(Defaults::UI::Font::HUD_KERNING);
|
||||||
const auto& ui_palette = Resource::Cache::get().getPalette("ui");
|
const auto& ui_palette = Resource::Cache::get().getPalette("ui");
|
||||||
|
|
||||||
RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text);
|
RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text);
|
||||||
Notifier::get().init(hud_text, ui_palette);
|
Notifier::get().init(hud_text, ui_palette);
|
||||||
|
|
||||||
current_scene_ = std::make_unique<GameScene>();
|
// Font dels placeholders (LOGO / TITLE / INTRO). És una font distinta a
|
||||||
|
// la de HUD perquè els placeholders desapareixeran amb les seues escenes
|
||||||
|
// finals; mentrestant tenen estètica pròpia.
|
||||||
|
auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY);
|
||||||
|
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, Defaults::UI::Font::PLACEHOLDER_FNT);
|
||||||
|
placeholder_text->setDefaultKerning(Defaults::UI::Font::PLACEHOLDER_KERNING);
|
||||||
|
|
||||||
|
// Font del menú de servei (F9). El títol va amb la variant `display`
|
||||||
|
// i els items amb `condensed-display` (més compacte perquè caben més
|
||||||
|
// files). Els dos noms i kernings viuen a `Defaults::UI::Font`.
|
||||||
|
auto load_menu_font = [&](const char* name, const char* gif, const char* fnt, int kerning) {
|
||||||
|
const std::string KEY = std::string(name) + ".gif";
|
||||||
|
Resource::Cache::get().loadSurface(KEY, gif);
|
||||||
|
Resource::Cache::get().getSurface(KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
|
||||||
|
auto text = std::make_shared<Text>(Resource::Cache::get().getSurface(KEY), fnt);
|
||||||
|
text->setDefaultKerning(kerning);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
auto menu_title_text = load_menu_font(
|
||||||
|
Defaults::UI::Font::MENU_TITLE_NAME,
|
||||||
|
Defaults::UI::Font::MENU_TITLE_GIF,
|
||||||
|
Defaults::UI::Font::MENU_TITLE_FNT,
|
||||||
|
Defaults::UI::Font::MENU_TITLE_KERNING);
|
||||||
|
auto menu_items_text = load_menu_font(
|
||||||
|
Defaults::UI::Font::MENU_ITEMS_NAME,
|
||||||
|
Defaults::UI::Font::MENU_ITEMS_GIF,
|
||||||
|
Defaults::UI::Font::MENU_ITEMS_FNT,
|
||||||
|
Defaults::UI::Font::MENU_ITEMS_KERNING);
|
||||||
|
ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette);
|
||||||
|
|
||||||
|
// Overlay hook: Screen::present() cridarà aquesta lambda al principi,
|
||||||
|
// ABANS de pujar els píxels al backend GPU. Així el menú queda per
|
||||||
|
// damunt de qualsevol escena sense que Screen hagi de conèixer game/ui.
|
||||||
|
Screen::get().setOverlayRenderer([]() { ServiceMenu::get().render(); });
|
||||||
|
|
||||||
|
// Flux d'escenes LOGO → TITLE → INTRO → GAME. Cada placeholder rep una
|
||||||
|
// factory que construeix la següent, construïda bottom-up des de GAME.
|
||||||
|
//
|
||||||
|
// Release: sempre s'arranca a LOGO (el flux complet, el que veu el
|
||||||
|
// jugador final). Debug: s'obri `debug.yaml`; si no existeix, el creem
|
||||||
|
// amb `start_scene: GAME` perquè el dia a dia de gameplay no es mengi
|
||||||
|
// els 15 s de placeholders. L'usuari pot editar-lo a mà per escollir
|
||||||
|
// un punt d'entrada intermedi.
|
||||||
|
#ifdef _DEBUG
|
||||||
|
const auto DEBUG_YAML = system_folder_ + "debug.yaml";
|
||||||
|
DebugConfig::createDefaultIfMissing(DEBUG_YAML);
|
||||||
|
const auto START = DebugConfig::resolveStartScene(DEBUG_YAML);
|
||||||
|
#else
|
||||||
|
const auto START = DebugConfig::StartScene::LOGO;
|
||||||
|
#endif
|
||||||
|
std::cout << "Director: start_scene=" << DebugConfig::toString(START) << '\n';
|
||||||
|
|
||||||
|
auto make_game = []() -> std::unique_ptr<Scene> { return std::make_unique<GameScene>(); };
|
||||||
|
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
|
||||||
|
return std::make_unique<IntroScene>(placeholder_text, make_game);
|
||||||
|
};
|
||||||
|
auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr<Scene> {
|
||||||
|
return std::make_unique<TitleScene>(placeholder_text, make_intro);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (START) {
|
||||||
|
case DebugConfig::StartScene::LOGO:
|
||||||
|
current_scene_ = std::make_unique<LogoScene>(placeholder_text, make_title);
|
||||||
|
break;
|
||||||
|
case DebugConfig::StartScene::TITLE:
|
||||||
|
current_scene_ = make_title();
|
||||||
|
break;
|
||||||
|
case DebugConfig::StartScene::INTRO:
|
||||||
|
current_scene_ = make_intro();
|
||||||
|
break;
|
||||||
|
case DebugConfig::StartScene::GAME:
|
||||||
|
current_scene_ = make_game();
|
||||||
|
break;
|
||||||
|
}
|
||||||
last_ticks_ms_ = SDL_GetTicks();
|
last_ticks_ms_ = SDL_GetTicks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +207,7 @@ Director::~Director() {
|
|||||||
// desfà les llibreries abans que corren els destructors estàtics.
|
// desfà les llibreries abans que corren els destructors estàtics.
|
||||||
current_scene_.reset();
|
current_scene_.reset();
|
||||||
Resource::Cache::get().clearAudio();
|
Resource::Cache::get().clearAudio();
|
||||||
|
GamepadManager::get().shutdown();
|
||||||
Audio::destroy();
|
Audio::destroy();
|
||||||
Screen::get().shutdown();
|
Screen::get().shutdown();
|
||||||
SDL_Quit();
|
SDL_Quit();
|
||||||
@@ -140,14 +243,72 @@ void Director::processEvents() {
|
|||||||
if (QUIT_REQUESTED) { current_scene_.reset(); }
|
if (QUIT_REQUESTED) { current_scene_.reset(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Director::processGamepadEvents() {
|
||||||
|
// Dues responsabilitats: (1) passar ADDED/REMOVED al GamepadManager
|
||||||
|
// perquè obri/tanqui mandos i emeti les notificacions hot-plug; (2)
|
||||||
|
// tractar el botó BACK com a toggle del menú quan està tancat (el
|
||||||
|
// paral·lel de F9 per al teclat). Quan el menú està obert, el BACK
|
||||||
|
// passa pel pipeline del `ServiceMenu::handleEvent()` via
|
||||||
|
// `processMenuInput()` i allà es converteix en `close()`.
|
||||||
|
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
|
||||||
|
bool consumed = false;
|
||||||
|
if (it->type == SDL_EVENT_GAMEPAD_ADDED || it->type == SDL_EVENT_GAMEPAD_REMOVED) {
|
||||||
|
GamepadManager::get().handleEvent(*it);
|
||||||
|
consumed = true;
|
||||||
|
} else if (it->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN &&
|
||||||
|
it->gbutton.button == SDL_GAMEPAD_BUTTON_BACK &&
|
||||||
|
!ServiceMenu::get().isOpen()) {
|
||||||
|
ServiceMenu::get().toggle();
|
||||||
|
consumed = true;
|
||||||
|
}
|
||||||
|
if (consumed) {
|
||||||
|
it = EventBuffer::events.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Director::processMenuInput() {
|
||||||
|
// Intercepta input abans que l'escena vegi els events. Si el menú està
|
||||||
|
// obert, consumeix TOTS els KEY_DOWN perquè ni GlobalInputs (F1–F8,
|
||||||
|
// F11, F12) ni el jugador els veuen. F9 obre el menú quan està tancat.
|
||||||
|
// Erase invalida iteradors, per això actualitzem `it` a cada branca.
|
||||||
|
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
|
||||||
|
if (ServiceMenu::get().isOpen() && ServiceMenu::get().handleEvent(*it)) {
|
||||||
|
it = EventBuffer::events.erase(it);
|
||||||
|
} else if (it->type == SDL_EVENT_KEY_DOWN &&
|
||||||
|
it->key.scancode == KeyConfig::get().scancode("menu_toggle")) {
|
||||||
|
ServiceMenu::get().toggle();
|
||||||
|
it = EventBuffer::events.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Director::switchSceneIfRequested() {
|
void Director::switchSceneIfRequested() {
|
||||||
if (!current_scene_) { return; }
|
if (!current_scene_) { return; }
|
||||||
if (auto next = current_scene_->nextScene()) {
|
if (auto next = current_scene_->nextScene()) {
|
||||||
|
// TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md).
|
||||||
|
std::cout << "Director: scene transition\n";
|
||||||
current_scene_ = std::move(next);
|
current_scene_ = std::move(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto Director::iterate() -> SDL_AppResult {
|
auto Director::iterate() -> SDL_AppResult {
|
||||||
|
// Ocultació automàtica del cursor: observem els events abans que els
|
||||||
|
// altres steps els consumeixin; Mouse no els modifica, només reacciona
|
||||||
|
// a SDL_EVENT_MOUSE_MOTION per reaparèixer el cursor.
|
||||||
|
for (const auto& ev : EventBuffer::events) { Mouse::handleEvent(ev); }
|
||||||
|
|
||||||
|
// Ordre important: primer els events de gamepad (hot-plug + BACK que
|
||||||
|
// obre el menú quan està tancat), després el menú (pot consumir tecles
|
||||||
|
// i INJECTAR un SDL_EVENT_QUIT des de l'acció "Eixir del joc"), i
|
||||||
|
// finalment processEvents perquè vegi el QUIT al mateix frame — altrament
|
||||||
|
// el QUIT injectat es perdria quan es netegi EventBuffer al final.
|
||||||
|
processGamepadEvents();
|
||||||
|
processMenuInput();
|
||||||
processEvents();
|
processEvents();
|
||||||
|
|
||||||
if (!current_scene_) {
|
if (!current_scene_) {
|
||||||
@@ -161,6 +322,8 @@ auto Director::iterate() -> SDL_AppResult {
|
|||||||
last_ticks_ms_ = NOW_MS;
|
last_ticks_ms_ = NOW_MS;
|
||||||
|
|
||||||
Audio::update();
|
Audio::update();
|
||||||
|
Mouse::updateCursorVisibility();
|
||||||
|
ServiceMenu::get().update(delta_ms);
|
||||||
current_scene_->update(delta_ms);
|
current_scene_->update(delta_ms);
|
||||||
current_scene_->render();
|
current_scene_->render();
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,7 @@ class Director {
|
|||||||
|
|
||||||
void createSystemFolder();
|
void createSystemFolder();
|
||||||
void processEvents();
|
void processEvents();
|
||||||
|
static void processGamepadEvents();
|
||||||
|
static void processMenuInput();
|
||||||
void switchSceneIfRequested();
|
void switchSceneIfRequested();
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "game/entities/concrete/mummy.hpp"
|
#include "game/entities/concrete/mummy.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -20,9 +21,36 @@ namespace {
|
|||||||
return k_rng_;
|
return k_rng_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
|
||||||
|
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
|
||||||
|
// animació d'1 frame loopant, neutra — la momia es podrà moure però
|
||||||
|
// visualment es quedarà estàtica.
|
||||||
|
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
|
||||||
|
std::vector<int> frames = {0};
|
||||||
|
float fps = 8.0F;
|
||||||
|
bool loop = true;
|
||||||
|
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
|
||||||
|
const auto& anim = yaml["animations"][key];
|
||||||
|
if (anim.contains("frames")) {
|
||||||
|
frames.clear();
|
||||||
|
for (const auto& f : anim["frames"]) {
|
||||||
|
// cppcheck-suppress useStlAlgorithm
|
||||||
|
frames.push_back(f.get_value<int>());
|
||||||
|
}
|
||||||
|
if (frames.empty()) { frames.push_back(0); }
|
||||||
|
}
|
||||||
|
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||||
|
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||||
|
}
|
||||||
|
return {frames, fps, loop};
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Mummy::Mummy(const fkyaml::node& yaml) {
|
Mummy::Mummy(const fkyaml::node& yaml)
|
||||||
|
: anim_walk_down_(buildAnimation(yaml, "walk_down")),
|
||||||
|
anim_walk_up_(buildAnimation(yaml, "walk_up")),
|
||||||
|
anim_walk_lateral_(buildAnimation(yaml, "walk")) {
|
||||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
|
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
|
||||||
collides_ = true;
|
collides_ = true;
|
||||||
interactable_ = false;
|
interactable_ = false;
|
||||||
@@ -43,16 +71,36 @@ Mummy::Mummy(const fkyaml::node& yaml) {
|
|||||||
initial = yaml["ai"]["initial_direction"].get_value<std::string>();
|
initial = yaml["ai"]["initial_direction"].get_value<std::string>();
|
||||||
}
|
}
|
||||||
if (initial == "right") {
|
if (initial == "right") {
|
||||||
setDirection(1.0F, 0.0F);
|
changeDirection(1.0F, 0.0F);
|
||||||
} else if (initial == "left") {
|
} else if (initial == "left") {
|
||||||
setDirection(-1.0F, 0.0F);
|
changeDirection(-1.0F, 0.0F);
|
||||||
} else if (initial == "up") {
|
} else if (initial == "up") {
|
||||||
setDirection(0.0F, -1.0F);
|
changeDirection(0.0F, -1.0F);
|
||||||
} else if (initial == "down") {
|
} 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 {
|
auto Mummy::chooseDirection(const std::vector<Direction>& allowed) -> Direction {
|
||||||
if (allowed.empty()) { return Direction::NONE; }
|
if (allowed.empty()) { return Direction::NONE; }
|
||||||
std::uniform_int_distribution<std::size_t> dist(0, allowed.size() - 1);
|
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;
|
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) {
|
void Mummy::step(float delta_time) {
|
||||||
// Desplaçament nominal en aquest frame segons la direcció actual.
|
// Desplaçament nominal en aquest frame segons la direcció actual.
|
||||||
const float DX = dir_x_ * speed_ * delta_time;
|
const float DX = dir_x_ * speed_ * delta_time;
|
||||||
@@ -84,60 +157,63 @@ void Mummy::step(float delta_time) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bloqueig: cal girar. Recollim les direccions perpendiculars a l'actual
|
// Bloqueig per la vora del playfield: amarrem la caja a la vora exacta.
|
||||||
// que tinguin un pròxim tram lliure. Evitem U-turn llevat que no hi hagi
|
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
|
||||||
// cap alternativa (comportament típic d'Oh Mummy!: no es recula sense
|
|
||||||
// motiu). Si la direcció perpendicular també està bloquejada, no s'hi
|
// Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap
|
||||||
// inclou; si totes les perpendiculars estan bloquejades, afegim l'opcio
|
// altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu).
|
||||||
// oposada com a últim recurs (cul-de-sac).
|
const auto POOLS = collectDirectionPools(speed_ * delta_time);
|
||||||
const float STEP = 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 {
|
struct Candidate {
|
||||||
float dx;
|
float dx;
|
||||||
float dy;
|
float dy;
|
||||||
Direction dir;
|
Direction dir;
|
||||||
};
|
};
|
||||||
const std::array<Candidate, 4> ALL{{
|
const std::array<Candidate, 4> ALL{{
|
||||||
{.dx = STEP, .dy = 0.0F, .dir = Direction::RIGHT},
|
{.dx = step, .dy = 0.0F, .dir = Direction::RIGHT},
|
||||||
{.dx = -STEP, .dy = 0.0F, .dir = Direction::LEFT},
|
{.dx = -step, .dy = 0.0F, .dir = Direction::LEFT},
|
||||||
{.dx = 0.0F, .dy = -STEP, .dir = Direction::UP},
|
{.dx = 0.0F, .dy = -step, .dir = Direction::UP},
|
||||||
{.dx = 0.0F, .dy = STEP, .dir = Direction::DOWN},
|
{.dx = 0.0F, .dy = step, .dir = Direction::DOWN},
|
||||||
}};
|
}};
|
||||||
|
DirectionPools pools;
|
||||||
std::vector<Direction> perp_allowed;
|
|
||||||
std::vector<Direction> uturn_allowed;
|
|
||||||
for (const auto& c : ALL) {
|
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_CURRENT = (c.dx * dir_x_ > 0.0F) || (c.dy * 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 sabem que està bloquejada
|
||||||
if (IS_CURRENT) { continue; } // ja hem vist que està bloquejada
|
|
||||||
const auto TEST = candidateBox(c.dx, c.dy);
|
const auto TEST = candidateBox(c.dx, c.dy);
|
||||||
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
|
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) {
|
if (IS_UTURN) {
|
||||||
uturn_allowed.push_back(c.dir);
|
pools.uturn.push_back(c.dir);
|
||||||
} else {
|
} 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;
|
void Mummy::applyChoice(Direction dir) {
|
||||||
const auto CHOICE = chooseDirection(pool);
|
switch (dir) {
|
||||||
switch (CHOICE) {
|
|
||||||
case Direction::UP:
|
case Direction::UP:
|
||||||
setDirection(0.0F, -1.0F);
|
changeDirection(0.0F, -1.0F);
|
||||||
break;
|
break;
|
||||||
case Direction::DOWN:
|
case Direction::DOWN:
|
||||||
setDirection(0.0F, 1.0F);
|
changeDirection(0.0F, 1.0F);
|
||||||
break;
|
break;
|
||||||
case Direction::LEFT:
|
case Direction::LEFT:
|
||||||
setDirection(-1.0F, 0.0F);
|
changeDirection(-1.0F, 0.0F);
|
||||||
break;
|
break;
|
||||||
case Direction::RIGHT:
|
case Direction::RIGHT:
|
||||||
setDirection(1.0F, 0.0F);
|
changeDirection(1.0F, 0.0F);
|
||||||
break;
|
break;
|
||||||
case Direction::NONE:
|
case Direction::NONE:
|
||||||
setDirection(0.0F, 0.0F);
|
changeDirection(0.0F, 0.0F);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Mummy::onPlayerContact(Player* /*player*/) {
|
void Mummy::onPlayerContact(Player* /*player*/) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
|
#include "game/entities/animation_component.hpp"
|
||||||
#include "game/entities/hazard.hpp"
|
#include "game/entities/hazard.hpp"
|
||||||
|
|
||||||
// Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció
|
// Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció
|
||||||
@@ -44,4 +45,36 @@ class Mummy : public DynamicHazard {
|
|||||||
private:
|
private:
|
||||||
// Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy).
|
// Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy).
|
||||||
[[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect;
|
[[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_;
|
||||||
};
|
};
|
||||||
@@ -128,6 +128,18 @@ class Entity {
|
|||||||
// col·lisió coincideix amb el sprite.
|
// col·lisió coincideix amb el sprite.
|
||||||
void setCollisionBox(CollisionBox box) { collision_box_ = box; }
|
void setCollisionBox(CollisionBox box) { collision_box_ = box; }
|
||||||
|
|
||||||
|
// Accés a la caixa crua (offsets + mida) — el loader la consulta per
|
||||||
|
// a col·locar el sprite quan `anchor: feet` ha d'alinear la caja (no
|
||||||
|
// el sprite) amb el tile. Nullopt si l'entitat no en té.
|
||||||
|
[[nodiscard]] auto getCollisionBox() const -> const std::optional<CollisionBox>& { return collision_box_; }
|
||||||
|
|
||||||
|
// Flip del sprite. Les entitats estàtiques mai no el toquen (queda a
|
||||||
|
// SDL_FLIP_NONE); les dinàmiques que miren cap als costats (p. ex.
|
||||||
|
// Mummy en direcció horitzontal) el canvien quan gira. El manager
|
||||||
|
// el passa a Surface::render.
|
||||||
|
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
|
||||||
|
void setFlip(SDL_FlipMode flip) { flip_ = flip; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
std::string name_;
|
std::string name_;
|
||||||
TilePos tile_{};
|
TilePos tile_{};
|
||||||
@@ -137,5 +149,6 @@ class Entity {
|
|||||||
std::shared_ptr<Surface> surface_{nullptr};
|
std::shared_ptr<Surface> surface_{nullptr};
|
||||||
bool collides_{false};
|
bool collides_{false};
|
||||||
bool interactable_{false};
|
bool interactable_{false};
|
||||||
|
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
||||||
EventBus* bus_{nullptr};
|
EventBus* bus_{nullptr};
|
||||||
};
|
};
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "game/entities/entity.hpp"
|
#include "game/entities/entity.hpp"
|
||||||
#include "game/entities/entity_factory.hpp"
|
#include "game/entities/entity_factory.hpp"
|
||||||
|
|
||||||
@@ -30,10 +31,10 @@ namespace EntityLoader {
|
|||||||
auto it = cache.find(name);
|
auto it = cache.find(name);
|
||||||
if (it != cache.end()) { return it->second; }
|
if (it != cache.end()) { return it->second; }
|
||||||
|
|
||||||
const std::string PATH = templatePath(name);
|
const std::string FULL = Resource::Helper::resolve(templatePath(name));
|
||||||
std::ifstream file(PATH);
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + PATH);
|
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + FULL);
|
||||||
}
|
}
|
||||||
fkyaml::node node = fkyaml::node::deserialize(file);
|
fkyaml::node node = fkyaml::node::deserialize(file);
|
||||||
cache.emplace(name, node);
|
cache.emplace(name, node);
|
||||||
@@ -41,9 +42,10 @@ namespace EntityLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node> {
|
auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node> {
|
||||||
std::ifstream file(level_yaml_path);
|
const auto FULL = Resource::Helper::resolve(level_yaml_path);
|
||||||
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + level_yaml_path);
|
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + FULL);
|
||||||
}
|
}
|
||||||
fkyaml::node root = fkyaml::node::deserialize(file);
|
fkyaml::node root = fkyaml::node::deserialize(file);
|
||||||
std::vector<fkyaml::node> spawns;
|
std::vector<fkyaml::node> spawns;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ void EntityManager::renderEntity(const Entity& e) {
|
|||||||
.w = RECT.w,
|
.w = RECT.w,
|
||||||
.h = RECT.h,
|
.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() {
|
void EntityManager::renderAll() {
|
||||||
@@ -105,9 +105,16 @@ namespace {
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bloc `collision: { size: [w, h], offset: [x, y] }` de la plantilla
|
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
|
||||||
// d'entitat. `offset` és opcional: si no apareix, s'entén "la caixa
|
// de la plantilla d'entitat.
|
||||||
// queda bottom-center del sprite" (convenció de falsa perspectiva).
|
// - 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
|
// Retorna nullopt si no hi ha bloc, i l'entitat es queda sense caixa
|
||||||
// explícita (getCollisionRect torna tot el sprite).
|
// explícita (getCollisionRect torna tot el sprite).
|
||||||
auto readCollisionBox(const fkyaml::node& tmpl, float sprite_w, float sprite_h) -> std::optional<Entity::CollisionBox> {
|
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>();
|
const int H = sz[1].get_value<int>();
|
||||||
int off_x = static_cast<int>((sprite_w - static_cast<float>(W)) / 2.0F);
|
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));
|
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")) {
|
if (c.contains("offset")) {
|
||||||
const auto& o = c["offset"];
|
const auto& o = c["offset"];
|
||||||
if (o.is_sequence() && o.size() == 2) {
|
if (o.is_sequence() && o.size() == 2) {
|
||||||
@@ -258,16 +275,26 @@ namespace {
|
|||||||
const auto& p = spawn["position"];
|
const auto& p = spawn["position"];
|
||||||
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
|
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
|
||||||
|
|
||||||
// Anchor "feet": el tile [col,row] és el tile dels peus. El sprite
|
// Anchor "feet": el tile [col,row] és el tile dels peus de l'actor.
|
||||||
// es centra horitzontalment sobre el tile i la seua vora inferior
|
// Si l'entitat té caixa de col·lisió explícita, la caixa s'alinea
|
||||||
// s'alinea amb la vora inferior del tile. Els píxels que sobresurten
|
// exactament amb el tile (el sprite queda col·locat per als offsets
|
||||||
// (alçada del sprite per damunt del tile dels peus) queden "per
|
// que la caja defineix — típicament sobresurt cap amunt per falsa
|
||||||
// sobre" sense afectar cap col·lisió — falsa perspectiva.
|
// 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_x = PX.x;
|
||||||
float rect_y = PX.y;
|
float rect_y = PX.y;
|
||||||
if (anchor == "feet") {
|
if (anchor == "feet") {
|
||||||
rect_x = PX.x + (static_cast<float>(Map::tile_size) - sprite.width) / 2.0F;
|
const auto& box_opt = entity->getCollisionBox();
|
||||||
rect_y = PX.y + static_cast<float>(Map::tile_size) - sprite.height;
|
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});
|
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
#include "core/input/gamepad_manager.hpp"
|
||||||
|
#include "core/input/input_state.hpp"
|
||||||
#include "core/resources/resource_cache.hpp"
|
#include "core/resources/resource_cache.hpp"
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
#include "game/entities/event_bus.hpp"
|
#include "game/entities/event_bus.hpp"
|
||||||
#include "game/map/map.hpp"
|
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -19,12 +21,36 @@ namespace {
|
|||||||
// Alias local per reduir soroll; la constant viu a Defaults::Player.
|
// Alias local per reduir soroll; la constant viu a Defaults::Player.
|
||||||
constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S;
|
constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S;
|
||||||
|
|
||||||
|
// Dead-zone del stick analògic esquerre (rang Sint16: -32768..32767).
|
||||||
|
// Converteix eix analògic en direcció digital; per sota, es considera
|
||||||
|
// repòs i no contribueix a la velocitat.
|
||||||
|
constexpr Sint16 STICK_DEADZONE = 12000;
|
||||||
|
|
||||||
|
// Acumula la contribució del primer gamepad (cruceta + stick esquerre)
|
||||||
|
// sobre els acumuladors de velocitat. Extracció d'`update()` per a
|
||||||
|
// mantenir la complexitat cognitiva sota llindar (threshold 25).
|
||||||
|
void accumulateGamepadMovement(float& vx, float& vy) {
|
||||||
|
SDL_Gamepad* pad = GamepadManager::get().getPrimary();
|
||||||
|
if (pad == nullptr) { return; }
|
||||||
|
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; }
|
||||||
|
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; }
|
||||||
|
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; }
|
||||||
|
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; }
|
||||||
|
const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
|
||||||
|
const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
|
||||||
|
if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; }
|
||||||
|
if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; }
|
||||||
|
if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; }
|
||||||
|
if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; }
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void Player::loadFrom(const std::string& yaml_path) {
|
void Player::loadFrom(const std::string& yaml_path) {
|
||||||
std::ifstream file(yaml_path);
|
const auto FULL = Resource::Helper::resolve(yaml_path);
|
||||||
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path);
|
throw std::runtime_error("Player: no s'ha pogut obrir " + FULL);
|
||||||
}
|
}
|
||||||
auto yaml = fkyaml::node::deserialize(file);
|
auto yaml = fkyaml::node::deserialize(file);
|
||||||
|
|
||||||
@@ -32,7 +58,7 @@ void Player::loadFrom(const std::string& yaml_path) {
|
|||||||
sprite_h = yaml["frameHeight"].get_value<int>();
|
sprite_h = yaml["frameHeight"].get_value<int>();
|
||||||
|
|
||||||
if (!yaml.contains("collision")) {
|
if (!yaml.contains("collision")) {
|
||||||
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path);
|
throw std::runtime_error("Player: manca bloc `collision:` a " + FULL);
|
||||||
}
|
}
|
||||||
const auto& c = yaml["collision"];
|
const auto& c = yaml["collision"];
|
||||||
collision_size = c["size"].get_value<int>();
|
collision_size = c["size"].get_value<int>();
|
||||||
@@ -47,15 +73,20 @@ void Player::loadFrom(const std::string& yaml_path) {
|
|||||||
collision_y_offset = (sprite_h - base_size) - collision_padding;
|
collision_y_offset = (sprite_h - base_size) - collision_padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
Player::Player(const std::string& anim_key, float x, float y, const Controls& controls)
|
Player::Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info)
|
||||||
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
|
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
|
||||||
controls_(controls) {
|
controls_(controls),
|
||||||
|
map_(map_info) {
|
||||||
setPos(x, y);
|
setPos(x, y);
|
||||||
setCurrentAnimation("stand");
|
setCurrentAnimation("stand");
|
||||||
}
|
}
|
||||||
|
|
||||||
void Player::update(float delta_time) {
|
void Player::update(float delta_time) {
|
||||||
const bool* keys = SDL_GetKeyboardState(nullptr);
|
// Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el
|
||||||
|
// service menu (o qualsevol UI modal) està obert, el wrapper retorna
|
||||||
|
// un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari
|
||||||
|
// navega el menú. Polling-blocked; veure `core/input/input_state.hpp`.
|
||||||
|
const bool* keys = InputState::getKeyboardState();
|
||||||
float vx = 0.0F;
|
float vx = 0.0F;
|
||||||
float vy = 0.0F;
|
float vy = 0.0F;
|
||||||
if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; }
|
if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; }
|
||||||
@@ -63,52 +94,71 @@ void Player::update(float delta_time) {
|
|||||||
if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; }
|
if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; }
|
||||||
if (keys[controls_.down]) { vy += SPEED_PX_PER_S; }
|
if (keys[controls_.down]) { vy += SPEED_PX_PER_S; }
|
||||||
|
|
||||||
|
// Cruceta i stick esquerre del primer gamepad (si n'hi ha). També
|
||||||
|
// respecten el bloqueig d'`InputState`, així que amb el menú obert
|
||||||
|
// els botons tornen false/0 i no contribueixen a la velocitat.
|
||||||
|
// Direccions oposades (teclat left + d-pad right) es cancel·len de
|
||||||
|
// forma natural; premuda simultània en la mateixa direcció no duplica
|
||||||
|
// la velocitat perquè cada branca només resta/suma una vegada.
|
||||||
|
accumulateGamepadMovement(vx, vy);
|
||||||
|
|
||||||
setVelX(vx);
|
setVelX(vx);
|
||||||
setVelY(vy);
|
setVelY(vy);
|
||||||
|
|
||||||
// El sprite original mira a la dreta; flipem només si es mou a l'esquerra
|
// Tria de facing amb prevalença lateral: si hi ha component horitzontal
|
||||||
// i restaurem si torna a la dreta. Moviments purament verticals conserven
|
// (inclòs el diagonal), l'animació és lateral i el flip segueix el signe
|
||||||
// l'últim flip perquè el jugador no "gire" sense motiu.
|
// de `vx`. Només en moviment purament vertical passem a UP/DOWN, i en
|
||||||
if (vx < 0.0F) {
|
// aquest cas el flip es força a NONE perquè els sprites frontals no
|
||||||
setFlip(SDL_FLIP_HORIZONTAL);
|
// s'han d'espillar segons l'últim lateral. En aturar-se conservem
|
||||||
} else if (vx > 0.0F) {
|
// `facing_` per a l'animació stand corresponent.
|
||||||
|
if (vx != 0.0F) {
|
||||||
|
facing_ = Facing::SIDE;
|
||||||
|
setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);
|
||||||
|
} else if (vy < 0.0F) {
|
||||||
|
facing_ = Facing::UP;
|
||||||
|
setFlip(SDL_FLIP_NONE);
|
||||||
|
} else if (vy > 0.0F) {
|
||||||
|
facing_ = Facing::DOWN;
|
||||||
setFlip(SDL_FLIP_NONE);
|
setFlip(SDL_FLIP_NONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool MOVING = vx != 0.0F || vy != 0.0F;
|
|
||||||
setCurrentAnimation(MOVING ? "walk" : "stand");
|
|
||||||
|
|
||||||
// Snap al passadís: si només hi ha moviment horitzontal (o vertical) i
|
// Snap al passadís: si només hi ha moviment horitzontal (o vertical) i
|
||||||
// la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la
|
// la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la
|
||||||
// Y (o X) es corregeix gradualment per enfilar el passadís. Amb
|
// Y (o X) es corregeix gradualment per enfilar el passadís. Amb
|
||||||
// moviment diagonal no es fa snap — l'usuari pilota manualment.
|
// moviment diagonal no es fa snap — l'usuari pilota manualment.
|
||||||
const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F);
|
const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F);
|
||||||
const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F);
|
const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F);
|
||||||
|
|
||||||
|
// Pos d'inici de frame (abans de snap i integració): si en acabar el
|
||||||
|
// frame coincideix amb la posició final, el jugador no s'ha mogut
|
||||||
|
// realment (ni per input, ni per snap, ni ha superat la col·lisió) i cal
|
||||||
|
// forçar l'animació `stand` corresponent al facing — així s'atura el
|
||||||
|
// caminat quan xoca contra un mur o un sarcòfag.
|
||||||
|
const float FRAME_START_X = getPosX();
|
||||||
|
const float FRAME_START_Y = getPosY();
|
||||||
|
|
||||||
if (ONLY_H) {
|
if (ONLY_H) {
|
||||||
snapToHorizontalLane(delta_time);
|
snapToHorizontalLane(delta_time);
|
||||||
} else if (ONLY_V) {
|
} else if (ONLY_V) {
|
||||||
snapToVerticalLane(delta_time);
|
snapToVerticalLane(delta_time);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guardem la posició prèvia i deixem que AnimatedSprite::update aplique
|
// Animació del frame + moviment col·lisió-aware. `tryMove` aplica els
|
||||||
// la velocitat com sempre; així no dupliquem la integració temporal ni
|
// deltes per eix i reverteix l'eix que col·lisiona, així el jugador
|
||||||
// l'animació. Després, axis-separated collision: revertim i re-apliquem
|
// llisca contra sarcòfags i murs en compte de quedar-se bloquejat en
|
||||||
// cada eix comprovant el mapa de colisions, de manera que el jugador
|
// sec. La caixa que s'envia al callback és `collisionBox()` (override
|
||||||
// llisca contra els sarcòfags en compte de quedar-se bloquejat en sec.
|
// a Player retornant `getBaseBox()`), no el sprite sencer.
|
||||||
const float OLD_X = getPosX();
|
animate(delta_time);
|
||||||
const float OLD_Y = getPosY();
|
tryMove(vx * delta_time, vy * delta_time, [this](const SDL_FRect& box) { return map_.collides(box); });
|
||||||
AnimatedSprite::update(delta_time);
|
|
||||||
const float NEW_X = getPosX();
|
|
||||||
const float NEW_Y = getPosY();
|
|
||||||
setPos(OLD_X, OLD_Y);
|
|
||||||
|
|
||||||
setPosX(NEW_X);
|
|
||||||
if (Map::collides(baseBox())) { setPosX(OLD_X); }
|
|
||||||
setPosY(NEW_Y);
|
|
||||||
if (Map::collides(baseBox())) { setPosY(OLD_Y); }
|
|
||||||
|
|
||||||
clampToPlayfield();
|
clampToPlayfield();
|
||||||
|
|
||||||
|
// Animació basada en moviment real: si la col·lisió (o l'absència
|
||||||
|
// d'input) ha deixat el jugador al mateix lloc que al principi del
|
||||||
|
// frame, queda en `stand` del facing actual.
|
||||||
|
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
|
||||||
|
setCurrentAnimation(animationName(MOVED));
|
||||||
|
|
||||||
// Detecció de canvi de tile. Es calcula *després* del clamp i de les
|
// Detecció de canvi de tile. Es calcula *després* del clamp i de les
|
||||||
// col·lisions perquè la posició aquí ja és la definitiva del frame;
|
// col·lisions perquè la posició aquí ja és la definitiva del frame;
|
||||||
// així els sarcòfags no reben falsos positius mentre el Player rellisca
|
// així els sarcòfags no reben falsos positius mentre el Player rellisca
|
||||||
@@ -124,29 +174,41 @@ void Player::update(float delta_time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto Player::animationName(bool moving) const -> const char* {
|
||||||
|
switch (facing_) {
|
||||||
|
case Facing::UP:
|
||||||
|
return moving ? "walk_up" : "stand_up";
|
||||||
|
case Facing::DOWN:
|
||||||
|
return moving ? "walk_down" : "stand_down";
|
||||||
|
case Facing::SIDE:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return moving ? "walk" : "stand";
|
||||||
|
}
|
||||||
|
|
||||||
auto Player::getCurrentTile() const -> std::pair<int, int> {
|
auto Player::getCurrentTile() const -> std::pair<int, int> {
|
||||||
// El grid del tilemap és de `Map::tile_size` píxels i està 16-alineat
|
// El grid del tilemap és de `tile_size` píxels i està 16-alineat
|
||||||
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El
|
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El
|
||||||
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
|
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
|
||||||
const auto BOX = baseBox();
|
const auto BOX = baseBox();
|
||||||
const float CX = BOX.x + (BOX.w * 0.5F);
|
const float CX = BOX.x + (BOX.w * 0.5F);
|
||||||
const float CY = BOX.y + (BOX.h * 0.5F);
|
const float CY = BOX.y + (BOX.h * 0.5F);
|
||||||
const int COL = static_cast<int>(std::floor(
|
const int COL = static_cast<int>(std::floor(
|
||||||
(CX - static_cast<float>(Map::tilemap_origin_x)) / static_cast<float>(Map::tile_size)));
|
(CX - static_cast<float>(map_.tilemap_origin_x)) / static_cast<float>(map_.tile_size)));
|
||||||
const int ROW = static_cast<int>(std::floor(
|
const int ROW = static_cast<int>(std::floor(
|
||||||
(CY - static_cast<float>(Map::tilemap_origin_y)) / static_cast<float>(Map::tile_size)));
|
(CY - static_cast<float>(map_.tilemap_origin_y)) / static_cast<float>(map_.tile_size)));
|
||||||
return {COL, ROW};
|
return {COL, ROW};
|
||||||
}
|
}
|
||||||
|
|
||||||
void Player::clampToPlayfield() {
|
void Player::clampToPlayfield() {
|
||||||
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del
|
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del
|
||||||
// playfield (muros). El cap del sprite sí pot sobresortir per damunt.
|
// playfield (muros). El cap del sprite sí pot sobresortir per damunt.
|
||||||
const auto MIN_X = static_cast<float>(Map::playfield_x - collision_x_offset);
|
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
|
||||||
const auto MAX_X = static_cast<float>(
|
const auto MAX_X = static_cast<float>(
|
||||||
Map::playfield_x + Map::playfield_w - collision_x_offset - collision_size);
|
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
|
||||||
const auto MIN_Y = static_cast<float>(Map::playfield_y - collision_y_offset);
|
const auto MIN_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
|
||||||
const auto MAX_Y = static_cast<float>(
|
const auto MAX_Y = static_cast<float>(
|
||||||
Map::playfield_y + Map::playfield_h - collision_y_offset - collision_size);
|
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
|
||||||
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
|
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
|
||||||
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
|
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
|
||||||
}
|
}
|
||||||
@@ -182,24 +244,24 @@ void Player::snapToLane(
|
|||||||
const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
|
const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
|
||||||
const float SAVED = (this->*get_pos)();
|
const float SAVED = (this->*get_pos)();
|
||||||
(this->*set_pos)(SAVED + std::copysign(STEP, DELTA));
|
(this->*set_pos)(SAVED + std::copysign(STEP, DELTA));
|
||||||
if (Map::collides(baseBox())) { (this->*set_pos)(SAVED); }
|
if (map_.collides(baseBox())) { (this->*set_pos)(SAVED); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Player::snapToHorizontalLane(float delta_time) {
|
void Player::snapToHorizontalLane(float delta_time) {
|
||||||
snapToLane(delta_time,
|
snapToLane(delta_time,
|
||||||
static_cast<float>(Map::playfield_y - collision_y_offset),
|
static_cast<float>(map_.playfield_y - collision_y_offset),
|
||||||
static_cast<float>(Map::pitch_y),
|
static_cast<float>(map_.pitch_y),
|
||||||
Map::grid_rows,
|
map_.grid_rows,
|
||||||
&Player::getPosY,
|
&Player::getPosY,
|
||||||
&Player::setPosY);
|
&Player::setPosY);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Player::snapToVerticalLane(float delta_time) {
|
void Player::snapToVerticalLane(float delta_time) {
|
||||||
snapToLane(delta_time,
|
snapToLane(delta_time,
|
||||||
static_cast<float>(Map::playfield_x - collision_x_offset),
|
static_cast<float>(map_.playfield_x - collision_x_offset),
|
||||||
static_cast<float>(Map::pitch_x),
|
static_cast<float>(map_.pitch_x),
|
||||||
Map::grid_cols,
|
map_.grid_cols,
|
||||||
&Player::getPosX,
|
&Player::getPosX,
|
||||||
&Player::setPosX);
|
&Player::setPosX);
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/rendering/sprite/animated_sprite.hpp"
|
#include "core/rendering/sprite/animated_sprite.hpp"
|
||||||
|
#include "game/map/map.hpp"
|
||||||
|
|
||||||
class EventBus;
|
class EventBus;
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ class Player : public AnimatedSprite {
|
|||||||
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
||||||
static void loadFrom(const std::string& yaml_path);
|
static void loadFrom(const std::string& yaml_path);
|
||||||
|
|
||||||
Player(const std::string& anim_key, float x, float y, const Controls& controls);
|
Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info);
|
||||||
|
|
||||||
void update(float delta_time) override;
|
void update(float delta_time) override;
|
||||||
|
|
||||||
@@ -65,6 +67,12 @@ class Player : public AnimatedSprite {
|
|||||||
// amb el jugador (momies, trampes, porta).
|
// amb el jugador (momies, trampes, porta).
|
||||||
[[nodiscard]] auto getBaseBox() const -> SDL_FRect;
|
[[nodiscard]] auto getBaseBox() const -> SDL_FRect;
|
||||||
|
|
||||||
|
// Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta
|
||||||
|
// caixa al callback de col·lisió. La base del Player és més petita
|
||||||
|
// que el sprite (el cap pot sobresortir per murs/sarcòfags sense
|
||||||
|
// bloquejar el moviment).
|
||||||
|
[[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Alias privat històric. Mantingut perquè viu dins de l'implementació
|
// Alias privat històric. Mantingut perquè viu dins de l'implementació
|
||||||
// (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
|
// (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
|
||||||
@@ -81,6 +89,10 @@ class Player : public AnimatedSprite {
|
|||||||
// wrappers deleguen a `snapToLane` només canviant l'eix actiu.
|
// wrappers deleguen a `snapToLane` només canviant l'eix actiu.
|
||||||
void snapToHorizontalLane(float delta_time);
|
void snapToHorizontalLane(float delta_time);
|
||||||
void snapToVerticalLane(float delta_time);
|
void snapToVerticalLane(float delta_time);
|
||||||
|
|
||||||
|
// Nom d'animació per al `facing_` actual. Si `moving` és cert s'usa
|
||||||
|
// la variant `walk*`, altrament la variant `stand*`.
|
||||||
|
[[nodiscard]] auto animationName(bool moving) const -> const char*;
|
||||||
void snapToLane(
|
void snapToLane(
|
||||||
float delta_time,
|
float delta_time,
|
||||||
float base,
|
float base,
|
||||||
@@ -90,6 +102,22 @@ class Player : public AnimatedSprite {
|
|||||||
void (Player::*set_pos)(float));
|
void (Player::*set_pos)(float));
|
||||||
|
|
||||||
Controls controls_;
|
Controls controls_;
|
||||||
|
// Còpia plana de la geometria del mapa + punters a `collides`/
|
||||||
|
// `inside_playfield`. POD trivial; copiada al ctor i no muta durant
|
||||||
|
// la vida del Player. Vol dir que aquest Player només és vàlid per
|
||||||
|
// a un mapa concret — si el mapa canvia, la Scene ha de
|
||||||
|
// reinstanciar-lo.
|
||||||
|
Map::Info map_;
|
||||||
|
|
||||||
|
// Direcció "cap on mira" el jugador. Governa quin conjunt d'animacions
|
||||||
|
// s'aplica (lateral, amunt, avall) i si el sprite es flipeja o no. Els
|
||||||
|
// moviments purament horitzontals o diagonals prenen `SIDE` (prevalença
|
||||||
|
// lateral), els purament verticals fan `UP`/`DOWN`. En aturar-se es
|
||||||
|
// preserva l'última orientació perquè la versió stand coincideixi.
|
||||||
|
enum class Facing : std::uint8_t { SIDE,
|
||||||
|
UP,
|
||||||
|
DOWN };
|
||||||
|
Facing facing_{Facing::SIDE};
|
||||||
|
|
||||||
// Bus del nivell (opcional). Propietat externa (EntityManager); el
|
// Bus del nivell (opcional). Propietat externa (EntityManager); el
|
||||||
// Player només en guarda un punter per publicar events.
|
// Player només en guarda un punter per publicar events.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -21,13 +22,14 @@ namespace {
|
|||||||
namespace Map {
|
namespace Map {
|
||||||
|
|
||||||
void loadFrom(const std::string& yaml_path) {
|
void loadFrom(const std::string& yaml_path) {
|
||||||
std::ifstream file(yaml_path);
|
const auto FULL = Resource::Helper::resolve(yaml_path);
|
||||||
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
throw std::runtime_error("Map: no s'ha pogut obrir " + yaml_path);
|
throw std::runtime_error("Map: no s'ha pogut obrir " + FULL);
|
||||||
}
|
}
|
||||||
auto yaml = fkyaml::node::deserialize(file);
|
auto yaml = fkyaml::node::deserialize(file);
|
||||||
if (!yaml.contains("geometry")) {
|
if (!yaml.contains("geometry")) {
|
||||||
throw std::runtime_error("Map: manca bloc `geometry:` a " + yaml_path);
|
throw std::runtime_error("Map: manca bloc `geometry:` a " + FULL);
|
||||||
}
|
}
|
||||||
const auto& g = yaml["geometry"];
|
const auto& g = yaml["geometry"];
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ namespace Map {
|
|||||||
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
|
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
|
||||||
<< " sarcs " << sarc_w << "x" << sarc_h
|
<< " sarcs " << sarc_w << "x" << sarc_h
|
||||||
<< " pitch " << pitch_x << "x" << pitch_y
|
<< " pitch " << pitch_x << "x" << pitch_y
|
||||||
<< " des de " << yaml_path << '\n';
|
<< " des de " << FULL << '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
auto getSarcRect(int col, int row) -> SDL_FRect {
|
auto getSarcRect(int col, int row) -> SDL_FRect {
|
||||||
@@ -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);
|
return box.x >= static_cast<float>(playfield_x) && box.y >= static_cast<float>(playfield_y) && box.x + box.w <= static_cast<float>(playfield_x + playfield_w) && box.y + box.h <= static_cast<float>(playfield_y + playfield_h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto makeInfo() -> Info {
|
||||||
|
return {
|
||||||
|
.playfield_x = playfield_x,
|
||||||
|
.playfield_y = playfield_y,
|
||||||
|
.playfield_w = playfield_w,
|
||||||
|
.playfield_h = playfield_h,
|
||||||
|
.pitch_x = pitch_x,
|
||||||
|
.pitch_y = pitch_y,
|
||||||
|
.grid_cols = grid_cols,
|
||||||
|
.grid_rows = grid_rows,
|
||||||
|
.tile_size = tile_size,
|
||||||
|
.tilemap_origin_x = tilemap_origin_x,
|
||||||
|
.tilemap_origin_y = tilemap_origin_y,
|
||||||
|
.collides = &collides,
|
||||||
|
.inside_playfield = &insidePlayfield,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Map
|
} // namespace Map
|
||||||
@@ -14,6 +14,14 @@
|
|||||||
// els membres viuen com a globals del namespace i els callers hi accedeixen
|
// els membres viuen com a globals del namespace i els callers hi accedeixen
|
||||||
// amb `Map::grid_cols`, `Map::collides(...)`, etc. El canvi respecte a
|
// amb `Map::grid_cols`, `Map::collides(...)`, etc. El canvi respecte a
|
||||||
// l'anterior `class Map { static ... };` és purament sintàctic.
|
// l'anterior `class Map { static ... };` és purament sintàctic.
|
||||||
|
//
|
||||||
|
// `MapInfo` (POD trivialment copiable) empaqueta la geometria + dos
|
||||||
|
// punters a funció de col·lisió per injectar-la a entitats que han
|
||||||
|
// d'evitar conèixer el namespace `Map::` directament. Construïda via
|
||||||
|
// `Map::makeInfo()` després de `loadFrom`. Avui només l'usa el Player
|
||||||
|
// (COUP-02 a docs/audit-game.md); momies, roques i `loadFromLevel`
|
||||||
|
// continuen llegint `Map::*` directament — la nota original justifica
|
||||||
|
// que mentre hi hagi un Map global actiu, l'acoblament és acceptable.
|
||||||
namespace Map {
|
namespace Map {
|
||||||
|
|
||||||
// Geometria de la graella (carregada de pyramid.yaml > geometry).
|
// Geometria de la graella (carregada de pyramid.yaml > geometry).
|
||||||
@@ -73,4 +81,27 @@ namespace Map {
|
|||||||
// entitats dinàmiques per validar posicions candidates del seu step.
|
// entitats dinàmiques per validar posicions candidates del seu step.
|
||||||
[[nodiscard]] auto insidePlayfield(const SDL_FRect& box) -> bool;
|
[[nodiscard]] auto insidePlayfield(const SDL_FRect& box) -> bool;
|
||||||
|
|
||||||
|
// Snapshot plà de la geometria del mapa + punters a `collides` /
|
||||||
|
// `insidePlayfield`. Pensat per ser passat per valor (POD petit,
|
||||||
|
// trivialment copiable). Un cop construïda, llegeix els valors
|
||||||
|
// congelats — si `Map::loadFrom` es torna a cridar amb un mapa
|
||||||
|
// diferent, cal demanar un MapInfo nou.
|
||||||
|
struct Info {
|
||||||
|
int playfield_x{0};
|
||||||
|
int playfield_y{0};
|
||||||
|
int playfield_w{0};
|
||||||
|
int playfield_h{0};
|
||||||
|
int pitch_x{0};
|
||||||
|
int pitch_y{0};
|
||||||
|
int grid_cols{0};
|
||||||
|
int grid_rows{0};
|
||||||
|
int tile_size{0};
|
||||||
|
int tilemap_origin_x{0};
|
||||||
|
int tilemap_origin_y{0};
|
||||||
|
bool (*collides)(const SDL_FRect&){nullptr};
|
||||||
|
bool (*inside_playfield)(const SDL_FRect&){nullptr};
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] auto makeInfo() -> Info;
|
||||||
|
|
||||||
} // namespace Map
|
} // namespace Map
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
#include "core/rendering/screen.hpp"
|
#include "core/rendering/screen.hpp"
|
||||||
#include "core/rendering/surface.hpp"
|
#include "core/rendering/surface.hpp"
|
||||||
#include "core/resources/resource_cache.hpp"
|
#include "core/resources/resource_cache.hpp"
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -67,9 +68,10 @@ namespace {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Tilemap::Tilemap(const std::string& yaml_path) {
|
Tilemap::Tilemap(const std::string& yaml_path) {
|
||||||
std::ifstream file(yaml_path);
|
const auto FULL = Resource::Helper::resolve(yaml_path);
|
||||||
|
std::ifstream file(FULL);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
throw std::runtime_error("Tilemap: no s'ha pogut obrir " + yaml_path);
|
throw std::runtime_error("Tilemap: no s'ha pogut obrir " + FULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto yaml = fkyaml::node::deserialize(file);
|
auto yaml = fkyaml::node::deserialize(file);
|
||||||
@@ -111,7 +113,7 @@ Tilemap::Tilemap(const std::string& yaml_path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::cout << "Tilemap: " << COLS << "x" << ROWS << " tiles ("
|
std::cout << "Tilemap: " << COLS << "x" << ROWS << " tiles ("
|
||||||
<< width_px_ << "x" << height_px_ << " px) des de " << yaml_path << '\n';
|
<< width_px_ << "x" << height_px_ << " px) des de " << FULL << '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
void Tilemap::render() const {
|
void Tilemap::render() const {
|
||||||
|
|||||||
@@ -14,6 +14,21 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// --- Estat mutable (privat al TU) ---
|
||||||
|
// Encapsulat ací per impedir mutacions directes des de fora del namespace
|
||||||
|
// Options:: (decisió COUP-03 / docs/audit-game.md). Els callers llegeixen
|
||||||
|
// via getters const-ref i muten via setters específics que persisteixen.
|
||||||
|
std::string g_config_version;
|
||||||
|
std::string g_config_file_path;
|
||||||
|
std::string g_postfx_file_path;
|
||||||
|
std::string g_crtpi_file_path;
|
||||||
|
std::string g_locale{Defaults::I18n::LOCALE};
|
||||||
|
Options::Window g_window{};
|
||||||
|
Options::Video g_video{};
|
||||||
|
Options::Audio g_audio{};
|
||||||
|
std::vector<Options::PostFXPreset> g_postfx_presets{};
|
||||||
|
std::vector<Options::CrtPiPreset> g_crtpi_presets{};
|
||||||
|
|
||||||
// Taules enum ↔ string per a la serialització a YAML. La primera entrada
|
// Taules enum ↔ string per a la serialització a YAML. La primera entrada
|
||||||
// és el fallback quan l'enum no té nom conegut o el text no casa amb
|
// és el fallback quan l'enum no té nom conegut o el text no casa amb
|
||||||
// cap entrada coneguda — per això `LETTERBOX`/`NEAREST`/`OFF` encapçalen.
|
// cap entrada coneguda — per això `LETTERBOX`/`NEAREST`/`OFF` encapçalen.
|
||||||
@@ -93,40 +108,40 @@ namespace {
|
|||||||
void parseWindow(const fkyaml::node& yaml) {
|
void parseWindow(const fkyaml::node& yaml) {
|
||||||
if (!yaml.contains("window")) { return; }
|
if (!yaml.contains("window")) { return; }
|
||||||
const auto& node = yaml["window"];
|
const auto& node = yaml["window"];
|
||||||
readField(node, "zoom", Options::window.zoom);
|
readField(node, "zoom", g_window.zoom);
|
||||||
readField(node, "fullscreen", Options::window.fullscreen);
|
readField(node, "fullscreen", g_window.fullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
void parseGpu(const fkyaml::node& video_node) {
|
void parseGpu(const fkyaml::node& video_node) {
|
||||||
if (!video_node.contains("gpu")) { return; }
|
if (!video_node.contains("gpu")) { return; }
|
||||||
const auto& node = video_node["gpu"];
|
const auto& node = video_node["gpu"];
|
||||||
readField(node, "acceleration", Options::video.gpu.acceleration);
|
readField(node, "acceleration", g_video.gpu.acceleration);
|
||||||
readField(node, "preferred_driver", Options::video.gpu.preferred_driver);
|
readField(node, "preferred_driver", g_video.gpu.preferred_driver);
|
||||||
}
|
}
|
||||||
|
|
||||||
void parseShader(const fkyaml::node& video_node) {
|
void parseShader(const fkyaml::node& video_node) {
|
||||||
if (!video_node.contains("shader")) { return; }
|
if (!video_node.contains("shader")) { return; }
|
||||||
const auto& node = video_node["shader"];
|
const auto& node = video_node["shader"];
|
||||||
readField(node, "enabled", Options::video.shader.enabled);
|
readField(node, "enabled", g_video.shader.enabled);
|
||||||
readField(node, "current_shader", Options::video.shader.current_shader);
|
readField(node, "current_shader", g_video.shader.current_shader);
|
||||||
readField(node, "current_postfx_preset", Options::video.shader.current_postfx_preset);
|
readField(node, "current_postfx_preset", g_video.shader.current_postfx_preset);
|
||||||
readField(node, "current_crtpi_preset", Options::video.shader.current_crtpi_preset);
|
readField(node, "current_crtpi_preset", g_video.shader.current_crtpi_preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
void parseVideo(const fkyaml::node& yaml) {
|
void parseVideo(const fkyaml::node& yaml) {
|
||||||
if (!yaml.contains("video")) { return; }
|
if (!yaml.contains("video")) { return; }
|
||||||
const auto& node = yaml["video"];
|
const auto& node = yaml["video"];
|
||||||
readField(node, "vsync", Options::video.vsync);
|
readField(node, "vsync", g_video.vsync);
|
||||||
if (node.contains("scaling_mode")) {
|
if (node.contains("scaling_mode")) {
|
||||||
Options::video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value<std::string>());
|
g_video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value<std::string>());
|
||||||
}
|
}
|
||||||
if (node.contains("texture_filter")) {
|
if (node.contains("texture_filter")) {
|
||||||
Options::video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value<std::string>());
|
g_video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value<std::string>());
|
||||||
}
|
}
|
||||||
if (node.contains("render_info_position")) {
|
if (node.contains("render_info_position")) {
|
||||||
Options::video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value<std::string>());
|
g_video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value<std::string>());
|
||||||
}
|
}
|
||||||
readField(node, "render_info_show_time", Options::video.render_info_show_time);
|
readField(node, "render_info_show_time", g_video.render_info_show_time);
|
||||||
parseGpu(node);
|
parseGpu(node);
|
||||||
parseShader(node);
|
parseShader(node);
|
||||||
}
|
}
|
||||||
@@ -134,22 +149,22 @@ namespace {
|
|||||||
void parseMusic(const fkyaml::node& audio_node) {
|
void parseMusic(const fkyaml::node& audio_node) {
|
||||||
if (!audio_node.contains("music")) { return; }
|
if (!audio_node.contains("music")) { return; }
|
||||||
const auto& node = audio_node["music"];
|
const auto& node = audio_node["music"];
|
||||||
readField(node, "enabled", Options::audio.music.enabled);
|
readField(node, "enabled", g_audio.music.enabled);
|
||||||
readField(node, "volume", Options::audio.music.volume);
|
readField(node, "volume", g_audio.music.volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
void parseSound(const fkyaml::node& audio_node) {
|
void parseSound(const fkyaml::node& audio_node) {
|
||||||
if (!audio_node.contains("sound")) { return; }
|
if (!audio_node.contains("sound")) { return; }
|
||||||
const auto& node = audio_node["sound"];
|
const auto& node = audio_node["sound"];
|
||||||
readField(node, "enabled", Options::audio.sound.enabled);
|
readField(node, "enabled", g_audio.sound.enabled);
|
||||||
readField(node, "volume", Options::audio.sound.volume);
|
readField(node, "volume", g_audio.sound.volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
void parseAudio(const fkyaml::node& yaml) {
|
void parseAudio(const fkyaml::node& yaml) {
|
||||||
if (!yaml.contains("audio")) { return; }
|
if (!yaml.contains("audio")) { return; }
|
||||||
const auto& node = yaml["audio"];
|
const auto& node = yaml["audio"];
|
||||||
readField(node, "enabled", Options::audio.enabled);
|
readField(node, "enabled", g_audio.enabled);
|
||||||
readField(node, "volume", Options::audio.volume);
|
readField(node, "volume", g_audio.volume);
|
||||||
parseMusic(node);
|
parseMusic(node);
|
||||||
parseSound(node);
|
parseSound(node);
|
||||||
}
|
}
|
||||||
@@ -175,7 +190,7 @@ namespace {
|
|||||||
.curvature = 0.0F,
|
.curvature = 0.0F,
|
||||||
.bleeding = 0.6F,
|
.bleeding = 0.6F,
|
||||||
.flicker = 0.0F},
|
.flicker = 0.0F},
|
||||||
{.name = "CURVED",
|
{.name = "Curved",
|
||||||
.vignette = 0.5F,
|
.vignette = 0.5F,
|
||||||
.scanlines = 0.6F,
|
.scanlines = 0.6F,
|
||||||
.chroma = 0.1F,
|
.chroma = 0.1F,
|
||||||
@@ -184,7 +199,7 @@ namespace {
|
|||||||
.curvature = 0.8F,
|
.curvature = 0.8F,
|
||||||
.bleeding = 0.0F,
|
.bleeding = 0.0F,
|
||||||
.flicker = 0.0F},
|
.flicker = 0.0F},
|
||||||
{.name = "SCANLINES",
|
{.name = "Scanlines",
|
||||||
.vignette = 0.0F,
|
.vignette = 0.0F,
|
||||||
.scanlines = 0.8F,
|
.scanlines = 0.8F,
|
||||||
.chroma = 0.0F,
|
.chroma = 0.0F,
|
||||||
@@ -193,7 +208,7 @@ namespace {
|
|||||||
.curvature = 0.0F,
|
.curvature = 0.0F,
|
||||||
.bleeding = 0.0F,
|
.bleeding = 0.0F,
|
||||||
.flicker = 0.0F},
|
.flicker = 0.0F},
|
||||||
{.name = "SUBTLE",
|
{.name = "Subtle",
|
||||||
.vignette = 0.3F,
|
.vignette = 0.3F,
|
||||||
.scanlines = 0.4F,
|
.scanlines = 0.4F,
|
||||||
.chroma = 0.05F,
|
.chroma = 0.05F,
|
||||||
@@ -202,7 +217,7 @@ namespace {
|
|||||||
.curvature = 0.0F,
|
.curvature = 0.0F,
|
||||||
.bleeding = 0.0F,
|
.bleeding = 0.0F,
|
||||||
.flicker = 0.0F},
|
.flicker = 0.0F},
|
||||||
{.name = "CRT LIVE",
|
{.name = "CRT Live",
|
||||||
.vignette = 0.5F,
|
.vignette = 0.5F,
|
||||||
.scanlines = 0.6F,
|
.scanlines = 0.6F,
|
||||||
.chroma = 0.3F,
|
.chroma = 0.3F,
|
||||||
@@ -247,7 +262,7 @@ namespace {
|
|||||||
// Presets per defecte del shader CRT-Pi, portats de projecte_2026.
|
// Presets per defecte del shader CRT-Pi, portats de projecte_2026.
|
||||||
auto defaultCrtPiPresets() -> std::vector<Options::CrtPiPreset> {
|
auto defaultCrtPiPresets() -> std::vector<Options::CrtPiPreset> {
|
||||||
return {
|
return {
|
||||||
{.name = "DEFAULT",
|
{.name = "Default",
|
||||||
.scanline_weight = 6.0F,
|
.scanline_weight = 6.0F,
|
||||||
.scanline_gap_brightness = 0.12F,
|
.scanline_gap_brightness = 0.12F,
|
||||||
.bloom_factor = 3.5F,
|
.bloom_factor = 3.5F,
|
||||||
@@ -262,7 +277,7 @@ namespace {
|
|||||||
.enable_gamma = true,
|
.enable_gamma = true,
|
||||||
.enable_curvature = false,
|
.enable_curvature = false,
|
||||||
.enable_sharper = false},
|
.enable_sharper = false},
|
||||||
{.name = "CURVED",
|
{.name = "Curved",
|
||||||
.scanline_weight = 6.0F,
|
.scanline_weight = 6.0F,
|
||||||
.scanline_gap_brightness = 0.12F,
|
.scanline_gap_brightness = 0.12F,
|
||||||
.bloom_factor = 3.5F,
|
.bloom_factor = 3.5F,
|
||||||
@@ -277,7 +292,7 @@ namespace {
|
|||||||
.enable_gamma = true,
|
.enable_gamma = true,
|
||||||
.enable_curvature = true,
|
.enable_curvature = true,
|
||||||
.enable_sharper = false},
|
.enable_sharper = false},
|
||||||
{.name = "SHARP",
|
{.name = "Sharp",
|
||||||
.scanline_weight = 6.0F,
|
.scanline_weight = 6.0F,
|
||||||
.scanline_gap_brightness = 0.12F,
|
.scanline_gap_brightness = 0.12F,
|
||||||
.bloom_factor = 3.5F,
|
.bloom_factor = 3.5F,
|
||||||
@@ -292,7 +307,7 @@ namespace {
|
|||||||
.enable_gamma = true,
|
.enable_gamma = true,
|
||||||
.enable_curvature = false,
|
.enable_curvature = false,
|
||||||
.enable_sharper = true},
|
.enable_sharper = true},
|
||||||
{.name = "MINIMAL",
|
{.name = "Minimal",
|
||||||
.scanline_weight = 8.0F,
|
.scanline_weight = 8.0F,
|
||||||
.scanline_gap_brightness = 0.05F,
|
.scanline_gap_brightness = 0.05F,
|
||||||
.bloom_factor = 2.0F,
|
.bloom_factor = 2.0F,
|
||||||
@@ -423,14 +438,89 @@ namespace {
|
|||||||
|
|
||||||
namespace Options {
|
namespace Options {
|
||||||
|
|
||||||
|
// --- Getters de l'estat ---
|
||||||
|
auto window() -> const Window& { return g_window; }
|
||||||
|
auto video() -> const Video& { return g_video; }
|
||||||
|
auto audio() -> const Audio& { return g_audio; }
|
||||||
|
auto locale() -> const std::string& { return g_locale; }
|
||||||
|
auto configVersion() -> const std::string& { return g_config_version; }
|
||||||
|
auto postfxPresets() -> const std::vector<PostFXPreset>& { return g_postfx_presets; }
|
||||||
|
auto crtpiPresets() -> const std::vector<CrtPiPreset>& { return g_crtpi_presets; }
|
||||||
|
|
||||||
|
// --- Setters: muten + persisteixen ---
|
||||||
|
void setWindowZoom(int value) {
|
||||||
|
g_window.zoom = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setWindowFullscreen(bool value) {
|
||||||
|
g_window.fullscreen = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setVideoVSync(bool value) {
|
||||||
|
g_video.vsync = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setVideoScalingMode(Defaults::ScalingMode value) {
|
||||||
|
g_video.scaling_mode = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value) {
|
||||||
|
g_video.render_info_position = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setVideoRenderInfoShowTime(bool value) {
|
||||||
|
g_video.render_info_show_time = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setShaderEnabled(bool value) {
|
||||||
|
g_video.shader.enabled = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setActiveShaderName(const std::string& value) {
|
||||||
|
g_video.shader.current_shader = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setPostFXPreset(const std::string& value) {
|
||||||
|
g_video.shader.current_postfx_preset = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setCrtPiPreset(const std::string& value) {
|
||||||
|
g_video.shader.current_crtpi_preset = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setAudioEnabled(bool value) {
|
||||||
|
g_audio.enabled = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setAudioVolume(float value) {
|
||||||
|
g_audio.volume = std::clamp(value, 0.0F, 1.0F);
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setAudioMusicEnabled(bool value) {
|
||||||
|
g_audio.music.enabled = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setAudioMusicVolume(float value) {
|
||||||
|
g_audio.music.volume = std::clamp(value, 0.0F, 1.0F);
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setAudioSoundEnabled(bool value) {
|
||||||
|
g_audio.sound.enabled = value;
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
void setAudioSoundVolume(float value) {
|
||||||
|
g_audio.sound.volume = std::clamp(value, 0.0F, 1.0F);
|
||||||
|
saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
void setConfigFile(const std::string& path) {
|
void setConfigFile(const std::string& path) {
|
||||||
config_file_path = path;
|
g_config_file_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto loadFromFile() -> bool {
|
auto loadFromFile() -> bool {
|
||||||
if (config_file_path.empty()) { return false; }
|
if (g_config_file_path.empty()) { return false; }
|
||||||
|
|
||||||
const std::string CONTENT = readFileOrEmpty(config_file_path);
|
const std::string CONTENT = readFileOrEmpty(g_config_file_path);
|
||||||
if (CONTENT.empty()) {
|
if (CONTENT.empty()) {
|
||||||
// Primera execució: creem el fitxer amb els valors per defecte.
|
// Primera execució: creem el fitxer amb els valors per defecte.
|
||||||
return saveToFile();
|
return saveToFile();
|
||||||
@@ -449,12 +539,12 @@ namespace Options {
|
|||||||
if (file_version != Texts::VERSION) {
|
if (file_version != Texts::VERSION) {
|
||||||
std::cout << "Options: config.yaml versió \"" << file_version
|
std::cout << "Options: config.yaml versió \"" << file_version
|
||||||
<< "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n";
|
<< "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n";
|
||||||
backupFile(config_file_path);
|
backupFile(g_config_file_path);
|
||||||
return saveToFile();
|
return saveToFile();
|
||||||
}
|
}
|
||||||
config_version = file_version;
|
g_config_version = file_version;
|
||||||
|
|
||||||
if (yaml.contains("locale")) { Options::locale = yaml["locale"].get_value<std::string>(); }
|
if (yaml.contains("locale")) { g_locale = yaml["locale"].get_value<std::string>(); }
|
||||||
parseWindow(yaml);
|
parseWindow(yaml);
|
||||||
parseVideo(yaml);
|
parseVideo(yaml);
|
||||||
parseAudio(yaml);
|
parseAudio(yaml);
|
||||||
@@ -462,84 +552,84 @@ namespace Options {
|
|||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
|
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
|
||||||
std::cout << "Options: reescrivint el fitxer amb defaults.\n";
|
std::cout << "Options: reescrivint el fitxer amb defaults.\n";
|
||||||
backupFile(config_file_path);
|
backupFile(g_config_file_path);
|
||||||
return saveToFile();
|
return saveToFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto saveToFile() -> bool {
|
auto saveToFile() -> bool {
|
||||||
if (config_file_path.empty()) { return false; }
|
if (g_config_file_path.empty()) { return false; }
|
||||||
|
|
||||||
config_version = Texts::VERSION;
|
g_config_version = Texts::VERSION;
|
||||||
|
|
||||||
std::ostringstream out;
|
std::ostringstream out;
|
||||||
out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n";
|
out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n";
|
||||||
out << "version: \"" << config_version << "\"\n";
|
out << "version: \"" << g_config_version << "\"\n";
|
||||||
out << "locale: \"" << locale << "\"\n\n";
|
out << "locale: \"" << g_locale << "\"\n\n";
|
||||||
|
|
||||||
out << "window:\n";
|
out << "window:\n";
|
||||||
out << " zoom: " << window.zoom << "\n";
|
out << " zoom: " << g_window.zoom << "\n";
|
||||||
out << " fullscreen: " << (window.fullscreen ? "true" : "false") << "\n\n";
|
out << " fullscreen: " << (g_window.fullscreen ? "true" : "false") << "\n\n";
|
||||||
|
|
||||||
out << "video:\n";
|
out << "video:\n";
|
||||||
out << " vsync: " << (video.vsync ? "true" : "false") << "\n";
|
out << " vsync: " << (g_video.vsync ? "true" : "false") << "\n";
|
||||||
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, video.scaling_mode) << "\"\n";
|
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, g_video.scaling_mode) << "\"\n";
|
||||||
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, video.texture_filter) << "\"\n";
|
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, g_video.texture_filter) << "\"\n";
|
||||||
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, video.render_info_position) << "\"\n";
|
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, g_video.render_info_position) << "\"\n";
|
||||||
out << " render_info_show_time: " << (video.render_info_show_time ? "true" : "false") << "\n";
|
out << " render_info_show_time: " << (g_video.render_info_show_time ? "true" : "false") << "\n";
|
||||||
out << " gpu:\n";
|
out << " gpu:\n";
|
||||||
out << " acceleration: " << (video.gpu.acceleration ? "true" : "false") << "\n";
|
out << " acceleration: " << (g_video.gpu.acceleration ? "true" : "false") << "\n";
|
||||||
out << " preferred_driver: \"" << video.gpu.preferred_driver << "\"\n";
|
out << " preferred_driver: \"" << g_video.gpu.preferred_driver << "\"\n";
|
||||||
out << " shader:\n";
|
out << " shader:\n";
|
||||||
out << " enabled: " << (video.shader.enabled ? "true" : "false") << "\n";
|
out << " enabled: " << (g_video.shader.enabled ? "true" : "false") << "\n";
|
||||||
out << " current_shader: \"" << video.shader.current_shader << "\"\n";
|
out << " current_shader: \"" << g_video.shader.current_shader << "\"\n";
|
||||||
out << " current_postfx_preset: \"" << video.shader.current_postfx_preset << "\"\n";
|
out << " current_postfx_preset: \"" << g_video.shader.current_postfx_preset << "\"\n";
|
||||||
out << " current_crtpi_preset: \"" << video.shader.current_crtpi_preset << "\"\n\n";
|
out << " current_crtpi_preset: \"" << g_video.shader.current_crtpi_preset << "\"\n\n";
|
||||||
|
|
||||||
out << "audio:\n";
|
out << "audio:\n";
|
||||||
out << " enabled: " << (audio.enabled ? "true" : "false") << "\n";
|
out << " enabled: " << (g_audio.enabled ? "true" : "false") << "\n";
|
||||||
out << " volume: " << audio.volume << "\n";
|
out << " volume: " << g_audio.volume << "\n";
|
||||||
out << " music:\n";
|
out << " music:\n";
|
||||||
out << " enabled: " << (audio.music.enabled ? "true" : "false") << "\n";
|
out << " enabled: " << (g_audio.music.enabled ? "true" : "false") << "\n";
|
||||||
out << " volume: " << audio.music.volume << "\n";
|
out << " volume: " << g_audio.music.volume << "\n";
|
||||||
out << " sound:\n";
|
out << " sound:\n";
|
||||||
out << " enabled: " << (audio.sound.enabled ? "true" : "false") << "\n";
|
out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\n";
|
||||||
out << " volume: " << audio.sound.volume << "\n";
|
out << " volume: " << g_audio.sound.volume << "\n";
|
||||||
|
|
||||||
return writeFile(config_file_path, out.str());
|
return writeFile(g_config_file_path, out.str());
|
||||||
}
|
}
|
||||||
|
|
||||||
auto buildScreenConfig() -> Screen::Config {
|
auto buildScreenConfig() -> Screen::Config {
|
||||||
return {
|
return {
|
||||||
.zoom = window.zoom,
|
.zoom = g_window.zoom,
|
||||||
.fullscreen = window.fullscreen,
|
.fullscreen = g_window.fullscreen,
|
||||||
.vsync = video.vsync,
|
.vsync = g_video.vsync,
|
||||||
.scaling_mode = video.scaling_mode,
|
.scaling_mode = g_video.scaling_mode,
|
||||||
.texture_filter = video.texture_filter,
|
.texture_filter = g_video.texture_filter,
|
||||||
.gpu_acceleration = video.gpu.acceleration,
|
.gpu_acceleration = g_video.gpu.acceleration,
|
||||||
.preferred_driver = video.gpu.preferred_driver,
|
.preferred_driver = g_video.gpu.preferred_driver,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
auto buildAudioConfig() -> ::Audio::Config {
|
auto buildAudioConfig() -> ::Audio::Config {
|
||||||
return {
|
return {
|
||||||
.enabled = audio.enabled,
|
.enabled = g_audio.enabled,
|
||||||
.volume = audio.volume,
|
.volume = g_audio.volume,
|
||||||
.music_enabled = audio.music.enabled,
|
.music_enabled = g_audio.music.enabled,
|
||||||
.music_volume = audio.music.volume,
|
.music_volume = g_audio.music.volume,
|
||||||
.sound_enabled = audio.sound.enabled,
|
.sound_enabled = g_audio.sound.enabled,
|
||||||
.sound_volume = audio.sound.volume,
|
.sound_volume = g_audio.sound.volume,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void setPostFXFile(const std::string& path) {
|
void setPostFXFile(const std::string& path) {
|
||||||
postfx_file_path = path;
|
g_postfx_file_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto loadPostFXFromFile() -> bool {
|
auto loadPostFXFromFile() -> bool {
|
||||||
return loadPresetsFromFile<PostFXPreset>(
|
return loadPresetsFromFile<PostFXPreset>(
|
||||||
postfx_file_path,
|
g_postfx_file_path,
|
||||||
postfx_presets,
|
g_postfx_presets,
|
||||||
&defaultPostFXPresets,
|
&defaultPostFXPresets,
|
||||||
&parsePostFXPreset,
|
&parsePostFXPreset,
|
||||||
&savePostFXToFile,
|
&savePostFXToFile,
|
||||||
@@ -548,21 +638,21 @@ namespace Options {
|
|||||||
|
|
||||||
auto savePostFXToFile() -> bool {
|
auto savePostFXToFile() -> bool {
|
||||||
return savePresetsToFile<PostFXPreset>(
|
return savePresetsToFile<PostFXPreset>(
|
||||||
postfx_file_path,
|
g_postfx_file_path,
|
||||||
postfx_presets,
|
g_postfx_presets,
|
||||||
&writePostFXPreset,
|
&writePostFXPreset,
|
||||||
"Presets PostFX per a AEE 2026. Editable a mà.",
|
"Presets PostFX per a AEE 2026. Editable a mà.",
|
||||||
"Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset.");
|
"Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset.");
|
||||||
}
|
}
|
||||||
|
|
||||||
void setCrtPiFile(const std::string& path) {
|
void setCrtPiFile(const std::string& path) {
|
||||||
crtpi_file_path = path;
|
g_crtpi_file_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto loadCrtPiFromFile() -> bool {
|
auto loadCrtPiFromFile() -> bool {
|
||||||
return loadPresetsFromFile<CrtPiPreset>(
|
return loadPresetsFromFile<CrtPiPreset>(
|
||||||
crtpi_file_path,
|
g_crtpi_file_path,
|
||||||
crtpi_presets,
|
g_crtpi_presets,
|
||||||
&defaultCrtPiPresets,
|
&defaultCrtPiPresets,
|
||||||
&parseCrtPiPreset,
|
&parseCrtPiPreset,
|
||||||
&saveCrtPiToFile,
|
&saveCrtPiToFile,
|
||||||
@@ -571,29 +661,29 @@ namespace Options {
|
|||||||
|
|
||||||
auto saveCrtPiToFile() -> bool {
|
auto saveCrtPiToFile() -> bool {
|
||||||
return savePresetsToFile<CrtPiPreset>(
|
return savePresetsToFile<CrtPiPreset>(
|
||||||
crtpi_file_path,
|
g_crtpi_file_path,
|
||||||
crtpi_presets,
|
g_crtpi_presets,
|
||||||
&writeCrtPiPreset,
|
&writeCrtPiPreset,
|
||||||
"Presets CRT-Pi per a AEE 2026. Editable a mà.",
|
"Presets CRT-Pi per a AEE 2026. Editable a mà.",
|
||||||
"Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset.");
|
"Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset.");
|
||||||
}
|
}
|
||||||
|
|
||||||
auto buildActiveShader() -> Rendering::ShaderType {
|
auto buildActiveShader() -> Rendering::ShaderType {
|
||||||
if (video.shader.current_shader == "crtpi") {
|
if (g_video.shader.current_shader == "crtpi") {
|
||||||
return Rendering::ShaderType::CRTPI;
|
return Rendering::ShaderType::CRTPI;
|
||||||
}
|
}
|
||||||
return Rendering::ShaderType::POSTFX;
|
return Rendering::ShaderType::POSTFX;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame {
|
auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame {
|
||||||
const std::string& active_preset = (video.shader.current_shader == "crtpi")
|
const std::string& active_preset = (g_video.shader.current_shader == "crtpi")
|
||||||
? video.shader.current_crtpi_preset
|
? g_video.shader.current_crtpi_preset
|
||||||
: video.shader.current_postfx_preset;
|
: g_video.shader.current_postfx_preset;
|
||||||
return {
|
return {
|
||||||
.target_position = video.render_info_position,
|
.target_position = g_video.render_info_position,
|
||||||
.show_time = video.render_info_show_time,
|
.show_time = g_video.render_info_show_time,
|
||||||
.shader_enabled = video.shader.enabled,
|
.shader_enabled = g_video.shader.enabled,
|
||||||
.shader_name = video.shader.current_shader,
|
.shader_name = g_video.shader.current_shader,
|
||||||
.shader_preset = active_preset,
|
.shader_preset = active_preset,
|
||||||
.text_color = text_color,
|
.text_color = text_color,
|
||||||
.shadow_color = shadow_color,
|
.shadow_color = shadow_color,
|
||||||
@@ -603,12 +693,12 @@ namespace Options {
|
|||||||
auto buildCrtPiParams() -> Rendering::CrtPiParams {
|
auto buildCrtPiParams() -> Rendering::CrtPiParams {
|
||||||
// Si el shader està deshabilitat, retornem defaults (el dispatch anirà per
|
// Si el shader està deshabilitat, retornem defaults (el dispatch anirà per
|
||||||
// PostFX passthrough, però sent nets per si algú consulta aquests params).
|
// PostFX passthrough, però sent nets per si algú consulta aquests params).
|
||||||
if (!video.shader.enabled) { return {}; }
|
if (!g_video.shader.enabled) { return {}; }
|
||||||
|
|
||||||
const auto IT = std::ranges::find_if(crtpi_presets,
|
const auto IT = std::ranges::find_if(g_crtpi_presets,
|
||||||
[](const CrtPiPreset& preset) { return preset.name == video.shader.current_crtpi_preset; });
|
[](const CrtPiPreset& preset) { return preset.name == g_video.shader.current_crtpi_preset; });
|
||||||
if (IT == crtpi_presets.end()) {
|
if (IT == g_crtpi_presets.end()) {
|
||||||
std::cout << "Options: preset CrtPi \"" << video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
|
std::cout << "Options: preset CrtPi \"" << g_video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -631,12 +721,12 @@ namespace Options {
|
|||||||
|
|
||||||
auto buildPostFXParams() -> Rendering::PostFXParams {
|
auto buildPostFXParams() -> Rendering::PostFXParams {
|
||||||
// Si el shader està deshabilitat, retornem params a zero → passthrough.
|
// Si el shader està deshabilitat, retornem params a zero → passthrough.
|
||||||
if (!video.shader.enabled) { return {}; }
|
if (!g_video.shader.enabled) { return {}; }
|
||||||
|
|
||||||
const auto IT = std::ranges::find_if(postfx_presets,
|
const auto IT = std::ranges::find_if(g_postfx_presets,
|
||||||
[](const PostFXPreset& preset) { return preset.name == video.shader.current_postfx_preset; });
|
[](const PostFXPreset& preset) { return preset.name == g_video.shader.current_postfx_preset; });
|
||||||
if (IT == postfx_presets.end()) {
|
if (IT == g_postfx_presets.end()) {
|
||||||
std::cout << "Options: preset PostFX \"" << video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
|
std::cout << "Options: preset PostFX \"" << g_video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
// del config.yaml. Els subsistemes (Screen, Audio, ...) reben structs tipats
|
// del config.yaml. Els subsistemes (Screen, Audio, ...) reben structs tipats
|
||||||
// a través dels build*Config(), de manera que no queden acoblats a aquest
|
// a través dels build*Config(), de manera que no queden acoblats a aquest
|
||||||
// namespace ni al format concret del fitxer.
|
// namespace ni al format concret del fitxer.
|
||||||
|
//
|
||||||
|
// L'estat mutable viu en un anonymous namespace dins d'`options.cpp`: els
|
||||||
|
// callers el llegeixen via getters const-ref (`window()`, `video()`, ...) i
|
||||||
|
// el muten via setters específics que persisteixen a config.yaml. Els
|
||||||
|
// setters NO notifiquen els subsistemes (Screen/Audio): el caller (típic.
|
||||||
|
// `core/input/global_inputs.cpp`) crida després el setter del subsistema.
|
||||||
|
// Aquesta asimetria preserva la direcció `Director → Screen/Audio` i evita
|
||||||
|
// que Options aprenga del layout dels subsistemes (decisió WONTFIX d'aquest
|
||||||
|
// refactor: veure docs/audit-game.md → COUP-03).
|
||||||
namespace Options {
|
namespace Options {
|
||||||
|
|
||||||
struct Window {
|
struct Window {
|
||||||
@@ -95,20 +104,36 @@ namespace Options {
|
|||||||
Sound sound{};
|
Sound sound{};
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Estat global (inline vars per a que una sola definició viva a tot
|
// --- Getters de l'estat (read-only) ---
|
||||||
// el programa sense necessitar un .cpp extra per a cada una) ---
|
[[nodiscard]] auto window() -> const Window&;
|
||||||
inline std::string config_version{};
|
[[nodiscard]] auto video() -> const Video&;
|
||||||
inline std::string config_file_path{};
|
[[nodiscard]] auto audio() -> const Audio&;
|
||||||
inline std::string postfx_file_path{};
|
[[nodiscard]] auto locale() -> const std::string&;
|
||||||
inline std::string crtpi_file_path{};
|
[[nodiscard]] auto configVersion() -> const std::string&;
|
||||||
inline std::string locale{Defaults::I18n::LOCALE};
|
[[nodiscard]] auto postfxPresets() -> const std::vector<PostFXPreset>&;
|
||||||
inline Window window{};
|
[[nodiscard]] auto crtpiPresets() -> const std::vector<CrtPiPreset>&;
|
||||||
inline Video video{};
|
|
||||||
inline Audio audio{};
|
|
||||||
inline std::vector<PostFXPreset> postfx_presets{};
|
|
||||||
inline std::vector<CrtPiPreset> crtpi_presets{};
|
|
||||||
|
|
||||||
// --- API pública ---
|
// --- Setters: muten + persisteixen a config.yaml ---
|
||||||
|
// No notifiquen els subsistemes; el caller ha de propagar el canvi al
|
||||||
|
// subsistema corresponent (p.ex. Screen::setWindowZoom) per separat.
|
||||||
|
void setWindowZoom(int value);
|
||||||
|
void setWindowFullscreen(bool value);
|
||||||
|
void setVideoVSync(bool value);
|
||||||
|
void setVideoScalingMode(Defaults::ScalingMode value);
|
||||||
|
void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value);
|
||||||
|
void setVideoRenderInfoShowTime(bool value);
|
||||||
|
void setShaderEnabled(bool value);
|
||||||
|
void setActiveShaderName(const std::string& value); // "postfx" | "crtpi"
|
||||||
|
void setPostFXPreset(const std::string& value);
|
||||||
|
void setCrtPiPreset(const std::string& value);
|
||||||
|
void setAudioEnabled(bool value);
|
||||||
|
void setAudioVolume(float value);
|
||||||
|
void setAudioMusicEnabled(bool value);
|
||||||
|
void setAudioMusicVolume(float value);
|
||||||
|
void setAudioSoundEnabled(bool value);
|
||||||
|
void setAudioSoundVolume(float value);
|
||||||
|
|
||||||
|
// --- API pública: càrrega/desament de fitxers ---
|
||||||
void setConfigFile(const std::string& path);
|
void setConfigFile(const std::string& path);
|
||||||
auto loadFromFile() -> bool;
|
auto loadFromFile() -> bool;
|
||||||
auto saveToFile() -> bool;
|
auto saveToFile() -> bool;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#include "game/options_apply.hpp"
|
||||||
|
|
||||||
|
#include "core/audio/audio.hpp"
|
||||||
|
#include "core/rendering/screen.hpp"
|
||||||
|
#include "game/options.hpp"
|
||||||
|
|
||||||
|
namespace OptionsApply {
|
||||||
|
|
||||||
|
void reapplyShader() {
|
||||||
|
Screen::get().setActiveShader(Options::buildActiveShader());
|
||||||
|
Screen::get().applyPostFXParams(Options::buildPostFXParams());
|
||||||
|
Screen::get().applyCrtPiParams(Options::buildCrtPiParams());
|
||||||
|
}
|
||||||
|
|
||||||
|
void reapplyAudio() {
|
||||||
|
if (Audio* audio = Audio::get()) {
|
||||||
|
audio->applySettings(Options::buildAudioConfig());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace OptionsApply
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Helpers de reaplicació en calent: muten els subsistemes (Screen / Audio)
|
||||||
|
// perquè reflecteixin el nou estat d'Options sense tocar la finestra ni el
|
||||||
|
// dispositiu d'àudio. Compartits entre `core/input/global_inputs.cpp` i el
|
||||||
|
// menú de servei (`game/ui/service_menu.cpp`), que són els dos llocs on la
|
||||||
|
// UI pot canviar opcions en calent.
|
||||||
|
namespace OptionsApply {
|
||||||
|
|
||||||
|
// Aplica shader actiu + uniforms PostFX + uniforms CrtPi al Screen segons
|
||||||
|
// l'estat actual d'Options. S'invoca després de qualsevol canvi a
|
||||||
|
// video.shader.* (enable, current_shader, preset).
|
||||||
|
void reapplyShader();
|
||||||
|
|
||||||
|
// Reenvia la config actual d'Options al subsistema Audio (reaplica volums
|
||||||
|
// i enables). S'invoca després de qualsevol canvi a audio.* (enable,
|
||||||
|
// volume, music.*, sound.*).
|
||||||
|
void reapplyAudio();
|
||||||
|
|
||||||
|
} // namespace OptionsApply
|
||||||
@@ -98,7 +98,7 @@ GameScene::GameScene(Config config)
|
|||||||
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
|
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
|
||||||
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
|
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
|
||||||
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
|
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
|
||||||
players_.emplace_back(std::make_unique<Player>(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1));
|
players_.emplace_back(std::make_unique<Player>(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo()));
|
||||||
|
|
||||||
// Connectem el Player al bus perquè emeti `on_tile_entered` i els
|
// Connectem el Player al bus perquè emeti `on_tile_entered` i els
|
||||||
// sarcòfags rebin les notificacions de pas per tile.
|
// sarcòfags rebin les notificacions de pas per tile.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
#include "game/ui/service_menu.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "core/audio/audio.hpp"
|
||||||
|
#include "core/input/input_state.hpp"
|
||||||
|
#include "core/input/key_config.hpp"
|
||||||
|
#include "core/locale/locale.hpp"
|
||||||
|
#include "core/rendering/canvas.hpp"
|
||||||
|
#include "core/rendering/screen.hpp"
|
||||||
|
#include "core/rendering/text.hpp"
|
||||||
|
#include "core/system/event_buffer.hpp"
|
||||||
|
#include "game/options.hpp"
|
||||||
|
#include "game/options_apply.hpp"
|
||||||
|
#include "game/ui/ui_palette.hpp"
|
||||||
|
#include "utils/defaults.hpp"
|
||||||
|
#include "utils/defines.hpp"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Geometria de la caixa (píxels de canvas lògic 320×240).
|
||||||
|
//
|
||||||
|
// Dos nivells de distància vertical:
|
||||||
|
// - `GAP_Y` és la distància estructural: marge superior al títol,
|
||||||
|
// títol↔separador, separador↔primer item i últim item↔marge inferior.
|
||||||
|
// - `ITEM_GAP_Y` és la distància entre items consecutius (més tight
|
||||||
|
// per a que la llista no quede massa airejada).
|
||||||
|
constexpr int BOX_WIDTH = 260;
|
||||||
|
constexpr int PAD_X = 10;
|
||||||
|
constexpr int GAP_Y = 6;
|
||||||
|
constexpr int ITEM_GAP_Y = 2;
|
||||||
|
// Les fonts display reserven ~2 px de descendent al fons de la seua
|
||||||
|
// caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests
|
||||||
|
// glifs, eixos 2 px queden buits i el gap visual sota el títol sembla
|
||||||
|
// més gran que el del damunt. Compensem-ho restant-los al càlcul
|
||||||
|
// d'alçada i al posicionament del separador; així el títol queda
|
||||||
|
// òpticament centrat entre la vora superior i la línia divisòria.
|
||||||
|
constexpr int TITLE_DESCENDER_TRIM = 2;
|
||||||
|
|
||||||
|
constexpr int MIN_ZOOM = 1;
|
||||||
|
constexpr int MAX_ZOOM = 6;
|
||||||
|
constexpr int VOLUME_STEP_PCT = 5; // Pas d'UI (5 %) per als volums.
|
||||||
|
|
||||||
|
// --- Animació ---
|
||||||
|
//
|
||||||
|
// `open_anim_` va de 0 (invisible) a 1 (totalment obert). La velocitat
|
||||||
|
// dicta quant triga a obrir-se o tancar-se. HEIGHT_RATE és el factor de
|
||||||
|
// suavitzat exponencial per a l'alçada quan canvia el nombre d'items
|
||||||
|
// visibles dins d'una mateixa pàgina.
|
||||||
|
constexpr float OPEN_SPEED = 8.0F; // 1.0 / 0.125 s
|
||||||
|
constexpr float CLOSE_SPEED = 10.0F; // 1.0 / 0.1 s — més ràpid que obrir
|
||||||
|
constexpr float HEIGHT_RATE = 12.0F; // smoothing alçada (~150 ms @ 90 %)
|
||||||
|
constexpr float TRANSITION_SPEED = 5.5F; // ~180 ms; reservat per al lliscament lateral de push/pop.
|
||||||
|
constexpr float MS_PER_SECOND = 1000.0F;
|
||||||
|
constexpr std::uint8_t BG_ALPHA = 220; // Opacitat màxima del fons.
|
||||||
|
|
||||||
|
// Les claus de locale viuen al .yaml, però el `menu_toggle` id el llegim
|
||||||
|
// ací a cada event per evitar cachejar un scancode que KeyConfig recàrrega.
|
||||||
|
constexpr const char* KEY_ID_MENU_TOGGLE = "menu_toggle";
|
||||||
|
|
||||||
|
auto boolLabel(bool value) -> std::string {
|
||||||
|
return Locale::get().text(value ? "menu.values.on" : "menu.values.off");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto scalingLabel(Defaults::ScalingMode mode) -> std::string {
|
||||||
|
switch (mode) {
|
||||||
|
case Defaults::ScalingMode::DISABLED:
|
||||||
|
return Locale::get().text("menu.values.scaling_disabled");
|
||||||
|
case Defaults::ScalingMode::STRETCH:
|
||||||
|
return Locale::get().text("menu.values.scaling_stretch");
|
||||||
|
case Defaults::ScalingMode::LETTERBOX:
|
||||||
|
return Locale::get().text("menu.values.scaling_letterbox");
|
||||||
|
case Defaults::ScalingMode::OVERSCAN:
|
||||||
|
return Locale::get().text("menu.values.scaling_overscan");
|
||||||
|
case Defaults::ScalingMode::INTEGER:
|
||||||
|
return Locale::get().text("menu.values.scaling_integer");
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto renderInfoLabel(Defaults::RenderInfoPosition pos) -> std::string {
|
||||||
|
switch (pos) {
|
||||||
|
case Defaults::RenderInfoPosition::OFF:
|
||||||
|
return Locale::get().text("menu.values.render_info_off");
|
||||||
|
case Defaults::RenderInfoPosition::TOP:
|
||||||
|
return Locale::get().text("menu.values.render_info_top");
|
||||||
|
case Defaults::RenderInfoPosition::BOTTOM:
|
||||||
|
return Locale::get().text("menu.values.render_info_bottom");
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Troba l'índex del preset actiu dins del vector; si no es troba cau a 0
|
||||||
|
// perquè el menú mostri el primer element disponible (evita índexs fora
|
||||||
|
// de rang que petarien `presets[idx]`).
|
||||||
|
template <typename Preset>
|
||||||
|
auto indexOfPreset(const std::vector<Preset>& presets, const std::string& name) -> std::size_t {
|
||||||
|
const auto IT = std::ranges::find_if(presets,
|
||||||
|
[&](const Preset& p) { return p.name == name; });
|
||||||
|
return (IT == presets.end()) ? 0 : std::distance(presets.begin(), IT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ease-out quadratic: 1 - (1 - t)². Accelera al principi i frena al final.
|
||||||
|
// S'aplica a open_anim_ perquè el menú aparegui amb sensació de "pes".
|
||||||
|
auto easeOutQuad(float t) -> float {
|
||||||
|
t = std::clamp(t, 0.0F, 1.0F);
|
||||||
|
const float INV = 1.0F - t;
|
||||||
|
return 1.0F - (INV * INV);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --- Factories d'Items ---
|
||||||
|
|
||||||
|
auto ServiceMenu::makeToggle(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<bool()> getter,
|
||||||
|
std::function<void(bool)> setter,
|
||||||
|
std::function<bool()> visible) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::TOGGLE;
|
||||||
|
item.label_key = std::move(label_key);
|
||||||
|
item.get_bool = std::move(getter);
|
||||||
|
item.set_bool = std::move(setter);
|
||||||
|
item.visible = std::move(visible);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeCycle(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<std::size_t()> get_index,
|
||||||
|
std::function<std::size_t()> get_count,
|
||||||
|
std::function<void(std::size_t)> set_index,
|
||||||
|
std::function<std::string(std::size_t)> value_label,
|
||||||
|
std::function<bool()> visible) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::CYCLE;
|
||||||
|
item.label_key = std::move(label_key);
|
||||||
|
item.get_index = std::move(get_index);
|
||||||
|
item.get_count = std::move(get_count);
|
||||||
|
item.set_index = std::move(set_index);
|
||||||
|
item.value_label = std::move(value_label);
|
||||||
|
item.visible = std::move(visible);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeIntRange(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<int()> get_int,
|
||||||
|
std::function<void(int)> set_int,
|
||||||
|
int min_value,
|
||||||
|
int max_value,
|
||||||
|
int step,
|
||||||
|
std::function<std::string(int)> int_display,
|
||||||
|
std::function<bool()> visible) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::INT_RANGE;
|
||||||
|
item.label_key = std::move(label_key);
|
||||||
|
item.get_int = std::move(get_int);
|
||||||
|
item.set_int = std::move(set_int);
|
||||||
|
item.min_value = min_value;
|
||||||
|
item.max_value = max_value;
|
||||||
|
item.step = step;
|
||||||
|
item.int_display = std::move(int_display);
|
||||||
|
item.visible = std::move(visible);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeSubmenu(std::string label_key, std::function<void()> push_sub) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::SUBMENU;
|
||||||
|
item.label_key = std::move(label_key);
|
||||||
|
item.push_sub = std::move(push_sub);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeAction(std::string label_key, std::function<void()> action) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::ACTION;
|
||||||
|
item.label_key = std::move(label_key);
|
||||||
|
item.action = std::move(action);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeLabel(std::string text, std::function<bool()> visible) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::LABEL;
|
||||||
|
item.label_key = std::move(text); // Per a LABEL el camp és text literal.
|
||||||
|
item.visible = std::move(visible);
|
||||||
|
item.selectable = false;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeKeyBind(std::string label_key,
|
||||||
|
std::function<SDL_Scancode()> getter,
|
||||||
|
std::function<void(SDL_Scancode)> setter) -> Item {
|
||||||
|
Item item;
|
||||||
|
item.kind = Item::Kind::KEY_BIND;
|
||||||
|
item.label_key = std::move(label_key);
|
||||||
|
item.get_scancode = std::move(getter);
|
||||||
|
item.set_scancode = std::move(setter);
|
||||||
|
// Si no hi ha setter, l'item queda purament informatiu: no pot capturar
|
||||||
|
// una tecla nova. Així la primera iteració de la pàgina Controls pot
|
||||||
|
// mostrar les vinculacions actuals sense tenir encara la infraestructura
|
||||||
|
// de mutació a `Options::`.
|
||||||
|
item.selectable = static_cast<bool>(item.set_scancode);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cicle de vida ---
|
||||||
|
|
||||||
|
auto ServiceMenu::get() -> ServiceMenu& {
|
||||||
|
static ServiceMenu instance_;
|
||||||
|
return instance_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::init(std::shared_ptr<Text> title_font,
|
||||||
|
std::shared_ptr<Text> items_font,
|
||||||
|
const Palette& ui_palette) {
|
||||||
|
font_ = std::move(title_font);
|
||||||
|
items_font_ = std::move(items_font);
|
||||||
|
ui_palette_ = ui_palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::toggle() {
|
||||||
|
if (open_) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
stack_.clear();
|
||||||
|
stack_.push_back(buildRoot());
|
||||||
|
open_ = true;
|
||||||
|
closing_ = false;
|
||||||
|
animated_h_ = 0.0F; // Comença des de 0 perquè la caixa crega.
|
||||||
|
// Bloqueja l'input de polling: els consumidors com `Player` veuran
|
||||||
|
// l'estat del teclat tot-a-zero mentre el menú sigui obert. Els
|
||||||
|
// events (KEY_DOWN, etc.) ja els consumeix `processMenuInput`,
|
||||||
|
// així que la navegació del menú no es veu afectada.
|
||||||
|
InputState::setBlocked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::close() {
|
||||||
|
if (!open_) { return; }
|
||||||
|
open_ = false;
|
||||||
|
closing_ = true; // `open_anim_` decaurà a update; la caixa es veu mentre dura.
|
||||||
|
// Allibera el polling de seguida — la closing animation és purament
|
||||||
|
// visual i no ha d'impedir que el jugador es mogui un cop ha tancat.
|
||||||
|
InputState::setBlocked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `isVisible` inclou l'animació de tancament: retorna true mentre open_anim_>0
|
||||||
|
// encara que open_ ja siga false. Així render() es crida fins que la caixa
|
||||||
|
// ha acabat d'encongir-se.
|
||||||
|
auto ServiceMenu::isVisible() const -> bool {
|
||||||
|
return open_ || closing_ || open_anim_ > 0.0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::update(int delta_ms) {
|
||||||
|
const float DT = static_cast<float>(delta_ms) / MS_PER_SECOND;
|
||||||
|
|
||||||
|
if (closing_) {
|
||||||
|
open_anim_ -= CLOSE_SPEED * DT;
|
||||||
|
if (open_anim_ <= 0.0F) {
|
||||||
|
open_anim_ = 0.0F;
|
||||||
|
closing_ = false;
|
||||||
|
stack_.clear(); // Allibera les pàgines ara que la caixa ja no es veu.
|
||||||
|
animated_h_ = 0.0F;
|
||||||
|
transition_active_ = false;
|
||||||
|
}
|
||||||
|
} else if (open_ && open_anim_ < 1.0F) {
|
||||||
|
open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * DT));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transició push/pop entre pàgines. Mentre està activa, el contingut es
|
||||||
|
// manté amagat i només es veu la caixa redimensionant-se fins a l'alçada
|
||||||
|
// de la pàgina nova; quan acaba, reapareix el nou contingut.
|
||||||
|
if (transition_active_) {
|
||||||
|
transition_progress_ += TRANSITION_SPEED * DT;
|
||||||
|
if (transition_progress_ >= 1.0F) {
|
||||||
|
transition_progress_ = 1.0F;
|
||||||
|
transition_active_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alçada suavitzada: quan un item canvia la seua visibilitat en calent
|
||||||
|
// (p.ex. shader off oculta shader_type i preset), la caixa reacciona amb
|
||||||
|
// una animació en comptes de pegar un salt brusc.
|
||||||
|
if (open_ && !stack_.empty()) {
|
||||||
|
const auto TARGET = static_cast<float>(targetBoxHeight(topConst()));
|
||||||
|
if (animated_h_ <= 0.0F) {
|
||||||
|
animated_h_ = TARGET;
|
||||||
|
} else {
|
||||||
|
const float DIFF = TARGET - animated_h_;
|
||||||
|
if (std::fabs(DIFF) < 0.5F) {
|
||||||
|
animated_h_ = TARGET;
|
||||||
|
} else {
|
||||||
|
const float T = std::min(1.0F, HEIGHT_RATE * DT);
|
||||||
|
animated_h_ += DIFF * T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navegació de pila ---
|
||||||
|
|
||||||
|
void ServiceMenu::pushPage(Page page) {
|
||||||
|
// Cursor inicial: primer item visible I seleccionable; si la pàgina
|
||||||
|
// només té LABELs, queda a 0 (cap item rebrà focus i l'usuari veu els
|
||||||
|
// textos sense més).
|
||||||
|
page.cursor = 0;
|
||||||
|
for (std::size_t i = 0; i < page.items.size(); ++i) {
|
||||||
|
const auto& item = page.items[i];
|
||||||
|
if ((!item.visible || item.visible()) && item.selectable) {
|
||||||
|
page.cursor = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack_.push_back(std::move(page));
|
||||||
|
transition_active_ = true;
|
||||||
|
transition_progress_ = 0.0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::popPage() {
|
||||||
|
if (stack_.size() > 1) {
|
||||||
|
stack_.pop_back();
|
||||||
|
transition_active_ = true;
|
||||||
|
transition_progress_ = 0.0F;
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::top() -> Page& {
|
||||||
|
return stack_.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::topConst() const -> const Page& {
|
||||||
|
return stack_.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::isItemVisible(const Item& item) -> bool {
|
||||||
|
return !item.visible || item.visible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca el pròxim item visible i seleccionable en la direcció indicada
|
||||||
|
// (+1 avall, -1 amunt), amb wrap-around als extrems. Items no seleccionables
|
||||||
|
// (p.ex. LABELs amb versió/hash) es salten perquè el cursor no hi cau.
|
||||||
|
void ServiceMenu::moveCursor(int direction) {
|
||||||
|
if (stack_.empty()) { return; }
|
||||||
|
auto& page = top();
|
||||||
|
if (page.items.empty()) { return; }
|
||||||
|
|
||||||
|
const auto N = page.items.size();
|
||||||
|
std::size_t idx = page.cursor;
|
||||||
|
for (std::size_t step = 0; step < N; ++step) {
|
||||||
|
idx = (idx + static_cast<std::size_t>(direction + static_cast<int>(N))) % N;
|
||||||
|
if (isItemVisible(page.items[idx]) && page.items[idx].selectable) {
|
||||||
|
page.cursor = idx;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::activateCycle(const Item& item, int direction) {
|
||||||
|
if (!item.get_index || !item.get_count || !item.set_index) { return; }
|
||||||
|
const auto COUNT = item.get_count();
|
||||||
|
if (COUNT == 0) { return; }
|
||||||
|
const auto CUR = item.get_index();
|
||||||
|
// `direction` és +1 / -1. Traduïm sempre a un offset positiu dins del
|
||||||
|
// rang del cicle per evitar barrejar int i size_t.
|
||||||
|
const std::size_t OFFSET = (direction > 0) ? 1 : (COUNT - 1);
|
||||||
|
item.set_index((CUR + OFFSET) % COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::activateIntRange(const Item& item, int direction) {
|
||||||
|
if (!item.get_int || !item.set_int) { return; }
|
||||||
|
const int CUR = item.get_int();
|
||||||
|
const int NEXT = std::clamp(CUR + (direction * item.step), item.min_value, item.max_value);
|
||||||
|
if (NEXT != CUR) { item.set_int(NEXT); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplica l'acció de l'item seleccionat. `direction` val +1 per a
|
||||||
|
// RIGHT/RETURN i -1 per a LEFT; els toggles usen +1 com a "invert".
|
||||||
|
void ServiceMenu::activateCurrent(int direction) {
|
||||||
|
if (stack_.empty()) { return; }
|
||||||
|
const auto& page = topConst();
|
||||||
|
if (page.items.empty()) { return; }
|
||||||
|
const auto& item = page.items[page.cursor];
|
||||||
|
if (!isItemVisible(item) || !item.selectable) { return; }
|
||||||
|
|
||||||
|
switch (item.kind) {
|
||||||
|
case Item::Kind::LABEL:
|
||||||
|
break; // No interacciona.
|
||||||
|
case Item::Kind::TOGGLE:
|
||||||
|
if (item.get_bool && item.set_bool) {
|
||||||
|
item.set_bool(!item.get_bool());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Item::Kind::CYCLE:
|
||||||
|
activateCycle(item, direction);
|
||||||
|
break;
|
||||||
|
case Item::Kind::INT_RANGE:
|
||||||
|
activateIntRange(item, direction);
|
||||||
|
break;
|
||||||
|
case Item::Kind::SUBMENU:
|
||||||
|
if (direction > 0 && item.push_sub) { item.push_sub(); }
|
||||||
|
break;
|
||||||
|
case Item::Kind::ACTION:
|
||||||
|
if (direction > 0 && item.action) { item.action(); }
|
||||||
|
break;
|
||||||
|
case Item::Kind::KEY_BIND:
|
||||||
|
// TODO: entrar en mode captura quan hi hagi `set_scancode`.
|
||||||
|
// De moment no fem res — els items sense setter ja no són
|
||||||
|
// seleccionables (veure `makeKeyBind`), per tant aquest case
|
||||||
|
// només es trepitja si algú en fa un amb setter en el futur.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input ---
|
||||||
|
|
||||||
|
auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
|
||||||
|
if (!open_) { return false; }
|
||||||
|
|
||||||
|
// Botons del gamepad: cruceta per navegar, EAST/NORTH com a OK,
|
||||||
|
// WEST/SOUTH com a BACK de pàgina, BACK tanca el menú sencer. Quan
|
||||||
|
// el menú està obert consumim TOTS els BUTTON_DOWN perquè cap botó
|
||||||
|
// arribi als consumidors de gameplay. Paral·lel al consum total de
|
||||||
|
// KEY_DOWN més avall.
|
||||||
|
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
|
||||||
|
switch (ev.gbutton.button) {
|
||||||
|
case SDL_GAMEPAD_BUTTON_DPAD_UP:
|
||||||
|
moveCursor(-1);
|
||||||
|
break;
|
||||||
|
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
|
||||||
|
moveCursor(+1);
|
||||||
|
break;
|
||||||
|
case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
|
||||||
|
activateCurrent(-1);
|
||||||
|
break;
|
||||||
|
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
|
||||||
|
case SDL_GAMEPAD_BUTTON_EAST:
|
||||||
|
case SDL_GAMEPAD_BUTTON_NORTH:
|
||||||
|
activateCurrent(+1);
|
||||||
|
break;
|
||||||
|
case SDL_GAMEPAD_BUTTON_WEST:
|
||||||
|
case SDL_GAMEPAD_BUTTON_SOUTH:
|
||||||
|
popPage();
|
||||||
|
break;
|
||||||
|
case SDL_GAMEPAD_BUTTON_BACK:
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.type != SDL_EVENT_KEY_DOWN) { return false; }
|
||||||
|
|
||||||
|
const auto SC = ev.key.scancode;
|
||||||
|
if (SC == KeyConfig::get().scancode(KEY_ID_MENU_TOGGLE)) {
|
||||||
|
close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (SC) {
|
||||||
|
case SDL_SCANCODE_UP:
|
||||||
|
moveCursor(-1);
|
||||||
|
break;
|
||||||
|
case SDL_SCANCODE_DOWN:
|
||||||
|
moveCursor(+1);
|
||||||
|
break;
|
||||||
|
case SDL_SCANCODE_LEFT:
|
||||||
|
activateCurrent(-1);
|
||||||
|
break;
|
||||||
|
case SDL_SCANCODE_RIGHT:
|
||||||
|
case SDL_SCANCODE_RETURN:
|
||||||
|
case SDL_SCANCODE_KP_ENTER:
|
||||||
|
activateCurrent(+1);
|
||||||
|
break;
|
||||||
|
case SDL_SCANCODE_BACKSPACE:
|
||||||
|
case SDL_SCANCODE_ESCAPE:
|
||||||
|
popPage();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Mentre el menú està obert consumim TOTS els KEY_DOWN perquè les
|
||||||
|
// hotkeys globals (F1–F8, F11, F12) no s'activen per sota.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Builders de pàgines ---
|
||||||
|
|
||||||
|
auto ServiceMenu::buildRoot() -> Page {
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.root";
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.video_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildVideo()); }));
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.audio_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildAudio()); }));
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.controls_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildControls()); }));
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.system_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildSystem()); }));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildVideo() -> Page {
|
||||||
|
using Mode = Defaults::ScalingMode;
|
||||||
|
using Pos = Defaults::RenderInfoPosition;
|
||||||
|
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.video";
|
||||||
|
|
||||||
|
// Zoom: IntRange [1..6] amb display "x{N}". Amagat si fullscreen (com
|
||||||
|
// a les hotkeys: no té efecte en pantalla completa).
|
||||||
|
page.items.push_back(makeIntRange(
|
||||||
|
"menu.items.zoom",
|
||||||
|
[]() { return Options::window().zoom; },
|
||||||
|
[](int v) {
|
||||||
|
Options::setWindowZoom(v);
|
||||||
|
Screen::get().setWindowZoom(v);
|
||||||
|
},
|
||||||
|
MIN_ZOOM,
|
||||||
|
MAX_ZOOM,
|
||||||
|
1,
|
||||||
|
[](int v) { return "x" + std::to_string(v); },
|
||||||
|
[]() { return !Options::window().fullscreen; }));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.screen",
|
||||||
|
[]() { return Options::window().fullscreen; },
|
||||||
|
[](bool v) {
|
||||||
|
Options::setWindowFullscreen(v);
|
||||||
|
Screen::get().setFullscreen(v);
|
||||||
|
}));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.vsync",
|
||||||
|
[]() { return Options::video().vsync; },
|
||||||
|
[](bool v) {
|
||||||
|
Options::setVideoVSync(v);
|
||||||
|
Screen::get().setVSync(v);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cicle dels 5 modes d'escalat.
|
||||||
|
page.items.push_back(makeCycle(
|
||||||
|
"menu.items.scaling_mode",
|
||||||
|
[]() { return static_cast<std::size_t>(Options::video().scaling_mode); },
|
||||||
|
[]() { return std::size_t{5}; },
|
||||||
|
[](std::size_t idx) {
|
||||||
|
const auto NEW_MODE = static_cast<Mode>(idx);
|
||||||
|
Options::setVideoScalingMode(NEW_MODE);
|
||||||
|
Screen::get().setScalingMode(NEW_MODE);
|
||||||
|
},
|
||||||
|
[](std::size_t idx) { return scalingLabel(static_cast<Mode>(idx)); }));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.shader",
|
||||||
|
[]() { return Options::video().shader.enabled; },
|
||||||
|
[](bool v) {
|
||||||
|
Options::setShaderEnabled(v);
|
||||||
|
OptionsApply::reapplyShader();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Tipus de shader i preset: només visibles si el shader està actiu
|
||||||
|
// (altrament no tenen efecte observable). Extrets a helpers per mantenir
|
||||||
|
// la complexitat cognitiva de `buildVideo` sota el llindar de clang-tidy.
|
||||||
|
page.items.push_back(makeShaderTypeItem());
|
||||||
|
page.items.push_back(makeShaderPresetItem());
|
||||||
|
|
||||||
|
page.items.push_back(makeCycle(
|
||||||
|
"menu.items.render_info",
|
||||||
|
[]() { return static_cast<std::size_t>(Options::video().render_info_position); },
|
||||||
|
[]() { return static_cast<std::size_t>(Defaults::RENDER_INFO_POSITION_COUNT); },
|
||||||
|
[](std::size_t idx) { Options::setVideoRenderInfoPosition(static_cast<Pos>(idx)); },
|
||||||
|
[](std::size_t idx) { return renderInfoLabel(static_cast<Pos>(idx)); }));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.uptime",
|
||||||
|
[]() { return Options::video().render_info_show_time; },
|
||||||
|
[](bool v) { Options::setVideoRenderInfoShowTime(v); },
|
||||||
|
[]() { return Options::video().render_info_position != Pos::OFF; }));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeShaderTypeItem() -> Item {
|
||||||
|
return makeCycle(
|
||||||
|
"menu.items.shader_type",
|
||||||
|
[]() { return Options::video().shader.current_shader == "crtpi" ? std::size_t{1} : std::size_t{0}; },
|
||||||
|
[]() { return std::size_t{2}; },
|
||||||
|
[](std::size_t idx) {
|
||||||
|
Options::setActiveShaderName(idx == 1 ? "crtpi" : "postfx");
|
||||||
|
OptionsApply::reapplyShader();
|
||||||
|
},
|
||||||
|
[](std::size_t idx) { return idx == 1 ? std::string{"CRT-Pi"} : std::string{"PostFX"}; },
|
||||||
|
[]() { return Options::video().shader.enabled; });
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::makeShaderPresetItem() -> Item {
|
||||||
|
// Els getters i setters es ramifiquen per `current_shader`: el selector
|
||||||
|
// actiu viu a `postfxPresets()` o `crtpiPresets()` segons el tipus triat.
|
||||||
|
auto is_crtpi = []() { return Options::video().shader.current_shader == "crtpi"; };
|
||||||
|
return makeCycle(
|
||||||
|
"menu.items.preset",
|
||||||
|
[is_crtpi]() {
|
||||||
|
return is_crtpi()
|
||||||
|
? indexOfPreset(Options::crtpiPresets(), Options::video().shader.current_crtpi_preset)
|
||||||
|
: indexOfPreset(Options::postfxPresets(), Options::video().shader.current_postfx_preset);
|
||||||
|
},
|
||||||
|
[is_crtpi]() {
|
||||||
|
return is_crtpi() ? Options::crtpiPresets().size() : Options::postfxPresets().size();
|
||||||
|
},
|
||||||
|
[is_crtpi](std::size_t idx) {
|
||||||
|
if (is_crtpi()) {
|
||||||
|
const auto& presets = Options::crtpiPresets();
|
||||||
|
if (idx < presets.size()) { Options::setCrtPiPreset(presets[idx].name); }
|
||||||
|
} else {
|
||||||
|
const auto& presets = Options::postfxPresets();
|
||||||
|
if (idx < presets.size()) { Options::setPostFXPreset(presets[idx].name); }
|
||||||
|
}
|
||||||
|
OptionsApply::reapplyShader();
|
||||||
|
},
|
||||||
|
[is_crtpi](std::size_t idx) -> std::string {
|
||||||
|
if (is_crtpi()) {
|
||||||
|
const auto& presets = Options::crtpiPresets();
|
||||||
|
return idx < presets.size() ? presets[idx].name : std::string{};
|
||||||
|
}
|
||||||
|
const auto& presets = Options::postfxPresets();
|
||||||
|
return idx < presets.size() ? presets[idx].name : std::string{};
|
||||||
|
},
|
||||||
|
[]() { return Options::video().shader.enabled; });
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildAudio() -> Page {
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.audio";
|
||||||
|
|
||||||
|
auto volume_display = [](int pct) { return std::to_string(pct); };
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.master_enable",
|
||||||
|
[]() { return Options::audio().enabled; },
|
||||||
|
[](bool v) {
|
||||||
|
Options::setAudioEnabled(v);
|
||||||
|
OptionsApply::reapplyAudio();
|
||||||
|
}));
|
||||||
|
|
||||||
|
page.items.push_back(makeIntRange(
|
||||||
|
"menu.items.master_volume",
|
||||||
|
[]() { return Audio::toPercent(Options::audio().volume); },
|
||||||
|
[](int pct) {
|
||||||
|
Options::setAudioVolume(Audio::fromPercent(pct));
|
||||||
|
OptionsApply::reapplyAudio();
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
VOLUME_STEP_PCT,
|
||||||
|
volume_display,
|
||||||
|
[]() { return Options::audio().enabled; }));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.music",
|
||||||
|
[]() { return Options::audio().music.enabled; },
|
||||||
|
[](bool v) {
|
||||||
|
Options::setAudioMusicEnabled(v);
|
||||||
|
OptionsApply::reapplyAudio();
|
||||||
|
},
|
||||||
|
[]() { return Options::audio().enabled; }));
|
||||||
|
|
||||||
|
page.items.push_back(makeIntRange(
|
||||||
|
"menu.items.music_volume",
|
||||||
|
[]() { return Audio::toPercent(Options::audio().music.volume); },
|
||||||
|
[](int pct) {
|
||||||
|
Options::setAudioMusicVolume(Audio::fromPercent(pct));
|
||||||
|
OptionsApply::reapplyAudio();
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
VOLUME_STEP_PCT,
|
||||||
|
volume_display,
|
||||||
|
[]() { return Options::audio().enabled && Options::audio().music.enabled; }));
|
||||||
|
|
||||||
|
page.items.push_back(makeToggle(
|
||||||
|
"menu.items.sounds",
|
||||||
|
[]() { return Options::audio().sound.enabled; },
|
||||||
|
[](bool v) {
|
||||||
|
Options::setAudioSoundEnabled(v);
|
||||||
|
OptionsApply::reapplyAudio();
|
||||||
|
},
|
||||||
|
[]() { return Options::audio().enabled; }));
|
||||||
|
|
||||||
|
page.items.push_back(makeIntRange(
|
||||||
|
"menu.items.sounds_volume",
|
||||||
|
[]() { return Audio::toPercent(Options::audio().sound.volume); },
|
||||||
|
[](int pct) {
|
||||||
|
Options::setAudioSoundVolume(Audio::fromPercent(pct));
|
||||||
|
OptionsApply::reapplyAudio();
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
VOLUME_STEP_PCT,
|
||||||
|
volume_display,
|
||||||
|
[]() { return Options::audio().enabled && Options::audio().sound.enabled; }));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildControls() -> Page {
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.controls";
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.keyboard_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildKeyboardControls()); }));
|
||||||
|
// Comandament: la pàgina existeix però encara no té infraestructura
|
||||||
|
// d'integració (no detectem gamepads ni mapegem botons). Entra-hi per
|
||||||
|
// veure un placeholder fins que la lògica arribi.
|
||||||
|
page.items.push_back(makeSubmenu("menu.items.gamepad_submenu",
|
||||||
|
[]() { ServiceMenu::get().pushPage(buildGamepadControls()); }));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildKeyboardControls() -> Page {
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.keyboard";
|
||||||
|
|
||||||
|
// Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2,
|
||||||
|
// només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles
|
||||||
|
// per sota, sense tocar cap altra cosa.
|
||||||
|
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1")));
|
||||||
|
|
||||||
|
// Vinculacions actuals: les llegim directament de `Defaults::KeysGame`.
|
||||||
|
// Encara no hi ha setter, per tant els items surten com a informatius
|
||||||
|
// (no seleccionables). La lògica de captura i persistència arribarà en
|
||||||
|
// una segona iteració amb `Options::keysGameP1()`.
|
||||||
|
page.items.push_back(makeKeyBind("menu.items.move_up",
|
||||||
|
[]() { return Defaults::KeysGame::UP; }));
|
||||||
|
page.items.push_back(makeKeyBind("menu.items.move_down",
|
||||||
|
[]() { return Defaults::KeysGame::DOWN; }));
|
||||||
|
page.items.push_back(makeKeyBind("menu.items.move_left",
|
||||||
|
[]() { return Defaults::KeysGame::LEFT; }));
|
||||||
|
page.items.push_back(makeKeyBind("menu.items.move_right",
|
||||||
|
[]() { return Defaults::KeysGame::RIGHT; }));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildGamepadControls() -> Page {
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.gamepad";
|
||||||
|
page.center_items = true;
|
||||||
|
// Placeholder: un sol LABEL que indica que la pàgina encara no té
|
||||||
|
// contingut real. Quan s'afegeixi el suport de gamepad, aquesta
|
||||||
|
// pàgina es substituirà pel llistat real de botons mapejables.
|
||||||
|
page.items.push_back(makeLabel(Locale::get().text("menu.labels.gamepad_unavailable")));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::buildSystem() -> Page {
|
||||||
|
Page page;
|
||||||
|
page.title_key = "menu.titles.system";
|
||||||
|
// Tots els items són botons/etiquetes; no n'hi ha cap amb columna de
|
||||||
|
// valor a la dreta, així que els centrem per tenir la jerarquia visual
|
||||||
|
// pròpia d'un menú d'accions.
|
||||||
|
page.center_items = true;
|
||||||
|
|
||||||
|
// Versió + hash de commit com a primer item no-seleccionable. Format
|
||||||
|
// "ver.X.Y.Z (hash)"; el hash el posa CMake a version.h.in.
|
||||||
|
page.items.push_back(makeLabel(
|
||||||
|
std::string("ver.") + Texts::VERSION + " (" + Version::GIT_HASH + ")"));
|
||||||
|
|
||||||
|
// Reiniciar el joc: placeholder sense efecte fins que Director exposi
|
||||||
|
// un pas de restart en calent (fora d'abast d'aquesta iteració).
|
||||||
|
page.items.push_back(makeAction(
|
||||||
|
"menu.items.restart",
|
||||||
|
[]() {
|
||||||
|
// TODO: connectar amb un hipotètic Director::requestRestart().
|
||||||
|
}));
|
||||||
|
|
||||||
|
page.items.push_back(makeAction(
|
||||||
|
"menu.items.exit_game",
|
||||||
|
[]() {
|
||||||
|
SDL_Event quit_event{};
|
||||||
|
quit_event.type = SDL_EVENT_QUIT;
|
||||||
|
EventBuffer::events.push_back(quit_event);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
|
||||||
|
auto ServiceMenu::formatValue(const Item& item) -> std::string {
|
||||||
|
switch (item.kind) {
|
||||||
|
case Item::Kind::LABEL:
|
||||||
|
return {}; // Es dibuixa només la label (pàgina centrada o no).
|
||||||
|
case Item::Kind::TOGGLE:
|
||||||
|
return item.get_bool ? boolLabel(item.get_bool()) : std::string{};
|
||||||
|
case Item::Kind::CYCLE:
|
||||||
|
return (item.value_label && item.get_index) ? item.value_label(item.get_index()) : std::string{};
|
||||||
|
case Item::Kind::INT_RANGE: {
|
||||||
|
if (!item.get_int) { return {}; }
|
||||||
|
const int V = item.get_int();
|
||||||
|
return item.int_display ? item.int_display(V) : std::to_string(V);
|
||||||
|
}
|
||||||
|
case Item::Kind::SUBMENU:
|
||||||
|
return Locale::get().text("menu.values.submenu_arrow");
|
||||||
|
case Item::Kind::ACTION:
|
||||||
|
return {};
|
||||||
|
case Item::Kind::KEY_BIND:
|
||||||
|
return item.get_scancode ? SDL_GetScancodeName(item.get_scancode()) : std::string{};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ServiceMenu::targetBoxHeight(const Page& page) const -> int {
|
||||||
|
if (!font_ || !items_font_) { return 0; }
|
||||||
|
const int TITLE_H = font_->getLineHeight();
|
||||||
|
const int ITEM_H = items_font_->getLineHeight();
|
||||||
|
const auto VISIBLE_ITEMS = static_cast<int>(std::ranges::count_if(page.items,
|
||||||
|
[](const Item& item) { return isItemVisible(item); }));
|
||||||
|
// Layout: GAP_Y enmig dels blocs estructurals (top, títol↔separador,
|
||||||
|
// separador↔primer item, últim item↔bottom). Entre items consecutius,
|
||||||
|
// ITEM_GAP_Y (més petit, perquè els items no queden massa airejats).
|
||||||
|
int h = GAP_Y; // marge superior
|
||||||
|
h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // títol + gap (compensant descendent)
|
||||||
|
if (!page.subtitle.empty()) {
|
||||||
|
h += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM; // subtítol idem
|
||||||
|
}
|
||||||
|
h += 1 + GAP_Y; // separador d'1 px + gap estructural
|
||||||
|
if (VISIBLE_ITEMS > 0) {
|
||||||
|
h += VISIBLE_ITEMS * ITEM_H;
|
||||||
|
h += (VISIBLE_ITEMS - 1) * ITEM_GAP_Y;
|
||||||
|
h += GAP_Y; // marge inferior
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const {
|
||||||
|
const std::uint32_t BG_ARGB = ui_palette_[UiColor::MENU_BG];
|
||||||
|
const std::uint32_t BORDER_ARGB = ui_palette_[UiColor::MENU_BORDER];
|
||||||
|
const auto ALPHA = static_cast<std::uint8_t>(std::lround(BG_ALPHA * eased));
|
||||||
|
|
||||||
|
// Fons semi-transparent (el joc queda visible apagat per darrere).
|
||||||
|
Screen::get().blendRect(box_x, box_y, box_w, box_h, BG_ARGB, ALPHA);
|
||||||
|
|
||||||
|
// Vora opaca per damunt perquè la caixa quedi definida.
|
||||||
|
Screen::get().fillRect(box_x, box_y, box_w, 1, BORDER_ARGB);
|
||||||
|
Screen::get().fillRect(box_x, box_y + box_h - 1, box_w, 1, BORDER_ARGB);
|
||||||
|
Screen::get().fillRect(box_x, box_y, 1, box_h, BORDER_ARGB);
|
||||||
|
Screen::get().fillRect(box_x + box_w - 1, box_y, 1, box_h, BORDER_ARGB);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w) const {
|
||||||
|
const int TITLE_KERNING = font_->getDefaultKerning();
|
||||||
|
const int TITLE_H = font_->getLineHeight();
|
||||||
|
const int ITEM_KERNING = items_font_->getDefaultKerning();
|
||||||
|
const int ITEM_H = items_font_->getLineHeight();
|
||||||
|
int cursor_y = box_y + GAP_Y; // marge superior
|
||||||
|
const int CENTER_X = box_x + (box_w / 2);
|
||||||
|
|
||||||
|
// Amplada reservada per al cursor "> ". La label no canvia de posició
|
||||||
|
// quan canvia la selecció — el cursor es pinta dins d'aquest carril,
|
||||||
|
// a l'esquerra, i la label sempre comença al mateix X.
|
||||||
|
const int CURSOR_WIDTH = items_font_->length("> ", ITEM_KERNING);
|
||||||
|
const int LABEL_X = box_x + PAD_X + CURSOR_WIDTH;
|
||||||
|
|
||||||
|
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
||||||
|
CENTER_X,
|
||||||
|
cursor_y,
|
||||||
|
Locale::get().text(page.title_key),
|
||||||
|
TITLE_KERNING,
|
||||||
|
UiColor::MENU_TITLE);
|
||||||
|
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
|
||||||
|
|
||||||
|
if (!page.subtitle.empty()) {
|
||||||
|
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
||||||
|
CENTER_X,
|
||||||
|
cursor_y,
|
||||||
|
page.subtitle,
|
||||||
|
TITLE_KERNING,
|
||||||
|
UiColor::MENU_TITLE);
|
||||||
|
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
|
||||||
|
}
|
||||||
|
|
||||||
|
Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiColor::MENU_BORDER]);
|
||||||
|
cursor_y += 1 + GAP_Y;
|
||||||
|
|
||||||
|
bool first_item = true;
|
||||||
|
for (std::size_t i = 0; i < page.items.size(); ++i) {
|
||||||
|
const auto& item = page.items[i];
|
||||||
|
if (!isItemVisible(item)) { continue; }
|
||||||
|
|
||||||
|
if (!first_item) { cursor_y += ITEM_GAP_Y; }
|
||||||
|
first_item = false;
|
||||||
|
|
||||||
|
const bool SELECTED = item.selectable && (i == page.cursor);
|
||||||
|
const std::string LABEL_TEXT = (item.kind == Item::Kind::LABEL)
|
||||||
|
? item.label_key
|
||||||
|
: Locale::get().text(item.label_key);
|
||||||
|
const std::string VALUE = formatValue(item);
|
||||||
|
|
||||||
|
Uint8 label_color = UiColor::MENU_LABEL;
|
||||||
|
if (item.kind == Item::Kind::LABEL) {
|
||||||
|
// Mateix to que el títol de la pàgina: les LABELs actuen com
|
||||||
|
// a subtítol-divisor (p.ex. "Jugador 1" a Teclat, "ver.X.Y" a
|
||||||
|
// Sistema) i han de tenir la mateixa presència visual.
|
||||||
|
label_color = UiColor::MENU_TITLE;
|
||||||
|
} else if (SELECTED) {
|
||||||
|
label_color = UiColor::MENU_CURSOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
int label_x = LABEL_X;
|
||||||
|
if (page.center_items) {
|
||||||
|
// En pàgines centrades, la label cau al centre del box i el
|
||||||
|
// cursor es col·loca immediatament a la seua esquerra.
|
||||||
|
const int LABEL_W = items_font_->length(LABEL_TEXT, ITEM_KERNING);
|
||||||
|
label_x = CENTER_X - (LABEL_W / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SELECTED) {
|
||||||
|
items_font_->writeDX(Text::COLOR_FLAG,
|
||||||
|
label_x - CURSOR_WIDTH,
|
||||||
|
cursor_y,
|
||||||
|
"> ",
|
||||||
|
ITEM_KERNING,
|
||||||
|
UiColor::MENU_CURSOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
items_font_->writeDX(Text::COLOR_FLAG,
|
||||||
|
label_x,
|
||||||
|
cursor_y,
|
||||||
|
LABEL_TEXT,
|
||||||
|
ITEM_KERNING,
|
||||||
|
label_color);
|
||||||
|
|
||||||
|
// Pàgines centrades no dibuixen columna de valor (s'assumeix que
|
||||||
|
// només tenen ACTION / LABEL); els Toggle/Cycle viuen en pàgines
|
||||||
|
// d'opcions amb layout esquerra-dreta.
|
||||||
|
if (!page.center_items && !VALUE.empty()) {
|
||||||
|
const int VALUE_W = items_font_->length(VALUE, ITEM_KERNING);
|
||||||
|
items_font_->writeDX(Text::COLOR_FLAG,
|
||||||
|
box_x + box_w - PAD_X - VALUE_W,
|
||||||
|
cursor_y,
|
||||||
|
VALUE,
|
||||||
|
ITEM_KERNING,
|
||||||
|
SELECTED ? UiColor::MENU_CURSOR : UiColor::MENU_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor_y += ITEM_H;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServiceMenu::render() {
|
||||||
|
if (!isVisible() || stack_.empty() || !font_) { return; }
|
||||||
|
|
||||||
|
const auto& page = topConst();
|
||||||
|
const float EASED = easeOutQuad(open_anim_);
|
||||||
|
|
||||||
|
// Alçada animada per suavitzar transicions entre pàgines i canvis de
|
||||||
|
// visibilitat d'items; l'eased la multiplica per simular el "créixer"
|
||||||
|
// vertical durant l'obertura.
|
||||||
|
const int FULL_H = static_cast<int>(animated_h_ > 0.0F ? animated_h_ : targetBoxHeight(page));
|
||||||
|
int box_h = std::max(2, static_cast<int>(static_cast<float>(FULL_H) * EASED));
|
||||||
|
|
||||||
|
const int BOX_X = (GameCanvas::WIDTH - BOX_WIDTH) / 2;
|
||||||
|
const int BOX_Y = (GameCanvas::HEIGHT - box_h) / 2;
|
||||||
|
|
||||||
|
renderBox(BOX_X, BOX_Y, BOX_WIDTH, box_h, EASED);
|
||||||
|
|
||||||
|
// Clip al rectangle interior de la caixa (dins de la vora, que és d'1
|
||||||
|
// px). Així el text que cau fora — durant el creixement inicial, el
|
||||||
|
// canvi d'alçada en amagar/mostrar items, o el push/pop entre pàgines
|
||||||
|
// — queda retallat en comptes d'anar a parar sobre l'escena.
|
||||||
|
Rendering::pushClip({
|
||||||
|
.x = BOX_X + 1,
|
||||||
|
.y = BOX_Y + 1,
|
||||||
|
.w = BOX_WIDTH - 2,
|
||||||
|
.h = std::max(0, box_h - 2),
|
||||||
|
});
|
||||||
|
renderPage(page, BOX_X, BOX_Y, BOX_WIDTH);
|
||||||
|
Rendering::popClip();
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/rendering/surface.hpp" // Palette
|
||||||
|
|
||||||
|
class Text;
|
||||||
|
|
||||||
|
// Menú de servei (F9). Caixa centrada amb títol i items navegables per
|
||||||
|
// fletxes + Retorn; Retrocés / Escape tanca la pàgina actual o el menú.
|
||||||
|
// És accessible només dins de `GameScene` (els placeholders LOGO/TITLE/INTRO
|
||||||
|
// duren 5 s i no tenen sentit com a entrada al menú; veure plan §11).
|
||||||
|
//
|
||||||
|
// La pila de pàgines (`stack_`) permet submenús; la pàgina de dalt és la
|
||||||
|
// que rep input i es dibuixa. `pushPage` apila, `popPage` treu la de dalt;
|
||||||
|
// en tornar a quedar amb una sola pàgina, backspace/escape tanca el menú.
|
||||||
|
class ServiceMenu {
|
||||||
|
public:
|
||||||
|
static auto get() -> ServiceMenu&;
|
||||||
|
|
||||||
|
ServiceMenu(const ServiceMenu&) = delete;
|
||||||
|
ServiceMenu(ServiceMenu&&) = delete;
|
||||||
|
auto operator=(const ServiceMenu&) -> ServiceMenu& = delete;
|
||||||
|
auto operator=(ServiceMenu&&) -> ServiceMenu& = delete;
|
||||||
|
|
||||||
|
// Injecta les dues fonts del menú i la paleta UI. Ha de cridar-se una
|
||||||
|
// sola vegada (des de Director) abans de qualsevol `render()` o
|
||||||
|
// `toggle()`. Cada font pot portar el seu kerning pre-configurat
|
||||||
|
// (via `Text::setDefaultKerning`).
|
||||||
|
void init(std::shared_ptr<Text> title_font,
|
||||||
|
std::shared_ptr<Text> items_font,
|
||||||
|
const Palette& ui_palette);
|
||||||
|
|
||||||
|
// "Obert" = el menú accepta input. Torna `false` durant l'animació de
|
||||||
|
// tancament (la caixa es veu però ja no respon).
|
||||||
|
[[nodiscard]] auto isOpen() const -> bool { return open_; }
|
||||||
|
|
||||||
|
void toggle();
|
||||||
|
void close();
|
||||||
|
|
||||||
|
// Avança animacions d'obertura, tancament i alçada. Ha de cridar-se
|
||||||
|
// cada frame des del Director (no des de cada escena).
|
||||||
|
void update(int delta_ms);
|
||||||
|
|
||||||
|
void render();
|
||||||
|
|
||||||
|
// Retorna true si el menú ha consumit l'esdeveniment. Quan el menú està
|
||||||
|
// obert, consumeix tots els KEY_DOWN perquè les hotkeys globals (F1–F8,
|
||||||
|
// F11, F12) no es disparin per sota. Quan està tancat sempre retorna
|
||||||
|
// false; és el caller qui decideix obrir-lo amb la hotkey.
|
||||||
|
auto handleEvent(const SDL_Event& ev) -> bool;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// --- Struct d'un item d'una pàgina ---
|
||||||
|
//
|
||||||
|
// La unió de totes les "variants" (toggle / cycle / ...) en un sol
|
||||||
|
// struct amb `std::function` nominalment opcionals simplifica la
|
||||||
|
// construcció dels items (nomes s'omplen els slots del Kind rellevant)
|
||||||
|
// a canvi d'una mica d'overhead. Amb una dotzena d'items no és un
|
||||||
|
// problema de rendiment.
|
||||||
|
struct Item {
|
||||||
|
enum class Kind : std::uint8_t {
|
||||||
|
LABEL, // Text pur, no interacciona; ideal per a versions/notes.
|
||||||
|
TOGGLE, // ON / OFF amb bool getter/setter
|
||||||
|
CYCLE, // Índex dins d'una llista; L=prev, R=next (wrap)
|
||||||
|
INT_RANGE, // L/R descent/ascent per step, clamp a [min,max]
|
||||||
|
SUBMENU, // RETURN/R: push la pàgina construïda per `pushSub`
|
||||||
|
ACTION, // RETURN: dispara `action`
|
||||||
|
KEY_BIND, // Mostra el nom d'una tecla; RETURN captura-ne de nova.
|
||||||
|
};
|
||||||
|
Kind kind{Kind::TOGGLE};
|
||||||
|
// Per a tots els Kind menys LABEL, `label_key` és una clau de Locale.
|
||||||
|
// Per a LABEL, és text literal (no passa per Locale) perquè típicament
|
||||||
|
// porta valors dinàmics (versió, hash de commit, etc.).
|
||||||
|
std::string label_key;
|
||||||
|
|
||||||
|
std::function<bool()> get_bool;
|
||||||
|
std::function<void(bool)> set_bool;
|
||||||
|
|
||||||
|
std::function<std::size_t()> get_index;
|
||||||
|
std::function<std::size_t()> get_count;
|
||||||
|
std::function<void(std::size_t)> set_index;
|
||||||
|
std::function<std::string(std::size_t)> value_label;
|
||||||
|
|
||||||
|
std::function<int()> get_int;
|
||||||
|
std::function<void(int)> set_int;
|
||||||
|
int min_value{0};
|
||||||
|
int max_value{0};
|
||||||
|
int step{1};
|
||||||
|
std::function<std::string(int)> int_display;
|
||||||
|
|
||||||
|
std::function<void()> push_sub;
|
||||||
|
std::function<void()> action;
|
||||||
|
|
||||||
|
// Per a KEY_BIND: getter que retorna l'scancode actual i setter
|
||||||
|
// opcional que el muta. Si `set_scancode` és buit, l'item es veu
|
||||||
|
// però la captura queda deshabilitada (placeholder).
|
||||||
|
std::function<SDL_Scancode()> get_scancode;
|
||||||
|
std::function<void(SDL_Scancode)> set_scancode;
|
||||||
|
|
||||||
|
// Si està buit, l'item sempre és visible. Si retorna false, l'item
|
||||||
|
// es salta tant al render com a la navegació (cursor salta).
|
||||||
|
std::function<bool()> visible;
|
||||||
|
|
||||||
|
// Si `false`, el cursor no pot aterrar-hi: es salta a l'hora de
|
||||||
|
// navegar amb ↑/↓. Default `true` per la gran majoria d'items; les
|
||||||
|
// etiquetes LABEL el deixen `false` perquè no tenen interacció.
|
||||||
|
bool selectable{true};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Page {
|
||||||
|
std::string title_key; // "menu.titles.video" etc.
|
||||||
|
std::string subtitle; // Text ja format (buit si no aplica).
|
||||||
|
std::vector<Item> items;
|
||||||
|
std::size_t cursor{0};
|
||||||
|
// Si `true`, els items es dibuixen centrats dins de la caixa i el
|
||||||
|
// cursor es col·loca immediatament a l'esquerra de l'etiqueta.
|
||||||
|
// Adequat per a pàgines que només tenen botons d'acció (p.ex. Sistema).
|
||||||
|
bool center_items{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
ServiceMenu() = default;
|
||||||
|
~ServiceMenu() = default;
|
||||||
|
|
||||||
|
// --- Factories d'Items ---
|
||||||
|
//
|
||||||
|
// Cada factory omple els slots rellevants del Kind i deixa la resta
|
||||||
|
// default-construïts. Redueix el soroll al lloc d'ús i, el més important,
|
||||||
|
// elimina el warning `-Wmissing-designated-field-initializers` que
|
||||||
|
// apareixeria amb designated-initializers parcials.
|
||||||
|
[[nodiscard]] static auto makeToggle(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<bool()> getter,
|
||||||
|
std::function<void(bool)> setter,
|
||||||
|
std::function<bool()> visible = {}) -> Item;
|
||||||
|
|
||||||
|
[[nodiscard]] static auto makeCycle(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<std::size_t()> get_index,
|
||||||
|
std::function<std::size_t()> get_count,
|
||||||
|
std::function<void(std::size_t)> set_index,
|
||||||
|
std::function<std::string(std::size_t)> value_label,
|
||||||
|
std::function<bool()> visible = {}) -> Item;
|
||||||
|
|
||||||
|
[[nodiscard]] static auto makeIntRange(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<int()> get_int,
|
||||||
|
std::function<void(int)> set_int,
|
||||||
|
int min_value,
|
||||||
|
int max_value,
|
||||||
|
int step,
|
||||||
|
std::function<std::string(int)> int_display = {},
|
||||||
|
std::function<bool()> visible = {}) -> Item;
|
||||||
|
|
||||||
|
[[nodiscard]] static auto makeSubmenu(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<void()> push_sub) -> Item;
|
||||||
|
|
||||||
|
[[nodiscard]] static auto makeAction(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<void()> action) -> Item;
|
||||||
|
|
||||||
|
// Etiqueta no seleccionable. `text` és el literal a mostrar (no passa
|
||||||
|
// per Locale), perquè l'ús principal és mostrar versions o hashos que
|
||||||
|
// ja es calculen a codi.
|
||||||
|
[[nodiscard]] static auto makeLabel(
|
||||||
|
std::string text,
|
||||||
|
std::function<bool()> visible = {}) -> Item;
|
||||||
|
|
||||||
|
// Item de vinculació de tecla. Mostra el nom de l'scancode al costat
|
||||||
|
// dret. Si `setter` és buit, l'item surt com a informatiu (no captura).
|
||||||
|
// Quan el setter sigui operatiu, RETURN entrarà en mode captura i la
|
||||||
|
// pròxima tecla que es premi sobreescriurà la vinculació.
|
||||||
|
[[nodiscard]] static auto makeKeyBind(
|
||||||
|
std::string label_key,
|
||||||
|
std::function<SDL_Scancode()> getter,
|
||||||
|
std::function<void(SDL_Scancode)> setter = {}) -> Item;
|
||||||
|
|
||||||
|
// --- Navegació i estat ---
|
||||||
|
void pushPage(Page page);
|
||||||
|
void popPage();
|
||||||
|
[[nodiscard]] auto top() -> Page&;
|
||||||
|
[[nodiscard]] auto topConst() const -> const Page&;
|
||||||
|
[[nodiscard]] static auto isItemVisible(const Item& item) -> bool;
|
||||||
|
void moveCursor(int direction); // +1 / -1, salta items no visibles
|
||||||
|
void activateCurrent(int direction); // Aplica l'acció segons el Kind
|
||||||
|
static void activateCycle(const Item& item, int direction);
|
||||||
|
static void activateIntRange(const Item& item, int direction);
|
||||||
|
|
||||||
|
// --- Constructors de pàgines (viuen a .cpp) ---
|
||||||
|
// Són static perquè no necessiten estat d'instància: tots els getters
|
||||||
|
// i setters que capturen dins dels items accedeixen via `Options::` o
|
||||||
|
// `ServiceMenu::get()` directament.
|
||||||
|
[[nodiscard]] static auto buildRoot() -> Page;
|
||||||
|
[[nodiscard]] static auto buildVideo() -> Page;
|
||||||
|
[[nodiscard]] static auto buildAudio() -> Page;
|
||||||
|
[[nodiscard]] static auto buildControls() -> Page;
|
||||||
|
[[nodiscard]] static auto buildKeyboardControls() -> Page;
|
||||||
|
[[nodiscard]] static auto buildGamepadControls() -> Page;
|
||||||
|
[[nodiscard]] static auto buildSystem() -> Page;
|
||||||
|
|
||||||
|
// Helpers privats d'items de vídeo: aïllen la lògica de
|
||||||
|
// `shader_type` i `preset` fora de `buildVideo` perquè no exploti la
|
||||||
|
// complexitat cognitiva del constructor de pàgines.
|
||||||
|
[[nodiscard]] static auto makeShaderTypeItem() -> Item;
|
||||||
|
[[nodiscard]] static auto makeShaderPresetItem() -> Item;
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
// "Visible" inclou l'animació de tancament: retorna true mentre la caixa
|
||||||
|
// encara ha de dibuixar-se, encara que open_ siga false.
|
||||||
|
[[nodiscard]] auto isVisible() const -> bool;
|
||||||
|
[[nodiscard]] auto targetBoxHeight(const Page& page) const -> int;
|
||||||
|
|
||||||
|
void renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const;
|
||||||
|
void renderPage(const Page& page, int box_x, int box_y, int box_w) const;
|
||||||
|
[[nodiscard]] static auto formatValue(const Item& item) -> std::string;
|
||||||
|
|
||||||
|
// --- Estat ---
|
||||||
|
std::shared_ptr<Text> font_; // Títol del menú (`ari-w9500-display`).
|
||||||
|
std::shared_ptr<Text> items_font_; // Items del menú (`ari-w9500-condensed-display`).
|
||||||
|
Palette ui_palette_{};
|
||||||
|
std::vector<Page> stack_;
|
||||||
|
bool open_{false};
|
||||||
|
bool closing_{false}; // `true` entre `close()` i fi de l'animació.
|
||||||
|
float open_anim_{0.0F}; // 0 = invisible, 1 = totalment obert.
|
||||||
|
float animated_h_{0.0F}; // Alçada suavitzada cap a `targetBoxHeight`.
|
||||||
|
bool transition_active_{false}; // Push/pop de pàgina en curs.
|
||||||
|
float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina.
|
||||||
|
};
|
||||||
@@ -11,4 +11,12 @@ namespace UiColor {
|
|||||||
constexpr auto RENDER_INFO_SHADOW = Defaults::UI::RENDER_INFO_SHADOW;
|
constexpr auto RENDER_INFO_SHADOW = Defaults::UI::RENDER_INFO_SHADOW;
|
||||||
constexpr auto CANVAS_BG = Defaults::UI::CANVAS_BG;
|
constexpr auto CANVAS_BG = Defaults::UI::CANVAS_BG;
|
||||||
|
|
||||||
|
constexpr auto MENU_BG = Defaults::UI::MENU_BG;
|
||||||
|
constexpr auto MENU_BORDER = Defaults::UI::MENU_BORDER;
|
||||||
|
constexpr auto MENU_TITLE = Defaults::UI::MENU_TITLE;
|
||||||
|
constexpr auto MENU_LABEL = Defaults::UI::MENU_LABEL;
|
||||||
|
constexpr auto MENU_VALUE = Defaults::UI::MENU_VALUE;
|
||||||
|
constexpr auto MENU_CURSOR = Defaults::UI::MENU_CURSOR;
|
||||||
|
constexpr auto MENU_DISABLED = Defaults::UI::MENU_DISABLED;
|
||||||
|
|
||||||
} // namespace UiColor
|
} // namespace UiColor
|
||||||
@@ -51,7 +51,7 @@ namespace Defaults {
|
|||||||
constexpr bool SHADER_ENABLED = true; // Aplica PostFX; si false, sempre passthrough.
|
constexpr bool SHADER_ENABLED = true; // Aplica PostFX; si false, sempre passthrough.
|
||||||
constexpr const char* CURRENT_SHADER = "postfx"; // Shader actiu: "postfx" o "crtpi".
|
constexpr const char* CURRENT_SHADER = "postfx"; // Shader actiu: "postfx" o "crtpi".
|
||||||
constexpr const char* POSTFX_DEFAULT_PRESET = "CRT"; // Preset inicial PostFX.
|
constexpr const char* POSTFX_DEFAULT_PRESET = "CRT"; // Preset inicial PostFX.
|
||||||
constexpr const char* CRTPI_DEFAULT_PRESET = "DEFAULT"; // Preset inicial CrtPi.
|
constexpr const char* CRTPI_DEFAULT_PRESET = "Default"; // Preset inicial CrtPi.
|
||||||
constexpr const char* POSTFX_FILE = "shaders/postfx.yaml"; // Relatiu a la carpeta de sistema.
|
constexpr const char* POSTFX_FILE = "shaders/postfx.yaml"; // Relatiu a la carpeta de sistema.
|
||||||
constexpr const char* CRTPI_FILE = "shaders/crtpi.yaml"; // Relatiu a la carpeta de sistema.
|
constexpr const char* CRTPI_FILE = "shaders/crtpi.yaml"; // Relatiu a la carpeta de sistema.
|
||||||
constexpr RenderInfoPosition RENDER_INFO_POSITION = RenderInfoPosition::OFF; // Overlay de debug amagat per defecte.
|
constexpr RenderInfoPosition RENDER_INFO_POSITION = RenderInfoPosition::OFF; // Overlay de debug amagat per defecte.
|
||||||
@@ -90,6 +90,43 @@ namespace Defaults {
|
|||||||
// pel codi. Si algun dia es volen temes múltiples, es pot moure a YAML
|
// pel codi. Si algun dia es volen temes múltiples, es pot moure a YAML
|
||||||
// sense més impacte que dos accessors extra.
|
// sense més impacte que dos accessors extra.
|
||||||
namespace UI {
|
namespace UI {
|
||||||
|
// Fonts d'UI centralitzades: un únic lloc per conèixer la carpeta
|
||||||
|
// (data/fonts/), el nom-clau al Resource::Cache i els fitxers .gif /
|
||||||
|
// .fnt de cada font. Afegir-ne una nova és una constant ací i ja.
|
||||||
|
namespace Font {
|
||||||
|
constexpr const char* DIR = "data/fonts/";
|
||||||
|
|
||||||
|
// HUD/Notifier/RenderInfo: font principal de UI del joc.
|
||||||
|
// El kerning és la separació extra entre glifs (sumat a l'amplada
|
||||||
|
// natural). 1 és el valor clàssic; valors negatius ajunten glifs
|
||||||
|
// per a fonts de display amb sidebearings amples.
|
||||||
|
constexpr const char* HUD_NAME = "8bithud";
|
||||||
|
constexpr const char* HUD_GIF = "data/fonts/8bithud.gif";
|
||||||
|
constexpr const char* HUD_FNT = "data/fonts/8bithud.fnt";
|
||||||
|
constexpr int HUD_KERNING = 1;
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
constexpr int PLACEHOLDER_KERNING = 1;
|
||||||
|
|
||||||
|
// Menú de servei (F9): títol en display normal, items en la
|
||||||
|
// variant condensed-display (més compacta, perquè caben més
|
||||||
|
// files sense encongir la caixa). Kerning 0 a totes dues perquè
|
||||||
|
// els glifs de display ja porten sidebearings generosos.
|
||||||
|
constexpr const char* MENU_TITLE_NAME = "ari-w9500-display";
|
||||||
|
constexpr const char* MENU_TITLE_GIF = "data/fonts/ari-w9500-display.gif";
|
||||||
|
constexpr const char* MENU_TITLE_FNT = "data/fonts/ari-w9500-display.fnt";
|
||||||
|
constexpr int MENU_TITLE_KERNING = 0;
|
||||||
|
|
||||||
|
constexpr const char* MENU_ITEMS_NAME = "ari-w9500-condensed-display";
|
||||||
|
constexpr const char* MENU_ITEMS_GIF = "data/fonts/ari-w9500-condensed-display.gif";
|
||||||
|
constexpr const char* MENU_ITEMS_FNT = "data/fonts/ari-w9500-condensed-display.fnt";
|
||||||
|
constexpr int MENU_ITEMS_KERNING = 0;
|
||||||
|
} // namespace Font
|
||||||
|
|
||||||
// Overlay de RenderInfo (F12).
|
// Overlay de RenderInfo (F12).
|
||||||
constexpr Uint8 RENDER_INFO_TEXT = 47;
|
constexpr Uint8 RENDER_INFO_TEXT = 47;
|
||||||
constexpr Uint8 RENDER_INFO_SHADOW = 34;
|
constexpr Uint8 RENDER_INFO_SHADOW = 34;
|
||||||
@@ -108,6 +145,15 @@ namespace Defaults {
|
|||||||
constexpr Uint8 EXIT_TEXT = 88;
|
constexpr Uint8 EXIT_TEXT = 88;
|
||||||
constexpr Uint8 EXIT_BG = 40;
|
constexpr Uint8 EXIT_BG = 40;
|
||||||
constexpr int EXIT_TIME_MS = 4000;
|
constexpr int EXIT_TIME_MS = 4000;
|
||||||
|
|
||||||
|
// Índexs de paleta per al menú de servei (F9).
|
||||||
|
constexpr Uint8 MENU_BG = 40; // Fons de la caixa.
|
||||||
|
constexpr Uint8 MENU_BORDER = 197; // Vora.
|
||||||
|
constexpr Uint8 MENU_TITLE = 213; // Títol i subtítol.
|
||||||
|
constexpr Uint8 MENU_LABEL = 47; // Etiquetes dels items.
|
||||||
|
constexpr Uint8 MENU_VALUE = 197; // Valor actual.
|
||||||
|
constexpr Uint8 MENU_CURSOR = 88; // Item seleccionat.
|
||||||
|
constexpr Uint8 MENU_DISABLED = 34; // Label en gris quan no aplica.
|
||||||
} // namespace UI
|
} // namespace UI
|
||||||
|
|
||||||
// Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no
|
// Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace Texts {
|
namespace Texts {
|
||||||
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
|
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
|
||||||
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
||||||
constexpr const char* VERSION = "0.15.0"; // Single source of truth — Makefile el grep
|
constexpr const char* VERSION = "0.20.0"; // Single source of truth — Makefile el grep
|
||||||
} // namespace Texts
|
} // namespace Texts
|
||||||
|
|
||||||
namespace GameCanvas {
|
namespace GameCanvas {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||