Compare commits

...

72 Commits

Author SHA1 Message Date
JailDesigner 87f818ef96 Merge branch 'feat/service-menu': menu de servei F12 amb VIDEO/AUDIO/OPCIONS/SISTEMA 2026-05-24 12:32:55 +02:00
JailDesigner 7eafe21623 feat(service-menu): submenu RESOLUCIO amb canvi en calent de l'offscreen 2026-05-24 12:30:47 +02:00
JailDesigner 22827c28fa feat(service-menu): pobla SISTEMA amb reinici, eixir i confirmacions 2026-05-24 12:18:39 +02:00
JailDesigner 8c21345f14 feat(service-menu): pobla OPCIONS amb idioma i toggle del HUD de debug 2026-05-24 11:56:11 +02:00
JailDesigner 56d7d4af52 feat(service-menu): pobla AUDIO amb toggles i sliders de volum 2026-05-24 11:49:14 +02:00
JailDesigner 71c43ec6fe feat(service-menu): pobla VIDEO amb zoom, fullscreen, vsync, AA i postfx 2026-05-24 11:37:36 +02:00
JailDesigner 443b461974 feat(service-menu): esquelet amb F12, brackets sci-fi i highlight animat 2026-05-24 11:25:09 +02:00
JailDesigner cc16908b86 Merge branch 'feat/locale-system': sistema i18n amb canvi al vol 2026-05-24 10:36:54 +02:00
JailDesigner c4c6881bd6 feat(locale): canvi d'idioma al vol amb F7 i persistència 2026-05-24 10:35:39 +02:00
JailDesigner 35d720bb77 feat(locale): sistema i18n YAML amb català i anglès 2026-05-24 10:28:56 +02:00
JailDesigner 274ce1ca63 Merge branch 'refactor/english-identifiers': identificadors valencians/castellans a anglès 2026-05-24 08:12:56 +02:00
JailDesigner 252e881e93 refactor: renombra jugador*/zona/radi/MARGE/origen/letra residuals a anglès 2026-05-24 08:09:41 +02:00
JailDesigner d36ad7d1c5 refactor(scenes): renombra ancho/altura/centre_punt residuals a anglès 2026-05-24 08:03:28 +02:00
JailDesigner 7305d2f5dc refactor(scenes): renombra identificadors valencians de logo/title a anglès 2026-05-24 08:00:40 +02:00
JailDesigner 4cfad053f0 refactor(effects): renombra temps_vida/temps_max a elapsed_time/max_lifetime 2026-05-24 07:59:14 +02:00
JailDesigner 807f71ffa7 refactor(defaults): renombra VELOCITAT/CANVI_ANGLE/MAX_BALES a anglès 2026-05-24 07:57:12 +02:00
JailDesigner d12f24d798 refactor(enemy): renombra esta_/animacio_/timer_invulnerabilitat_ a anglès 2026-05-24 07:56:35 +02:00
JailDesigner f9d2539a45 refactor(enemy): renombra drotacio/rotacio/FACTOR_HERENCIA a anglès 2026-05-24 07:52:21 +02:00
JailDesigner 87bfccd14f refactor(enemy): renombra palpitacio* a pulse* 2026-05-24 07:46:07 +02:00
JailDesigner e5e3729215 refactor(enemies): renombra QUADRAT/MOLINILLO a SQUARE/PINWHEEL 2026-05-24 07:40:54 +02:00
JailDesigner 6210985548 Merge branch 'fix/shaders-glslc-optional': glslc opcional si els headers SPV ja estan al repo 2026-05-23 12:55:51 +02:00
JailDesigner 20250a0d6d fix(cmake): glslc opcional si els headers SPV ja estan commiteats al repo 2026-05-23 12:55:48 +02:00
JailDesigner e5616f7c3a Merge branch 'tweak/misc-adjustments': retocs varis (paleta, glow, audio, física, destell del títol) 2026-05-22 23:48:59 +02:00
JailDesigner 3b1e469a4f feat(title): destell hiperespacial al VP quan la nau desapareix (sparkle 4-puntes còncau) 2026-05-22 23:46:56 +02:00
JailDesigner 70ca19eb87 fix(wounded-chain): amplifier 1.25 perquè la cadena agafi el contacte post-rebot 2026-05-22 23:32:28 +02:00
JailDesigner 7e52eaeddb tweak(friendly-fire): la bala empeny la nau abans de morir → els debris hereten la inèrcia 2026-05-22 23:24:42 +02:00
JailDesigner d618b6d561 feat(audio): so propi per a la nau a HURT (hurt.wav, separat del HIT de bala) 2026-05-22 23:20:18 +02:00
JailDesigner e954d4ea59 tweak(playfield): rejilla violeta synthwave + brillos +5%; starfield unificat al color del títol 2026-05-22 23:10:06 +02:00
JailDesigner b1ee23cd20 tweak(stage-messages): missatges level start/completed amb color ambre del PRESS START 2026-05-22 23:02:23 +02:00
JailDesigner d86b10c14e tweak(collision): impuls extra a l'enemic en el moment que mata la nau (factor 0.3·mass·vel) 2026-05-22 22:59:27 +02:00
JailDesigner 1ea38d4f6a fix(ship-death): debris hereten inèrcia (captura velocitat abans del markHit) i comparteixen dispersió amb enemics 2026-05-22 22:47:02 +02:00
JailDesigner 26bd5a9efa tweak(playfield): el grid principal es dibuixa sobre el subgrid a les interseccions 2026-05-22 22:43:32 +02:00
JailDesigner 4b0d85c010 tweak(palette): colors neon purs per als 3 enemics (cyan/roig/magenta) 2026-05-22 22:39:04 +02:00
JailDesigner 149b485a9b Merge branch 'tweak/enemy-mix-stage1': ajustos d'enemics (mix stage 1, spawn col·lidible, ull al cuadrado) 2026-05-22 22:34:54 +02:00
JailDesigner 6b1f064cda tweak(cuadrado): ull amb pupil·la al centre del rombe 2026-05-22 22:34:54 +02:00
JailDesigner 1cef6a2c23 tweak(enemy): durant l'spawn ja poden ser abatuts i rebotar amb la nau (sense fer dany) 2026-05-22 22:27:44 +02:00
JailDesigner 007460dc51 tweak(stages): stage 1 amb mix dels 3 tipus d'enemic (34/33/33) 2026-05-22 22:12:17 +02:00
JailDesigner 10057a82de tweak(audio): amplifica hit.wav +6dB i puja canals simultanis a 50 2026-05-22 22:09:03 +02:00
JailDesigner 73fa5bf1d1 Merge branch 'tweak/firework-glow': halo neon per a fireworks amb color propi 2026-05-22 21:57:36 +02:00
JailDesigner c32b564da1 feat(firework): halo neon per partícula amb color de glow propi (explosió enemic: línia blanca + halo daurat) 2026-05-22 21:57:11 +02:00
JailDesigner 7b9b5ce569 Merge branch 'tweak/pentagon-design': halo neon proporcional i pentàgon doble 2026-05-22 21:38:29 +02:00
JailDesigner f0b3a1fbc4 feat(render): halo neon proporcional al bounding_radius de la shape (opt-out a text) 2026-05-22 21:35:01 +02:00
JailDesigner 869b4374ba tweak(pentagon): pentàgon doble concentric (interior rotat 36°) 2026-05-22 20:11:29 +02:00
JailDesigner ea192cd9de tweak(debug): l'overlay arranca ocult sempre; F11 segueix alternant-lo 2026-05-22 19:53:26 +02:00
JailDesigner 5d30f6be68 Merge branch 'tweak/playfield-grid': ones d'aigua + starfield parallax al fons 2026-05-22 19:52:07 +02:00
JailDesigner a342d79b86 feat(starfield): mou estrelles amb la mitjana de velocitats de les naus 2026-05-22 19:51:40 +02:00
JailDesigner 1db7368c9f feat(starfield): capa parallax al fons del playfield amb tint blanc-cyan 2026-05-22 19:46:57 +02:00
JailDesigner 88b002b277 feat(playfield): ones d'aigua a la rejilla per explosions i pas de nau 2026-05-22 19:22:09 +02:00
JailDesigner 044a3a3bbf tweak(playfield): subdivisions de 5 a 4 a la subgraella 2026-05-22 18:56:24 +02:00
JailDesigner 49070aa843 Merge branch 'fix/bullet-collision-swept': col·lisió bales swept + debris 2026-05-22 18:43:46 +02:00
JailDesigner 18e05e36e6 feat(bullet): debris en trencar-se amb so HIT mogut des d'enemy.herir() 2026-05-22 18:42:23 +02:00
JailDesigner bf79eecca0 fix(bullet): col·lisió swept, sense grace_timer, mor al border visual 2026-05-22 18:24:54 +02:00
JailDesigner b80216dce1 Merge branch 'feat/ship-hurt-state': estat HURT a la nau 2026-05-22 17:32:04 +02:00
JailDesigner 87138f9a1f feat(ship): la nau entra a HURT al xocar amb un enemic, mor en un segon impacte 2026-05-22 17:30:33 +02:00
JailDesigner c6560514d8 Merge branch 'feat/title-intro-sequence': intro coreografiada al títol 2026-05-22 14:05:57 +02:00
JailDesigner 839f73e1ef feat(title): intro amb path Z (zoom+pivot al VP) en lloc d'offset Y
El logo i el footer ara entren simulant un moviment 3D des de l'usuari
cap al VP: arrenquen grans i a la posició projectada extrema (factor
d'escala SCALE_START > 1, pivot al centre de pantalla) i convergeixen
a la seva mida i posició finals. Substitueix l'offset Y lineal anterior.
2026-05-22 14:03:28 +02:00
JailDesigner 2ca2062011 feat(title): intro coreografiada amb logo, footer i naus escalonats
Logo cau des de dalt; quan aterra, JAILGAMES i COPYRIGHT pugen des de
baix amb stagger pam-pam; després arrenquen les naus i, en aterrar
elles, apareix PRESS START. Magic numbers a Defaults::Title::Sequence.
2026-05-22 13:51:09 +02:00
JailDesigner 03209ee23b Merge branch 'feat/title-neon-palette': paleta neon synthwave a títol 2026-05-22 13:25:18 +02:00
JailDesigner c61299f17f feat(title): paleta neon synthwave per element a l'escena de títol 2026-05-22 13:04:11 +02:00
JailDesigner 880af293ef log: primer missatge 'Game start', últim 'Bye!' 2026-05-22 12:50:53 +02:00
JailDesigner 67c59992c9 Merge branch 'feat/sdl-callbacks': migració a SDL_MAIN_USE_CALLBACKS 2026-05-22 12:48:39 +02:00
JailDesigner be3d696f60 feat(main): activa SDL_MAIN_USE_CALLBACKS
main.cpp queda amb les 4 callbacks de SDL3: AppInit construeix el
Director, AppEvent enruta cada event a handleEvent(), AppIterate crida
iterate(), AppQuit reabsorbeix la propietat amb unique_ptr.
El Director::run() i el bucle while interns desapareixen; el bootstrap
de SDLManager/Audio/Context/DebugOverlay/Notifier viu ara al final del
constructor. SDL_Quit() ja no es crida explícitament — SDL ho fa
després de SDL_AppQuit.
2026-05-22 12:45:12 +02:00
JailDesigner 6b8f6a267d refactor(director): migra la persistència ConfigYaml al Director
main.cpp queda només amb 'Director director(argc, argv); return director.run()'.
El Director crida ConfigYaml::* directament; l'struct ConfigPersistence
desapareix de engine_config.hpp. La separació core/game es relaxa al
Director, que és EL programa, no part del motor.
2026-05-22 12:41:05 +02:00
JailDesigner 120b8ada38 refactor(director): extreu iterate/handleEvent/advanceScene del runFrameLoop
run() ara delega a iterate() i handleEvent() per cada frame.
runFrameLoop desapareix; la seva lògica es divideix entre els tres
nous mètodes. La primera escena es construeix lazy via advanceScene()
dins d'iterate(). Cap canvi de comportament visible.
2026-05-22 12:38:16 +02:00
JailDesigner 8bb052981d refactor(director): locals de run() a membres unique_ptr
Preparació per a SDL_MAIN_USE_CALLBACKS: SDLManager, SceneContext,
DebugOverlay i l'escena actual ja viuen com a membres del Director.
El flux de run() és idèntic; només canvia el storage.
2026-05-22 12:35:19 +02:00
JailDesigner 7fc8e48596 Merge branch 'feat/title-3d': escena del títol migrada a 3D real 2026-05-22 12:12:22 +02:00
JailDesigner ff518195f8 fix(title): comentari trencat per la substitució sed del cleanup 2026-05-22 12:06:48 +02:00
JailDesigner 54d3e683a1 refactor(title): la 3D és l'única — elimina backup 2D i renomena als noms canònics 2026-05-22 12:04:16 +02:00
JailDesigner a29c2b9cc2 fix(ship-3d): exit convergeix al VP sense travessar-lo (sense creuament entre naus) 2026-05-22 11:57:16 +02:00
JailDesigner 85e7e70767 feat(title-3d): horitzó ampliat (starfield Z=1500, naus exiting travessen el VP) 2026-05-22 11:50:26 +02:00
JailDesigner 3f10c61e22 tweak(ship-3d): SHIP_FLOAT_SCALE a 2.0 2026-05-22 11:40:47 +02:00
JailDesigner 5de9a5003b tweak(ship-3d): descans més amunt i naus més grans (FLOAT_SCALE 1.5, TARGET_DIST 480) 2026-05-22 11:30:54 +02:00
101 changed files with 5292 additions and 4142 deletions
+12 -6
View File
@@ -135,9 +135,8 @@ add_dependencies(${PROJECT_NAME} resource_pack)
# --- COMPILACIÓ DE SHADERS GLSL → SPIR-V (headers C++ embedits) ---
# Compila els shaders .glsl a SPIR-V i els converteix en headers C++ embedits
# (source/core/rendering/gpu/spv/*.h). Aquests headers es commiteen al repo:
# en macOS no cal glslc (els headers ja existeixen). En Linux/Windows glslc
# és obligatori per regenerar els headers en cada canvi del GLSL.
# (source/core/rendering/gpu/spv/*.h). Aquests headers es commiteen al repo,
# així que glslc només és necessari quan canvien els .glsl o falten headers.
#
# Per a macOS hi ha a més els headers MSL escrits a mà a source/core/rendering/gpu/msl/.
set(SHADERS_DIR "${CMAKE_SOURCE_DIR}/shaders")
@@ -156,6 +155,13 @@ set(ALL_SHADER_SOURCES
"${SHADERS_DIR}/postfx.frag.glsl"
"${SHADERS_DIR}/bloom.frag.glsl"
)
set(ALL_SHADER_HEADERS_PRESENT TRUE)
foreach(_spv_header IN LISTS ALL_SHADER_HEADERS)
if(NOT EXISTS "${_spv_header}")
set(ALL_SHADER_HEADERS_PRESENT FALSE)
break()
endif()
endforeach()
find_program(GLSLC_EXE NAMES glslc HINTS ${Vulkan_GLSLC_EXECUTABLE})
if(GLSLC_EXE)
add_custom_command(
@@ -172,10 +178,10 @@ if(GLSLC_EXE)
add_custom_target(shaders DEPENDS ${ALL_SHADER_HEADERS})
add_dependencies(${PROJECT_NAME} shaders)
message(STATUS "Shaders: glslc trobat (${GLSLC_EXE}); headers SPV es regeneraran si canvia el GLSL")
elseif(APPLE)
message(STATUS "Shaders: glslc no trobat en macOS — s'usaran els headers SPV ja commiteats")
elseif(ALL_SHADER_HEADERS_PRESENT)
message(STATUS "Shaders: glslc no trobat — s'usaran els headers SPV ja commiteats al repo")
else()
message(FATAL_ERROR "glslc no trobat: instal·la 'shaderc' o 'vulkan-sdk' per compilar shaders SPIR-V (obligatori a Linux/Windows)")
message(FATAL_ERROR "glslc no trobat i falten headers SPV: instal·la 'shaderc' o 'vulkan-sdk' per generar-los")
endif()
# --- STATIC ANALYSIS / FORMAT TARGETS ---
+84
View File
@@ -0,0 +1,84 @@
# Orni Attack - locale: Catala (valencia)
# Interficie traduida; pool in-game identic a en.yaml (es queda en angles).
# Tots els textos en ASCII: VectorText no suporta caracters accentuats.
notification:
press_again_exit: "PREMEU ESC UN ALTRE COP PER EIXIR"
zoom: "ZOOM: {z}X"
fullscreen_on: "PANTALLA COMPLETA"
fullscreen_off: "MODE FINESTRA"
vsync_on: "VSYNC ACTIU"
vsync_off: "VSYNC INACTIU"
antialias_on: "AA ACTIU"
antialias_off: "AA INACTIU"
postfx_on: "POSTPROCESSAT ACTIU"
postfx_off: "POSTPROCESSAT INACTIU"
locale_switched: "IDIOMA: {lang}"
language:
ca: "CATALA"
en: "ANGLES"
hud:
level: "NIVELL "
title:
press_start: "PREMEU START PER JUGAR"
game_screen:
game_over: "FI DEL JOC"
continue: "CONTINUAR"
continues_left: "CONTINUACIONS: {n}"
stage:
start:
- "ORNI ALERT!"
- "INCOMING ORNIS!"
- "ROLLING THREAT!"
- "ENEMY WAVE!"
- "WAVE OF ORNIS DETECTED!"
- "NEXT SWARM APPROACHING!"
- "BRACE FOR THE NEXT WAVE!"
- "ANOTHER ATTACK INCOMING!"
- "SENSORS DETECT HOSTILE ORNIS..."
- "UNIDENTIFIED ROLLING OBJECTS INBOUND!"
- "ENEMY FORCES MOBILIZING!"
- "PREPARE FOR IMPACT!"
completed: "GOOD JOB COMMANDER!"
service_menu:
title: "MENU DE SERVEI"
video: "VIDEO"
audio: "AUDIO"
options: "OPCIONS"
system: "SISTEMA"
controls: "CONTROLS"
back: "ENRERE"
exit: "EIXIR DEL JOC"
# Items del submenu VIDEO
video_zoom: "ZOOM"
video_fullscreen: "PANTALLA COMPLETA"
video_vsync: "VSYNC"
video_aa: "ANTIALIAS"
video_postfx: "POSTPROCESSAT"
video_resolution: "RESOLUCIO"
# Items del submenu OPCIONS
options_language: "IDIOMA"
options_show_info: "MOSTRAR INFO"
# Items del submenu AUDIO
audio_master: "AUDIO"
audio_master_volume: "VOLUM GENERAL"
audio_music: "MUSICA"
audio_music_volume: "VOLUM MUSICA"
audio_sound: "EFECTES"
audio_sound_volume: "VOLUM EFECTES"
# Items del submenu SISTEMA
system_restart: "REINICIAR"
# Pagines de confirmacio (estructura: titol + NO/SI)
confirm_restart: "ESTAS SEGUR DE REINICIAR?"
confirm_exit: "ESTAS SEGUR DE EIXIR?"
confirm_no: "NO"
confirm_yes: "SI"
# Valors comuns
value_on: "ACTIU"
value_off: "INACTIU"
+83
View File
@@ -0,0 +1,83 @@
# Orni Attack - locale: English
# In-game pool kept English in both locales per design.
notification:
press_again_exit: "PRESS ESC AGAIN TO EXIT"
zoom: "ZOOM: {z}X"
fullscreen_on: "FULLSCREEN"
fullscreen_off: "WINDOWED"
vsync_on: "VSYNC ON"
vsync_off: "VSYNC OFF"
antialias_on: "AA ON"
antialias_off: "AA OFF"
postfx_on: "POSTPROCESS ON"
postfx_off: "POSTPROCESS OFF"
locale_switched: "LANGUAGE: {lang}"
language:
ca: "CATALAN"
en: "ENGLISH"
hud:
level: "LEVEL "
title:
press_start: "PRESS START TO PLAY"
game_screen:
game_over: "GAME OVER"
continue: "CONTINUE"
continues_left: "CONTINUES LEFT: {n}"
stage:
start:
- "ORNI ALERT!"
- "INCOMING ORNIS!"
- "ROLLING THREAT!"
- "ENEMY WAVE!"
- "WAVE OF ORNIS DETECTED!"
- "NEXT SWARM APPROACHING!"
- "BRACE FOR THE NEXT WAVE!"
- "ANOTHER ATTACK INCOMING!"
- "SENSORS DETECT HOSTILE ORNIS..."
- "UNIDENTIFIED ROLLING OBJECTS INBOUND!"
- "ENEMY FORCES MOBILIZING!"
- "PREPARE FOR IMPACT!"
completed: "GOOD JOB COMMANDER!"
service_menu:
title: "SERVICE MENU"
video: "VIDEO"
audio: "AUDIO"
options: "OPTIONS"
system: "SYSTEM"
controls: "CONTROLS"
back: "BACK"
exit: "EXIT GAME"
# Items of VIDEO submenu
video_zoom: "ZOOM"
video_fullscreen: "FULLSCREEN"
video_vsync: "VSYNC"
video_aa: "ANTIALIAS"
video_postfx: "POSTPROCESS"
video_resolution: "RESOLUTION"
# Items of OPTIONS submenu
options_language: "LANGUAGE"
options_show_info: "SHOW INFO"
# Items of AUDIO submenu
audio_master: "AUDIO"
audio_master_volume: "MASTER VOLUME"
audio_music: "MUSIC"
audio_music_volume: "MUSIC VOLUME"
audio_sound: "SOUNDS"
audio_sound_volume: "SOUND VOLUME"
# Items of SYSTEM submenu
system_restart: "RESTART"
# Confirmation pages (structure: title + NO/YES)
confirm_restart: "REALLY RESTART?"
confirm_exit: "REALLY EXIT?"
confirm_no: "NO"
confirm_yes: "YES"
# Common values
value_on: "ON"
value_off: "OFF"
+5 -1
View File
@@ -1,7 +1,11 @@
# enemy_pentagon.shp - ORNI enemic (pentàgon regular, radi=20)
# enemy_pentagon.shp - ORNI enemic (pentàgon doble concentric, radi exterior=20)
name: enemy_pentagon
scale: 1.0
center: 0, 0
# Pentàgon exterior (vèrtex apuntant amunt, radi 20)
polyline: 0,-20 19.02,-6.18 11.76,16.18 -11.76,16.18 -19.02,-6.18 0,-20
# Pentàgon interior (radi 10, rotat 36° → vèrtex apuntant a les arestes exteriors)
polyline: 5.88,-8.09 9.51,3.09 0,10 -9.51,3.09 -5.88,-8.09 5.88,-8.09
+8 -1
View File
@@ -1,7 +1,14 @@
# enemy_square.shp - ORNI enemic (quadrat regular, radi=20)
# enemy_square.shp - ORNI enemic (rombe, radi=20) + ull amb pupil·la al centre
name: enemy_square
scale: 1.0
center: 0, 0
# Rombe exterior
polyline: 0,-20 20,0 0,20 -20,0 0,-20
# Ull (dos arcs units, forma d'almetlla). Amplada 20px, altura 8px.
polyline: -10,0 -5,-3 0,-4 5,-3 10,0 5,3 0,4 -5,3 -10,0
# Pupil·la (octàgon, radi 2) al centre
polyline: 0,-2 1.41,-1.41 2,0 1.41,1.41 0,2 -1.41,1.41 -2,0 -1.41,-1.41 0,-2
-28
View File
@@ -1,28 +0,0 @@
# ship2_perspective.shp - Nave P2 con perspectiva pre-calculada
# Posición optimizada: "4 del reloj" (Abajo-Derecha)
# Dirección: Volando hacia el fondo (centro pantalla)
name: ship2_perspective
scale: 1.0
center: 0, 0
# TRANSFORMACIÓN APLICADA:
# 1. Rotación -45° (apuntando al centro desde abajo-dcha)
# 2. Proyección de perspectiva:
# - Punta (p1): Reducida al 60% (simula lejanía)
# - Base (p2, p3): Aumentada al 110% (simula cercanía)
# 3. Flip horizontal (simétrica a ship_starfield.shp)
#
# Nuevos Punts (aprox):
# p1 (Punta): (-4, -4) -> Lejos, pequeña y apuntando arriba-izq
# p2 (Ala Izq): (-3, 11) -> Cerca, lado interior
# p4 (Base Cnt): (3, 5) -> Centro base
# p3 (Ala Dcha): (11, 2) -> Cerca, lado exterior (más grande)
#polyline: -4,-4 -3,11 3,5 11,2 -4,-4
polyline: -4,-4 -3,11 11,2 -4,-4
# Circulito central (octàgon r=2.5)
# Distintiu visual del jugador 2
# Sin perspectiva (está en el centro de la nave)
polyline: 0,-2.5 1.77,-1.77 2.5,0 1.77,1.77 0,2.5 -1.77,1.77 -2.5,0 -1.77,-1.77 0,-2.5
-21
View File
@@ -1,21 +0,0 @@
# ship_perspective.shp - Nave con perspectiva pre-calculada
# Posición optimizada: "8 del reloj" (Abajo-Izquierda)
# Dirección: Volando hacia el fondo (centro pantalla)
name: ship_perspective
scale: 1.0
center: 0, 0
# TRANSFORMACIÓN APLICADA:
# 1. Rotación +45° (apuntando al centro desde abajo-izq)
# 2. Proyección de perspectiva:
# - Punta (p1): Reducida al 60% (simula lejanía)
# - Base (p2, p3): Aumentada al 110% (simula cercanía)
#
# Nuevos Puntos (aprox):
# p1 (Punta): (4, -4) -> Lejos, pequeña y apuntando arriba-dcha
# p2 (Ala Dcha): (3, 11) -> Cerca, lado interior
# p4 (Base Cnt): (-3, 5) -> Centro base
# p3 (Ala Izq): (-11, 2) -> Cerca, lado exterior (más grande)
polyline: 4,-4 3,11 -3,5 -11,2 4,-4
+9
View File
@@ -0,0 +1,9 @@
# title_flash.shp - Sparkle 4-puntes amb costats còncaus (Atari-style)
# 4 puntes als cardinals (radi 30) i valls còncaus als 45° (corba Bezier
# quadràtica amb control point ±8). 5 punts per arc subdividint la corba.
name: title_flash
scale: 1.0
center: 0, 0
polyline: 0,-30 3.76,-21.76 8.64,-14.64 14.64,-8.64 21.76,-3.76 30,0 21.76,3.76 14.64,8.64 8.64,14.64 3.76,21.76 0,30 -3.76,21.76 -8.64,14.64 -14.64,8.64 -21.76,3.76 -30,0 -21.76,-3.76 -14.64,-8.64 -8.64,-14.64 -3.76,-21.76 0,-30
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -7,7 +7,7 @@ metadata:
description: "Progressive difficulty curve from novice to expert"
stages:
# STAGE 1: Tutorial - Only pentagons, slow speed
# STAGE 1: Tutorial - Mix de tots els tipus, velocitat lenta
- stage_id: 1
total_enemies: 50
spawn_config:
@@ -15,9 +15,9 @@ stages:
initial_delay: 0.3
spawn_interval: 0.4
enemy_distribution:
pentagon: 100
cuadrado: 0
molinillo: 0
pentagon: 34
cuadrado: 33
molinillo: 33
difficulty_multipliers:
speed_multiplier: 0.7
rotation_multiplier: 0.8
+17 -4
View File
@@ -238,14 +238,27 @@ auto Audio::effectiveVolume(float volume, bool channel_enabled) const -> float {
return (enabled_ && channel_enabled) ? volume * config_.volume : 0.0F;
}
// Estableix el volum dels sons (float 0.0..1.0)
// Estableix el volum dels sons (float 0.0..1.0). Actualitza el valor cachejat
// a config_ perquè els getters i les re-aplicacions internes (enableSound,
// setMasterVolume) puguin tornar al volum que l'usuari va triar.
void Audio::setSoundVolume(float sound_volume, Group group) {
engine_->setSoundVolume(effectiveVolume(sound_volume, sound_enabled_), static_cast<int>(group));
config_.sound_volume = std::clamp(sound_volume, MIN_VOLUME, MAX_VOLUME);
engine_->setSoundVolume(effectiveVolume(config_.sound_volume, sound_enabled_), static_cast<int>(group));
}
// Estableix el volum de la música (float 0.0..1.0)
// Estableix el volum de la música (float 0.0..1.0). Cf. setSoundVolume.
void Audio::setMusicVolume(float music_volume) {
engine_->setMusicVolume(effectiveVolume(music_volume, music_enabled_));
config_.music_volume = std::clamp(music_volume, MIN_VOLUME, MAX_VOLUME);
engine_->setMusicVolume(effectiveVolume(config_.music_volume, music_enabled_));
}
// Estableix el volum master (multiplicador aplicat a sound + music). Re-aplica
// els canals perquè el canvi tingui efecte immediat sense esperar al següent
// setSoundVolume/setMusicVolume explícit.
void Audio::setMasterVolume(float master_volume) {
config_.volume = std::clamp(master_volume, MIN_VOLUME, MAX_VOLUME);
setSoundVolume(config_.sound_volume);
setMusicVolume(config_.music_volume);
}
// Aplica una nueva configuración (substitueix la config cachejada i reaplica enables/volums)
+8
View File
@@ -101,6 +101,14 @@ class Audio {
// --- Control de volum (API interna: float 0.0..1.0) ---
void setSoundVolume(float volume, Group group = Group::ALL); // Ajusta el volum dels efectes
void setMusicVolume(float volume); // Ajusta el volum de la música
void setMasterVolume(float volume); // Ajusta el master (re-aplica sound + music)
// Getters dels volums actuals (lectura de la config_ cachejada). Reflexen
// el valor que l'usuari ha triat l'última vegada, independent del gating
// d'enabled/channel.
[[nodiscard]] auto getMasterVolume() const -> float { return config_.volume; }
[[nodiscard]] auto getSoundVolume() const -> float { return config_.sound_volume; }
[[nodiscard]] auto getMusicVolume() const -> float { return config_.music_volume; }
// --- Helpers de conversió para la capa de presentació ---
// UI (menús, notificacions) manega enters 0..100; internament viu float 0..1.
+1 -1
View File
@@ -46,7 +46,7 @@ namespace Ja {
};
// --- Constants ---
inline constexpr int MAX_SIMULTANEOUS_CHANNELS = 20;
inline constexpr int MAX_SIMULTANEOUS_CHANNELS = 50;
inline constexpr int MAX_GROUPS = 2;
// Cap superior de canals que poden estar simultàniament reproduint un so
// con efecte (eco/reverb). Si está al límit, las noves crides con efecte
+1 -11
View File
@@ -12,7 +12,6 @@
#include <SDL3/SDL.h>
#include <functional>
#include <string>
namespace Config {
@@ -65,16 +64,7 @@ namespace Config {
KeyboardBindings keyboard_controls{}; // Defaults globals per Input
GamepadBindings gamepad_controls{};
bool console{false};
};
// Capa de persistència delegada cap a l'EngineConfig. Permet al Director
// orquestrar init/load/save sense conèixer cap esquema concret (YAML,
// SQLite, ...) ni la capa que el conté (`game/config_yaml.cpp`).
struct ConfigPersistence {
std::function<void()> init_defaults; // Restaura valors per defecte
std::function<void(const std::string& path)> set_path; // Indica on guardar
std::function<bool()> load; // Llegeix path → EngineConfig
std::function<bool()> save; // Escriu EngineConfig → path
std::string locale{"ca"}; // "ca" | "en" — fixat a l'arrencada, sense hot-swap
};
} // namespace Config
+1
View File
@@ -26,6 +26,7 @@
#include "core/defaults/playfield.hpp"
#include "core/defaults/rendering.hpp"
#include "core/defaults/ship.hpp"
#include "core/defaults/starfield_parallax.hpp"
#include "core/defaults/title.hpp"
#include "core/defaults/trail.hpp"
#include "core/defaults/window.hpp"
+1
View File
@@ -39,6 +39,7 @@ namespace Defaults::Sound {
constexpr const char* EXPLOSION2 = "effects/explosion2.wav"; // Explosión alternativa
constexpr const char* FRIENDLY_FIRE_HIT = "effects/friendly_fire.wav"; // Friendly fire hit
constexpr const char* HIT = "effects/hit.wav"; // Enemic ferit (primer impacte → HURT)
constexpr const char* HURT = "effects/hurt.wav"; // Nau pròpia entra a HURT
constexpr const char* INIT_HUD = "effects/init_hud.wav"; // Para la animación del HUD
constexpr const char* LASER = "effects/laser_shoot.wav"; // Disparo
constexpr const char* LOGO = "effects/logo.wav"; // Logo
+42
View File
@@ -5,6 +5,48 @@
#include <SDL3/SDL.h>
namespace Defaults::FX::Glow {
// Neon glow per outline gruixut, aplicat automàticament per renderShape.
// Els gruixos d'halo són RÀTIOS del bounding_radius de la shape (escalat
// per scale), de manera que un pentàgon (radius 20) té halo gros i una bala
// (radius 3) té halo subtil. El core (últim pass) usa el gruix de línia
// global (1.5px) — no escala amb la shape.
//
// Cap superior: si la shape és molt gran (logos del títol, intro), el
// bounding_radius es satura a aquest valor — així cap shape té més
// glow que el pentàgon (referència de gameplay).
constexpr float MAX_REFERENCE_RADIUS = 20.0F;
struct Pass {
float thickness_ratio; // % del bounding_radius*scale. <0 → usa core (gruix global)
float alpha;
};
constexpr Pass PASSES[] = {
{.thickness_ratio = 0.55F, .alpha = 0.07F},
{.thickness_ratio = 0.35F, .alpha = 0.14F},
{.thickness_ratio = 0.20F, .alpha = 0.28F},
{.thickness_ratio = -1.0F, .alpha = 1.0F}, // core: línia "real"
};
// Glow per a línies "raw" (sense shape). Gruixos absoluts (px), no
// ratios — una línia individual no té bounding radius. Útil per a
// partícules de firework, sparks, etc.
namespace Line {
struct Pass {
float thickness; // px. <0 → usa el thickness passat pel caller (core)
float alpha;
};
constexpr Pass PASSES[] = {
{.thickness = 18.0F, .alpha = 0.10F},
{.thickness = 12.0F, .alpha = 0.20F},
{.thickness = 6.0F, .alpha = 0.40F},
{.thickness = -1.0F, .alpha = 1.0F}, // core: línia "real"
};
} // namespace Line
} // namespace Defaults::FX::Glow
namespace Defaults::FX::Firework {
// Color per defecte. La caller pot fer override (p.ex. heretar del pare),
+34 -34
View File
@@ -1,4 +1,4 @@
// enemies.hpp - Configuració per tipus d'enemic (Pentagon/Cuadrado/Molinillo), spawn i scoring
// enemies.hpp - Configuració per tipus d'enemic (Pentagon/Square/Molinillo), spawn i scoring
// © 2026 JailDesigner
#pragma once
@@ -17,57 +17,57 @@ namespace Defaults::Enemies {
// Pentagon (esquivador - zigzag evasion)
namespace Pentagon {
constexpr float VELOCITAT = 35.0F; // px/s (slightly slower)
constexpr float SPEED = 35.0F; // px/s (slightly slower)
constexpr float MASS = 5.0F; // Masa estándar
constexpr float CANVI_ANGLE_PROB = 0.20F; // 20% per wall hit (frequent zigzag)
constexpr float CANVI_ANGLE_MAX = 1.0F; // Max random angle change (rad)
constexpr float ANGLE_CHANGE_PROB = 0.20F; // 20% per wall hit (frequent zigzag)
constexpr float ANGLE_CHANGE_MAX = 1.0F; // Max random angle change (rad)
constexpr float ZIGZAG_PROB_PER_SECOND = 0.8F; // Probabilidad de zigzag por segundo
constexpr float DROTACIO_MIN = 0.75F; // Min visual rotation (rad/s) [+50%]
constexpr float DROTACIO_MAX = 3.75F; // Max visual rotation (rad/s) [+50%]
constexpr float ROTATION_DELTA_MIN = 0.75F; // Min visual rotation (rad/s) [+50%]
constexpr float ROTATION_DELTA_MAX = 3.75F; // Max visual rotation (rad/s) [+50%]
constexpr const char* SHAPE_FILE = "enemy_pentagon.shp";
} // namespace Pentagon
// Cuadrado (perseguidor - tracks player)
namespace Cuadrado {
constexpr float VELOCITAT = 40.0F; // px/s (medium speed)
// Square (perseguidor - tracks player)
namespace Square {
constexpr float SPEED = 40.0F; // px/s (medium speed)
constexpr float MASS = 8.0F; // Más pesado, "tanque"
constexpr float TRACKING_STRENGTH = 0.5F; // Interpolation toward player (0.0-1.0)
constexpr float TRACKING_INTERVAL = 1.0F; // Seconds between angle updates
constexpr float DROTACIO_MIN = 0.3F; // Slow rotation [+50%]
constexpr float DROTACIO_MAX = 1.5F; // [+50%]
constexpr float ROTATION_DELTA_MIN = 0.3F; // Slow rotation [+50%]
constexpr float ROTATION_DELTA_MAX = 1.5F; // [+50%]
constexpr const char* SHAPE_FILE = "enemy_square.shp";
} // namespace Cuadrado
} // namespace Square
// Molinillo (agressiu - fast straight lines, proximity spin-up)
namespace Molinillo {
constexpr float VELOCITAT = 50.0F; // px/s (fastest)
namespace Pinwheel {
constexpr float SPEED = 50.0F; // px/s (fastest)
constexpr float MASS = 4.0F; // Más liviano, ágil
constexpr float CANVI_ANGLE_PROB = 0.05F; // 5% per wall hit (rare direction change)
constexpr float CANVI_ANGLE_MAX = 0.3F; // Small angle adjustments
constexpr float DROTACIO_MIN = 3.0F; // Base rotation (rad/s) [+50%]
constexpr float DROTACIO_MAX = 6.0F; // [+50%]
constexpr float DROTACIO_PROXIMITY_MULTIPLIER = 3.0F; // Spin-up multiplier when near ship
constexpr float ANGLE_CHANGE_PROB = 0.05F; // 5% per wall hit (rare direction change)
constexpr float ANGLE_CHANGE_MAX = 0.3F; // Small angle adjustments
constexpr float ROTATION_DELTA_MIN = 3.0F; // Base rotation (rad/s) [+50%]
constexpr float ROTATION_DELTA_MAX = 6.0F; // [+50%]
constexpr float ROTATION_DELTA_PROXIMITY_MULTIPLIER = 3.0F; // Spin-up multiplier when near ship
constexpr float PROXIMITY_DISTANCE = 100.0F; // Distance threshold (px)
constexpr const char* SHAPE_FILE = "enemy_pinwheel.shp";
} // namespace Molinillo
} // namespace Pinwheel
// Animation parameters (shared)
namespace Animation {
// Palpitation
constexpr float PALPITACIO_TRIGGER_PROB = 0.01F; // 1% chance per second
constexpr float PALPITACIO_DURACIO_MIN = 1.0F; // Min duration (seconds)
constexpr float PALPITACIO_DURACIO_MAX = 3.0F; // Max duration (seconds)
constexpr float PALPITACIO_AMPLITUD_MIN = 0.08F; // Min scale variation
constexpr float PALPITACIO_AMPLITUD_MAX = 0.20F; // Max scale variation
constexpr float PALPITACIO_FREQ_MIN = 1.5F; // Min frequency (Hz)
constexpr float PALPITACIO_FREQ_MAX = 3.0F; // Max frequency (Hz)
constexpr float PULSE_TRIGGER_PROB = 0.01F; // 1% chance per second
constexpr float PULSE_DURATION_MIN = 1.0F; // Min duration (seconds)
constexpr float PULSE_DURATION_MAX = 3.0F; // Max duration (seconds)
constexpr float PULSE_AMPLITUD_MIN = 0.08F; // Min scale variation
constexpr float PULSE_AMPLITUD_MAX = 0.20F; // Max scale variation
constexpr float PULSE_FREQ_MIN = 1.5F; // Min frequency (Hz)
constexpr float PULSE_FREQ_MAX = 3.0F; // Max frequency (Hz)
// Rotation acceleration
constexpr float ROTACIO_ACCEL_TRIGGER_PROB = 0.02F; // 2% chance per second [4x more frequent]
constexpr float ROTACIO_ACCEL_DURACIO_MIN = 3.0F; // Min transition time
constexpr float ROTACIO_ACCEL_DURACIO_MAX = 8.0F; // Max transition time
constexpr float ROTACIO_ACCEL_MULTIPLIER_MIN = 0.3F; // Min speed multiplier [more dramatic]
constexpr float ROTACIO_ACCEL_MULTIPLIER_MAX = 4.0F; // Max speed multiplier [more dramatic]
constexpr float ROTATION_ACCEL_TRIGGER_PROB = 0.02F; // 2% chance per second [4x more frequent]
constexpr float ROTATION_ACCEL_DURATION_MIN = 3.0F; // Min transition time
constexpr float ROTATION_ACCEL_DURATION_MAX = 8.0F; // Max transition time
constexpr float ROTATION_ACCEL_MULTIPLIER_MIN = 0.3F; // Min speed multiplier [more dramatic]
constexpr float ROTATION_ACCEL_MULTIPLIER_MAX = 4.0F; // Max speed multiplier [more dramatic]
} // namespace Animation
// Wounded state (entre primer impacto y explosión)
@@ -94,8 +94,8 @@ namespace Defaults::Enemies {
// Scoring system (puntuación per type de enemy)
namespace Scoring {
constexpr int PENTAGON_SCORE = 100; // Pentágono (esquivador, 35 px/s)
constexpr int QUADRAT_SCORE = 150; // Cuadrado (perseguidor, 40 px/s)
constexpr int MOLINILLO_SCORE = 200; // Molinillo (agressiu, 50 px/s)
constexpr int SQUARE_SCORE = 150; // Square (perseguidor, 40 px/s)
constexpr int PINWHEEL_SCORE = 200; // Molinillo (agressiu, 50 px/s)
} // namespace Scoring
} // namespace Defaults::Enemies
+1 -1
View File
@@ -6,7 +6,7 @@
namespace Defaults::Entities {
constexpr int MAX_ORNIS = 15;
constexpr int MAX_BALES = 50;
constexpr int MAX_BULLETS = 50;
constexpr float SHIP_RADIUS = 12.0F;
constexpr float ENEMY_RADIUS = 20.0F;
+8 -3
View File
@@ -17,13 +17,18 @@ namespace Defaults::Game {
// Valores centinela del temporitzador de mort per-jugador.
constexpr float HIT_TIMER_INACTIVE_PLAYER = 999.0F; // Jugador permanentment inactiu
constexpr float HIT_TIMER_TRIGGER_DEATH = 0.001F; // Trigger inicial post-impacte (>0 sense disparar regla)
constexpr float COLLISION_SHIP_ENEMY_AMPLIFIER = 0.80F; // 80% hitbox (generous)
// Ha de ser ≥ 1.0F: PhysicsWorld separa els cossos al contacte exacte (dist == suma de radis),
// així que un amplificador < 1 fa que el check de gameplay no es dispari mai. Marge petit
// (1.05F) per tolerar floating-point i petites separacions post-impuls.
constexpr float COLLISION_SHIP_ENEMY_AMPLIFIER = 1.05F;
constexpr float COLLISION_BULLET_ENEMY_AMPLIFIER = 1.15F; // 115% hitbox (generous)
// Wounded chain: el rebot físic separa els cossos abans que arribi
// la detecció gameplay; amplier generós perquè el toc compti.
constexpr float COLLISION_WOUNDED_CHAIN_AMPLIFIER = 1.25F;
// Friendly fire system
constexpr bool FRIENDLY_FIRE_ENABLED = true; // Activar friendly fire
constexpr float COLLISION_BULLET_PLAYER_AMPLIFIER = 1.0F; // Hitbox exacto (100%)
constexpr float BULLET_GRACE_PERIOD = 0.2F; // Inmunidad post-disparo (s)
constexpr float BULLET_SPEED = 700.0F; // Velocidad escalar (px/s). Pascal: 7 px/frame × 20 FPS
// Transición LEVEL_START (mensajes aleatorios PRE-level)
@@ -54,7 +59,7 @@ namespace Defaults::Game {
constexpr float INIT_HUD_SHIP2_RATIO_INIT = 0.20F;
constexpr float INIT_HUD_SHIP2_RATIO_END = 1.0F;
// Posición inicial de la nave en INIT_HUD (75% de altura de zona de juego)
// Posición inicial de la nave en INIT_HUD (75% de altura de zone de juego)
constexpr float INIT_HUD_SHIP_START_Y_RATIO = 0.75F; // 75% desde el top de PLAYAREA
// Spawn positions (distribución horizontal para 2 jugadores)
+3 -3
View File
@@ -16,9 +16,9 @@ namespace Defaults::Palette {
// visible quan el halo s'expandeix.
constexpr SDL_Color SHIP = {.r = 255, .g = 255, .b = 255, .a = 255}; // Blanco neutro
constexpr SDL_Color BULLET = {.r = 155, .g = 255, .b = 175, .a = 255}; // Verde laser
constexpr SDL_Color PENTAGON = {.r = 155, .g = 195, .b = 255, .a = 255}; // Azul "esquivador"
constexpr SDL_Color QUADRAT = {.r = 255, .g = 140, .b = 140, .a = 255}; // Rojo "tank"
constexpr SDL_Color MOLINILLO = {.r = 255, .g = 160, .b = 255, .a = 255}; // Magenta agresivo
constexpr SDL_Color PENTAGON = {.r = 0, .g = 255, .b = 255, .a = 255}; // Cyan pur "esquivador"
constexpr SDL_Color SQUARE = {.r = 255, .g = 0, .b = 0, .a = 255}; // Roig pur "tank"
constexpr SDL_Color PINWHEEL = {.r = 255, .g = 0, .b = 255, .a = 255}; // Magenta pur "agressiu"
constexpr SDL_Color WOUNDED = {.r = 255, .g = 220, .b = 60, .a = 255}; // Dorado: enemigo herido
} // namespace Defaults::Palette
+15 -7
View File
@@ -18,13 +18,21 @@ namespace Defaults::Physics {
constexpr float IMPACT_MOMENTUM_FACTOR = 3.0F; // Factor de transferència de moment bala→enemic
} // namespace Bullet
// Ship → enemy: impuls explícit aplicat a l'enemic en el moment exacte
// que la nau mor per col·lisió amb ell (afegit per damunt del rebot
// natural de PhysicsWorld, que ja és present però subtil amb la
// damping de la nau).
namespace Ship {
constexpr float DEATH_IMPACT_MOMENTUM_FACTOR = 0.3F;
} // namespace Ship
// Explosions (debris physics)
namespace Debris {
constexpr float VELOCITAT_BASE = 80.0F; // Velocidad inicial (px/s)
constexpr float VARIACIO_VELOCITAT = 40.0F; // ±variació aleatòria (px/s)
constexpr float SPEED_BASE = 80.0F; // Velocidad inicial (px/s)
constexpr float VARIACIO_SPEED = 40.0F; // ±variació aleatòria (px/s)
constexpr float ACCELERACIO = -60.0F; // Fricció/desacceleració (px/s²)
constexpr float ROTACIO_MIN = 0.1F; // Rotación mínima (rad/s ~5.7°/s)
constexpr float ROTACIO_MAX = 0.3F; // Rotación màxima (rad/s ~17.2°/s)
constexpr float ROTATION_MIN = 0.1F; // Rotación mínima (rad/s ~5.7°/s)
constexpr float ROTATION_MAX = 0.3F; // Rotación màxima (rad/s ~17.2°/s)
constexpr float TEMPS_VIDA = 2.0F; // Vida mínima garantida (s) — després pot morir per velocitat baixa
constexpr float TEMPS_VIDA_NAU = 3.0F; // Ship debris min lifetime (matches DEATH_DURATION)
constexpr float SHRINK_RATE = 1.0F; // Reducció de mida (1.0 = encoge a 0 al final del min_lifetime)
@@ -40,8 +48,8 @@ namespace Defaults::Physics {
constexpr float RESTITUTION_BOUNDS = 0.7F;
// Herència de velocity angular (trayectorias curvas)
constexpr float FACTOR_HERENCIA_MIN = 0.7F; // Mínimo 70% del drotacio heredat
constexpr float FACTOR_HERENCIA_MAX = 1.0F; // Màxim 100% del drotacio heredat
constexpr float INHERITANCE_FACTOR_MIN = 0.7F; // Mínimo 70% del drotacio heredat
constexpr float INHERITANCE_FACTOR_MAX = 1.0F; // Màxim 100% del drotacio heredat
constexpr float FRICCIO_ANGULAR = 0.5F; // Desacceleració angular (rad/s²)
// Velocity heredada de la nau a l'explosió (80% del feel original).
@@ -60,7 +68,7 @@ namespace Defaults::Physics {
// Angular velocity sin for trajectory inheritance
// Excess above this threshold is converted to tangential linear velocity
// Prevents "vortex trap" problem with high-rotation enemies
constexpr float VELOCITAT_ROT_MAX = 1.5F; // rad/s (~86°/s)
constexpr float SPEED_ROT_MAX = 1.5F; // rad/s (~86°/s)
} // namespace Debris
} // namespace Defaults::Physics
+34 -17
View File
@@ -3,16 +3,21 @@
#pragma once
#include <SDL3/SDL.h>
namespace Defaults::Playfield {
// Estructura de la graella (cel·les omplen tota la PLAYAREA)
constexpr int COLUMNS = 16; // cell_w = PLAYAREA.w / 16
constexpr int ROWS = 8; // cell_h = PLAYAREA.h / 8
constexpr int SUBDIVISIONS = 5; // cada cel·la principal es divideix en N subcel·les
constexpr int SUBDIVISIONS = 4; // cada cel·la principal es divideix en N subcel·les
// Brillo respecte al color global (border = 1.0)
constexpr float GRID_BRIGHTNESS = 0.15F;
constexpr float SUBGRID_BRIGHTNESS = 0.05F;
constexpr float GRID_BRIGHTNESS = 0.20F;
constexpr float SUBGRID_BRIGHTNESS = 0.10F;
// Color de la rejilla (lila/violeta synthwave). Es modula amb brillantor.
constexpr SDL_Color GRID_COLOR = {.r = 160, .g = 80, .b = 255, .a = 255};
// Animació de creació amb timer intern del Playfield.
// L'animació total cobreix tot l'INIT_HUD (3 s). Cada línia es pinta en
@@ -25,20 +30,32 @@ namespace Defaults::Playfield {
constexpr float HEAD_LENGTH_PX = 8.0F; // longitud en píxels lògics del tram brillant
constexpr float HEAD_BRIGHTNESS = 0.0F; // brillo del cap (= border)
// Orbit (oscil·lació transversal de la línia quan la nau hi passa a prop).
constexpr float ORBIT_AMPLITUDE_MAX_PX = 3.0F; // desplaçament transversal màxim
constexpr float ORBIT_DECAY_PER_S = 4.0F; // decaiment de l'amplitud (px/s)
constexpr float ORBIT_FREQ_HZ = 8.0F; // freqüència del sin
constexpr float ORBIT_PROXIMITY_PX = 12.0F; // distància max de la línia per excitar-la
constexpr float ORBIT_SHIP_SPEED_THRESHOLD = 60.0F; // velocitat mínima per excitar (px/s)
// Ripples: deformacions circulars que travessen la graella com ones d'aigua.
// Cada ripple desplaça radialment cap a fora els vèrtexs de les línies que
// travessa, amb una envoltant que decau a les vores de l'anell i amb el temps.
namespace Ripple {
constexpr int POOL_SIZE = 32;
// Pulse (reacció a fireworks: punt brillant que es propaga al llarg de la
// línia a partir del punt de spawn).
constexpr int MAX_PULSES_PER_LINE = 2;
constexpr float PULSE_LIFETIME_S = 1.0F; // temps total fins desaparèixer
constexpr float PULSE_SPREAD_PER_S = 300.0F; // px/s de propagació (cap a cada extrem)
constexpr unsigned char PULSE_COLOR_R = 180;
constexpr unsigned char PULSE_COLOR_G = 230;
constexpr unsigned char PULSE_COLOR_B = 255;
// Ones grans (explosions / fireworks).
constexpr float BIG_AMPLITUDE_PX = 10.0F;
constexpr float BIG_SPEED_PX_S = 320.0F;
constexpr float BIG_LIFETIME_S = 1.4F;
constexpr float BIG_THICKNESS_PX = 40.0F;
// Ones petites (pas de nau, cadència estil trail).
constexpr float SMALL_AMPLITUDE_PX = 2.5F;
constexpr float SMALL_SPEED_PX_S = 160.0F;
constexpr float SMALL_LIFETIME_S = 0.55F;
constexpr float SMALL_THICKNESS_PX = 18.0F;
// Cadència "soltar gotetes" per nau (patró TrailManager).
constexpr float SHIP_COOLDOWN_S = 0.10F;
constexpr float SHIP_COOLDOWN_JITTER_S = 0.03F;
constexpr float SHIP_SPEED_THRESHOLD_PX_S = 80.0F;
// Subdivisió de línies quan estan dins una ripple.
constexpr int MAIN_SEGMENTS = 24; // línies principals
constexpr int SUB_SEGMENTS = 12; // sub-graella
} // namespace Ripple
} // namespace Defaults::Playfield
+63
View File
@@ -0,0 +1,63 @@
// service_menu.hpp - Constants del menu de servei (F12)
// © 2026 JailDesigner
#pragma once
#include <SDL3/SDL.h>
namespace Defaults::ServiceMenu {
// ---- Mides en coordenades logiques del joc (1280×720) ----
// BOX_WIDTH_MIN es el minim: si el titol o algun item no hi caben, el
// marc s'expandeix dinamicament amb animacio (cf. WIDTH_RATE).
constexpr int BOX_WIDTH_MIN = 460;
constexpr int GAP_Y = 22;
constexpr int TITLE_HEIGHT = 36; // scale 0.85 ≈ 34 px de text
constexpr int SUBTITLE_HEIGHT = 18; // scale 0.4 ≈ 16 px de text
constexpr int SEPARATOR_HEIGHT = 1;
constexpr int ITEM_HEIGHT = 38; // scale 0.55 ≈ 22 px de text + padding per al highlight
constexpr int ITEM_GAP_Y = 6;
// Brackets als 4 cantons (substitueixen la vora completa: estètica sci-fi).
constexpr int CORNER_ARM_H = 48;
constexpr int CORNER_ARM_V = 28;
constexpr int CORNER_THICKNESS = 2;
// ---- Animacio open/close (mateixos parametres que aee_arcade) ----
constexpr float OPEN_SPEED = 8.0F; // ~125 ms a obrir
constexpr float CLOSE_SPEED = 10.0F; // ~100 ms a tancar
constexpr float HEIGHT_RATE = 12.0F; // smoothing exponencial de l'alçada de la caixa
constexpr float WIDTH_RATE = 12.0F; // smoothing per a canvis d'ample entre pagines
// ---- Animacio del highlight (rectangle del cursor) ----
// Rate=18 dona settling ~0.17 s al 95% (ease-out exponencial).
constexpr float HIGHLIGHT_RATE = 18.0F;
constexpr int HIGHLIGHT_TICK_LEN = 10; // longitud dels ticks a cada cantonada
constexpr int HIGHLIGHT_THICKNESS = 1;
constexpr int HIGHLIGHT_PAD_X = 18; // padding lateral del rect respecte al text
constexpr int HIGHLIGHT_PAD_Y = 4; // padding vertical
constexpr int TEXT_INSET_X = 16; // marge intern del text dins del highlight (label esq / valor dre)
constexpr int MIN_LABEL_VALUE_GAP = 30; // mínim gap entre label i valor (per al càlcul d'ample dinàmic)
// ---- Colors RGBA ----
constexpr SDL_Color BG_COLOR{.r = 0, .g = 12, .b = 24, .a = 215};
constexpr SDL_Color CORNER_COLOR{.r = 120, .g = 220, .b = 255, .a = 255}; // cian neon
constexpr SDL_Color TITLE_COLOR{.r = 200, .g = 240, .b = 255, .a = 255};
constexpr SDL_Color SUBTITLE_COLOR{.r = 110, .g = 170, .b = 210, .a = 220}; // cian apagat
constexpr SDL_Color SEPARATOR_COLOR{.r = 60, .g = 120, .b = 180, .a = 180};
constexpr SDL_Color LABEL_COLOR{.r = 170, .g = 210, .b = 240, .a = 255};
constexpr SDL_Color CURSOR_COLOR{.r = 255, .g = 230, .b = 120, .a = 255}; // groc per al text sel·leccionat
constexpr SDL_Color HIGHLIGHT_OUTLINE{.r = 255, .g = 230, .b = 120, .a = 255}; // mateix groc, opac
constexpr SDL_Color HIGHLIGHT_FILL{.r = 255, .g = 230, .b = 120, .a = 36}; // wash translucid
// ---- Tipografia (VectorText). Scale 1.0 = caracter 20×40 px ----
constexpr float TITLE_SCALE = 0.85F; // mateixa escala que el HUD del scoreboard
constexpr float SUBTITLE_SCALE = 0.40F; // sota el titol, info decorativa (versio/hash)
constexpr float ITEM_SCALE = 0.55F; // mateixa escala que les notificacions
constexpr float TEXT_SPACING = 2.0F;
// ---- Sons UI (relatius a data/sounds/), portats d'aee_arcade ----
constexpr const char* SELECT_SOUND = "ui/menu_select.wav";
constexpr const char* ACCEPT_SOUND = "ui/menu_accept.wav";
} // namespace Defaults::ServiceMenu
+6
View File
@@ -24,4 +24,10 @@ namespace Defaults::Ship {
constexpr float VISUAL_PUSH_DIVISOR = 33.33F; // SPEED / DIVISOR = empuje visual
constexpr float VISUAL_SCALE_DIVISOR = 12.0F; // SCALE = 1 + (PUSH / DIVISOR)
// Estat "ferit": entre primera col·lisió amb enemic i recuperació o segona col·lisió mortal.
namespace Hurt {
constexpr float DURATION = 15.0F; // Segons en estat ferit (provisional)
constexpr float BLINK_HZ = 10.0F; // Freqüència parpelleig color normal ↔ ferit
} // namespace Hurt
} // namespace Defaults::Ship
@@ -0,0 +1,36 @@
// starfield_parallax.hpp - Capa de fons del playfield: estrelles 2D amb parallax
// © 2026 JailDesigner
//
// 3 capes de profunditat. Cada capa té estrelles amb brillantor, mida i
// factor parallax propis. Les més properes són més brillants i grans i es
// mouen més ràpid quan el món es desplaça; les més llunyanes són tènues i
// petites i amb prou feines es mouen.
#pragma once
namespace Defaults::StarfieldParallax {
namespace Far {
constexpr int COUNT = 60;
constexpr float BRIGHTNESS = 0.15F;
constexpr float PARALLAX_FACTOR = 0.15F; // multiplicador sobre world_velocity
constexpr int SIZE_PX = 1; // 1 px (punt)
} // namespace Far
namespace Mid {
constexpr int COUNT = 50;
constexpr float BRIGHTNESS = 0.30F;
constexpr float PARALLAX_FACTOR = 0.35F;
constexpr int SIZE_PX = 2; // creu de 3x3 (extensió ±1)
} // namespace Mid
namespace Near {
constexpr int COUNT = 40;
constexpr float BRIGHTNESS = 0.55F;
constexpr float PARALLAX_FACTOR = 0.70F;
constexpr int SIZE_PX = 3; // creu de 5x5 (extensió ±2)
} // namespace Near
constexpr int TOTAL_COUNT = Far::COUNT + Mid::COUNT + Near::COUNT;
} // namespace Defaults::StarfieldParallax
+46 -5
View File
@@ -3,6 +3,8 @@
#pragma once
#include <SDL3/SDL.h>
#include <cmath>
#include "core/defaults/game.hpp"
@@ -66,7 +68,7 @@ namespace Defaults::Title {
constexpr float FLOATING_SCALE = 1.0F * SHIP_BASE_SCALE; // Flotante: scale base
// Offset de entrada (ajustat automáticoament a l'scale)
// Fórmula: (radi màxim de la ship * scale de entrada) + margen
// Fórmula: (radius màxim de la ship * scale de entrada) + margen
constexpr float ENTRY_OFFSET = (SHIP_MAX_RADIUS * ENTRY_SCALE_START) + ENTRY_OFFSET_MARGIN;
// Vec2 de fuga (centro para l'animación de salida)
@@ -79,7 +81,7 @@ namespace Defaults::Title {
// Durades de animación
constexpr float ENTRY_DURATION = 2.0F; // Entrada (segons)
constexpr float EXIT_DURATION = 1.0F; // Salida (segons)
constexpr float EXIT_DURATION = 1.5F; // Salida (segons)
// Flotació (oscil·lació reduïda y diferenciada per ship)
constexpr float FLOAT_AMPLITUDE_X = 4.0F; // Amplitud X (píxels)
@@ -94,9 +96,6 @@ namespace Defaults::Title {
constexpr float P1_ENTRY_DELAY = 0.0F; // P1 entra immediatament
constexpr float P2_ENTRY_DELAY = 0.5F; // P2 entra 0.5s después
// Delay global antes de start l'animación de entrada al state MAIN
constexpr float ENTRANCE_DELAY = 5.0F; // Temps de espera antes que las naves entrin
// Multiplicadors de freqüència para cada ship (variació sutil ±12%)
constexpr float P1_FREQUENCY_MULTIPLIER = 0.88F; // 12% més lenta
constexpr float P2_FREQUENCY_MULTIPLIER = 1.12F; // 12% més ràpida
@@ -126,4 +125,46 @@ namespace Defaults::Title {
constexpr float TEXT_SPACING = 2.0F;
} // namespace Layout
// Coreografia de la seqüència d'entrada al state MAIN.
// Tots els elements (logo, footer, naus, press start) entren ordenadament
// segons aquests thresholds. Vegeu title_scene.cpp/updateMainState.
//
// Per al logo i el footer, l'efecte simula un moviment 3D des de l'usuari
// cap al VP: el text arrenca gran i a la posició projectada extrema (com
// si estigués prop de la càmera, fora de pantalla) i acaba a la seva
// posició final amb escala normal (com si hagués aterrat al VP). Pivot:
// centre de pantalla (= projecció del VP 3D).
namespace Sequence {
// Factor d'escala inicial. >1 = sprite gran a l'inici (prop de l'usuari).
// La posició inicial es deriva: pivot=centre, delta multiplicat per aquest factor.
constexpr float LOGO_INTRO_SCALE_START = 2.5F;
constexpr float FOOTER_INTRO_SCALE_START = 2.5F;
// Durades de les animacions d'entrada (segons).
constexpr float LOGO_ENTRY_DURATION = 1.2F;
constexpr float JAILGAMES_ENTRY_DURATION = 0.7F;
constexpr float COPYRIGHT_ENTRY_DURATION = 0.7F;
// Stagger "pam-pam" entre l'arrencada de JAILGAMES i la de COPYRIGHT.
constexpr float COPYRIGHT_STAGGER = 0.18F;
// Delays entre etapes.
constexpr float SHIPS_DELAY_AFTER_FOOTER = 0.20F;
constexpr float PRESS_START_DELAY_AFTER_SHIPS = 0.40F;
} // namespace Sequence
// Paleta neon de l'escena de títol (cian + magenta synthwave).
// alpha = 255 (sentinela "color vàlid") fa que el pipeline ignori
// el color global de l'oscil·lador per a aquesta crida.
namespace Colors {
constexpr SDL_Color LOGO_MAIN = {.r = 80, .g = 240, .b = 255, .a = 255}; // Cian elèctric
constexpr SDL_Color LOGO_SHADOW = {.r = 255, .g = 60, .b = 180, .a = 255}; // Magenta neon (offset)
constexpr SDL_Color SHIP_P1 = {.r = 255, .g = 100, .b = 200, .a = 255}; // Rosa hot
constexpr SDL_Color SHIP_P2 = {.r = 160, .g = 120, .b = 255, .a = 255}; // Violeta elèctric
constexpr SDL_Color STARFIELD = {.r = 200, .g = 220, .b = 255, .a = 255}; // Blanc-blau gel
constexpr SDL_Color PRESS_START = {.r = 255, .g = 200, .b = 70, .a = 255}; // Ambre neon
constexpr SDL_Color JAILGAMES_LOGO = {.r = 120, .g = 220, .b = 200, .a = 255}; // Teal suau
constexpr SDL_Color COPYRIGHT = {.r = 140, .g = 180, .b = 200, .a = 255}; // Gris-cian apagat
} // namespace Colors
} // namespace Defaults::Title
+10 -10
View File
@@ -23,12 +23,12 @@ namespace Graphics {
}
void Border::bumpAt(Vec2 contact_point, float strength) {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
const std::array<float, SIDE_COUNT> DISTANCES = {
/* TOP */ std::abs(contact_point.y - zona.y),
/* RIGHT */ std::abs((zona.x + zona.w) - contact_point.x),
/* BOTTOM */ std::abs((zona.y + zona.h) - contact_point.y),
/* LEFT */ std::abs(contact_point.x - zona.x)};
/* TOP */ std::abs(contact_point.y - zone.y),
/* RIGHT */ std::abs((zone.x + zone.w) - contact_point.x),
/* BOTTOM */ std::abs((zone.y + zone.h) - contact_point.y),
/* LEFT */ std::abs(contact_point.x - zone.x)};
int closest_idx = 0;
float closest_dist = DISTANCES[0];
@@ -71,11 +71,11 @@ namespace Graphics {
} // namespace
void Border::draw() const {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const int X1 = static_cast<int>(zona.x);
const int Y1 = static_cast<int>(zona.y);
const int X2 = static_cast<int>(zona.x + zona.w);
const int Y2 = static_cast<int>(zona.y + zona.h);
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
const int X1 = static_cast<int>(zone.x);
const int Y1 = static_cast<int>(zone.y);
const int X2 = static_cast<int>(zone.x + zone.w);
const int Y2 = static_cast<int>(zone.y + zone.h);
const int OFF_TOP = static_cast<int>(sides_[SIDE_TOP].displacement_px);
const int OFF_RIGHT = static_cast<int>(sides_[SIDE_RIGHT].displacement_px);
+207 -168
View File
@@ -5,8 +5,8 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <limits>
#include "core/defaults.hpp"
#include "core/rendering/line_renderer.hpp"
@@ -21,20 +21,38 @@ namespace Graphics {
return 1.0F - (INV * INV * INV);
}
// Lerp del color base actual (oscil·lador) cap a un color destí en
// funció de f ∈ [0, 1]. Alpha > 0 perquè line_renderer l'usi directe.
auto lerpColor(SDL_Color target, float f) -> SDL_Color {
const float CLAMPED = std::clamp(f, 0.0F, 1.0F);
const SDL_Color BASE = Rendering::getLineColor();
const auto LERP_U8 = [&](unsigned char a, unsigned char b) {
const float OUT = (static_cast<float>(a) * (1.0F - CLAMPED)) + (static_cast<float>(b) * CLAMPED);
return static_cast<unsigned char>(OUT);
};
return SDL_Color{
.r = LERP_U8(BASE.r, target.r),
.g = LERP_U8(BASE.g, target.g),
.b = LERP_U8(BASE.b, target.b),
.a = 255};
auto randUniform(float min_v, float max_v) -> float {
const float NORM = static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
return min_v + (NORM * (max_v - min_v));
}
// Desplaçament radial acumulat al punt (px, py) sumant totes les ripples
// que el toquen. Retorna {dx, dy} a sumar a la posició original.
auto computeRippleDisplacement(float px, float py, const Playfield::Ripple* const* hits, int n_hits) -> Vec2 {
float dx_total = 0.0F;
float dy_total = 0.0F;
for (int i = 0; i < n_hits; i++) {
const auto& r = *hits[i];
const float RADIUS = r.age_s * r.speed_px_s;
const float THICKNESS = r.thickness_px;
const float DX = px - r.center.x;
const float DY = py - r.center.y;
const float D = std::sqrt((DX * DX) + (DY * DY));
if (D < 0.001F) {
continue; // centre exacte: no hi ha direcció radial
}
const float PHASE = (D - RADIUS) / THICKNESS;
if (std::fabs(PHASE) >= 1.0F) {
continue; // fora de l'anell d'aquesta ripple
}
const float ENVELOPE = std::cos(PHASE * Defaults::Math::PI * 0.5F);
const float AMP_EFF = r.amplitude_px * (1.0F - (r.age_s / r.lifetime_s));
const float UX = DX / D;
const float UY = DY / D;
dx_total += UX * AMP_EFF * ENVELOPE;
dy_total += UY * AMP_EFF * ENVELOPE;
}
return Vec2{.x = dx_total, .y = dy_total};
}
} // namespace
@@ -46,101 +64,86 @@ namespace Graphics {
void Playfield::update(float delta_time) {
elapsed_s_ += delta_time;
// Decau l'orbit i avança la fase del sin per cada línia.
const float ORBIT_DELTA_PHASE = Defaults::Playfield::ORBIT_FREQ_HZ * 2.0F * Defaults::Math::PI * delta_time;
const float ORBIT_DEC = Defaults::Playfield::ORBIT_DECAY_PER_S * delta_time;
for (auto& line : lines_) {
line.orbit_phase += ORBIT_DELTA_PHASE;
line.orbit_amplitude = std::max(0.0F, line.orbit_amplitude - ORBIT_DEC);
// Avança els pulses; els desactiva quan acaben de vida.
for (auto& pulse : line.pulses) {
if (!pulse.active) {
for (auto& ripple : ripples_) {
if (!ripple.active) {
continue;
}
pulse.age_s += delta_time;
if (pulse.age_s >= Defaults::Playfield::PULSE_LIFETIME_S) {
pulse.active = false;
}
ripple.age_s += delta_time;
if (ripple.age_s >= ripple.lifetime_s) {
ripple.active = false;
}
}
}
void Playfield::spawnPulseAt(Line& line, float center_t) {
for (auto& pulse : line.pulses) {
if (!pulse.active) {
pulse.active = true;
pulse.age_s = 0.0F;
pulse.center_t = std::clamp(center_t, 0.0F, 1.0F);
auto Playfield::findFreeRipple() -> Ripple* {
Ripple* oldest = nullptr;
for (auto& ripple : ripples_) {
if (!ripple.active) {
return &ripple;
}
if (oldest == nullptr || ripple.age_s > oldest->age_s) {
oldest = &ripple;
}
}
return oldest; // pool ple: substituïm la més vella
}
void Playfield::spawnBig(Vec2 pos) {
Ripple* r = findFreeRipple();
if (r == nullptr) {
return;
}
}
// Cap slot lliure: substituïm el més vell.
Pulse* oldest = line.pulses.data();
for (auto& pulse : line.pulses) {
if (pulse.age_s > oldest->age_s) {
oldest = &pulse;
}
}
oldest->active = true;
oldest->age_s = 0.0F;
oldest->center_t = std::clamp(center_t, 0.0F, 1.0F);
r->center = pos;
r->age_s = 0.0F;
r->lifetime_s = Defaults::Playfield::Ripple::BIG_LIFETIME_S;
r->speed_px_s = Defaults::Playfield::Ripple::BIG_SPEED_PX_S;
r->amplitude_px = Defaults::Playfield::Ripple::BIG_AMPLITUDE_PX;
r->thickness_px = Defaults::Playfield::Ripple::BIG_THICKNESS_PX;
r->active = true;
}
void Playfield::notifyFireworkSpawn(Vec2 pos) {
// Línia vertical més propera (per posició x) i horitzontal més propera (per y).
Line* closest_v = nullptr;
Line* closest_h = nullptr;
float min_dx = std::numeric_limits<float>::max();
float min_dy = std::numeric_limits<float>::max();
for (auto& line : lines_) {
if (line.is_vertical) {
const float DX = std::abs(pos.x - line.start.x);
if (DX < min_dx) {
min_dx = DX;
closest_v = &line;
}
} else {
const float DY = std::abs(pos.y - line.start.y);
if (DY < min_dy) {
min_dy = DY;
closest_h = &line;
}
}
}
if (closest_v != nullptr) {
const float LINE_LEN = closest_v->end.y - closest_v->start.y;
const float CENTER_T = (LINE_LEN > 0.0F) ? (pos.y - closest_v->start.y) / LINE_LEN : 0.5F;
spawnPulseAt(*closest_v, CENTER_T);
}
if (closest_h != nullptr) {
const float LINE_LEN = closest_h->end.x - closest_h->start.x;
const float CENTER_T = (LINE_LEN > 0.0F) ? (pos.x - closest_h->start.x) / LINE_LEN : 0.5F;
spawnPulseAt(*closest_h, CENTER_T);
}
}
void Playfield::notifyShipPass(Vec2 pos, float speed_px_s) {
if (speed_px_s < Defaults::Playfield::ORBIT_SHIP_SPEED_THRESHOLD) {
void Playfield::spawnSmall(Vec2 pos) {
Ripple* r = findFreeRipple();
if (r == nullptr) {
return;
}
const float MAX_DIST = Defaults::Playfield::ORBIT_PROXIMITY_PX;
for (auto& line : lines_) {
// Distància perpendicular del punt a la línia (que és horitzontal o vertical).
const float DIST = line.is_vertical
? std::abs(pos.x - line.start.x)
: std::abs(pos.y - line.start.y);
if (DIST < MAX_DIST) {
line.orbit_amplitude = Defaults::Playfield::ORBIT_AMPLITUDE_MAX_PX;
r->center = pos;
r->age_s = 0.0F;
r->lifetime_s = Defaults::Playfield::Ripple::SMALL_LIFETIME_S;
r->speed_px_s = Defaults::Playfield::Ripple::SMALL_SPEED_PX_S;
r->amplitude_px = Defaults::Playfield::Ripple::SMALL_AMPLITUDE_PX;
r->thickness_px = Defaults::Playfield::Ripple::SMALL_THICKNESS_PX;
r->active = true;
}
void Playfield::notifyExplosion(Vec2 pos) {
spawnBig(pos);
}
void Playfield::notifyShipMoving(std::uint8_t player_id, Vec2 pos, float speed_px_s, float delta_time) {
if (player_id >= ship_ripple_cooldown_.size()) {
return;
}
if (speed_px_s < Defaults::Playfield::Ripple::SHIP_SPEED_THRESHOLD_PX_S) {
ship_ripple_cooldown_[player_id] = 0.0F;
return;
}
ship_ripple_cooldown_[player_id] -= delta_time;
if (ship_ripple_cooldown_[player_id] > 0.0F) {
return;
}
spawnSmall(pos);
const float JITTER = randUniform(
-Defaults::Playfield::Ripple::SHIP_COOLDOWN_JITTER_S,
Defaults::Playfield::Ripple::SHIP_COOLDOWN_JITTER_S);
ship_ripple_cooldown_[player_id] =
Defaults::Playfield::Ripple::SHIP_COOLDOWN_S + JITTER;
}
void Playfield::buildLines() {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const float CELL_W = zona.w / static_cast<float>(Defaults::Playfield::COLUMNS);
const float CELL_H = zona.h / static_cast<float>(Defaults::Playfield::ROWS);
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
const float CELL_W = zone.w / static_cast<float>(Defaults::Playfield::COLUMNS);
const float CELL_H = zone.h / static_cast<float>(Defaults::Playfield::ROWS);
const float SUB_W = CELL_W / static_cast<float>(Defaults::Playfield::SUBDIVISIONS);
const float SUB_H = CELL_H / static_cast<float>(Defaults::Playfield::SUBDIVISIONS);
const int SUB_VERTS = Defaults::Playfield::COLUMNS * Defaults::Playfield::SUBDIVISIONS;
@@ -151,38 +154,32 @@ namespace Graphics {
// Verticals: posicions i ∈ [1, SUB_VERTS-1].
for (int i = 1; i < SUB_VERTS; i++) {
const float X = zona.x + (static_cast<float>(i) * SUB_W);
const float X = zone.x + (static_cast<float>(i) * SUB_W);
const bool IS_MAIN = (i % Defaults::Playfield::SUBDIVISIONS) == 0;
const float BRIGHTNESS = IS_MAIN
? Defaults::Playfield::GRID_BRIGHTNESS
: Defaults::Playfield::SUBGRID_BRIGHTNESS;
verticals.push_back(Line{
.start = {.x = X, .y = zona.y},
.end = {.x = X, .y = zona.y + zona.h},
.start = {.x = X, .y = zone.y},
.end = {.x = X, .y = zone.y + zone.h},
.brightness = BRIGHTNESS,
.spawn_time_s = 0.0F,
.is_vertical = true,
.orbit_amplitude = 0.0F,
.orbit_phase = 0.0F,
.pulses = {}});
.is_vertical = true});
}
// Horitzontals: posicions j ∈ [1, SUB_HORIZ-1].
for (int j = 1; j < SUB_HORIZ; j++) {
const float Y = zona.y + (static_cast<float>(j) * SUB_H);
const float Y = zone.y + (static_cast<float>(j) * SUB_H);
const bool IS_MAIN = (j % Defaults::Playfield::SUBDIVISIONS) == 0;
const float BRIGHTNESS = IS_MAIN
? Defaults::Playfield::GRID_BRIGHTNESS
: Defaults::Playfield::SUBGRID_BRIGHTNESS;
horizontals.push_back(Line{
.start = {.x = zona.x, .y = Y},
.end = {.x = zona.x + zona.w, .y = Y},
.start = {.x = zone.x, .y = Y},
.end = {.x = zone.x + zone.w, .y = Y},
.brightness = BRIGHTNESS,
.spawn_time_s = 0.0F,
.is_vertical = false,
.orbit_amplitude = 0.0F,
.orbit_phase = 0.0F,
.pulses = {}});
.is_vertical = false});
}
// Ona diagonal: la línia esquerra/superior naix a t=0 i les següents
@@ -199,13 +196,39 @@ namespace Graphics {
lines_.clear();
lines_.reserve(verticals.size() + horizontals.size());
// El spawn_time_s s'assigna per índex espacial perquè la diagonal de
// l'ona de creixement avanci uniformement. L'ordre dins lines_, en
// canvi, ha de garantir que el grid principal (més brillant) es
// dibuixi DESPRÉS del subgrid: així a les interseccions guanya el
// principal i no queden tallades pel subgrid.
for (int i = 0; i < NUM_V; i++) {
verticals[i].spawn_time_s = static_cast<float>(i) * INTERVAL_V;
lines_.push_back(verticals[i]);
}
for (int i = 0; i < NUM_H; i++) {
horizontals[i].spawn_time_s = static_cast<float>(i) * INTERVAL_H;
lines_.push_back(horizontals[i]);
}
// Passada 1: subgrid (verticals + horitzontals).
for (const auto& v : verticals) {
if (v.brightness < Defaults::Playfield::GRID_BRIGHTNESS) {
lines_.push_back(v);
}
}
for (const auto& h : horizontals) {
if (h.brightness < Defaults::Playfield::GRID_BRIGHTNESS) {
lines_.push_back(h);
}
}
// Passada 2: grid principal (verticals + horitzontals).
for (const auto& v : verticals) {
if (v.brightness >= Defaults::Playfield::GRID_BRIGHTNESS) {
lines_.push_back(v);
}
}
for (const auto& h : horizontals) {
if (h.brightness >= Defaults::Playfield::GRID_BRIGHTNESS) {
lines_.push_back(h);
}
}
}
@@ -215,90 +238,106 @@ namespace Graphics {
}
void Playfield::draw() const {
// Recollir ripples actives (punters per accés ràpid al hot loop).
std::array<const Ripple*, Defaults::Playfield::Ripple::POOL_SIZE> active{};
int n_active = 0;
for (const auto& ripple : ripples_) {
if (ripple.active) {
active[n_active++] = &ripple;
}
}
for (const auto& line : lines_) {
drawLine(line, active.data(), n_active);
}
}
void Playfield::drawLine(const Line& line, const Ripple* const* active, int n_active) const {
const float RAW_P = computeLineProgress(line);
if (RAW_P <= 0.0F) {
continue;
return;
}
const float P = easeOutCubic(RAW_P);
// Desplaçament perpendicular per orbit (verticals → x, horitzontals → y).
const float ORBIT_OFFSET = line.orbit_amplitude * std::sin(line.orbit_phase);
const float ORBIT_DX = line.is_vertical ? ORBIT_OFFSET : 0.0F;
const float ORBIT_DY = line.is_vertical ? 0.0F : ORBIT_OFFSET;
const float START_X = line.start.x + ORBIT_DX;
const float START_Y = line.start.y + ORBIT_DY;
const float START_X = line.start.x;
const float START_Y = line.start.y;
const float DX = line.end.x - line.start.x;
const float DY = line.end.y - line.start.y;
const float CURRENT_X = START_X + (DX * P);
const float CURRENT_Y = START_Y + (DY * P);
const float END_X = START_X + (DX * P);
const float END_Y = START_Y + (DY * P);
// Tram base (brillo de la línia).
// AABB de la porció visible de la línia + filtre de ripples.
const float LINE_MIN_X = std::min(START_X, END_X);
const float LINE_MAX_X = std::max(START_X, END_X);
const float LINE_MIN_Y = std::min(START_Y, END_Y);
const float LINE_MAX_Y = std::max(START_Y, END_Y);
std::array<const Ripple*, Defaults::Playfield::Ripple::POOL_SIZE> hits{};
int n_hits = 0;
for (int i = 0; i < n_active; i++) {
const auto& r = *active[i];
const float R_MAX = (r.age_s * r.speed_px_s) + r.thickness_px;
if ((r.center.x + R_MAX) < LINE_MIN_X || (r.center.x - R_MAX) > LINE_MAX_X ||
(r.center.y + R_MAX) < LINE_MIN_Y || (r.center.y - R_MAX) > LINE_MAX_Y) {
continue;
}
hits[n_hits++] = &r;
}
if (n_hits == 0) {
// Camí ràpid: una sola crida com abans.
Rendering::linea(
renderer_,
static_cast<int>(START_X),
static_cast<int>(START_Y),
static_cast<int>(CURRENT_X),
static_cast<int>(CURRENT_Y),
line.brightness);
// Cap brillant mentre creix: l'últim tram de la línia es repinta més brillant.
static_cast<int>(END_X),
static_cast<int>(END_Y),
line.brightness,
0.0F,
Defaults::Playfield::GRID_COLOR);
// Cap brillant mentre creix.
if (P < 1.0F) {
const float LENGTH = std::sqrt((DX * DX) + (DY * DY));
if (LENGTH > 0.0F) {
const float HEAD_T = std::max(0.0F, P - (Defaults::Playfield::HEAD_LENGTH_PX / LENGTH));
const float HEAD_X = START_X + (DX * HEAD_T);
const float HEAD_Y = START_Y + (DY * HEAD_T);
Rendering::linea(
renderer_,
static_cast<int>(HEAD_X),
static_cast<int>(HEAD_Y),
static_cast<int>(CURRENT_X),
static_cast<int>(CURRENT_Y),
Defaults::Playfield::HEAD_BRIGHTNESS);
static_cast<int>(START_X + (DX * HEAD_T)),
static_cast<int>(START_Y + (DY * HEAD_T)),
static_cast<int>(END_X),
static_cast<int>(END_Y),
Defaults::Playfield::HEAD_BRIGHTNESS,
0.0F,
Defaults::Playfield::GRID_COLOR);
}
}
return;
}
// Pulses: cada un és un segment brillant centrat a center_t que
// s'expandeix amb el temps i s'apaga.
const float LINE_LENGTH = std::sqrt((DX * DX) + (DY * DY));
if (LINE_LENGTH <= 0.0F) {
continue;
}
const SDL_Color PULSE_TARGET = {
.r = Defaults::Playfield::PULSE_COLOR_R,
.g = Defaults::Playfield::PULSE_COLOR_G,
.b = Defaults::Playfield::PULSE_COLOR_B,
.a = 255};
for (const auto& pulse : line.pulses) {
if (!pulse.active) {
continue;
}
const float HALF_WIDTH_T = (pulse.age_s * Defaults::Playfield::PULSE_SPREAD_PER_S) / LINE_LENGTH;
const float INTENSITY = std::max(
0.0F,
1.0F - (pulse.age_s / Defaults::Playfield::PULSE_LIFETIME_S));
const float T1 = std::clamp(pulse.center_t - HALF_WIDTH_T, 0.0F, 1.0F);
const float T2 = std::clamp(pulse.center_t + HALF_WIDTH_T, 0.0F, 1.0F);
if (T2 <= T1) {
continue;
}
const float P1_X = START_X + (DX * T1);
const float P1_Y = START_Y + (DY * T1);
const float P2_X = START_X + (DX * T2);
const float P2_Y = START_Y + (DY * T2);
const SDL_Color SEG_COLOR = lerpColor(PULSE_TARGET, INTENSITY);
// Camí deformat: subdividir en N segments i desplaçar cada vèrtex.
const bool IS_MAIN = line.brightness >= Defaults::Playfield::GRID_BRIGHTNESS;
const int N = IS_MAIN
? Defaults::Playfield::Ripple::MAIN_SEGMENTS
: Defaults::Playfield::Ripple::SUB_SEGMENTS;
const Vec2 D0 = computeRippleDisplacement(START_X, START_Y, hits.data(), n_hits);
float prev_x = START_X + D0.x;
float prev_y = START_Y + D0.y;
for (int i = 1; i <= N; i++) {
const float T = static_cast<float>(i) / static_cast<float>(N);
const float X = START_X + (DX * P * T);
const float Y = START_Y + (DY * P * T);
const Vec2 D = computeRippleDisplacement(X, Y, hits.data(), n_hits);
const float NX = X + D.x;
const float NY = Y + D.y;
Rendering::linea(
renderer_,
static_cast<int>(P1_X),
static_cast<int>(P1_Y),
static_cast<int>(P2_X),
static_cast<int>(P2_Y),
1.0F,
static_cast<int>(prev_x),
static_cast<int>(prev_y),
static_cast<int>(NX),
static_cast<int>(NY),
line.brightness,
0.0F,
SEG_COLOR);
}
Defaults::Playfield::GRID_COLOR);
prev_x = NX;
prev_y = NY;
}
}
+30 -20
View File
@@ -5,13 +5,16 @@
// rep un `creation_progress` global ∈ [0, 1] i cada línia computa quina porció
// li toca dibuixar segons el seu slot a la timeline.
//
// Disseny preparat per a futures capacitats:
// - Línies "vives" que reaccionen a explosions / pas de la nau (reaction_intensity).
// - Capes addicionals al fons (estrelles, gradients, scanlines).
// Reaccions disponibles:
// - Ripples: deformacions circulars (ones d'aigua) que travessen la graella.
// Disparades per explosions (grans) i pas de la nau (petites, cadència estil
// trail). Cada vèrtex d'una línia afectada es desplaça radialment cap a fora
// amb una envoltant en cos(·) que decau a les vores de l'anell i amb el temps.
#pragma once
#include <array>
#include <cstdint>
#include <vector>
#include "core/defaults/playfield.hpp"
@@ -24,44 +27,51 @@ namespace Graphics {
public:
explicit Playfield(Rendering::Renderer* renderer);
// Avança timers interns (creació + reaccions).
// Avança timers interns (creació + ripples).
void update(float delta_time);
// Pinta la graella. La porció dibuixada de cada línia depèn del timer intern.
// Pinta la graella. La porció dibuixada de cada línia depèn del timer intern,
// i s'aplica deformació radial per cada ripple activa que afecti la línia.
void draw() const;
// Notifica que una nau ha passat per (pos) a velocitat (speed_px_s).
// Si està prop d'alguna línia i va prou ràpida, la línia entra en orbit.
void notifyShipPass(Vec2 pos, float speed_px_s);
// Notifica que una nau ha passat per (pos) a (speed_px_s). Genera ones
// petites darrere la nau a cadència regular amb jitter (estil TrailManager).
void notifyShipMoving(std::uint8_t player_id, Vec2 pos, float speed_px_s, float delta_time);
// Notifica el spawn d'un firework a (pos). Les línies V i H més properes
// generen un pulse brillant que es propaga.
void notifyFireworkSpawn(Vec2 pos);
// Notifica una explosió a (pos): genera una ripple gran centrada al punt.
void notifyExplosion(Vec2 pos);
private:
struct Pulse {
bool active{false};
float center_t{0.5F}; // posició al llarg de la línia (0..1)
// Pública per accés des d'helpers a l'anonymous namespace del .cpp.
struct Ripple {
Vec2 center{};
float age_s{0.0F};
float lifetime_s{0.0F};
float speed_px_s{0.0F};
float amplitude_px{0.0F};
float thickness_px{0.0F};
bool active{false};
};
private:
struct Line {
Vec2 start; // top (verticals) o left (horitzontals)
Vec2 end; // bottom (verticals) o right (horitzontals)
float brightness; // base (GRID_BRIGHTNESS o SUBGRID_BRIGHTNESS)
float spawn_time_s; // moment de naixement
bool is_vertical; // direcció (per saber el perpendicular de l'orbit)
float orbit_amplitude; // amplitud actual de l'orbit (px, ≥ 0)
float orbit_phase; // fase del sin (avança contínuament)
std::array<Pulse, Defaults::Playfield::MAX_PULSES_PER_LINE> pulses;
bool is_vertical; // direcció
};
void buildLines();
void drawLine(const Line& line, const Ripple* const* active, int n_active) const;
[[nodiscard]] auto computeLineProgress(const Line& line) const -> float;
static void spawnPulseAt(Line& line, float center_t);
void spawnBig(Vec2 pos);
void spawnSmall(Vec2 pos);
auto findFreeRipple() -> Ripple*;
Rendering::Renderer* renderer_;
std::vector<Line> lines_;
std::array<Ripple, Defaults::Playfield::Ripple::POOL_SIZE> ripples_{};
std::array<float, 2> ship_ripple_cooldown_{};
float elapsed_s_{0.0F};
};
+36 -21
View File
@@ -4,20 +4,21 @@
#include "core/graphics/shape.hpp"
#include <algorithm>
#include <cmath>
#include <fstream>
#include <iostream>
#include <sstream>
namespace Graphics {
Shape::Shape(const std::string& filepath)
Shape::Shape(const std::string& filepath)
: center_({.x = 0.0F, .y = 0.0F}),
nom_("unnamed") {
load(filepath);
}
}
auto Shape::load(const std::string& filepath) -> bool {
auto Shape::load(const std::string& filepath) -> bool {
// Llegir file
std::ifstream file(filepath);
if (!file.is_open()) {
@@ -33,9 +34,9 @@ auto Shape::load(const std::string& filepath) -> bool {
// Parsejar
return parseFile(contingut);
}
}
auto Shape::parseFile(const std::string& contingut) -> bool {
auto Shape::parseFile(const std::string& contingut) -> bool {
std::istringstream iss(contingut);
std::string line;
@@ -85,11 +86,25 @@ auto Shape::parseFile(const std::string& contingut) -> bool {
return false;
}
bounding_radius_ = computeBoundingRadius(primitives_, center_);
return true;
}
}
// Helper: trim whitespace
auto Shape::trim(const std::string& str) -> std::string {
auto Shape::computeBoundingRadius(const std::vector<ShapePrimitive>& primitives,
const Vec2& center) -> float {
float max_dist_sq = 0.0F;
for (const auto& prim : primitives) {
for (const auto& p : prim.points) {
const float DX = p.x - center.x;
const float DY = p.y - center.y;
max_dist_sq = std::max(max_dist_sq, (DX * DX) + (DY * DY));
}
}
return std::sqrt(max_dist_sq);
}
// Helper: trim whitespace
auto Shape::trim(const std::string& str) -> std::string {
const char* whitespace = " \t\n\r";
size_t start = str.find_first_not_of(whitespace);
if (start == std::string::npos) {
@@ -98,28 +113,28 @@ auto Shape::trim(const std::string& str) -> std::string {
size_t end = str.find_last_not_of(whitespace);
return str.substr(start, end - start + 1);
}
}
// Helper: startsWith
auto Shape::startsWith(const std::string& str,
// Helper: startsWith
auto Shape::startsWith(const std::string& str,
const std::string& prefix) -> bool {
if (str.length() < prefix.length()) {
return false;
}
return str.starts_with(prefix);
}
}
// Helper: extract value after ':'
auto Shape::extractValue(const std::string& line) -> std::string {
// Helper: extract value after ':'
auto Shape::extractValue(const std::string& line) -> std::string {
size_t colon = line.find(':');
if (colon == std::string::npos) {
return "";
}
return line.substr(colon + 1);
}
}
// Helper: parse center "x, y"
void Shape::parseCenter(const std::string& value) {
// Helper: parse center "x, y"
void Shape::parseCenter(const std::string& value) {
std::string val = trim(value);
size_t comma = val.find(',');
if (comma != std::string::npos) {
@@ -131,10 +146,10 @@ void Shape::parseCenter(const std::string& value) {
center_ = {.x = 0.0F, .y = 0.0F};
}
}
}
}
// Helper: parse points "x1,y1 x2,y2 x3,y3"
auto Shape::parsePoints(const std::string& str) -> std::vector<Vec2> {
// Helper: parse points "x1,y1 x2,y2 x3,y3"
auto Shape::parsePoints(const std::string& str) -> std::vector<Vec2> {
std::vector<Vec2> points;
std::istringstream iss(trim(str));
std::string pair;
@@ -154,6 +169,6 @@ auto Shape::parsePoints(const std::string& str) -> std::vector<Vec2> {
}
return points;
}
}
} // namespace Graphics
+16 -9
View File
@@ -11,20 +11,20 @@
namespace Graphics {
// Tipo de primitiva dins de una shape
enum class PrimitiveType : std::uint8_t {
// Tipo de primitiva dins de una shape
enum class PrimitiveType : std::uint8_t {
POLYLINE, // Secuencia de points connectats
LINE // Línia individual (2 points)
};
};
// Primitiva individual (polyline o line)
struct ShapePrimitive {
// Primitiva individual (polyline o line)
struct ShapePrimitive {
PrimitiveType type;
std::vector<Vec2> points; // 2+ points per polyline, exactament 2 per line
};
};
// Clase Shape - representa una shape vectorial carregada desde .shp
class Shape {
// Clase Shape - representa una shape vectorial carregada desde .shp
class Shape {
public:
// Constructors
Shape() = default;
@@ -42,6 +42,9 @@ class Shape {
}
[[nodiscard]] auto getCenter() const -> const Vec2& { return center_; }
[[nodiscard]] auto getDefaultScale() const -> float { return escala_defecte_; }
// Distància màx. del center_ al vèrtex més llunyà; ús: dimensionar
// efectes proporcionals a la mida de la shape (halos, glow).
[[nodiscard]] auto getBoundingRadius() const -> float { return bounding_radius_; }
[[nodiscard]] auto isValid() const -> bool { return !primitives_.empty(); }
// Info de depuració
@@ -53,6 +56,7 @@ class Shape {
Vec2 center_; // Centro/origin de la shape
float escala_defecte_{1.0F}; // Escala per defecte (normalment 1.0). Inicializada para
// que el ctor por defecto no deje el campo indeterminado.
float bounding_radius_{0.0F}; // Distància màx. del center_ al vèrtex més llunyà.
std::string nom_; // Nom de la shape (per depuració)
// Helpers privats per parsejar. Son estáticos: no necesitan estado
@@ -62,6 +66,9 @@ class Shape {
[[nodiscard]] static auto extractValue(const std::string& line) -> std::string;
void parseCenter(const std::string& value);
[[nodiscard]] static auto parsePoints(const std::string& str) -> std::vector<Vec2>;
};
[[nodiscard]] static auto computeBoundingRadius(
const std::vector<ShapePrimitive>& primitives,
const Vec2& center) -> float;
};
} // namespace Graphics
+87 -146
View File
@@ -1,168 +1,109 @@
// starfield.cpp - Implementació del sistema de estrelles de fons
// starfield.cpp - Implementació del starfield 3D
// © 2026 JailDesigner
#include "core/graphics/starfield.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <iostream>
#include "core/defaults.hpp"
#include "core/graphics/shape_loader.hpp"
#include "core/rendering/shape_renderer.hpp"
namespace Graphics {
// Constructor
Starfield::Starfield(Rendering::Renderer* renderer,
const Vec2& punt_fuga,
const SDL_FRect& area,
int densitat)
: shape_estrella_(ShapeLoader::load("star.shp")),
renderer_(renderer),
punt_fuga_(punt_fuga),
area_(area) {
if (!shape_estrella_ || !shape_estrella_->isValid()) {
std::cerr << "ERROR: No s'ha pogut load star.shp" << '\n';
namespace {
// Helper: número aleatori en [0, 1) usant rand()/RAND_MAX (mateixa convenció
// que la resta del joc — veure starfield.cpp).
auto randFloat01() -> float {
return static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
auto randRange(float lo, float hi) -> float {
return lo + (randFloat01() * (hi - lo));
}
} // namespace
Starfield::Starfield(Rendering::Renderer* renderer, const Camera3D* camera, int density)
: renderer_(renderer),
camera_(camera),
octahedron_(makeOctahedron()) {
stars_.resize(static_cast<std::size_t>(std::max(0, density)));
for (auto& star : stars_) {
// Pre-omplir amb estrelles distribuïdes per tot el rang Z, no només al far.
initStar(star, /*spawn_at_far=*/false);
}
}
void Starfield::initStar(Star& star, bool spawn_at_far) {
star.position.x = randRange(-HALF_SPAWN_X, HALF_SPAWN_X);
star.position.y = randRange(-HALF_SPAWN_Y, HALF_SPAWN_Y);
star.position.z = spawn_at_far
? Z_FAR_SPAWN
: randRange(Z_NEAR_RESPAWN, Z_FAR_SPAWN);
star.velocity_z = -randRange(MIN_VELOCITY_Z, MAX_VELOCITY_Z);
star.rot_phase_y = randFloat01() * Defaults::Math::PI * 2.0F;
star.rot_phase_x = randFloat01() * Defaults::Math::PI * 2.0F;
star.rot_speed_y = randRange(MIN_ROT_SPEED, MAX_ROT_SPEED);
star.rot_speed_x = randRange(MIN_ROT_SPEED, MAX_ROT_SPEED);
star.scale = STAR_BASE_SCALE + (randRange(-1.0F, 1.0F) * STAR_SCALE_JITTER);
}
auto Starfield::computeBrightness(const Star& star) const -> float {
// Lerp segons distància Z normalitzada [Z_NEAR_RESPAWN .. Z_FAR_SPAWN].
const float SPAN = Z_FAR_SPAWN - Z_NEAR_RESPAWN;
const float T_RAW = (star.position.z - Z_NEAR_RESPAWN) / SPAN;
const float T = std::clamp(T_RAW, 0.0F, 1.0F);
const float BRIGHTNESS = BRIGHTNESS_NEAR + (T * (BRIGHTNESS_FAR - BRIGHTNESS_NEAR));
return std::clamp(BRIGHTNESS * brightness_mult_, 0.0F, 1.0F);
}
void Starfield::update(float delta_time) {
for (auto& star : stars_) {
star.position.z += star.velocity_z * delta_time;
star.rot_phase_y += star.rot_speed_y * delta_time;
star.rot_phase_x += star.rot_speed_x * delta_time;
if (star.position.z < Z_NEAR_RESPAWN) {
initStar(star, /*spawn_at_far=*/true);
}
}
}
void Starfield::draw() const {
if (camera_ == nullptr || renderer_ == nullptr) {
return;
}
// Ordena de més lluny a més a prop perquè el blending additiu acumule
// brightness sense que els elements de davant queden tapats pels de
// darrere. Còpia d'índexs per no modificar l'ordre intern de stars_.
std::vector<std::size_t> order(stars_.size());
for (std::size_t i = 0; i < order.size(); ++i) {
order[i] = i;
}
std::ranges::sort(order, [&](std::size_t a, std::size_t b) {
return stars_[a].position.z > stars_[b].position.z;
});
// Configurar 3 capes con diferents velocitats i escales
// Capa 0: Fons llunyà (lenta, pequeña)
capes_.push_back({20.0F, 0.3F, 0.8F, densitat / 3});
// Capa 1: Profunditat mitjana
capes_.push_back({40.0F, 0.5F, 1.2F, densitat / 3});
// Capa 2: Primer pla (ràpida, grande)
capes_.push_back({80.0F, 0.8F, 2.0F, densitat / 3});
// Calcular radi màxim (distancia del centro al racó més llunyà)
float dx = std::max(punt_fuga_.x, area_.w - punt_fuga_.x);
float dy = std::max(punt_fuga_.y, area_.h - punt_fuga_.y);
radi_max_ = std::sqrt((dx * dx) + (dy * dy));
// Inicialitzar estrelles con posicions distribuïdes (pre-omplir pantalla)
for (int capa_idx = 0; capa_idx < 3; capa_idx++) {
int num = capes_[capa_idx].num_estrelles;
for (int i = 0; i < num; i++) {
Estrella estrella;
estrella.capa = capa_idx;
// Angle aleatori
estrella.angle = (static_cast<float>(rand()) / static_cast<float>(RAND_MAX)) * 2.0F * Defaults::Math::PI;
// Distancia aleatòria (0.0 a 1.0) per omplir toda la pantalla
estrella.distancia_centre = static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
// Calcular posición desde la distancia
float radi = estrella.distancia_centre * radi_max_;
estrella.position.x = punt_fuga_.x + (radi * std::cos(estrella.angle));
estrella.position.y = punt_fuga_.y + (radi * std::sin(estrella.angle));
estrelles_.push_back(estrella);
for (std::size_t idx : order) {
const Star& star = stars_[idx];
const Transform3D TRANSFORM{
.position = star.position,
.rotation_euler = Vec3{.x = star.rot_phase_x, .y = star.rot_phase_y, .z = 0.0F},
.scale = star.scale,
};
drawWireframe(renderer_, *camera_, octahedron_, TRANSFORM, computeBrightness(star), color_);
}
}
}
// Inicialitzar una estrella (nueva o regenerada)
void Starfield::initStar(Estrella& estrella) const {
// Angle aleatori des del point de fuga hacia fuera
estrella.angle = (static_cast<float>(rand()) / static_cast<float>(RAND_MAX)) * 2.0F * Defaults::Math::PI;
// Distancia inicial pequeña (5% del radi màxim) - neix prop del centro
estrella.distancia_centre = 0.05F;
// Posición inicial: mucho prop del point de fuga
float radi = estrella.distancia_centre * radi_max_;
estrella.position.x = punt_fuga_.x + (radi * std::cos(estrella.angle));
estrella.position.y = punt_fuga_.y + (radi * std::sin(estrella.angle));
}
// Verificar si una estrella está fuera de l'àrea
auto Starfield::isOutsideArea(const Estrella& estrella) const -> bool {
return (estrella.position.x < area_.x ||
estrella.position.x > area_.x + area_.w ||
estrella.position.y < area_.y ||
estrella.position.y > area_.y + area_.h);
}
// Calcular scale dinàmica segons distancia del centro
auto Starfield::computeScale(const Estrella& estrella) const -> float {
const CapaConfig& capa = capes_[estrella.capa];
// Interpolació lineal basada en distancia del centro
// distancia_centre: 0.0 (centro) → 1.0 (vora)
return capa.escala_min +
((capa.escala_max - capa.escala_min) * estrella.distancia_centre);
}
// Calcular brightness dinàmica segons distancia del centro
auto Starfield::computeBrightness(const Estrella& estrella) const -> float {
// Interpolació lineal: estrelles properes (vora) més brillants
// distancia_centre: 0.0 (centro, llunyanes) → 1.0 (vora, properes)
float brightness_base = Defaults::Brightness::STARFIELD_MIN +
((Defaults::Brightness::STARFIELD_MAX - Defaults::Brightness::STARFIELD_MIN) *
estrella.distancia_centre);
// Aplicar multiplicador i limitar a 1.0
return std::min(1.0F, brightness_base * multiplicador_brightness_);
}
// Actualitzar posicions de las estrelles
void Starfield::update(float delta_time) {
for (auto& estrella : estrelles_) {
// Obtenir configuración de la capa
const CapaConfig& capa = capes_[estrella.capa];
// Moure hacia fuera des del centro
float velocity = capa.velocitat_base;
float dx = velocity * std::cos(estrella.angle) * delta_time;
float dy = velocity * std::sin(estrella.angle) * delta_time;
estrella.position.x += dx;
estrella.position.y += dy;
// Actualitzar distancia del centro
float dx_centre = estrella.position.x - punt_fuga_.x;
float dy_centre = estrella.position.y - punt_fuga_.y;
float dist_px = std::sqrt((dx_centre * dx_centre) + (dy_centre * dy_centre));
estrella.distancia_centre = dist_px / radi_max_;
// Si ha sortit de l'àrea, regenerar-la
if (isOutsideArea(estrella)) {
initStar(estrella);
}
}
}
// Establir multiplicador de brightness
void Starfield::setBrightness(float multiplier) {
multiplicador_brightness_ = std::max(0.0F, multiplier); // Evitar valors negatius
}
// Dibuixar todas las estrelles
void Starfield::draw() {
if (!shape_estrella_->isValid()) {
return;
void Starfield::setBrightness(float multiplier) {
brightness_mult_ = std::max(0.0F, multiplier);
}
for (const auto& estrella : estrelles_) {
// Calcular scale i brightness dinàmicament
float scale = computeScale(estrella);
float brightness = computeBrightness(estrella);
// Renderizar estrella sin rotación
Rendering::renderShape(
renderer_,
shape_estrella_,
estrella.position,
0.0F, // angle (las estrelles no giren)
scale, // scale dinàmica
1.0F, // progress (siempre visible)
brightness // brightness dinàmica
);
void Starfield::setColor(SDL_Color color) {
color_ = color;
}
}
} // namespace Graphics
+46 -57
View File
@@ -1,83 +1,72 @@
// starfield.hpp - Sistema de estrelles de fons con efecte de profunditat
// starfield.hpp - Camp d'estrelles 3D per a l'escena de títol
// © 2026 JailDesigner
//
// Cada estrella és un octaedre
// wireframe situat en l'espai mundial (Vec3). Es desplacen cap a la càmera
// (Z disminueix); quan creuen el pla Z_NEAR_RESPAWN es regeneren a Z_FAR_SPAWN
// amb X/Y aleatori. Cada octaedre rota lentament sobre Y i X per donar volum.
#pragma once
#include "core/rendering/render_context.hpp"
#include <SDL3/SDL.h>
#include <memory>
#include <vector>
#include "core/graphics/shape.hpp"
#include "core/graphics/camera3d.hpp"
#include "core/graphics/wireframe3d.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Graphics {
// Configuración per cada capa de profunditat
struct CapaConfig {
float velocitat_base; // Velocidad base de esta capa (px/s)
float escala_min; // Escala mínima prop del centro
float escala_max; // Escala màxima al límit de pantalla
int num_estrelles; // Nombre de estrelles en esta capa
};
// Clase Starfield - camp de estrelles animat con efecte de profunditat
class Starfield {
class Starfield {
public:
// Constructor
// - renderer: SDL renderer
// - punt_fuga: point de origin/fuga des de on surten las estrelles
// - area: rectangle on actuen las estrelles (SDL_FRect)
// - densitat: nombre total de estrelles (es divideix entre capes)
Starfield(Rendering::Renderer* renderer,
const Vec2& punt_fuga,
const SDL_FRect& area,
int densitat = 150);
Starfield(Rendering::Renderer* renderer, const Camera3D* camera, int density = 200);
// Actualitzar posicions de las estrelles
void update(float delta_time);
void draw() const;
// Dibuixar todas las estrelles
void draw();
// Setters per ajustar parámetros en time real
void setVanishingPoint(const Vec2& point) { punt_fuga_ = point; }
void setBrightness(float multiplier);
void setColor(SDL_Color color);
private:
// Estructura interna per cada estrella
struct Estrella {
Vec2 position; // Posición actual
float angle; // Angle de movement (radians)
float distancia_centre; // Distancia normalitzada del centro (0.0-1.0)
int capa; // Índex de capa (0=lluny, 1=mitjà, 2=prop)
struct Star {
Vec3 position{};
float velocity_z{0.0F}; // Negatiu: cap a càmera
float rot_phase_y{0.0F};
float rot_phase_x{0.0F};
float rot_speed_y{0.0F};
float rot_speed_x{0.0F};
float scale{1.0F};
};
// Inicialitzar una estrella (nueva o regenerada)
void initStar(Estrella& estrella) const;
static void initStar(Star& star, bool spawn_at_far);
[[nodiscard]] auto computeBrightness(const Star& star) const -> float;
// Verificar si una estrella está fuera de l'àrea
[[nodiscard]] auto isOutsideArea(const Estrella& estrella) const -> bool;
// Calcular scale dinàmica segons distancia del centro
[[nodiscard]] auto computeScale(const Estrella& estrella) const -> float;
// Calcular brightness dinàmica segons distancia del centro
[[nodiscard]] auto computeBrightness(const Estrella& estrella) const -> float;
// Dades
std::vector<Estrella> estrelles_;
std::vector<CapaConfig> capes_; // Configuración de las 3 capes
std::shared_ptr<Shape> shape_estrella_;
Rendering::Renderer* renderer_;
const Camera3D* camera_;
std::vector<Star> stars_;
Mesh3D octahedron_;
float brightness_mult_{1.0F};
SDL_Color color_{.r = 0, .g = 0, .b = 0, .a = 0}; // alpha=0 → usa color global
// Configuración
Vec2 punt_fuga_; // Vec2 de origin de las estrelles
SDL_FRect area_; // Àrea activa
float radi_max_; // Distancia màxima del centro al límit de pantalla
float multiplicador_brightness_{1.0F}; // Multiplicador de brightness (1.0 = default)
};
// Volum de spawn / regeneració en l'espai 3D.
static constexpr float Z_NEAR_RESPAWN = 5.0F; // Si Z < aquest valor → regenera
static constexpr float Z_FAR_SPAWN = 1500.0F; // Z de regeneració (lluny — més profunditat)
static constexpr float HALF_SPAWN_X = 900.0F; // X aleatori dins [-, +]
static constexpr float HALF_SPAWN_Y = 540.0F; // Y aleatori dins [-, +]
// Mida i moviment.
static constexpr float STAR_BASE_SCALE = 1.8F;
static constexpr float STAR_SCALE_JITTER = 0.6F;
static constexpr float MIN_VELOCITY_Z = 80.0F;
static constexpr float MAX_VELOCITY_Z = 200.0F;
static constexpr float MIN_ROT_SPEED = 0.2F;
static constexpr float MAX_ROT_SPEED = 0.8F;
// Brightness en funció de la distància Z (a prop = més brillant).
static constexpr float BRIGHTNESS_FAR = 0.15F;
static constexpr float BRIGHTNESS_NEAR = 1.0F;
};
} // namespace Graphics
-105
View File
@@ -1,105 +0,0 @@
// starfield3d.cpp - Implementació del starfield 3D
// © 2026 JailDesigner
#include "core/graphics/starfield3d.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include "core/defaults.hpp"
namespace Graphics {
namespace {
// Helper: número aleatori en [0, 1) usant rand()/RAND_MAX (mateixa convenció
// que la resta del joc — veure starfield.cpp).
auto randFloat01() -> float {
return static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
auto randRange(float lo, float hi) -> float {
return lo + (randFloat01() * (hi - lo));
}
} // namespace
Starfield3D::Starfield3D(Rendering::Renderer* renderer, const Camera3D* camera, int density)
: renderer_(renderer),
camera_(camera),
octahedron_(makeOctahedron()) {
stars_.resize(static_cast<std::size_t>(std::max(0, density)));
for (auto& star : stars_) {
// Pre-omplir amb estrelles distribuïdes per tot el rang Z, no només al far.
initStar(star, /*spawn_at_far=*/false);
}
}
void Starfield3D::initStar(Star& star, bool spawn_at_far) {
star.position.x = randRange(-HALF_SPAWN_X, HALF_SPAWN_X);
star.position.y = randRange(-HALF_SPAWN_Y, HALF_SPAWN_Y);
star.position.z = spawn_at_far
? Z_FAR_SPAWN
: randRange(Z_NEAR_RESPAWN, Z_FAR_SPAWN);
star.velocity_z = -randRange(MIN_VELOCITY_Z, MAX_VELOCITY_Z);
star.rot_phase_y = randFloat01() * Defaults::Math::PI * 2.0F;
star.rot_phase_x = randFloat01() * Defaults::Math::PI * 2.0F;
star.rot_speed_y = randRange(MIN_ROT_SPEED, MAX_ROT_SPEED);
star.rot_speed_x = randRange(MIN_ROT_SPEED, MAX_ROT_SPEED);
star.scale = STAR_BASE_SCALE + (randRange(-1.0F, 1.0F) * STAR_SCALE_JITTER);
}
auto Starfield3D::computeBrightness(const Star& star) const -> float {
// Lerp segons distància Z normalitzada [Z_NEAR_RESPAWN .. Z_FAR_SPAWN].
const float SPAN = Z_FAR_SPAWN - Z_NEAR_RESPAWN;
const float T_RAW = (star.position.z - Z_NEAR_RESPAWN) / SPAN;
const float T = std::clamp(T_RAW, 0.0F, 1.0F);
const float BRIGHTNESS = BRIGHTNESS_NEAR + (T * (BRIGHTNESS_FAR - BRIGHTNESS_NEAR));
return std::clamp(BRIGHTNESS * brightness_mult_, 0.0F, 1.0F);
}
void Starfield3D::update(float delta_time) {
for (auto& star : stars_) {
star.position.z += star.velocity_z * delta_time;
star.rot_phase_y += star.rot_speed_y * delta_time;
star.rot_phase_x += star.rot_speed_x * delta_time;
if (star.position.z < Z_NEAR_RESPAWN) {
initStar(star, /*spawn_at_far=*/true);
}
}
}
void Starfield3D::draw() const {
if (camera_ == nullptr || renderer_ == nullptr) {
return;
}
// Ordena de més lluny a més a prop perquè el blending additiu acumule
// brightness sense que els elements de davant queden tapats pels de
// darrere. Còpia d'índexs per no modificar l'ordre intern de stars_.
std::vector<std::size_t> order(stars_.size());
for (std::size_t i = 0; i < order.size(); ++i) {
order[i] = i;
}
std::ranges::sort(order, [&](std::size_t a, std::size_t b) {
return stars_[a].position.z > stars_[b].position.z;
});
for (std::size_t idx : order) {
const Star& star = stars_[idx];
const Transform3D TRANSFORM{
.position = star.position,
.rotation_euler = Vec3{.x = star.rot_phase_x, .y = star.rot_phase_y, .z = 0.0F},
.scale = star.scale,
};
drawWireframe(renderer_, *camera_, octahedron_, TRANSFORM, computeBrightness(star));
}
}
void Starfield3D::setBrightness(float multiplier) {
brightness_mult_ = std::max(0.0F, multiplier);
}
} // namespace Graphics
-68
View File
@@ -1,68 +0,0 @@
// starfield3d.hpp - Camp de estrelles 3D real per a l'escena de títol
// © 2026 JailDesigner
//
// Equivalent 3D del `Graphics::Starfield`. Cada estrella és un octaedre
// wireframe situat en l'espai mundial (Vec3). Es desplacen cap a la càmera
// (Z disminueix); quan creuen el pla Z_NEAR_RESPAWN es regeneren a Z_FAR_SPAWN
// amb X/Y aleatori. Cada octaedre rota lentament sobre Y i X per donar volum.
#pragma once
#include <vector>
#include "core/graphics/camera3d.hpp"
#include "core/graphics/wireframe3d.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Graphics {
class Starfield3D {
public:
Starfield3D(Rendering::Renderer* renderer, const Camera3D* camera, int density = 200);
void update(float delta_time);
void draw() const;
void setBrightness(float multiplier);
private:
struct Star {
Vec3 position{};
float velocity_z{0.0F}; // Negatiu: cap a càmera
float rot_phase_y{0.0F};
float rot_phase_x{0.0F};
float rot_speed_y{0.0F};
float rot_speed_x{0.0F};
float scale{1.0F};
};
static void initStar(Star& star, bool spawn_at_far);
[[nodiscard]] auto computeBrightness(const Star& star) const -> float;
Rendering::Renderer* renderer_;
const Camera3D* camera_;
std::vector<Star> stars_;
Mesh3D octahedron_;
float brightness_mult_{1.0F};
// Volum de spawn / regeneració en l'espai 3D.
static constexpr float Z_NEAR_RESPAWN = 5.0F; // Si Z < aquest valor → regenera
static constexpr float Z_FAR_SPAWN = 800.0F; // Z de regeneració (lluny)
static constexpr float HALF_SPAWN_X = 600.0F; // X aleatori dins [-, +]
static constexpr float HALF_SPAWN_Y = 360.0F; // Y aleatori dins [-, +]
// Mida i moviment.
static constexpr float STAR_BASE_SCALE = 1.8F;
static constexpr float STAR_SCALE_JITTER = 0.6F;
static constexpr float MIN_VELOCITY_Z = 80.0F;
static constexpr float MAX_VELOCITY_Z = 200.0F;
static constexpr float MIN_ROT_SPEED = 0.2F;
static constexpr float MAX_ROT_SPEED = 0.8F;
// Brightness en funció de la distància Z (a prop = més brillant).
static constexpr float BRIGHTNESS_FAR = 0.15F;
static constexpr float BRIGHTNESS_NEAR = 1.0F;
};
} // namespace Graphics
+140
View File
@@ -0,0 +1,140 @@
// starfield_parallax.cpp - Implementació del starfield 2D amb parallax
// © 2026 JailDesigner
#include "core/graphics/starfield_parallax.hpp"
#include <cstdlib>
#include "core/defaults.hpp"
#include "core/rendering/line_renderer.hpp"
namespace Graphics {
namespace {
auto randUniform(float min_v, float max_v) -> float {
const float NORM = static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
return min_v + (NORM * (max_v - min_v));
}
} // namespace
StarfieldParallax::StarfieldParallax(Rendering::Renderer* renderer)
: renderer_(renderer) {
buildStars();
}
void StarfieldParallax::buildStars() {
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
const float MIN_X = zone.x;
const float MAX_X = zone.x + zone.w;
const float MIN_Y = zone.y;
const float MAX_Y = zone.y + zone.h;
// Color únic per a totes les estrelles: el mateix blanc-blau gel
// del starfield del títol (Defaults::Title::Colors::STARFIELD).
const auto FILL_LAYER = [&](int layer, int count, int& idx) {
for (int i = 0; i < count; i++) {
stars_[idx++] = Star{
.x = randUniform(MIN_X, MAX_X),
.y = randUniform(MIN_Y, MAX_Y),
.layer = layer,
.color = Defaults::Title::Colors::STARFIELD};
}
};
int idx = 0;
FILL_LAYER(0, Defaults::StarfieldParallax::Far::COUNT, idx);
FILL_LAYER(1, Defaults::StarfieldParallax::Mid::COUNT, idx);
FILL_LAYER(2, Defaults::StarfieldParallax::Near::COUNT, idx);
}
auto StarfieldParallax::layerBrightness(int layer) -> float {
switch (layer) {
case 0:
return Defaults::StarfieldParallax::Far::BRIGHTNESS;
case 1:
return Defaults::StarfieldParallax::Mid::BRIGHTNESS;
case 2:
return Defaults::StarfieldParallax::Near::BRIGHTNESS;
default:
return 0.0F;
}
}
auto StarfieldParallax::layerParallax(int layer) -> float {
switch (layer) {
case 0:
return Defaults::StarfieldParallax::Far::PARALLAX_FACTOR;
case 1:
return Defaults::StarfieldParallax::Mid::PARALLAX_FACTOR;
case 2:
return Defaults::StarfieldParallax::Near::PARALLAX_FACTOR;
default:
return 0.0F;
}
}
auto StarfieldParallax::layerSize(int layer) -> int {
switch (layer) {
case 0:
return Defaults::StarfieldParallax::Far::SIZE_PX;
case 1:
return Defaults::StarfieldParallax::Mid::SIZE_PX;
case 2:
return Defaults::StarfieldParallax::Near::SIZE_PX;
default:
return 1;
}
}
void StarfieldParallax::update(float delta_time, Vec2 world_velocity) {
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
const float MIN_X = zone.x;
const float MAX_X = zone.x + zone.w;
const float MIN_Y = zone.y;
const float MAX_Y = zone.y + zone.h;
const float W = zone.w;
const float H = zone.h;
for (auto& star : stars_) {
const float FACTOR = layerParallax(star.layer);
star.x += world_velocity.x * FACTOR * delta_time;
star.y += world_velocity.y * FACTOR * delta_time;
// Wraparound (PLAYAREA torica).
while (star.x < MIN_X) {
star.x += W;
}
while (star.x > MAX_X) {
star.x -= W;
}
while (star.y < MIN_Y) {
star.y += H;
}
while (star.y > MAX_Y) {
star.y -= H;
}
}
}
void StarfieldParallax::draw() const {
for (const auto& star : stars_) {
const float B = layerBrightness(star.layer);
const int SIZE = layerSize(star.layer);
const int X = static_cast<int>(star.x);
const int Y = static_cast<int>(star.y);
if (SIZE <= 1) {
// Punt d'1 px: línia degenerada horitzontal de 1 px.
Rendering::linea(renderer_, X, Y, X + 1, Y, B, 0.0F, star.color);
} else {
// Creu "+" amb extensió HALF des del centre en cada direcció.
const int HALF = SIZE - 1; // SIZE=2 → ±1 (creu 3x3); SIZE=3 → ±2 (creu 5x5)
Rendering::linea(renderer_, X - HALF, Y, X + HALF + 1, Y, B, 0.0F, star.color);
Rendering::linea(renderer_, X, Y - HALF, X, Y + HALF + 1, B, 0.0F, star.color);
}
}
}
} // namespace Graphics
@@ -0,0 +1,51 @@
// starfield_parallax.hpp - Capa més profunda del fons: estrelles 2D amb parallax
// © 2026 JailDesigner
//
// Estrelles 2D distribuïdes en 3 capes de profunditat. Cada capa té el seu
// factor parallax: el "món" es desplaça amb world_velocity i les estrelles
// d'una capa es mouen amb world_velocity * parallax_factor. Les més
// properes es mouen més (factor alt) → sensació de profunditat.
// Quan una estrella surt de PLAYAREA, reapareix per la banda oposada
// (wraparound).
#pragma once
#include <SDL3/SDL.h>
#include <array>
#include "core/defaults/starfield_parallax.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Graphics {
class StarfieldParallax {
public:
explicit StarfieldParallax(Rendering::Renderer* renderer);
// Avança el desplaçament de les estrelles segons world_velocity (vector
// del moviment del món en px/s; típicament = -ship_velocity).
// world_velocity == {0, 0} → estrelles quietes.
void update(float delta_time, Vec2 world_velocity);
void draw() const;
private:
struct Star {
float x{0.0F};
float y{0.0F};
int layer{0}; // 0=Far, 1=Mid, 2=Near
SDL_Color color{}; // tint precomputat entre blanc i cyan
};
void buildStars();
static auto layerBrightness(int layer) -> float;
static auto layerParallax(int layer) -> float;
static auto layerSize(int layer) -> int;
Rendering::Renderer* renderer_;
std::array<Star, Defaults::StarfieldParallax::TOTAL_COUNT> stars_{};
};
} // namespace Graphics
+7 -6
View File
@@ -221,7 +221,8 @@ namespace Graphics {
// Ajustar X e Y para que position represente esquina superior izquierda
// (render_shape espera el centro, así que sumamos la mitad de ancho y altura)
Vec2 char_pos = {.x = current_x + (CHAR_WIDTH_SCALED / 2.0F), .y = position.y + (CHAR_HEIGHT_SCALED / 2.0F)};
Rendering::renderShape(renderer_, it->second, char_pos, 0.0F, scale, 1.0F, brightness, color);
// Text opt-out del glow: HUD/marker s'ha de mantenir net.
Rendering::renderShape(renderer_, it->second, char_pos, 0.0F, scale, 1.0F, brightness, color, 0.0F, 1.0F, /*glow=*/false);
// Avanzar posición
current_x += CHAR_WIDTH_SCALED + SPACING_SCALED;
@@ -234,19 +235,19 @@ namespace Graphics {
}
}
void VectorText::renderCentered(const std::string& text, const Vec2& centre_punt, float scale, float spacing, float brightness, SDL_Color color) const {
void VectorText::renderCentered(const std::string& text, const Vec2& centre_point, float scale, float spacing, float brightness, SDL_Color color) const {
// Calcular dimensions del text
float text_width = getTextWidth(text, scale, spacing);
float text_height = getTextHeight(scale);
// Calcular posición de l'esquina superior izquierda
// restant la meitat de las dimensions del point central
Vec2 posicio_esquerra = {
.x = centre_punt.x - (text_width / 2.0F),
.y = centre_punt.y - (text_height / 2.0F)};
Vec2 top_left_position = {
.x = centre_point.x - (text_width / 2.0F),
.y = centre_point.y - (text_height / 2.0F)};
// Delegar al método render() existent
render(text, posicio_esquerra, scale, spacing, brightness, color);
render(text, top_left_position, scale, spacing, brightness, color);
}
auto VectorText::getTextWidth(const std::string& text, float scale, float spacing) -> float {
+2 -2
View File
@@ -31,12 +31,12 @@ namespace Graphics {
// Renderizar string centrado en un punto
// - text: cadena a renderizar
// - centre_punt: punto central del texto (no esquina superior izquierda)
// - centre_point: punto central del texto (no esquina superior izquierda)
// - scale: factor de scale (1.0 = 20×40 px por carácter)
// - spacing: espacio entre caracteres en píxeles (a scale 1.0)
// - brightness: factor de brightness (0.0-1.0, default 1.0 = màxima brightness)
// - color: color RGBA explícit; si alpha==0 (default) s'usa l'oscil·lador global
void renderCentered(const std::string& text, const Vec2& centre_punt, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F, SDL_Color color = {0, 0, 0, 0}) const;
void renderCentered(const std::string& text, const Vec2& centre_point, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F, SDL_Color color = {0, 0, 0, 0}) const;
// Calcular ancho total de un string (útil para centrado).
// Es estático: no depende del estado del VectorText (el ancho viene de
+109
View File
@@ -0,0 +1,109 @@
// locale.cpp - Implementació del sistema de locale
// © 2026 JailDesigner
#include "core/locale/locale.hpp"
#include <cstddef>
#include <cstdint>
#include <exception>
#include <iostream>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
// Recorre el node YAML i aplana jerarquies en claus "a.b.c". Suporta
// mappings (recursió) i seqüències de strings (desa "a.b.0", "a.b.1"...).
// Altres tipus (nombres, booleans solts) s'ignoren silenciosament.
void flatten(const fkyaml::node& node, const std::string& prefix, std::unordered_map<std::string, std::string>& out) {
if (node.is_mapping()) {
for (auto it = node.begin(); it != node.end(); ++it) {
const std::string KEY = prefix.empty()
? it.key().get_value<std::string>()
: prefix + "." + it.key().get_value<std::string>();
flatten(it.value(), KEY, out);
}
return;
}
if (node.is_sequence()) {
std::size_t index = 0;
for (const auto& item : node) {
const std::string KEY = prefix + "." + std::to_string(index);
flatten(item, KEY, out);
index++;
}
return;
}
if (node.is_string()) {
out[prefix] = node.get_value<std::string>();
}
}
} // namespace
auto Locale::get() -> Locale& {
static Locale instance_;
return instance_;
}
auto Locale::load(const std::string& file_path) -> bool {
// Normalitza traient prefix "data/" com fa StageLoader: el pack de
// recursos indexa rutes relatives a `data/`.
std::string normalized = file_path;
if (normalized.starts_with("data/")) {
normalized = normalized.substr(5);
}
std::vector<uint8_t> bytes = Resource::Helper::loadFile(normalized);
if (bytes.empty()) {
std::cerr << "[Locale] no s'ha pogut load " << normalized << '\n';
return false;
}
try {
std::string yaml_content(bytes.begin(), bytes.end());
std::stringstream stream(yaml_content);
fkyaml::node yaml = fkyaml::node::deserialize(stream);
strings_.clear();
flatten(yaml, "", strings_);
std::cout << "[Locale] " << strings_.size() << " traduccions des de " << normalized << '\n';
return true;
} catch (const std::exception& e) {
std::cerr << "[Locale] error parsejant " << normalized << ": " << e.what() << '\n';
return false;
}
}
auto Locale::switchTo(const std::string& lang) -> bool {
return load("locale/" + lang + ".yaml");
}
auto Locale::text(const std::string& key) const -> std::string {
auto it = strings_.find(key);
if (it != strings_.end()) {
return it->second;
}
std::cerr << "[Locale] clau no trobada: " << key << '\n';
return key;
}
auto Locale::count(const std::string& prefix) const -> std::size_t {
std::size_t n = 0;
while (strings_.contains(prefix + "." + std::to_string(n))) {
n++;
}
return n;
}
auto localeSubstitute(std::string tpl, std::string_view placeholder, std::string_view value) -> std::string {
auto pos = tpl.find(placeholder);
if (pos != std::string::npos) {
tpl.replace(pos, placeholder.size(), value);
}
return tpl;
}
+56
View File
@@ -0,0 +1,56 @@
// locale.hpp - Sistema d'internacionalització (i18n) basat en YAML
// © 2026 JailDesigner
//
// Locale amb claus en notació de punts ("notification.fullscreen_on"). El YAML
// pot ser jerarquitzat i s'aplana en càrrega, així el consumidor només veu
// claus planes. Suporta seqüències de strings (es desen com prefix.0,
// prefix.1, ...). No hi ha hot-swap d'idioma: es fixa a l'arrencada des de
// `config.yaml` (camp `locale`) i només es recarrega reiniciant el joc.
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <unordered_map>
class Locale {
public:
static auto get() -> Locale&;
Locale(const Locale&) = delete;
Locale(Locale&&) = delete;
auto operator=(const Locale&) -> Locale& = delete;
auto operator=(Locale&&) -> Locale& = delete;
// Llig el fitxer YAML i emplena el mapping intern. Si hi ha un error de
// parse o el fitxer no existeix, deixa el mapping com estava i ho
// notifica per stderr. Retorna true només si la càrrega ha tingut èxit.
auto load(const std::string& file_path) -> bool;
// Canvi d'idioma en runtime. Recarrega `locale/<lang>.yaml`. Retorna true
// si la càrrega ha tingut èxit. Els lookups posteriors (tots els draw*
// criden Locale::text() cada frame) ja veuen el nou idioma. Els missatges
// ja capturats (toast actiu, banner de stage start ja triat) sobreviuen
// fins al seu cicle natural.
auto switchTo(const std::string& lang) -> bool;
// Retorna la traducció; si la clau no existeix, retorna la pròpia clau
// com a fallback visible (així una clau mal escrita es detecta sense
// trencar el render).
[[nodiscard]] auto text(const std::string& key) const -> std::string;
// Compta quantes claus consecutives existeixen amb el prefix donat
// (prefix.0, prefix.1, ...). Útil per pools indexats com stage.start.N.
[[nodiscard]] auto count(const std::string& prefix) const -> std::size_t;
private:
Locale() = default;
~Locale() = default;
std::unordered_map<std::string, std::string> strings_;
};
// Substitució simple d'un placeholder dins una plantilla (p.ex. "{n}" → "3").
// S'usa per interpolar valors runtime en strings traduïdes.
[[nodiscard]] auto localeSubstitute(std::string tpl, std::string_view placeholder, std::string_view value) -> std::string;
+36 -4
View File
@@ -3,19 +3,21 @@
#pragma once
#include <algorithm>
#include "core/entities/entity.hpp"
#include "core/types.hpp"
namespace Physics {
// Comprobación genèrica de colisión entre dues entidades
inline auto checkCollision(const Entities::Entity& a, const Entities::Entity& b, float amplifier = 1.0F) -> bool {
// Comprobación genèrica de colisión entre dues entidades
inline auto checkCollision(const Entities::Entity& a, const Entities::Entity& b, float amplifier = 1.0F) -> bool {
// Comprovar si ambdós són col·lisionables
if (!a.isCollidable() || !b.isCollidable()) {
return false;
}
// Calcular radi combinat (con amplificador per hitbox generós)
// Calcular radius combinat (con amplificador per hitbox generós)
float suma_radis = (a.getCollisionRadius() + b.getCollisionRadius()) * amplifier;
float suma_radis_sq = suma_radis * suma_radis;
@@ -27,6 +29,36 @@ inline auto checkCollision(const Entities::Entity& a, const Entities::Entity& b,
float dist_sq = (dx * dx) + (dy * dy);
return dist_sq <= suma_radis_sq;
}
}
// Swept collision: una entitat mòbil (radius r_a) s'ha desplaçat de p0 a p1 aquest
// frame. Comprova si el segment expandit pel radius conjunt (r_a + radius de b, amb
// amplificador) toca el cercle de l'entity b. Equival al check discrete quan
// p0 == p1 (sense moviment). Evita tunneling a velocitats altes.
inline auto checkCollisionSwept(const Vec2& p0, const Vec2& p1, float r_a, const Entities::Entity& b, float amplifier = 1.0F) -> bool {
if (!b.isCollidable()) {
return false;
}
const float SUM_R = (r_a + b.getCollisionRadius()) * amplifier;
const float SUM_R_SQ = SUM_R * SUM_R;
const Vec2& center_b = b.getCenter();
const float DX_SEG = p1.x - p0.x;
const float DY_SEG = p1.y - p0.y;
const float LEN_SQ = (DX_SEG * DX_SEG) + (DY_SEG * DY_SEG);
// Degenerat: punt-cercle (frame de spawn, o entitat parada).
if (LEN_SQ <= 0.0F) {
const float DX = p0.x - center_b.x;
const float DY = p0.y - center_b.y;
return ((DX * DX) + (DY * DY)) <= SUM_R_SQ;
}
// Projecció del centre sobre la recta del segment, clamp a [0,1] per acotar al segment.
const float T_RAW = (((center_b.x - p0.x) * DX_SEG) + ((center_b.y - p0.y) * DY_SEG)) / LEN_SQ;
const float T_CLAMPED = std::clamp(T_RAW, 0.0F, 1.0F);
const float CLOSEST_X = p0.x + (DX_SEG * T_CLAMPED);
const float CLOSEST_Y = p0.y + (DY_SEG * T_CLAMPED);
const float DX = CLOSEST_X - center_b.x;
const float DY = CLOSEST_Y - center_b.y;
return ((DX * DX) + (DY * DY)) <= SUM_R_SQ;
}
} // namespace Physics
@@ -5,6 +5,7 @@
#include <SDL3/SDL.h>
#include <SDL3/SDL_gpu.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
@@ -390,6 +391,10 @@ namespace Rendering::GPU {
color_target.store_op = SDL_GPU_STOREOP_STORE;
render_pass_ = SDL_BeginGPURenderPass(cmd_buffer_, &color_target, 1, nullptr);
// L'scissor és per render pass: en reobrir cal restaurar-lo des del top
// de la pila si pushClip/popClip s'han usat mid-frame.
applyCurrentScissor();
SDL_BindGPUGraphicsPipeline(render_pass_, line_pipeline_.get());
// UBO de líneas usa el tamaño lógico (también del offscreen).
@@ -415,6 +420,11 @@ namespace Rendering::GPU {
SDL_ReleaseGPUBuffer(dev, vbo);
SDL_ReleaseGPUBuffer(dev, ibo);
SDL_ReleaseGPUTransferBuffer(dev, tbo);
// Buidem el batch perquè pushClip/popClip puguin emetre seccions
// separades dins el mateix frame sense re-enviar geometria.
vertices_.clear();
indices_.clear();
}
void GpuFrameRenderer::bloomPass() {
@@ -603,6 +613,51 @@ namespace Rendering::GPU {
SDL_DrawGPUPrimitives(render_pass_, 3, 1, 0, 0);
}
void GpuFrameRenderer::pushClip(int logical_x, int logical_y, int logical_w, int logical_h) {
// Convertim coordenades lògiques (espai del joc, 1280×720) a píxels
// físics del offscreen (render_w_ × render_h_). Si l'usuari hi treballa
// amb upscale (p.ex. 1920×1080), l'scissor escala proporcionalment.
const float SX = render_w_ / logical_w_;
const float SY = render_h_ / logical_h_;
SDL_Rect rect{
.x = static_cast<int>(static_cast<float>(logical_x) * SX),
.y = static_cast<int>(static_cast<float>(logical_y) * SY),
.w = std::max(0, static_cast<int>(static_cast<float>(logical_w) * SX)),
.h = std::max(0, static_cast<int>(static_cast<float>(logical_h) * SY)),
};
// Emetem tot el batch acumulat *abans* d'activar l'scissor perquè quedi
// dibuixat sense retallar.
flushBatch();
clip_stack_.push_back(rect);
applyCurrentScissor();
}
void GpuFrameRenderer::popClip() {
// Emetem el batch que s'ha acumulat *dins* del clip actiu.
flushBatch();
if (!clip_stack_.empty()) {
clip_stack_.pop_back();
}
applyCurrentScissor();
}
void GpuFrameRenderer::applyCurrentScissor() {
if (render_pass_ == nullptr) {
return;
}
SDL_Rect rect{};
if (clip_stack_.empty()) {
// Sense clips: scissor cobreix tot el offscreen.
rect.x = 0;
rect.y = 0;
rect.w = static_cast<int>(render_w_);
rect.h = static_cast<int>(render_h_);
} else {
rect = clip_stack_.back();
}
SDL_SetGPUScissor(render_pass_, &rect);
}
void GpuFrameRenderer::endFrame() {
if (cmd_buffer_ == nullptr) {
return;
@@ -94,6 +94,15 @@ namespace Rendering::GPU {
// d'UI (notificacions, panels).
void pushRect(float x, float y, float w, float h, float r, float g, float b, float a);
// Clipping rectangular per a UI (scissor a SDL_GPU). pushClip/popClip
// forcen un flush intermedi del batch i activen/restauren l'scissor del
// pase actiu. Coordenades en píxels lògics del joc (1280×720); es
// converteixen a píxels físics del offscreen automàticament. Stack
// d'scissors per a clips niats. Quan la pila queda buida, l'scissor
// torna a cobrir el target sencer.
void pushClip(int logical_x, int logical_y, int logical_w, int logical_h);
void popClip();
// endFrame: flush del batch de líneas → composite postpro → submit + presenta.
void endFrame();
@@ -168,6 +177,10 @@ namespace Rendering::GPU {
std::vector<LineVertex> vertices_;
std::vector<uint16_t> indices_;
// Pila d'scissors actius en píxels físics del offscreen. Buida = sense
// clip (full target). Cada push/pop fa un flushBatch i reaplica scissor.
std::vector<SDL_Rect> clip_stack_;
// Estado del frame en curso.
SDL_GPUCommandBuffer* cmd_buffer_{nullptr};
SDL_GPUTexture* swapchain_texture_{nullptr};
@@ -190,6 +203,7 @@ namespace Rendering::GPU {
void bloomPass(); // pre-composite: H + V passes sobre les bloom textures
void compositePass();
void applyFinalViewport();
void applyCurrentScissor(); // re-aplica el top de clip_stack_ al render_pass_
};
} // namespace Rendering::GPU
+25 -2
View File
@@ -4,6 +4,7 @@
#include "core/rendering/line_renderer.hpp"
#include "core/defaults.hpp"
#include "core/defaults/effects.hpp"
namespace Rendering {
@@ -22,7 +23,8 @@ namespace Rendering {
int y2,
float brightness,
float thickness,
SDL_Color color) {
SDL_Color color,
float alpha) {
if (renderer == nullptr) {
return;
}
@@ -42,7 +44,28 @@ namespace Rendering {
const float W = (thickness > 0.0F) ? thickness : g_current_line_thickness;
renderer->pushLine(FX1, FY1, FX2, FY2, W, R, G, B, 1.0F);
renderer->pushLine(FX1, FY1, FX2, FY2, W, R, G, B, alpha);
}
void lineaGlow(Renderer* renderer,
int x1,
int y1,
int x2,
int y2,
float brightness,
float thickness,
SDL_Color color,
SDL_Color glow_color) {
// Color dels passes de halo: si glow_color té alpha>0, l'usem;
// altrament fem servir el color de la línia.
const SDL_Color HALO_COLOR = (glow_color.a > 0) ? glow_color : color;
for (const auto& pass : Defaults::FX::Glow::Line::PASSES) {
const bool IS_CORE = pass.thickness < 0.0F;
const float PASS_T = IS_CORE ? thickness : pass.thickness;
const SDL_Color PASS_C = IS_CORE ? color : HALO_COLOR;
linea(renderer, x1, y1, x2, y2, brightness, PASS_T, PASS_C, pass.alpha);
}
}
void setLineColor(SDL_Color color) { g_current_line_color = color; }
+21 -1
View File
@@ -17,9 +17,13 @@ namespace Rendering {
// Dibuja una línea entre dos puntos en coordenadas lógicas (1280×720).
// brightness: factor de brillo (0.0..1.0, default 1.0 = brillo máximo).
// Pre-multiplica el RGB del color (color dim sobre fons negre).
// thickness: grosor en píxeles lógicos. Si <= 0 usa g_current_line_thickness.
// color: si alpha==0, se usa el color global del oscilador; si alpha>0 se
// usa este color directo (paleta semántica por entidad).
// alpha: alpha que arriba al GPU (default 1.0 = opac, behavior original).
// Valors <1.0 fan que la línia es barregi de veritat sobre el dest
// en comptes de sobrepintar-lo (útil per halos translúcids).
void linea(Renderer* renderer,
int x1,
int y1,
@@ -27,7 +31,23 @@ namespace Rendering {
int y2,
float brightness = 1.0F,
float thickness = 0.0F,
SDL_Color color = {0, 0, 0, 0});
SDL_Color color = {0, 0, 0, 0},
float alpha = 1.0F);
// Versió amb halo neon: dibuixa la línia amb diversos passos de gruix
// creixent i alfa decreixent (config a Defaults::FX::Glow::Line::PASSES).
// El core (últim pass) usa el thickness/alpha que passa el caller.
// glow_color: si alpha>0, els passes de halo usen aquest color en lloc
// del color de la línia (p.ex. línia blanca amb halo daurat).
void lineaGlow(Renderer* renderer,
int x1,
int y1,
int x2,
int y2,
float brightness = 1.0F,
float thickness = 0.0F,
SDL_Color color = {0, 0, 0, 0},
SDL_Color glow_color = {0, 0, 0, 0});
// Color global de las líneas (lo actualiza ColorOscillator vía SDLManager).
void setLineColor(SDL_Color color);
+33 -6
View File
@@ -14,6 +14,7 @@
#include "core/defaults/rendering.hpp"
#include "core/defaults/window.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/coordinate_transform.hpp"
#include "core/system/notifier.hpp"
#include "project.h"
@@ -250,7 +251,10 @@ void SDLManager::increaseWindowSize() {
float new_zoom = zoom_factor_ + Defaults::Window::ZOOM_INCREMENT;
applyZoom(new_zoom);
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(std::format("ZOOM: {:.1f}X", zoom_factor_));
notifier->notifyInfo(localeSubstitute(
Locale::get().text("notification.zoom"),
"{z}",
std::format("{:.1f}", zoom_factor_)));
}
}
@@ -261,7 +265,10 @@ void SDLManager::decreaseWindowSize() {
float new_zoom = zoom_factor_ - Defaults::Window::ZOOM_INCREMENT;
applyZoom(new_zoom);
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(std::format("ZOOM: {:.1f}X", zoom_factor_));
notifier->notifyInfo(localeSubstitute(
Locale::get().text("notification.zoom"),
"{z}",
std::format("{:.1f}", zoom_factor_)));
}
}
@@ -310,7 +317,7 @@ void SDLManager::toggleFullscreen() {
Mouse::setForceHidden(is_fullscreen_);
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(is_fullscreen_ ? "PANTALLA COMPLETA" : "MODE FINESTRA");
notifier->notifyInfo(Locale::get().text(is_fullscreen_ ? "notification.fullscreen_on" : "notification.fullscreen_off"));
}
}
@@ -364,7 +371,7 @@ void SDLManager::toggleVSync() {
on_persist_();
}
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(cfg_->rendering.vsync != 0 ? "VSYNC ACTIU" : "VSYNC INACTIU");
notifier->notifyInfo(Locale::get().text(cfg_->rendering.vsync != 0 ? "notification.vsync_on" : "notification.vsync_off"));
}
}
@@ -374,7 +381,27 @@ void SDLManager::toggleAntialias() {
// No persistim: l'AA és toggleable runtime però el seu estat no es
// guarda al YAML de moment (decisió volgudament conservadora).
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(cfg_->rendering.antialias != 0 ? "AA ACTIU" : "AA INACTIU");
notifier->notifyInfo(Locale::get().text(cfg_->rendering.antialias != 0 ? "notification.antialias_on" : "notification.antialias_off"));
}
}
void SDLManager::setRenderResolution(int w, int h) {
if (!Defaults::Rendering::isValidRenderResolution(w, h)) {
std::cerr << "[SDLManager] Resolucio no valida (" << w << "x" << h
<< "), ignorant.\n";
return;
}
if (w == cfg_->rendering.render_width && h == cfg_->rendering.render_height) {
return; // ja era l'actual
}
if (!gpu_renderer_.resizeRenderTarget(static_cast<float>(w), static_cast<float>(h))) {
std::cerr << "[SDLManager] resizeRenderTarget ha fallat.\n";
return;
}
cfg_->rendering.render_width = w;
cfg_->rendering.render_height = h;
if (on_persist_) {
on_persist_();
}
}
@@ -384,6 +411,6 @@ void SDLManager::togglePostFx() {
// No persistim: el toggle és per A/B testing visual, l'estat per defecte
// del joc continua sent "postfx ON" segons defaults/YAML.
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(NEW_STATE ? "POSTPROCESSAT ACTIU" : "POSTPROCESSAT INACTIU");
notifier->notifyInfo(Locale::get().text(NEW_STATE ? "notification.postfx_on" : "notification.postfx_off"));
}
}
+6
View File
@@ -36,6 +36,10 @@ class SDLManager {
void toggleVSync(); // F4
void toggleAntialias(); // F5
void togglePostFx(); // F6
// Canvia la resolució del render target offscreen (recrea la textura).
// Cal cridar-lo fora d'un frame (event phase, no draw phase). Si el
// valor no es un preset valid o ja es l'actual, es no-op.
void setRenderResolution(int w, int h);
auto handleWindowEvent(const SDL_Event& event) -> bool; // Per a SDL_EVENT_WINDOW_RESIZED
// Funciones principals (renderizado).
@@ -47,6 +51,8 @@ class SDLManager {
// Getters
auto getRenderer() -> Rendering::Renderer* { return &gpu_renderer_; }
[[nodiscard]] auto getScaleFactor() const -> float { return zoom_factor_; }
[[nodiscard]] auto isFullscreen() const -> bool { return is_fullscreen_; }
[[nodiscard]] auto isPostFxEnabled() const -> bool { return gpu_renderer_.isPostFxEnabled(); }
// [NUEVO] Actualitzar context de renderizado (factor de scale global)
void updateRenderingContext() const;
+81 -27
View File
@@ -3,31 +3,77 @@
#include "core/rendering/shape_renderer.hpp"
#include <algorithm>
#include <cmath>
#include "core/defaults/effects.hpp"
#include "core/graphics/shape.hpp"
#include "core/rendering/line_renderer.hpp"
namespace Rendering {
// Helper: transformar un point con rotación, scale i traslación
static auto transformPoint(const Vec2& point, const Vec2& shape_centre, const Vec2& position, float angle, float scale) -> Vec2 {
// 1. Centrar el point respecte al centro de la shape
float centered_x = point.x - shape_centre.x;
float centered_y = point.y - shape_centre.y;
const float CENTERED_X = point.x - shape_centre.x;
const float CENTERED_Y = point.y - shape_centre.y;
// 2. Aplicar scale al point
float scaled_x = centered_x * scale;
float scaled_y = centered_y * scale;
const float SCALED_X = CENTERED_X * scale;
const float SCALED_Y = CENTERED_Y * scale;
// 3. Aplicar rotación 2D (Z-axis)
float cos_a = std::cos(angle);
float sin_a = std::sin(angle);
const float COS_A = std::cos(angle);
const float SIN_A = std::sin(angle);
float rotated_x = (scaled_x * cos_a) - (scaled_y * sin_a);
float rotated_y = (scaled_x * sin_a) + (scaled_y * cos_a);
const float ROTATED_X = (SCALED_X * COS_A) - (SCALED_Y * SIN_A);
const float ROTATED_Y = (SCALED_X * SIN_A) + (SCALED_Y * COS_A);
// 4. Aplicar traslación a posición mundial
return {.x = rotated_x + position.x, .y = rotated_y + position.y};
return {.x = ROTATED_X + position.x, .y = ROTATED_Y + position.y};
}
// Una passada de renderitzat: itera primitives de la shape i emet línies
// amb el thickness/alpha indicats. Es crida N vegades en glow mode (una
// per pass de halo + core), o 1 vegada quan glow=false.
static void renderSinglePass(Rendering::Renderer* renderer,
const std::shared_ptr<Graphics::Shape>& shape,
const Vec2& position,
float angle,
float scale,
float brightness,
SDL_Color color,
float thickness,
float alpha) {
const Vec2& shape_centre = shape->getCenter();
// Petita extensió a línies gruixudes per tapar forats entre segments.
// A vèrtex aguts (~108°) un valor alt produeix "espigues" — 15%.
const float EFFECTIVE_T = (thickness > 0.0F) ? thickness : getLineThickness();
const float EXTEND = (EFFECTIVE_T > 2.0F) ? (EFFECTIVE_T * 0.15F) : 0.0F;
for (const auto& primitive : shape->getPrimitives()) {
if (primitive.type == Graphics::PrimitiveType::POLYLINE) {
for (size_t i = 0; i < primitive.points.size() - 1; i++) {
Vec2 p1 = transformPoint(primitive.points[i], shape_centre, position, angle, scale);
Vec2 p2 = transformPoint(primitive.points[i + 1], shape_centre, position, angle, scale);
if (EXTEND > 0.0F) {
const float DX = p2.x - p1.x;
const float DY = p2.y - p1.y;
const float LEN = std::sqrt((DX * DX) + (DY * DY));
if (LEN > 1e-6F) {
const float UX = (DX / LEN) * EXTEND;
const float UY = (DY / LEN) * EXTEND;
p1.x -= UX;
p1.y -= UY;
p2.x += UX;
p2.y += UY;
}
}
linea(renderer, static_cast<int>(p1.x), static_cast<int>(p1.y), static_cast<int>(p2.x), static_cast<int>(p2.y), brightness, thickness, color, alpha);
}
} else if (primitive.points.size() >= 2) { // LINE
const Vec2 P1 = transformPoint(primitive.points[0], shape_centre, position, angle, scale);
const Vec2 P2 = transformPoint(primitive.points[1], shape_centre, position, angle, scale);
linea(renderer, static_cast<int>(P1.x), static_cast<int>(P1.y), static_cast<int>(P2.x), static_cast<int>(P2.y), brightness, thickness, color, alpha);
}
}
}
void renderShape(Rendering::Renderer* renderer,
@@ -37,7 +83,10 @@ namespace Rendering {
float scale,
float progress,
float brightness,
SDL_Color color) {
SDL_Color color,
float thickness,
float alpha,
bool glow) {
if (!shape || !shape->isValid()) {
return;
}
@@ -45,21 +94,26 @@ namespace Rendering {
return;
}
const Vec2& shape_centre = shape->getCenter();
if (!glow) {
renderSinglePass(renderer, shape, position, angle, scale, brightness, color, thickness, alpha);
return;
}
for (const auto& primitive : shape->getPrimitives()) {
if (primitive.type == Graphics::PrimitiveType::POLYLINE) {
// POLYLINE: conectar puntos consecutivos.
for (size_t i = 0; i < primitive.points.size() - 1; i++) {
const Vec2 P1 = transformPoint(primitive.points[i], shape_centre, position, angle, scale);
const Vec2 P2 = transformPoint(primitive.points[i + 1], shape_centre, position, angle, scale);
linea(renderer, static_cast<int>(P1.x), static_cast<int>(P1.y), static_cast<int>(P2.x), static_cast<int>(P2.y), brightness, 0.0F, color);
}
} else if (primitive.points.size() >= 2) { // LINE
const Vec2 P1 = transformPoint(primitive.points[0], shape_centre, position, angle, scale);
const Vec2 P2 = transformPoint(primitive.points[1], shape_centre, position, angle, scale);
linea(renderer, static_cast<int>(P1.x), static_cast<int>(P1.y), static_cast<int>(P2.x), static_cast<int>(P2.y), brightness, 0.0F, color);
// Glow: multi-pass amb halos translúcids proporcionals al tamany de
// la shape. Cada pass amb thickness_ratio<0 usa el thickness/alpha
// que ha passat el caller (és el "core" / línia real). Saturem la
// mida de referència a MAX_REFERENCE_RADIUS perquè shapes molt
// grans (logos) no tinguin halo desproporcionat.
const float RAW_REF = shape->getBoundingRadius() * scale;
const float REFERENCE_SIZE = std::min(RAW_REF, Defaults::FX::Glow::MAX_REFERENCE_RADIUS);
for (const auto& pass : Defaults::FX::Glow::PASSES) {
float pass_thickness = thickness;
float pass_alpha = alpha;
if (pass.thickness_ratio > 0.0F) {
pass_thickness = REFERENCE_SIZE * pass.thickness_ratio;
pass_alpha = pass.alpha * alpha; // respecta el master alpha del caller
}
renderSinglePass(renderer, shape, position, angle, scale, brightness, color, pass_thickness, pass_alpha);
}
}
+9 -1
View File
@@ -21,6 +21,11 @@ namespace Rendering {
// - scale: factor de scale (1.0 = mida original)
// - progress: progrés de l'animación (0.0-1.0, default 1.0 = tot visible)
// - brightness: factor de brightness (0.0-1.0, default 1.0 = màxima brightness)
// - color: si alpha==0, usa oscil·lador global
// - thickness: gruix de línia. <=0 → usa global (g_current_line_thickness)
// - alpha: alpha que arriba al GPU (default 1.0 = opac). <1.0 = halo real
// - glow: si true, redibuixa la shape amb halos translúcids proporcionals
// al bounding_radius*scale (efecte neon). Si false, single-pass.
void renderShape(Rendering::Renderer* renderer,
const std::shared_ptr<Graphics::Shape>& shape,
const Vec2& position,
@@ -28,6 +33,9 @@ namespace Rendering {
float scale = 1.0F,
float progress = 1.0F,
float brightness = 1.0F,
SDL_Color color = {0, 0, 0, 0}); // alpha==0 → usa global oscilador
SDL_Color color = {0, 0, 0, 0},
float thickness = 0.0F,
float alpha = 1.0F,
bool glow = true);
} // namespace Rendering
+3 -3
View File
@@ -2,8 +2,8 @@
// © 2026 JailDesigner
//
// Sistema global propiedad del Director. Se actualiza y dibuja cada frame
// después de la escena (queda on top). En builds debug arranca visible,
// en release oculto. F11 alterna visibilidad.
// después de la escena (queda on top). Arranca oculto sempre; F11 alterna
// visibilidad durant l'execució.
#pragma once
@@ -32,7 +32,7 @@ namespace System {
private:
Graphics::VectorText text_;
const Config::RenderingConfig* rendering_cfg_;
bool visible_{true};
bool visible_{false};
// FPS counter — se actualiza cada FPS_UPDATE_INTERVAL segundos.
float fps_accumulator_{0.0F};
+155 -134
View File
@@ -15,16 +15,18 @@
#include "core/defaults/window.hpp"
#include "core/input/input.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/resources/resource_loader.hpp"
#include "core/system/notifier.hpp"
#include "core/system/service_menu.hpp"
#include "core/utils/path_utils.hpp"
#include "debug_overlay.hpp"
#include "game/config_yaml.hpp"
#include "game/scenes/game_scene.hpp"
#include "game/scenes/logo_scene.hpp"
#include "game/scenes/title_scene.hpp"
#include "game/scenes/title_scene_3d.hpp"
#include "global_events.hpp"
#include "project.h"
#include "scene.hpp"
@@ -40,17 +42,15 @@ using SceneManager::SceneContext;
using SceneType = SceneContext::SceneType;
// Constructor
Director::Director(std::vector<std::string> const& args,
Config::EngineConfig& cfg,
Config::ConfigPersistence persistence)
: cfg_(&cfg),
persistence_(std::move(persistence)) {
std::cout << "Orni Attack - Inici\n";
Director::Director(int argc, char* argv[])
: cfg_(&ConfigYaml::engine_config) {
std::cout << "Game start\n";
// Inicialitzar opciones con valors per defecte
persistence_.init_defaults();
ConfigYaml::init();
// Comprovar arguments del programa
// Convertir arguments a std::vector<std::string> i comprovar-los
std::vector<std::string> args(argv, argv + argc);
executable_path_ = checkProgramArguments(args);
// Inicialitzar sistema de rutes
@@ -96,10 +96,14 @@ Director::Director(std::vector<std::string> const& args,
createSystemFolder(std::string("jailgames/") + Project::NAME);
// Establir ruta del file de configuración
persistence_.set_path(system_folder_ + "/config.yaml");
ConfigYaml::setConfigFile(system_folder_ + "/config.yaml");
// Carregar o crear configuración
persistence_.load();
ConfigYaml::loadFromFile();
// Carregar locale segons la config (per defecte "ca"). Si la càrrega
// falla, Locale::text() retorna la clau crua i el joc segueix funcionant.
Locale::get().load(std::string("locale/") + cfg_->locale + ".yaml");
// Inicialitzar sistema de input
Input::init("data/gamecontrollerdb.txt");
@@ -117,22 +121,75 @@ Director::Director(std::vector<std::string> const& args,
}
std::cout << '\n';
// === Bootstrap de finestra, audio i subsistemes de runtime ===
int initial_width = static_cast<int>(std::round(
Defaults::Window::WIDTH * cfg_->window.zoom_factor));
int initial_height = static_cast<int>(std::round(
Defaults::Window::HEIGHT * cfg_->window.zoom_factor));
sdl_ = std::make_unique<SDLManager>(initial_width, initial_height, cfg_->window.fullscreen, *cfg_, [] { ConfigYaml::saveToFile(); });
// CRÍTIC: forçar ocultació del cursor DESPRÉS d'inicialitzar SDL,
// perquè la creació de la finestra el reactiva.
if (!cfg_->window.fullscreen) {
Mouse::forceHide();
}
const Audio::Config AUDIO_CONFIG{
.enabled = Defaults::Audio::ENABLED,
.volume = Defaults::Audio::VOLUME,
.music_enabled = Defaults::Audio::MUSIC_ENABLED,
.music_volume = Defaults::Audio::MUSIC_VOLUME,
.sound_enabled = Defaults::Audio::SOUND_ENABLED,
.sound_volume = Defaults::Audio::SOUND_VOLUME,
};
Audio::init(AUDIO_CONFIG);
Audio::get()->applySettings(AUDIO_CONFIG);
AudioResource::getMusic("title.ogg");
AudioResource::getMusic("game.ogg");
if (cfg_->console) {
std::cout << "Música precacheada\n";
}
context_ = std::make_unique<SceneContext>();
#ifdef _DEBUG
context_->setNextScene(SceneType::TITLE);
#else
context_->setNextScene(SceneType::LOGO);
#endif
debug_overlay_ = std::make_unique<System::DebugOverlay>(
sdl_->getRenderer(),
cfg_->rendering);
System::Notifier::init(sdl_->getRenderer());
System::ServiceMenu::init(sdl_->getRenderer(), sdl_.get(), debug_overlay_.get());
last_ticks_ms_ = SDL_GetTicks();
}
Director::~Director() {
// Guardar opciones
persistence_.save();
ConfigYaml::saveToFile();
// Destruir subsistemes en ordre invers a la construcció. El Notifier
// referencia el renderer, així que ha de morir abans que sdl_.
// SDL_Quit() el crida SDL automàticament després de SDL_AppQuit; no
// l'hem de cridar nosaltres.
current_scene_.reset();
debug_overlay_.reset();
System::ServiceMenu::destroy();
System::Notifier::destroy();
context_.reset();
sdl_.reset();
// Cleanup input
Input::destroy();
// Cleanup audio
Audio::destroy();
// Cleanup SDL
SDL_Quit();
std::cout << "\nAdéu!\n";
std::cout << "\nBye!\n";
}
// Comprovar arguments del programa
@@ -145,8 +202,8 @@ auto Director::checkProgramArguments(std::vector<std::string> const& args)
cfg_->console = true;
std::cout << "Mode consola activat\n";
} else if (argument == "--reset-config") {
persistence_.init_defaults();
persistence_.save();
ConfigYaml::init();
ConfigYaml::saveToFile();
std::cout << "Configuración restablida als valors per defecte\n";
}
}
@@ -218,91 +275,13 @@ void Director::createSystemFolder(const std::string& folder) {
}
}
// Bucle principal del juego
auto Director::run() -> int {
// Calculate initial size from saved zoom_factor
int initial_width = static_cast<int>(std::round(
Defaults::Window::WIDTH * cfg_->window.zoom_factor));
int initial_height = static_cast<int>(std::round(
Defaults::Window::HEIGHT * cfg_->window.zoom_factor));
// Crear gestor SDL amb la engine_config + callback de persistència
// per a quan toggleVSync (F4) muti vsync. Mantenim sdl_manager agnòstic.
SDLManager sdl(initial_width, initial_height, cfg_->window.fullscreen, *cfg_, [this] { persistence_.save(); });
// CRÍTIC: Forçar ocultació del cursor DESPRÉS de toda la inicialización SDL
// Això evita que SDL mostre el cursor automàticament durante la creació de la finestra
if (!cfg_->window.fullscreen) {
Mouse::forceHide();
}
// Inicializar sistema de audio (config inyectada desde Defaults)
const Audio::Config AUDIO_CONFIG{
.enabled = Defaults::Audio::ENABLED,
.volume = Defaults::Audio::VOLUME,
.music_enabled = Defaults::Audio::MUSIC_ENABLED,
.music_volume = Defaults::Audio::MUSIC_VOLUME,
.sound_enabled = Defaults::Audio::SOUND_ENABLED,
.sound_volume = Defaults::Audio::SOUND_VOLUME,
};
Audio::init(AUDIO_CONFIG);
Audio::get()->applySettings(AUDIO_CONFIG); // Aplicar volúmenes iniciales al motor
// Precachear música para evitar lag al empezar
AudioResource::getMusic("title.ogg");
AudioResource::getMusic("game.ogg");
if (cfg_->console) {
std::cout << "Música precacheada\n";
}
// Crear context de escenes
SceneContext context;
#ifdef _DEBUG
context.setNextScene(SceneType::TITLE);
#else
context.setNextScene(SceneType::LOGO);
#endif
// Overlay de debug (FPS + VSync). Vive en el Director porque es global
// a todas las escenas. Toggle con F11 (visible por defecto en _DEBUG).
System::DebugOverlay debug_overlay(sdl.getRenderer(), cfg_->rendering);
// Sistema de notificacions toast: singleton accessible des d'on calgui
// (F1-F5 a sdl_manager, ESC a global_events). El renderer ha de viure
// tant com el Notifier; el destruim explícitament abans de tornar.
System::Notifier::init(sdl.getRenderer());
// Bucle principal: construir escena → frame loop → destruir → siguiente.
while (context.nextScene() != SceneType::EXIT) {
SceneManager::actual = context.nextScene();
std::unique_ptr<Scene> scene = buildScene(context.nextScene(), sdl, context);
if (!scene) {
break;
}
runFrameLoop(*scene, sdl, context, debug_overlay);
}
SceneManager::actual = SceneType::EXIT;
System::Notifier::destroy();
return 0;
}
auto Director::buildScene(SceneType type, SDLManager& sdl, SceneContext& context)
-> std::unique_ptr<Scene> {
switch (type) {
case SceneType::LOGO:
return std::make_unique<LogoScene>(sdl, context);
case SceneType::TITLE: {
// Env var ORNI_TITLE_3D=1 redirigeix la TITLE clàssica cap a la
// variant 3D real en proves; en qualsevol altre cas, la 2D.
const char* env = std::getenv("ORNI_TITLE_3D");
if (env != nullptr && env[0] == '1' && env[1] == '\0') {
return std::make_unique<TitleScene3D>(sdl, context);
}
case SceneType::TITLE:
return std::make_unique<TitleScene>(sdl, context);
}
case SceneType::TITLE_3D:
return std::make_unique<TitleScene3D>(sdl, context);
case SceneType::GAME:
return std::make_unique<GameScene>(sdl, context);
case SceneType::EXIT:
@@ -311,55 +290,97 @@ auto Director::buildScene(SceneType type, SDLManager& sdl, SceneContext& context
}
}
void Director::runFrameLoop(Scene& scene, SDLManager& sdl, SceneContext& context, System::DebugOverlay& debug_overlay) {
SDL_Event event;
Uint64 last_time = SDL_GetTicks();
auto Director::advanceScene() -> SDL_AppResult {
current_scene_.reset();
const SceneType NEXT = context_->nextScene();
if (NEXT == SceneType::EXIT) {
SceneManager::actual = SceneType::EXIT;
return SDL_APP_SUCCESS;
}
SceneManager::actual = NEXT;
current_scene_ = buildScene(NEXT, *sdl_, *context_);
if (!current_scene_) {
SceneManager::actual = SceneType::EXIT;
return SDL_APP_SUCCESS;
}
return SDL_APP_CONTINUE;
}
while (!scene.isFinished()) {
// Delta time real, capeado a 50ms para evitar grandes saltos.
auto Director::handleEvent(const SDL_Event& event) -> SDL_AppResult {
// 1. Window events (resize, minimize, focus...)
if (sdl_->handleWindowEvent(event)) {
return SDL_APP_CONTINUE;
}
// 2. Events globals (F1-F6, ESC, QUIT, gamepad hotplug).
// GlobalEvents marca context_->nextScene() = EXIT en ESC doble o QUIT;
// activem la bandera per fer-ho fluir cap a SDL_APP_SUCCESS al pròxim tick.
if (GlobalEvents::handle(event, *sdl_, *context_)) {
if (context_->nextScene() == SceneType::EXIT) {
wants_quit_ = true;
}
return SDL_APP_CONTINUE;
}
// 3. F11 → toggle del debug overlay (cas especial fora de GlobalEvents).
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_F11) {
debug_overlay_->toggle();
return SDL_APP_CONTINUE;
}
// 4. Esdeveniment específic de l'escena actual.
if (current_scene_) {
current_scene_->handleEvent(event);
}
return SDL_APP_CONTINUE;
}
auto Director::iterate() -> SDL_AppResult {
if (wants_quit_) {
return SDL_APP_SUCCESS;
}
// Pivotar a la següent escena si l'actual ha acabat (o és la primera).
if (!current_scene_ || current_scene_->isFinished()) {
SDL_AppResult pivot = advanceScene();
if (pivot != SDL_APP_CONTINUE) {
return pivot;
}
}
// Delta time real, capeado a 50ms per evitar grans salts.
const Uint64 NOW = SDL_GetTicks();
float delta_time = static_cast<float>(NOW - last_time) / 1000.0F;
last_time = NOW;
float delta_time = static_cast<float>(NOW - last_ticks_ms_) / 1000.0F;
last_ticks_ms_ = NOW;
delta_time = std::min(delta_time, 0.05F);
Mouse::updateCursorVisibility();
Input::get()->update();
// Event loop: primero ventana, después globales, después F11
// (toggle del overlay), después escena.
while (SDL_PollEvent(&event)) {
if (sdl.handleWindowEvent(event)) {
continue;
}
if (GlobalEvents::handle(event, sdl, context)) {
continue;
}
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_F11) {
debug_overlay.toggle();
continue;
}
scene.handleEvent(event);
}
scene.update(delta_time);
debug_overlay.update(delta_time);
current_scene_->update(delta_time);
debug_overlay_->update(delta_time);
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->update(delta_time);
}
if (auto* menu = System::ServiceMenu::get(); menu != nullptr) {
menu->update(delta_time);
}
Audio::update();
// Si la swapchain no está disponible (ventana minimizada, etc.),
// saltarse draw+present ese frame: dibujar dejaría vértices
// colgando en el batch interno sin nadie que los presente.
if (!sdl.clear(0, 0, 0)) {
continue;
// Si la swapchain no està disponible (finestra minimitzada, etc.),
// saltar-se draw+present aquest frame.
if (!sdl_->clear(0, 0, 0)) {
return SDL_APP_CONTINUE;
}
sdl.updateRenderingContext();
scene.draw();
debug_overlay.draw(); // sempre per damunt de l'escena
sdl_->updateRenderingContext();
current_scene_->draw();
debug_overlay_->draw(); // sempre per damunt de l'escena
if (const auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->draw(); // toast: per damunt de tot
}
sdl.present();
if (const auto* menu = System::ServiceMenu::get(); menu != nullptr) {
menu->draw(); // service menu: per damunt fins i tot dels toasts
}
sdl_->present();
return SDL_APP_CONTINUE;
}
+32 -14
View File
@@ -1,5 +1,7 @@
#pragma once
#include <SDL3/SDL.h>
#include <memory>
#include <string>
#include <vector>
@@ -15,22 +17,38 @@ namespace System {
class Director {
public:
// `cfg` ha de viure tant com el Director (típicament owned per main).
// `persistence` encapsula init/load/save delegats a la capa concreta
// (game/ConfigYaml::*).
Director(std::vector<std::string> const& args,
Config::EngineConfig& cfg,
Config::ConfigPersistence persistence);
// El Director és el programa: posseeix la configuració (via ConfigYaml)
// i orquestra tots els subsistemes. main.cpp és pur tràmit que el
// construeix i delega cap a SDL.
Director(int argc, char* argv[]);
~Director();
// Bucle principal del juego.
auto run() -> int;
// Una iteració del bucle: pivot d'escena si cal, delta time, update i
// render. Retorna SDL_APP_CONTINUE per seguir, SDL_APP_SUCCESS si vol
// sortir net, SDL_APP_FAILURE si no es pot recuperar.
auto iterate() -> SDL_AppResult;
// Enruta un sol esdeveniment cap a la cadena finestra → globals → F11 →
// escena. Si detecta sortida (ESC doble, QUIT) marca wants_quit_ perquè
// el següent iterate() retorni SDL_APP_SUCCESS.
auto handleEvent(const SDL_Event& event) -> SDL_AppResult;
private:
std::string executable_path_;
std::string system_folder_;
Config::EngineConfig* cfg_;
Config::ConfigPersistence persistence_;
Config::EngineConfig* cfg_{nullptr};
// Subsistemes que viuen tant com el Director (abans eren locals de run()).
// Preparació per a la migració a SDL_MAIN_USE_CALLBACKS: amb les 4
// callbacks de SDL3 no hi ha un scope que englobi tot el bucle, així
// que cal que aquest estat sigui membre del Director.
std::unique_ptr<SDLManager> sdl_;
std::unique_ptr<SceneManager::SceneContext> context_;
std::unique_ptr<System::DebugOverlay> debug_overlay_;
std::unique_ptr<Scene> current_scene_;
Uint64 last_ticks_ms_{0};
bool wants_quit_{false};
auto checkProgramArguments(std::vector<std::string> const& args)
-> std::string;
@@ -43,8 +61,8 @@ class Director {
SceneManager::SceneContext& context)
-> std::unique_ptr<Scene>;
// Ejecuta el bucle de frames de UNA escena hasta que scene.isFinished()
// sea true. Maneja delta_time, eventos (globales + escena), update y draw.
// El debug_overlay es global a todas las escenas; el Director lo posee.
static void runFrameLoop(Scene& scene, SDLManager& sdl, SceneManager::SceneContext& context, System::DebugOverlay& debug_overlay);
// Pivota a la següent escena: destrueix l'actual, llegeix context_->nextScene()
// i construeix la nova. Retorna SDL_APP_SUCCESS si la nova és EXIT o no es pot
// construir; SDL_APP_CONTINUE si tot OK.
auto advanceScene() -> SDL_AppResult;
};
+15 -15
View File
@@ -4,52 +4,52 @@
namespace GameConfig {
// Mode de juego
enum class Mode : std::uint8_t {
// Mode de juego
enum class Mode : std::uint8_t {
NORMAL, // Partida normal
DEMO // Mode demostració (futur)
};
};
// Configuración de una match
struct MatchConfig {
bool jugador1_actiu{false}; // Es active el player 1?
bool jugador2_actiu{false}; // Es active el player 2?
// Configuración de una match
struct MatchConfig {
bool player1_active{false}; // Es active el player 1?
bool player2_active{false}; // Es active el player 2?
Mode mode{Mode::NORMAL}; // Mode de juego
// Métodos auxiliars
// Retorna true si solo hay un player active
[[nodiscard]] auto isSinglePlayer() const -> bool {
return (jugador1_actiu && !jugador2_actiu) ||
(!jugador1_actiu && jugador2_actiu);
return (player1_active && !player2_active) ||
(!player1_active && player2_active);
}
// Retorna true si hay dos jugadors active
[[nodiscard]] auto isCoop() const -> bool {
return jugador1_actiu && jugador2_actiu;
return player1_active && player2_active;
}
// Retorna true si no hay sin player active
[[nodiscard]] auto hasNoPlayers() const -> bool {
return !jugador1_actiu && !jugador2_actiu;
return !player1_active && !player2_active;
}
// Compte de jugadors active (0, 1 o 2)
[[nodiscard]] auto getPlayerCount() const -> uint8_t {
return (jugador1_actiu ? 1 : 0) + (jugador2_actiu ? 1 : 0);
return (player1_active ? 1 : 0) + (player2_active ? 1 : 0);
}
// Retorna l'ID de l'únic player active (0 o 1)
// Solo vàlid si es_un_jugador() retorna true
[[nodiscard]] auto getSinglePlayerId() const -> uint8_t {
if (jugador1_actiu && !jugador2_actiu) {
if (player1_active && !player2_active) {
return 0;
}
if (!jugador1_actiu && jugador2_actiu) {
if (!player1_active && player2_active) {
return 1;
}
return 0; // Fallback (necesario comprovar es_un_jugador() primer)
}
};
};
} // namespace GameConfig
+67 -2
View File
@@ -7,8 +7,11 @@
#include "core/input/input.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/system/notifier.hpp"
#include "core/system/service_menu.hpp"
#include "game/config_yaml.hpp"
#include "scene_context.hpp"
// Using declarations per simplificar el codi
@@ -17,6 +20,31 @@ using SceneType = SceneContext::SceneType;
namespace GlobalEvents {
namespace {
// Reenvia el KEY_DOWN al menu de servei si esta obert i la tecla no
// es F1-F12 ni ESC (que sempre passen com a globals). Retorna true si
// el menu l'ha consumit.
auto forwardToServiceMenu(const SDL_Event& event) -> bool {
if (event.type != SDL_EVENT_KEY_DOWN) {
return false;
}
auto* menu = System::ServiceMenu::get();
if (menu == nullptr || !menu->isOpen()) {
return false;
}
const SDL_Scancode SC = event.key.scancode;
const bool PASSTHROUGH = (SC == SDL_SCANCODE_ESCAPE) ||
(SC >= SDL_SCANCODE_F1 && SC <= SDL_SCANCODE_F12);
if (PASSTHROUGH) {
return false;
}
menu->handleEvent(event);
return true;
}
} // namespace
auto handle(const SDL_Event& event, SDLManager& sdl, SceneContext& context) -> bool {
// 1. Permitir que Input procese el evento (para hotplug de gamepads)
auto event_msg = Input::get()->handleEvent(event);
@@ -34,7 +62,15 @@ namespace GlobalEvents {
// 3. Gestió del ratolí (auto-ocultar)
Mouse::handleEvent(event);
// 4. Procesar acciones globales directamente desde eventos SDL
// 4. Service Menu (F12): consumeix tot KEY_DOWN excepte tecles de
// funció (F1-F12) i ESC, que continuen sent globals (zoom, fullscreen,
// vsync, AA, postfx, locale, exit prompt). Aixi el menu captura
// ENTER/BACKSPACE/UP/DOWN/LEFT/RIGHT i lletres mentre esta obert.
if (forwardToServiceMenu(event)) {
return true;
}
// 5. Procesar acciones globales directamente desde eventos SDL
// (NO usar Input::checkAction() para evitar desfase de timing)
if (event.type == SDL_EVENT_KEY_DOWN) {
switch (event.key.scancode) {
@@ -62,6 +98,35 @@ namespace GlobalEvents {
sdl.togglePostFx();
return true;
case SDL_SCANCODE_F7: {
// Toggle d'idioma en runtime entre català i anglès. Els
// strings ja capturats (toast actiu, banner stage start)
// sobreviuen fins al seu cicle; la resta (HUD, pantalles,
// pròxims toasts) es refresquen al següent frame perquè
// criden Locale::text() cada draw.
const std::string NEW_LANG = (ConfigYaml::engine_config.locale == "ca") ? "en" : "ca";
if (Locale::get().switchTo(NEW_LANG)) {
ConfigYaml::engine_config.locale = NEW_LANG;
ConfigYaml::saveToFile();
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
notifier->notifyInfo(localeSubstitute(
Locale::get().text("notification.locale_switched"),
"{lang}",
Locale::get().text("language." + NEW_LANG)));
}
}
return true;
}
case SDL_SCANCODE_F12: {
// Toggle del menu de servei. Sempre passa com a global
// (alterna obert/tancat des de qualsevol escena).
if (auto* menu = System::ServiceMenu::get(); menu != nullptr) {
menu->toggle();
}
return true;
}
case SDL_SCANCODE_ESCAPE: {
// Doble pulsació per confirmar sortida: la primera ESC
// dispara un toast d'avís; només si aquest toast concret
@@ -71,7 +136,7 @@ namespace GlobalEvents {
// sortida en lloc de tancar.
auto* notifier = System::Notifier::get();
if (notifier != nullptr && !notifier->isExitPromptActive()) {
notifier->notifyExit("PREMEU ESC UN ALTRE COP PER EIXIR");
notifier->notifyExit(Locale::get().text("notification.press_again_exit"));
return true;
}
// Notifier inexistent (degradació elegant) o segona ESC
+61
View File
@@ -0,0 +1,61 @@
// relaunch.cpp - Implementacio del reinici en calent
// © 2026 JailDesigner
#include "core/system/relaunch.hpp"
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <iostream>
#ifdef _WIN32
#include <process.h> // _execv
#else
#include <unistd.h> // execv
#endif
namespace {
// Estat global (process-scope). Aquesta TU es la unica que gestiona el
// reinici, aixi que els static interns no s'escapen.
char** g_argv = nullptr;
bool g_requested = false;
} // namespace
namespace System::Relaunch {
void setArgv(int /*argc*/, char** argv) {
g_argv = argv;
}
void request() {
g_requested = true;
}
auto isRequested() -> bool {
return g_requested;
}
void execIfRequested() {
#ifdef __EMSCRIPTEN__
// Al navegador el reinici real seria location.reload(); aqui no fem res.
return;
#else
if (!g_requested || g_argv == nullptr || g_argv[0] == nullptr) {
return;
}
std::cout << "[Relaunch] Reiniciant " << g_argv[0] << "...\n";
#ifdef _WIN32
_execv(g_argv[0], g_argv);
#else
execv(g_argv[0], g_argv);
#endif
// Si arribem aqui, execv ha fallat. Tots els subsistemes ja estan
// destruits; sortim amb error i el shell rebra el codi.
std::cerr << "[Relaunch] Ha fallat: " << std::strerror(errno) << '\n';
std::exit(EXIT_FAILURE);
#endif
}
} // namespace System::Relaunch
+33
View File
@@ -0,0 +1,33 @@
// relaunch.hpp - Reinici en calent del proces (execv)
// © 2026 JailDesigner
//
// Helper desacoblat per a permetre que el menu de servei demani un reinici
// sense conèixer Director ni main.cpp. Patro:
//
// main() → Relaunch::setArgv(argc, argv) (a l'arrencada)
// ServiceMenu → Relaunch::request() (en activar REINICIAR)
// main() → Relaunch::execIfRequested() (a SDL_AppQuit)
//
// L'execv() reemplaca el proces actual: si torna, ha fallat. A EMSCRIPTEN
// no es pot reiniciar; isRequested() seguira dient true pero execIfRequested
// sera no-op.
#pragma once
namespace System::Relaunch {
// Emmagatzema l'argv original. Cal cridar-ho una vegada des de main.
void setArgv(int argc, char** argv);
// Demana un reinici (no actua immediatament; nomes marca el flag).
void request();
// Consulta del flag.
[[nodiscard]] auto isRequested() -> bool;
// Si hi ha reinici demanat i tenim argv valid, fa execv. En cas d'exit
// no torna. Si execv falla, registra l'error i torna; el caller hauria
// de sortir normalment.
void execIfRequested();
} // namespace System::Relaunch
+1 -4
View File
@@ -16,10 +16,7 @@ namespace SceneManager {
// Tipo de escena del juego
enum class SceneType : std::uint8_t {
LOGO, // Pantalla de start (logo JAILGAMES)
TITLE, // Pantalla de título (versió 2D actual). Si l'env var
// ORNI_TITLE_3D=1 està activa, Director::buildScene
// redirigeix aquest valor a TitleScene3D.
TITLE_3D, // Pantalla de títol 3D real (variant en proves)
TITLE, // Pantalla de título (3D)
GAME, // Juego principal (Asteroids)
EXIT // Salir del programa
};
+930
View File
@@ -0,0 +1,930 @@
// service_menu.cpp - Implementacio del menu de servei
// © 2026 JailDesigner
#include "core/system/service_menu.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstddef>
#include <format>
#include <utility>
#include "core/audio/audio.hpp"
#include "core/config/engine_config.hpp"
#include "core/defaults/audio.hpp"
#include "core/defaults/rendering.hpp"
#include "core/defaults/service_menu.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/system/debug_overlay.hpp"
#include "core/system/relaunch.hpp"
#include "core/types.hpp"
#include "game/config_yaml.hpp"
#include "project.h"
namespace {
// Easing ease-out quadratic per a l'obertura/tancament. Identic a
// aee_arcade service_menu.cpp:114-120.
auto easeOutQuad(float t) -> float {
t = std::clamp(t, 0.0F, 1.0F);
const float INV = 1.0F - t;
return 1.0F - (INV * INV);
}
// Canvas logic del joc (constants compartides amb la resta del renderer).
constexpr float CANVAS_W = 1280.0F;
constexpr float CANVAS_H = 720.0F;
// Crida pushRect amb un SDL_Color (els components s'escalen a [0..1]).
void fillRect(Rendering::Renderer* renderer, float x, float y, float w, float h, SDL_Color color) {
renderer->pushRect(x, y, w, h, static_cast<float>(color.r) / 255.0F, static_cast<float>(color.g) / 255.0F, static_cast<float>(color.b) / 255.0F, static_cast<float>(color.a) / 255.0F);
}
void playSelectSound() {
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playSound(Defaults::ServiceMenu::SELECT_SOUND, Audio::Group::INTERFACE);
}
}
void playAcceptSound() {
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playSound(Defaults::ServiceMenu::ACCEPT_SOUND, Audio::Group::INTERFACE);
}
}
// VectorText nomes admet ASCII en majuscules. El git hash sortit de git
// rev-parse es lowercase (a-f), aixi que el passem a uppercase per al
// display sense modificar Project::GIT_HASH.
auto toUpperAscii(const std::string& s) -> std::string {
std::string result = s;
for (char& c : result) {
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
return result;
}
// Resol el text del label d'un item: prioritza label_text (literal) sobre
// label_key (locale). Retorna cadena buida si tots dos son buits.
auto resolveLabel(const System::ServiceMenu::Item& item) -> std::string {
if (!item.label_text.empty()) {
return item.label_text;
}
if (item.label_key.empty()) {
return {};
}
return Locale::get().text(item.label_key);
}
} // namespace
namespace System {
std::unique_ptr<ServiceMenu> ServiceMenu::instance;
void ServiceMenu::init(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay) {
instance.reset(new ServiceMenu(renderer, sdl, debug_overlay));
}
void ServiceMenu::destroy() {
instance.reset();
}
auto ServiceMenu::get() -> ServiceMenu* {
return instance.get();
}
ServiceMenu::ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay)
: renderer_(renderer),
sdl_(sdl),
debug_overlay_(debug_overlay),
text_(renderer) {}
auto ServiceMenu::isOpen() const -> bool {
return open_;
}
void ServiceMenu::toggle() {
if (!open_) {
open_ = true;
closing_ = false;
open_anim_ = 0.0F;
animated_h_ = 0.0F;
highlight_snap_ = true; // primera frame: enganxar el highlight al cursor
buildRootPage();
// L'ample comença ja al valor objectiu (la caixa surt amb l'amplada
// final i nomes anima l'alçada). L'ample s'animarà despres entre
// pagines (push/pop).
animated_w_ = computeTargetWidth();
playAcceptSound();
return;
}
// Ja obert: iniciem tancament. open_ es mante a true fins que l'animacio
// arriba a 0, per a permetre que update() segueixi avançant open_anim_.
closing_ = true;
playAcceptSound();
}
namespace {
// Helper local: construeix un item de tipus SUBMENU amb el callback
// d'entrada. Es manté local a aquesta TU per a poder construir la
// pagina arrel a buildRootPage sense designed-initializers parcials
// (clang-tidy es queixa quan en falten).
auto makeSubmenu(const std::string& label_key, std::function<void()> on_activate) -> ServiceMenu::Item {
return ServiceMenu::Item{
.kind = ServiceMenu::Kind::SUBMENU,
.label_key = label_key,
.label_text = {},
.selectable = true,
.on_activate = std::move(on_activate),
.get_value_text = {},
.on_change = {},
};
}
} // namespace
void ServiceMenu::buildRootPage() {
Page root;
root.title_key = "service_menu.title";
root.items = {
makeSubmenu("service_menu.video", [this] { pushPage(buildVideoPage()); }),
makeSubmenu("service_menu.audio", [this] { pushPage(buildAudioPage()); }),
makeSubmenu("service_menu.options", [this] { pushPage(buildOptionsPage()); }),
makeSubmenu("service_menu.system", [this] { pushPage(buildSystemPage()); }),
};
stack_.clear();
stack_.push_back(std::move(root));
}
auto ServiceMenu::buildVideoPage() -> Page {
// Helper: localitza ON/OFF per a TOGGLE items.
auto on_off_text = [](bool v) -> std::string {
return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off");
};
SDLManager* sdl = sdl_;
Page page;
page.title_key = "service_menu.video";
page.items = {
// ZOOM (INT_RANGE-style: ± delega a sdl.increase/decreaseWindowSize).
Item{
.kind = Kind::INT_RANGE,
.label_key = "service_menu.video_zoom",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [sdl] { return std::format("{:.1f}X", sdl->getScaleFactor()); },
.on_change = [sdl](int dir) {
if (dir > 0) {
sdl->increaseWindowSize();
} else {
sdl->decreaseWindowSize();
} },
},
// FULLSCREEN
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.video_fullscreen",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isFullscreen()); },
.on_change = [sdl](int) { sdl->toggleFullscreen(); },
},
// VSYNC
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.video_vsync",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.vsync != 0); },
.on_change = [sdl](int) { sdl->toggleVSync(); },
},
// RESOLUCIO (sub-submenu amb els 5 presets; mostra l'actual com a valor)
Item{
.kind = Kind::SUBMENU,
.label_key = "service_menu.video_resolution",
.label_text = {},
.selectable = true,
.on_activate = [this] { pushPage(buildResolutionPage()); },
.get_value_text = [] { return std::format("{}X{}",
ConfigYaml::engine_config.rendering.render_width,
ConfigYaml::engine_config.rendering.render_height); },
.on_change = {},
},
// ANTIALIAS
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.video_aa",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [on_off_text] { return on_off_text(ConfigYaml::engine_config.rendering.antialias != 0); },
.on_change = [sdl](int) { sdl->toggleAntialias(); },
},
// POSTPROCESSAT
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.video_postfx",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [sdl, on_off_text] { return on_off_text(sdl->isPostFxEnabled()); },
.on_change = [sdl](int) { sdl->togglePostFx(); },
},
};
return page;
}
auto ServiceMenu::buildResolutionPage() const -> Page {
Page page;
page.title_key = "service_menu.video_resolution";
// El cursor arrenca sobre el preset actual perquè l'usuari vegi quin
// esta seleccionat sense buscar-lo.
const int CURR_W = ConfigYaml::engine_config.rendering.render_width;
const int CURR_H = ConfigYaml::engine_config.rendering.render_height;
std::size_t cursor = 0;
SDLManager* sdl = sdl_;
for (std::size_t i = 0; i < Defaults::Rendering::RESOLUTION_PRESETS.size(); ++i) {
const auto& preset = Defaults::Rendering::RESOLUTION_PRESETS[i];
if (preset.w == CURR_W && preset.h == CURR_H) {
cursor = i;
}
const int PW = preset.w;
const int PH = preset.h;
page.items.push_back(Item{
.kind = Kind::ACTION,
.label_key = {},
.label_text = std::format("{}X{}", PW, PH),
.selectable = true,
.on_activate = [sdl, PW, PH] { sdl->setRenderResolution(PW, PH); },
.get_value_text = {},
.on_change = {},
});
}
page.cursor = cursor;
return page;
}
auto ServiceMenu::buildAudioPage() -> Page {
auto on_off_text = [](bool v) -> std::string {
return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off");
};
// Aplica un step de volum (±VOLUME_STEP) a un valor 0..1 i retorna el
// resultat clampat. El motor s'encarrega d'aplicar-lo amb el getter.
auto step_volume = [](float current, int dir) -> float {
const float STEP = Defaults::Audio::VOLUME_STEP;
return std::clamp(current + (static_cast<float>(dir) * STEP), 0.0F, 1.0F);
};
Page page;
page.title_key = "service_menu.audio";
page.items = {
// AUDIO (master ON/OFF)
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.audio_master",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [on_off_text] {
const Audio* a = Audio::get();
return on_off_text(a != nullptr && a->isEnabled()); },
.on_change = [](int) {
if (auto* a = Audio::get(); a != nullptr) {
a->toggleEnabled();
} },
},
// VOLUM GENERAL (master)
Item{
.kind = Kind::INT_RANGE,
.label_key = "service_menu.audio_master_volume",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [] {
const Audio* a = Audio::get();
const float V = (a != nullptr) ? a->getMasterVolume() : 0.0F;
return std::to_string(Audio::toPercent(V)); },
.on_change = [step_volume](int dir) {
if (auto* a = Audio::get(); a != nullptr) {
a->setMasterVolume(step_volume(a->getMasterVolume(), dir));
} },
},
// MUSICA ON/OFF
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.audio_music",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [on_off_text] {
const Audio* a = Audio::get();
return on_off_text(a != nullptr && a->isMusicEnabled()); },
.on_change = [](int) {
if (auto* a = Audio::get(); a != nullptr) {
a->toggleMusic();
} },
},
// VOLUM MUSICA
Item{
.kind = Kind::INT_RANGE,
.label_key = "service_menu.audio_music_volume",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [] {
const Audio* a = Audio::get();
const float V = (a != nullptr) ? a->getMusicVolume() : 0.0F;
return std::to_string(Audio::toPercent(V)); },
.on_change = [step_volume](int dir) {
if (auto* a = Audio::get(); a != nullptr) {
a->setMusicVolume(step_volume(a->getMusicVolume(), dir));
} },
},
// SONS ON/OFF
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.audio_sound",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [on_off_text] {
const Audio* a = Audio::get();
return on_off_text(a != nullptr && a->isSoundEnabled()); },
.on_change = [](int) {
if (auto* a = Audio::get(); a != nullptr) {
a->toggleSound();
} },
},
// VOLUM SONS
Item{
.kind = Kind::INT_RANGE,
.label_key = "service_menu.audio_sound_volume",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [] {
const Audio* a = Audio::get();
const float V = (a != nullptr) ? a->getSoundVolume() : 0.0F;
return std::to_string(Audio::toPercent(V)); },
.on_change = [step_volume](int dir) {
if (auto* a = Audio::get(); a != nullptr) {
a->setSoundVolume(step_volume(a->getSoundVolume(), dir));
} },
},
};
return page;
}
auto ServiceMenu::buildOptionsPage() const -> Page {
auto on_off_text = [](bool v) -> std::string {
return Locale::get().text(v ? "service_menu.value_on" : "service_menu.value_off");
};
DebugOverlay* debug = debug_overlay_;
Page page;
page.title_key = "service_menu.options";
page.items = {
// IDIOMA (cycle entre ca i en, mateix codi que F7).
Item{
.kind = Kind::CYCLE,
.label_key = "service_menu.options_language",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [] { return Locale::get().text("language." + ConfigYaml::engine_config.locale); },
.on_change = [](int) {
const std::string NEW_LANG = (ConfigYaml::engine_config.locale == "ca") ? "en" : "ca";
if (Locale::get().switchTo(NEW_LANG)) {
ConfigYaml::engine_config.locale = NEW_LANG;
ConfigYaml::saveToFile();
} },
},
// MOSTRAR INFO (debug overlay, equivalent a F11).
Item{
.kind = Kind::TOGGLE,
.label_key = "service_menu.options_show_info",
.label_text = {},
.selectable = true,
.on_activate = {},
.get_value_text = [debug, on_off_text] { return on_off_text(debug != nullptr && debug->isVisible()); },
.on_change = [debug](int) {
if (debug != nullptr) {
debug->toggle();
} },
},
};
return page;
}
auto ServiceMenu::buildSystemPage() -> Page {
Page page;
page.title_key = "service_menu.system";
// Versio + hash com a subtitol sota el titol (apagat, mes petit).
// Uppercase del hash perque VectorText nomes admet majuscules.
page.subtitle_provider = [] {
return std::format("V{} - {}", Project::VERSION, toUpperAscii(Project::GIT_HASH));
};
page.items = {
// REINICIAR (amb confirmacio).
Item{
.kind = Kind::ACTION,
.label_key = "service_menu.system_restart",
.label_text = {},
.selectable = true,
.on_activate = [this] {
pushConfirmPage("service_menu.confirm_restart", [] {
System::Relaunch::request();
SDL_Event quit_event{};
quit_event.type = SDL_EVENT_QUIT;
SDL_PushEvent(&quit_event);
});
},
.get_value_text = {},
.on_change = {},
},
// EIXIR DEL JOC (amb confirmacio).
Item{
.kind = Kind::ACTION,
.label_key = "service_menu.exit",
.label_text = {},
.selectable = true,
.on_activate = [this] {
pushConfirmPage("service_menu.confirm_exit", [] {
SDL_Event quit_event{};
quit_event.type = SDL_EVENT_QUIT;
SDL_PushEvent(&quit_event);
});
},
.get_value_text = {},
.on_change = {},
},
};
return page;
}
void ServiceMenu::pushConfirmPage(const std::string& title_key, std::function<void()> on_yes) {
auto yes_callback = std::move(on_yes);
Page page;
page.title_key = title_key;
page.cursor = 0; // per defecte sobre NO (segur)
page.items = {
Item{
.kind = Kind::ACTION,
.label_key = "service_menu.confirm_no",
.label_text = {},
.selectable = true,
.on_activate = [this] { popPage(); },
.get_value_text = {},
.on_change = {},
},
Item{
.kind = Kind::ACTION,
.label_key = "service_menu.confirm_yes",
.label_text = {},
.selectable = true,
.on_activate = std::move(yes_callback),
.get_value_text = {},
.on_change = {},
},
};
pushPage(std::move(page));
}
void ServiceMenu::pushPage(Page page) {
stack_.push_back(std::move(page));
// El cursor salta a una pagina nova: enganxem el highlight per a
// evitar que vagi lliscant des de la posicio anterior.
highlight_snap_ = true;
}
void ServiceMenu::popPage() {
if (stack_.size() <= 1) {
// Estem a la pagina arrel: BACKSPACE tanca el menu.
closing_ = true;
playAcceptSound();
return;
}
stack_.pop_back();
highlight_snap_ = true;
playAcceptSound();
}
void ServiceMenu::moveCursor(int direction) {
if (stack_.empty()) {
return;
}
Page& page = stack_.back();
const std::size_t N = page.items.size();
if (N == 0) {
return;
}
// Cerca el seguent item seleccionable amb wrap-around.
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 (page.items[idx].selectable) {
if (idx != page.cursor) {
page.cursor = idx;
playSelectSound();
}
return;
}
}
}
void ServiceMenu::activateCurrent() {
// ENTER = canvi de valor cap endavant (equivalent a RIGHT). Per a
// SUBMENU/ACTION entra/activa; per a TOGGLE/CYCLE/INT_RANGE incrementa.
changeValue(+1);
}
void ServiceMenu::changeValue(int direction) {
if (stack_.empty()) {
return;
}
const Page& page = stack_.back();
if (page.cursor >= page.items.size()) {
return;
}
const Item& item = page.items[page.cursor];
if (!item.selectable) {
return;
}
switch (item.kind) {
case Kind::TOGGLE:
case Kind::CYCLE:
case Kind::INT_RANGE:
if (item.on_change) {
item.on_change(direction);
playAcceptSound();
}
break;
case Kind::SUBMENU:
case Kind::ACTION:
// Nomes +1 entra/activa: LEFT no fa res (BACKSPACE per a sortir).
if (direction > 0 && item.on_activate) {
item.on_activate();
playAcceptSound();
}
break;
case Kind::LABEL:
break;
}
}
auto ServiceMenu::handleEvent(const SDL_Event& event) -> bool {
if (!open_ || stack_.empty() || event.type != SDL_EVENT_KEY_DOWN) {
return false;
}
switch (event.key.scancode) {
case SDL_SCANCODE_UP:
moveCursor(-1);
return true;
case SDL_SCANCODE_DOWN:
moveCursor(+1);
return true;
case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER:
activateCurrent();
return true;
case SDL_SCANCODE_RIGHT:
changeValue(+1);
return true;
case SDL_SCANCODE_LEFT:
changeValue(-1);
return true;
case SDL_SCANCODE_BACKSPACE:
popPage();
return true;
default:
return false;
}
}
auto ServiceMenu::computeTargetHeight() const -> float {
if (stack_.empty()) {
return 0.0F;
}
using namespace Defaults::ServiceMenu;
const Page& page = stack_.back();
int h = GAP_Y; // padding superior
h += TITLE_HEIGHT; // titol
if (page.subtitle_provider) {
h += GAP_Y / 2 + SUBTITLE_HEIGHT; // subtitol amb mig gap
}
h += GAP_Y; // gap abans del separador
h += SEPARATOR_HEIGHT + GAP_Y; // separador + gap
const auto N = static_cast<int>(page.items.size());
if (N > 0) {
h += (N * ITEM_HEIGHT) + ((N - 1) * ITEM_GAP_Y) + GAP_Y;
}
return static_cast<float>(h);
}
auto ServiceMenu::computeTargetWidth() const -> float {
using namespace Defaults::ServiceMenu;
if (stack_.empty()) {
return static_cast<float>(BOX_WIDTH_MIN);
}
const Page& page = stack_.back();
// Comencem amb l'ample del titol.
float content_w = Graphics::VectorText::getTextWidth(
Locale::get().text(page.title_key),
TITLE_SCALE,
TEXT_SPACING);
if (page.subtitle_provider) {
content_w = std::max(content_w, Graphics::VectorText::getTextWidth(page.subtitle_provider(), SUBTITLE_SCALE, TEXT_SPACING));
}
for (const Item& item : page.items) {
const std::string LABEL = resolveLabel(item);
if (LABEL.empty() && item.get_value_text) {
content_w = std::max(content_w, Graphics::VectorText::getTextWidth(item.get_value_text(), ITEM_SCALE, TEXT_SPACING));
} else if (item.get_value_text) {
const float LABEL_W = Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING);
const float VALUE_W = Graphics::VectorText::getTextWidth(
item.get_value_text(),
ITEM_SCALE,
TEXT_SPACING);
content_w = std::max(content_w,
LABEL_W + static_cast<float>(MIN_LABEL_VALUE_GAP) + VALUE_W);
} else {
content_w = std::max(content_w,
Graphics::VectorText::getTextWidth(LABEL, ITEM_SCALE, TEXT_SPACING));
}
}
// Padding total: highlight pad als dos costats + inset del text.
const float REQUIRED = content_w +
(2.0F * static_cast<float>(HIGHLIGHT_PAD_X)) +
(2.0F * static_cast<float>(TEXT_INSET_X));
return std::max(static_cast<float>(BOX_WIDTH_MIN), REQUIRED);
}
auto ServiceMenu::computeItemTopY(float box_y, std::size_t index, bool has_subtitle) -> float {
using namespace Defaults::ServiceMenu;
float items_y0 = box_y +
static_cast<float>(GAP_Y) +
static_cast<float>(TITLE_HEIGHT);
if (has_subtitle) {
items_y0 += static_cast<float>(GAP_Y / 2) + static_cast<float>(SUBTITLE_HEIGHT);
}
items_y0 += static_cast<float>(GAP_Y) +
static_cast<float>(SEPARATOR_HEIGHT) +
static_cast<float>(GAP_Y);
return items_y0 + (static_cast<float>(index) * static_cast<float>(ITEM_HEIGHT + ITEM_GAP_Y));
}
void ServiceMenu::update(float delta_time) {
if (!open_) {
return;
}
using namespace Defaults::ServiceMenu;
if (closing_) {
open_anim_ -= CLOSE_SPEED * delta_time;
if (open_anim_ <= 0.0F) {
open_anim_ = 0.0F;
animated_h_ = 0.0F;
open_ = false;
closing_ = false;
stack_.clear();
return;
}
} else {
open_anim_ = std::min(1.0F, open_anim_ + (OPEN_SPEED * delta_time));
}
// Smoothing exponencial cap a l'alçada i ample objectius de la pagina.
const float TARGET_H_BOX = closing_ ? 0.0F : computeTargetHeight();
const float TARGET_W_BOX = closing_ ? animated_w_ : computeTargetWidth();
const float ALPHA_H = 1.0F - std::exp(-HEIGHT_RATE * delta_time);
const float ALPHA_W = 1.0F - std::exp(-WIDTH_RATE * delta_time);
animated_h_ += (TARGET_H_BOX - animated_h_) * ALPHA_H;
animated_w_ += (TARGET_W_BOX - animated_w_) * ALPHA_W;
// Highlight: lerp cap a la Y de l'item del cursor. Snapping nomes en
// obrir o canviar de pagina; en moure el cursor amb UP/DOWN, el rect
// llisca suaument cap a la nova posicio.
if (stack_.empty()) {
return;
}
const Page& page = stack_.back();
if (page.items.empty()) {
highlight_snap_ = true;
return;
}
const float BOX_H_TARGET = computeTargetHeight();
const float BOX_Y_TARGET = (CANVAS_H - BOX_H_TARGET) * 0.5F;
const bool HAS_SUBTITLE = static_cast<bool>(page.subtitle_provider);
const float ITEM_TOP = computeItemTopY(BOX_Y_TARGET, page.cursor, HAS_SUBTITLE);
const float TARGET_Y = ITEM_TOP - static_cast<float>(HIGHLIGHT_PAD_Y);
const float TARGET_H = static_cast<float>(ITEM_HEIGHT) + (2.0F * static_cast<float>(HIGHLIGHT_PAD_Y));
if (highlight_snap_) {
highlight_y_ = TARGET_Y;
highlight_h_ = TARGET_H;
highlight_snap_ = false;
} else {
const float HL_ALPHA = 1.0F - std::exp(-HIGHLIGHT_RATE * delta_time);
highlight_y_ += (TARGET_Y - highlight_y_) * HL_ALPHA;
highlight_h_ += (TARGET_H - highlight_h_) * HL_ALPHA;
}
}
namespace {
// Dibuixa un rect (BG sombrejat + 4 ticks L als cantons), simulant
// un visor sci-fi al voltant de l'item sel·leccionat.
void drawHighlightRect(Rendering::Renderer* renderer, float x, float y, float w, float h) {
using namespace Defaults::ServiceMenu;
if (w <= 0.0F || h <= 0.0F) {
return;
}
// Wash de fons translucid.
fillRect(renderer, x, y, w, h, HIGHLIGHT_FILL);
const auto T = static_cast<float>(HIGHLIGHT_THICKNESS);
const auto L = static_cast<float>(HIGHLIGHT_TICK_LEN);
// Top-left
fillRect(renderer, x, y, L, T, HIGHLIGHT_OUTLINE);
fillRect(renderer, x, y, T, L, HIGHLIGHT_OUTLINE);
// Top-right
fillRect(renderer, x + w - L, y, L, T, HIGHLIGHT_OUTLINE);
fillRect(renderer, x + w - T, y, T, L, HIGHLIGHT_OUTLINE);
// Bottom-left
fillRect(renderer, x, y + h - T, L, T, HIGHLIGHT_OUTLINE);
fillRect(renderer, x, y + h - L, T, L, HIGHLIGHT_OUTLINE);
// Bottom-right
fillRect(renderer, x + w - L, y + h - T, L, T, HIGHLIGHT_OUTLINE);
fillRect(renderer, x + w - T, y + h - L, T, L, HIGHLIGHT_OUTLINE);
}
// Brackets als 4 cantons de la caixa (sci-fi HUD). Substitueix la vora
// completa per un marc obert.
void drawCornerBrackets(Rendering::Renderer* renderer, float x, float y, float w, float h) {
using namespace Defaults::ServiceMenu;
const auto T = static_cast<float>(CORNER_THICKNESS);
const auto AH = static_cast<float>(CORNER_ARM_H);
const auto AV = static_cast<float>(CORNER_ARM_V);
// Top-left
fillRect(renderer, x, y, AH, T, CORNER_COLOR);
fillRect(renderer, x, y, T, AV, CORNER_COLOR);
// Top-right
fillRect(renderer, x + w - AH, y, AH, T, CORNER_COLOR);
fillRect(renderer, x + w - T, y, T, AV, CORNER_COLOR);
// Bottom-left
fillRect(renderer, x, y + h - T, AH, T, CORNER_COLOR);
fillRect(renderer, x, y + h - AV, T, AV, CORNER_COLOR);
// Bottom-right
fillRect(renderer, x + w - AH, y + h - T, AH, T, CORNER_COLOR);
fillRect(renderer, x + w - T, y + h - AV, T, AV, CORNER_COLOR);
}
} // namespace
void ServiceMenu::draw() const {
if (!open_ || stack_.empty() || renderer_ == nullptr) {
return;
}
using namespace Defaults::ServiceMenu;
// Alçada final: smoothing × easing. easeOutQuad afegeix la sensacio
// de "snap" al final de l'obertura i l'inici del tancament.
const float EASED = easeOutQuad(open_anim_);
const float BOX_H = animated_h_ * EASED;
if (BOX_H < 1.0F) {
return;
}
const float BOX_W = animated_w_;
const float BOX_X = (CANVAS_W - BOX_W) * 0.5F;
const float BOX_Y = (CANVAS_H - BOX_H) * 0.5F;
const float CENTER_X = BOX_X + (BOX_W * 0.5F);
// Fons semi-transparent.
fillRect(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H, BG_COLOR);
// Brackets als cantons (substitueixen la vora completa).
drawCornerBrackets(renderer_, BOX_X, BOX_Y, BOX_W, BOX_H);
// Clip interior per a tallar text que sortiria del cuadre durant
// l'animacio open/close. Marge generos perquè no es mengi els brackets.
const int CLIP_X = static_cast<int>(BOX_X + static_cast<float>(CORNER_THICKNESS));
const int CLIP_Y = static_cast<int>(BOX_Y + static_cast<float>(CORNER_THICKNESS));
const int CLIP_W = static_cast<int>(BOX_W - (2.0F * static_cast<float>(CORNER_THICKNESS)));
const int CLIP_H = std::max(0, static_cast<int>(BOX_H - (2.0F * static_cast<float>(CORNER_THICKNESS))));
renderer_->pushClip(CLIP_X, CLIP_Y, CLIP_W, CLIP_H);
const Page& page = stack_.back();
const bool HAS_SUBTITLE = static_cast<bool>(page.subtitle_provider);
// Titol centrat al cim de la caixa.
const std::string TITLE = Locale::get().text(page.title_key);
const float TITLE_CY = BOX_Y + static_cast<float>(GAP_Y) + (static_cast<float>(TITLE_HEIGHT) * 0.5F);
text_.renderCentered(TITLE,
Vec2{.x = CENTER_X, .y = TITLE_CY},
TITLE_SCALE,
TEXT_SPACING,
1.0F,
TITLE_COLOR);
// Subtitol opcional: sota el titol, mes petit i apagat.
if (HAS_SUBTITLE) {
const float SUBTITLE_CY = BOX_Y + static_cast<float>(GAP_Y) +
static_cast<float>(TITLE_HEIGHT) +
(static_cast<float>(GAP_Y) / 4.0F) +
(static_cast<float>(SUBTITLE_HEIGHT) * 0.5F);
text_.renderCentered(page.subtitle_provider(),
Vec2{.x = CENTER_X, .y = SUBTITLE_CY},
SUBTITLE_SCALE,
TEXT_SPACING,
1.0F,
SUBTITLE_COLOR);
}
// Separador horitzontal sota el titol (o subtitol si n'hi ha).
float sep_y = BOX_Y + static_cast<float>(GAP_Y) + static_cast<float>(TITLE_HEIGHT);
if (HAS_SUBTITLE) {
sep_y += static_cast<float>(GAP_Y / 2) + static_cast<float>(SUBTITLE_HEIGHT);
}
sep_y += static_cast<float>(GAP_Y) * 0.5F;
const float SEP_Y = sep_y;
fillRect(renderer_,
BOX_X + static_cast<float>(GAP_Y),
SEP_Y,
BOX_W - (2.0F * static_cast<float>(GAP_Y)),
static_cast<float>(SEPARATOR_HEIGHT),
SEPARATOR_COLOR);
// Highlight rect: nomes si la pagina te items i el rect te alçada.
if (!page.items.empty() && highlight_h_ > 0.0F) {
const float HL_X = BOX_X + static_cast<float>(HIGHLIGHT_PAD_X);
const float HL_W = BOX_W - (2.0F * static_cast<float>(HIGHLIGHT_PAD_X));
drawHighlightRect(renderer_, HL_X, highlight_y_, HL_W, highlight_h_);
}
// Llista d'items.
// - Items amb valor (TOGGLE/CYCLE/INT_RANGE): label esquerra + valor dreta dins del highlight.
// - Items sense valor (SUBMENU/ACTION/LABEL): label centrat.
const float HL_LEFT = BOX_X + static_cast<float>(HIGHLIGHT_PAD_X);
const float HL_RIGHT = BOX_X + BOX_W - static_cast<float>(HIGHLIGHT_PAD_X);
const float TEXT_TOP_OFFSET = Graphics::VectorText::getTextHeight(ITEM_SCALE) * 0.5F;
for (std::size_t i = 0; i < page.items.size(); ++i) {
const Item& item = page.items[i];
const SDL_Color COL = (i == page.cursor) ? CURSOR_COLOR : LABEL_COLOR;
// resolveLabel prioritza label_text (literal) sobre label_key (locale).
const std::string LABEL = resolveLabel(item);
const float ITEM_TOP = computeItemTopY(BOX_Y, i, HAS_SUBTITLE);
const float ITEM_CY = ITEM_TOP + (static_cast<float>(ITEM_HEIGHT) * 0.5F);
if (LABEL.empty() && item.get_value_text) {
// Item nomes-valor (sense label): el text del valor es
// renderitza centrat com a label decoratiu. Util per a items
// d'informacio com la versio/hash a SISTEMA.
text_.renderCentered(item.get_value_text(),
Vec2{.x = CENTER_X, .y = ITEM_CY},
ITEM_SCALE,
TEXT_SPACING,
1.0F,
COL);
} else if (item.get_value_text) {
// Layout dues columnes: label esquerra, valor dreta.
const std::string VALUE = item.get_value_text();
const float TEXT_TOP_Y = ITEM_CY - TEXT_TOP_OFFSET;
const float VALUE_W = Graphics::VectorText::getTextWidth(VALUE, ITEM_SCALE, TEXT_SPACING);
text_.render(LABEL,
Vec2{.x = HL_LEFT + static_cast<float>(TEXT_INSET_X), .y = TEXT_TOP_Y},
ITEM_SCALE,
TEXT_SPACING,
1.0F,
COL);
text_.render(VALUE,
Vec2{.x = HL_RIGHT - static_cast<float>(TEXT_INSET_X) - VALUE_W, .y = TEXT_TOP_Y},
ITEM_SCALE,
TEXT_SPACING,
1.0F,
COL);
} else {
// Layout simple: label centrat.
text_.renderCentered(LABEL,
Vec2{.x = CENTER_X, .y = ITEM_CY},
ITEM_SCALE,
TEXT_SPACING,
1.0F,
COL);
}
}
renderer_->popClip();
}
} // namespace System
+145
View File
@@ -0,0 +1,145 @@
// service_menu.hpp - Menu de servei (singleton)
// © 2026 JailDesigner
//
// Overlay de configuracio global accessible amb F12 des de qualsevol escena
// (LOGO, TITLE, GAME). Captura tots els KEY_DOWN excepte F1-F12 i ESC, que
// continuen arribant a GlobalEvents. Mentre esta obert, GameScene::update()
// fa early return per pausar el joc; LOGO i TITLE continuen renderitzant-se
// sota el menu.
//
// Arquitectura inspirada en aee_arcade service_menu.{hpp,cpp}: pila de
// pagines amb cursor, animacio open/close amb easing easeOutQuad i clipping
// del contingut mentre la caixa creix/decreix.
//
// API singleton equivalent a Notifier: init() al startup amb un renderer,
// get() retorna el punter, destroy() al teardown.
#pragma once
#include <SDL3/SDL.h>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "core/graphics/vector_text.hpp"
#include "core/rendering/render_context.hpp"
class SDLManager;
namespace System {
class DebugOverlay;
class ServiceMenu {
public:
// Tipus d'item de menu. En aquesta iteracio nomes s'usen SUBMENU i
// LABEL; la resta queden reservats per a iteracions futures (toggles
// de vsync/zoom, picker d'idioma, restart, exit...).
enum class Kind : std::uint8_t {
LABEL, // No interactiu, nomes es dibuixa
TOGGLE, // bool flip — reservat
CYCLE, // index amb modul — reservat
INT_RANGE, // step ± — reservat
SUBMENU, // pushPage en activar — usat
ACTION // call al lambda en activar — reservat
};
struct Item {
Kind kind = Kind::LABEL;
std::string label_key; // Clau de locale (s'ignora si label_text no esta buit)
std::string label_text; // Text literal (no locale). Util per a labels que no necessiten traduccio (resolucions, etc.)
bool selectable = true;
// SUBMENU / ACTION: callback en ENTER / RIGHT.
std::function<void()> on_activate;
// TOGGLE / CYCLE / INT_RANGE: text del valor actual (renderitzat a la dreta).
std::function<std::string()> get_value_text;
// TOGGLE / CYCLE / INT_RANGE: callback amb +1 (RIGHT/ENTER) o -1 (LEFT).
std::function<void(int)> on_change;
};
struct Page {
std::string title_key;
// Subtitol opcional, renderitzat sota el titol amb tipografia mes
// petita i color apagat. Es una funcio perque pot ser dinamic
// (versio+hash, etc.). Si esta buit, no es renderitza.
std::function<std::string()> subtitle_provider;
std::vector<Item> items;
std::size_t cursor = 0;
};
// Inicialitza el singleton amb el renderer global, l'SDLManager (video
// toggles: fullscreen, vsync, AA, postfx, zoom) i el DebugOverlay
// (toggle del HUD de debug a OPCIONS). Tots propietat del Director.
static void init(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay);
static void destroy();
[[nodiscard]] static auto get() -> ServiceMenu*;
// F12: alterna obrir/tancar amb animacio.
void toggle();
[[nodiscard]] auto isOpen() const -> bool;
void update(float delta_time);
void draw() const;
// Processa el KEY_DOWN. Retorna true si l'ha consumit (UP/DOWN/ENTER/
// RIGHT/BACKSPACE/LEFT mentre esta obert). false en qualsevol altre cas.
auto handleEvent(const SDL_Event& event) -> bool;
private:
ServiceMenu(Rendering::Renderer* renderer, SDLManager* sdl, DebugOverlay* debug_overlay);
void buildRootPage();
[[nodiscard]] auto buildVideoPage() -> Page;
[[nodiscard]] auto buildResolutionPage() const -> Page;
[[nodiscard]] static auto buildAudioPage() -> Page;
[[nodiscard]] auto buildOptionsPage() const -> Page;
[[nodiscard]] auto buildSystemPage() -> Page;
// Pagina de confirmacio "ESTAS SEGUR? NO/SI". on_yes s'executa si
// l'usuari selecciona SI; el cursor per defecte apunta a NO.
void pushConfirmPage(const std::string& title_key, std::function<void()> on_yes);
void pushPage(Page page);
void popPage();
void moveCursor(int direction);
void activateCurrent();
// RIGHT (direction=+1) / LEFT (direction=-1). Per a TOGGLE/CYCLE/INT_RANGE
// crida on_change. Per a SUBMENU/ACTION nomes +1 (entra/activa).
void changeValue(int direction);
// Alçada objectiu de la caixa per a la pagina superior (sense animacio).
[[nodiscard]] auto computeTargetHeight() const -> float;
// Ample objectiu de la caixa per a la pagina superior (sense animacio).
// Pren com a base BOX_WIDTH_MIN i s'eixampla si algun text no hi cap.
[[nodiscard]] auto computeTargetWidth() const -> float;
// Y (top) de l'item index dins una caixa col·locada a box_y. Si la
// pagina te subtitol, els items es desplacen cap avall.
[[nodiscard]] static auto computeItemTopY(float box_y, std::size_t index, bool has_subtitle) -> float;
Rendering::Renderer* renderer_;
SDLManager* sdl_;
DebugOverlay* debug_overlay_;
Graphics::VectorText text_;
std::vector<Page> stack_;
bool open_ = false;
bool closing_ = false;
float open_anim_ = 0.0F; // 0..1 raw (sense easing)
float animated_h_ = 0.0F; // Alçada animada amb smoothing exponencial
float animated_w_ = 0.0F; // Ample animat (eixampla segons contingut)
// Estat del highlight (rectangle del cursor). Es lerpa cap a l'item
// actiu amb ease-out exponencial; quan el cursor "salta" (open o
// push/pop de pagina), s'enganxa directament al nou objectiu.
float highlight_y_ = 0.0F;
float highlight_h_ = 0.0F;
bool highlight_snap_ = true;
static std::unique_ptr<ServiceMenu> instance;
};
} // namespace System
+17
View File
@@ -20,6 +20,7 @@ namespace ConfigYaml {
Config::PlayerBindings& player1 = engine_config.player1;
Config::PlayerBindings& player2 = engine_config.player2;
bool& console = engine_config.console;
std::string& locale = engine_config.locale;
} // namespace
// ========== FUNCIONS AUXILIARS PER CONVERSIÓ DE CONTROLES ==========
@@ -208,6 +209,9 @@ namespace ConfigYaml {
rendering.render_width = Defaults::Rendering::RENDER_WIDTH_DEFAULT;
rendering.render_height = Defaults::Rendering::RENDER_HEIGHT_DEFAULT;
// Idioma
locale = "ca";
// Version
version = std::string(Project::VERSION);
}
@@ -446,6 +450,16 @@ namespace ConfigYaml {
loadPlayer1ControlsFromYaml(yaml);
loadPlayer2ControlsFromYaml(yaml);
// Idioma (opcional; valors admesos: "ca" | "en")
if (yaml.contains("locale")) {
try {
auto val = yaml["locale"].get_value<std::string>();
locale = (val == "ca" || val == "en") ? val : "ca";
} catch (...) {
locale = "ca";
}
}
if (console) {
std::cout << "Config carregada correctament desde: " << config_file_path
<< '\n';
@@ -532,6 +546,9 @@ namespace ConfigYaml {
file << " render_height: " << rendering.render_height
<< " # Parell amb render_width (720, 900, 1080, 1440, 2160)\n\n";
file << "# IDIOMA\n";
file << "locale: " << locale << " # ca | en\n\n";
// Guardar controls de jugadors
savePlayer1ControlsToYaml(file);
savePlayer2ControlsToYaml(file);
+3 -3
View File
@@ -3,9 +3,9 @@
//
// La configuració runtime viu en Config::EngineConfig (core/config/).
// Aquest fitxer afegeix una capa de persistència YAML que llegeix i
// escriu aquesta struct a disc. La connexió amb el Director es fa via
// Config::ConfigPersistence (lambdes a `main.cpp`), mantenint `core/`
// agnòstic respecte d'aquesta capa.
// escriu aquesta struct a disc. El Director crida ConfigYaml::* directament
// (init / setConfigFile / loadFromFile / saveToFile): la separació
// core/game queda relaxada al Director, que és EL programa, no part del motor.
#pragma once
+19 -19
View File
@@ -7,40 +7,40 @@
namespace Constants {
// Límits de objectes
constexpr int MAX_ORNIS = Defaults::Entities::MAX_ORNIS;
constexpr int MAX_BALES = Defaults::Entities::MAX_BALES;
constexpr int MAX_BULLETS = Defaults::Entities::MAX_BULLETS;
// Matemàtiques
constexpr float PI = Defaults::Math::PI;
// Helpers per comprovar límits de zona
// Helpers per comprovar límits de zone
inline auto isInPlayArea(float x, float y) -> bool {
const SDL_FPoint POINT = {x, y};
return SDL_PointInRectFloat(&POINT, &Defaults::Zones::PLAYAREA);
}
inline void getPlayAreaBounds(float& min_x, float& max_x, float& min_y, float& max_y) {
const auto& zona = Defaults::Zones::PLAYAREA;
min_x = zona.x;
max_x = zona.x + zona.w;
min_y = zona.y;
max_y = zona.y + zona.h;
const auto& zone = Defaults::Zones::PLAYAREA;
min_x = zone.x;
max_x = zone.x + zone.w;
min_y = zone.y;
max_y = zone.y + zone.h;
}
// Obtenir límits segurs (compensant radi de l'entidad)
inline void getSafePlayAreaBounds(float radi, float& min_x, float& max_x, float& min_y, float& max_y) {
const auto& zona = Defaults::Zones::PLAYAREA;
constexpr float MARGE_SEGURETAT = 10.0F; // Safety margin
// Obtenir límits segurs (compensant radius de l'entidad)
inline void getSafePlayAreaBounds(float radius, float& min_x, float& max_x, float& min_y, float& max_y) {
const auto& zone = Defaults::Zones::PLAYAREA;
constexpr float SAFETY_MARGIN = 10.0F; // Safety margin
min_x = zona.x + radi + MARGE_SEGURETAT;
max_x = zona.x + zona.w - radi - MARGE_SEGURETAT;
min_y = zona.y + radi + MARGE_SEGURETAT;
max_y = zona.y + zona.h - radi - MARGE_SEGURETAT;
min_x = zone.x + radius + SAFETY_MARGIN;
max_x = zone.x + zone.w - radius - SAFETY_MARGIN;
min_y = zone.y + radius + SAFETY_MARGIN;
max_y = zone.y + zone.h - radius - SAFETY_MARGIN;
}
// Obtenir centro de l'àrea de juego
inline void getPlayAreaCenter(float& centre_x, float& centre_y) {
const auto& zona = Defaults::Zones::PLAYAREA;
centre_x = zona.x + (zona.w / 2.0F);
centre_y = zona.y + (zona.h / 2.0F);
inline void getPlayAreaCenter(float& center_x, float& center_y) {
const auto& zone = Defaults::Zones::PLAYAREA;
center_x = zone.x + (zone.w / 2.0F);
center_y = zone.y + (zone.h / 2.0F);
}
} // namespace Constants
+1 -1
View File
@@ -39,7 +39,7 @@ namespace Effects {
// Política: viu sempre durant min_lifetime, després mor quan
// |velocity| < MIN_SPEED_TO_DIE (definit en Defaults). Així els
// fragments ràpids no "popen" en moviment.
float temps_vida; // Temps transcorregut (segons)
float elapsed_time; // Temps transcorregut (segons)
float min_lifetime; // Temps mínim garantit (segons)
bool active; // Està actiu?
+12 -12
View File
@@ -135,7 +135,7 @@ namespace Effects {
float speed =
velocitat_base +
(((std::rand() / static_cast<float>(RAND_MAX)) * 2.0F - 1.0F) *
Defaults::Physics::Debris::VARIACIO_VELOCITAT);
Defaults::Physics::Debris::VARIACIO_SPEED);
debris->velocity.x = (direccio.x * speed) + velocitat_objecte.x;
debris->velocity.y = (direccio.y * speed) + velocitat_objecte.y;
debris->acceleration = friction;
@@ -150,7 +150,7 @@ namespace Effects {
// Vida i shrinking — min_lifetime és el temps mínim garantit; després
// el fragment mor quan |velocity| < MIN_SPEED_TO_DIE.
debris->temps_vida = 0.0F;
debris->elapsed_time = 0.0F;
debris->min_lifetime = lifetime;
debris->factor_shrink = Defaults::Physics::Debris::SHRINK_RATE;
@@ -170,16 +170,16 @@ namespace Effects {
// FASE 1: Aplicar herència i variació
float factor_herencia =
Defaults::Physics::Debris::FACTOR_HERENCIA_MIN +
Defaults::Physics::Debris::INHERITANCE_FACTOR_MIN +
((std::rand() / static_cast<float>(RAND_MAX)) *
(Defaults::Physics::Debris::FACTOR_HERENCIA_MAX -
Defaults::Physics::Debris::FACTOR_HERENCIA_MIN));
(Defaults::Physics::Debris::INHERITANCE_FACTOR_MAX -
Defaults::Physics::Debris::INHERITANCE_FACTOR_MIN));
float velocitat_ang_heretada = velocitat_angular * factor_herencia;
float variacio = ((std::rand() / static_cast<float>(RAND_MAX)) * 0.2F) - 0.1F;
velocitat_ang_heretada *= (1.0F + variacio);
// FASE 2: Cap a la velocity màxima; l'excés es converteix en tangencial
constexpr float CAP = Defaults::Physics::Debris::VELOCITAT_ROT_MAX;
constexpr float CAP = Defaults::Physics::Debris::SPEED_ROT_MAX;
float abs_ang = std::abs(velocitat_ang_heretada);
float sign_ang = (velocitat_ang_heretada >= 0.0F) ? 1.0F : -1.0F;
@@ -213,10 +213,10 @@ namespace Effects {
// Rotación visual aleatòria (factor = 0.0 o sin velocidad angular)
debris.velocitat_rot_visual =
Defaults::Physics::Debris::ROTACIO_MIN +
Defaults::Physics::Debris::ROTATION_MIN +
((std::rand() / static_cast<float>(RAND_MAX)) *
(Defaults::Physics::Debris::ROTACIO_MAX -
Defaults::Physics::Debris::ROTACIO_MIN));
(Defaults::Physics::Debris::ROTATION_MAX -
Defaults::Physics::Debris::ROTATION_MIN));
// 50% probabilitat de rotación en sentit contrari
if (std::rand() % 2 == 0) {
@@ -266,12 +266,12 @@ namespace Effects {
}
// 1. Actualitzar time de vida
debris.temps_vida += delta_time;
debris.elapsed_time += delta_time;
// Política de mort: viu sí o sí durant min_lifetime; després mor
// quan la velocity cau per sota d'un llindar. Així els fragments
// ràpids no desapareixen en moviment.
if (debris.temps_vida >= debris.min_lifetime) {
if (debris.elapsed_time >= debris.min_lifetime) {
const float SPEED_SQ = (debris.velocity.x * debris.velocity.x) +
(debris.velocity.y * debris.velocity.y);
if (SPEED_SQ < Defaults::Physics::Debris::MIN_SPEED_TO_DIE_SQ) {
@@ -344,7 +344,7 @@ namespace Effects {
// 6. Shrink lineal sobre la longitud ORIGINAL (no iteratiu).
// SHRINK_T va de 0 a 1 al llarg de min_lifetime; després queda
// a 1 i el shrink_factor manté el valor mínim (1 - factor_shrink).
const float SHRINK_T = std::min(debris.temps_vida / debris.min_lifetime, 1.0F);
const float SHRINK_T = std::min(debris.elapsed_time / debris.min_lifetime, 1.0F);
const float SHRINK_FACTOR = std::max(0.0F, 1.0F - (debris.factor_shrink * SHRINK_T));
// 7. Reconstruir p1/p2 des de la geometria autoritaritzada:
+7 -2
View File
@@ -16,7 +16,7 @@ namespace Effects {
// tail = head velocity_normalitzada × current_length.
//
// Cicle de vida:
// Fase 1 (temps_vida < grow_duration): current_length creix linealment
// Fase 1 (elapsed_time < grow_duration): current_length creix linealment
// de 0 a max_length. Brillor al màxim.
// Fase 2: current_length = max_length × (speed/initial_speed) i brillor
// amb la mateixa proporció. Mor quan length o brightness cauen sota
@@ -30,11 +30,16 @@ namespace Effects {
float max_length; // Longitud màxima (final de la fase de creixement)
float grow_duration; // Temps de creixement de 0 a max_length (s)
float temps_vida; // Acumulador (s)
float elapsed_time; // Acumulador (s)
float initial_speed; // Speed inicial per a la proporció de fase 2
float brightness; // 0..1
SDL_Color color{}; // alpha==0 → oscilador global
// Halo neon (off per defecte). Si glow_color.a > 0, el halo usa
// glow_color (línia blanca + halo daurat, p.ex.); si alpha==0, el
// halo agafa el color de la línia.
bool glow{false};
SDL_Color glow_color{};
bool active;
};
+24 -8
View File
@@ -61,18 +61,20 @@ namespace Effects {
}
}
void FireworkManager::spawn(const Vec2& origen,
void FireworkManager::spawn(const Vec2& origin,
SDL_Color color,
float initial_speed,
int n_points,
float initial_brightness) {
float initial_brightness,
bool glow,
SDL_Color glow_color) {
if (n_points <= 0) {
return;
}
// Notificar als subscriptors (playfield pulses, etc.).
if (spawn_callback_) {
spawn_callback_(origen);
spawn_callback_(origin);
}
const float ANGLE_STEP = 2.0F * Defaults::Math::PI / static_cast<float>(n_points);
@@ -92,7 +94,7 @@ namespace Effects {
const float SPEED =
initial_speed + (randSigned() * Defaults::FX::Firework::SPEED_VARIATION);
fw->head = origen;
fw->head = origin;
fw->velocity = {.x = std::cos(ANGLE) * SPEED, .y = std::sin(ANGLE) * SPEED};
fw->acceleration = Defaults::FX::Firework::FRICTION;
@@ -100,11 +102,13 @@ namespace Effects {
fw->max_length = Defaults::FX::Firework::MAX_LENGTH;
fw->grow_duration = Defaults::FX::Firework::GROW_DURATION;
fw->temps_vida = 0.0F;
fw->elapsed_time = 0.0F;
fw->initial_speed = SPEED;
fw->brightness = initial_brightness;
fw->color = color;
fw->glow = glow;
fw->glow_color = glow_color;
fw->active = true;
}
}
@@ -115,7 +119,7 @@ namespace Effects {
continue;
}
fw.temps_vida += delta_time;
fw.elapsed_time += delta_time;
// 1. Fricció lineal (aplicar en la direcció del movement).
const float SPEED = std::sqrt(
@@ -140,9 +144,9 @@ namespace Effects {
bounceOffPlayArea(fw.head, fw.velocity);
// 4. Calcular longitud i brillor segons fase.
if (fw.temps_vida < fw.grow_duration) {
if (fw.elapsed_time < fw.grow_duration) {
// Fase 1: creixement lineal de 0 a max_length.
const float T = fw.temps_vida / fw.grow_duration;
const float T = fw.elapsed_time / fw.grow_duration;
fw.current_length = fw.max_length * T;
fw.brightness = Defaults::FX::Firework::INITIAL_BRIGHTNESS;
} else {
@@ -185,6 +189,17 @@ namespace Effects {
.y = fw.head.y - (DIR_Y * fw.current_length),
};
if (fw.glow) {
Rendering::lineaGlow(renderer_,
static_cast<int>(fw.head.x),
static_cast<int>(fw.head.y),
static_cast<int>(TAIL.x),
static_cast<int>(TAIL.y),
fw.brightness,
0.0F,
fw.color,
fw.glow_color);
} else {
Rendering::linea(renderer_,
static_cast<int>(fw.head.x),
static_cast<int>(fw.head.y),
@@ -195,6 +210,7 @@ namespace Effects {
fw.color);
}
}
}
void FireworkManager::reset() {
for (auto& fw : pool_) {
+7 -3
View File
@@ -21,7 +21,7 @@ namespace Effects {
class FireworkManager {
public:
// Notificació opcional cada vegada que es genera un burst.
using SpawnCallback = std::function<void(Vec2 origen)>;
using SpawnCallback = std::function<void(Vec2 origin)>;
explicit FireworkManager(Rendering::Renderer* renderer);
@@ -35,11 +35,15 @@ namespace Effects {
// initial_speed: velocitat radial inicial (px/s).
// n_points: nombre de línies. Default Defaults::FX::Firework::N_POINTS.
// initial_brightness: 0..1.
void spawn(const Vec2& origen,
// glow: si true, cada partícula es renderitza amb halo neon.
// glow_color: color del halo. Si alpha==0, agafa el color de la línia.
void spawn(const Vec2& origin,
SDL_Color color = Defaults::FX::Firework::DEFAULT_COLOR,
float initial_speed = Defaults::FX::Firework::SPEED,
int n_points = Defaults::FX::Firework::N_POINTS,
float initial_brightness = Defaults::FX::Firework::INITIAL_BRIGHTNESS);
float initial_brightness = Defaults::FX::Firework::INITIAL_BRIGHTNESS,
bool glow = false,
SDL_Color glow_color = {0, 0, 0, 0});
void update(float delta_time);
void draw() const;
+6 -6
View File
@@ -9,9 +9,9 @@
namespace Effects {
// FloatingScore: text animat que muestra points guanyats
// S'activa cuando es destrueix un enemy i s'esvaeix después de un time
struct FloatingScore {
// FloatingScore: text animat que muestra points guanyats
// S'activa cuando es destrueix un enemy i s'esvaeix después de un time
struct FloatingScore {
// Text a mostrar (e.g., "100", "150", "200")
std::string text;
@@ -22,12 +22,12 @@ struct FloatingScore {
Vec2 velocity; // px/s (normalment sin amunt: {0.0f, -30.0f})
// Animación de fade
float temps_vida; // Temps transcorregut (segons)
float temps_max; // Temps de vida màxim (segons)
float elapsed_time; // Temps transcorregut (segons)
float max_lifetime; // Temps de vida màxim (segons)
float brightness; // Brillantor calculada (0.0-1.0)
// Estat
bool active;
};
};
} // namespace Effects
+19 -19
View File
@@ -7,15 +7,15 @@
namespace Effects {
FloatingScoreManager::FloatingScoreManager(Rendering::Renderer* renderer)
FloatingScoreManager::FloatingScoreManager(Rendering::Renderer* renderer)
: text_(renderer) {
// Inicialitzar todos los slots como inactius
for (auto& pf : pool_) {
pf.active = false;
}
}
}
void FloatingScoreManager::crear(int points, const Vec2& position) {
void FloatingScoreManager::crear(int points, const Vec2& position) {
// 1. Trobar slot lliure
FloatingScore* pf = findFreeSlot();
if (pf == nullptr) {
@@ -27,13 +27,13 @@ void FloatingScoreManager::crear(int points, const Vec2& position) {
pf->position = position;
pf->velocity = {.x = Defaults::FloatingScore::VELOCITY_X,
.y = Defaults::FloatingScore::VELOCITY_Y};
pf->temps_vida = 0.0F;
pf->temps_max = Defaults::FloatingScore::LIFETIME;
pf->elapsed_time = 0.0F;
pf->max_lifetime = Defaults::FloatingScore::LIFETIME;
pf->brightness = 1.0F;
pf->active = true;
}
}
void FloatingScoreManager::update(float delta_time) {
void FloatingScoreManager::update(float delta_time) {
for (auto& pf : pool_) {
if (!pf.active) {
continue;
@@ -44,20 +44,20 @@ void FloatingScoreManager::update(float delta_time) {
pf.position.y += pf.velocity.y * delta_time;
// 2. Actualitzar time de vida
pf.temps_vida += delta_time;
pf.elapsed_time += delta_time;
// 3. Calcular brightness (fade lineal)
float progress = pf.temps_vida / pf.temps_max; // 0.0 → 1.0
float progress = pf.elapsed_time / pf.max_lifetime; // 0.0 → 1.0
pf.brightness = 1.0F - progress; // 1.0 → 0.0
// 4. Desactivar cuando acaba el time
if (pf.temps_vida >= pf.temps_max) {
if (pf.elapsed_time >= pf.max_lifetime) {
pf.active = false;
}
}
}
}
void FloatingScoreManager::draw() {
void FloatingScoreManager::draw() {
for (const auto& pf : pool_) {
if (!pf.active) {
continue;
@@ -69,15 +69,15 @@ void FloatingScoreManager::draw() {
text_.renderCentered(pf.text, pf.position, SCALE, SPACING, pf.brightness);
}
}
}
void FloatingScoreManager::reset() {
void FloatingScoreManager::reset() {
for (auto& pf : pool_) {
pf.active = false;
}
}
}
auto FloatingScoreManager::getActiveCount() const -> int {
auto FloatingScoreManager::getActiveCount() const -> int {
int count = 0;
for (const auto& pf : pool_) {
if (pf.active) {
@@ -85,15 +85,15 @@ auto FloatingScoreManager::getActiveCount() const -> int {
}
}
return count;
}
}
auto FloatingScoreManager::findFreeSlot() -> FloatingScore* {
auto FloatingScoreManager::findFreeSlot() -> FloatingScore* {
for (auto& pf : pool_) {
if (!pf.active) {
return &pf;
}
}
return nullptr; // Pool ple
}
}
} // namespace Effects
+12 -34
View File
@@ -3,7 +3,6 @@
#include "game/entities/bullet.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iostream>
@@ -43,8 +42,8 @@ void Bullet::init() {
// Inicialment inactiva
is_active_ = false;
center_ = {.x = 0.0F, .y = 0.0F};
prev_position_ = {.x = 0.0F, .y = 0.0F};
angle_ = 0.0F;
grace_timer_ = 0.0F;
// Reset del cuerpo físico
body_.position = Vec2{};
@@ -54,18 +53,16 @@ void Bullet::init() {
body_.clearAccumulators();
}
void Bullet::disparar(const Vec2& position, float angle, uint8_t owner_id) {
void Bullet::fire(const Vec2& position, float angle, uint8_t owner_id) {
// Activar bullet
is_active_ = true;
// Almacenar propietario (0=P1, 1=P2)
owner_id_ = owner_id;
// Activar grace period (prevents instant self-collision)
grace_timer_ = Defaults::Game::BULLET_GRACE_PERIOD;
// Posición y orientación iniciales = ship
center_ = position;
prev_position_ = position; // Al spawn no hi ha moviment encara: swept degenera a punt-cercle
angle_ = angle;
// Sincronizar el body físico: posición + velocidad cartesiana
@@ -82,37 +79,18 @@ void Bullet::disparar(const Vec2& position, float angle, uint8_t owner_id) {
Audio::get()->playSound(Defaults::Sound::LASER, Audio::Group::GAME);
}
void Bullet::update(float delta_time) {
if (!is_active_) {
return;
}
// Decrementar grace timer
if (grace_timer_ > 0.0F) {
grace_timer_ -= delta_time;
grace_timer_ = std::max(grace_timer_, 0.0F);
}
// El movimiento real lo hace PhysicsWorld::update() (integración).
// Aquí solo lógica de estado: detectar salida del PLAYAREA y desactivar.
float min_x;
float max_x;
float min_y;
float max_y;
Constants::getSafePlayAreaBounds(Defaults::Entities::BULLET_RADIUS,
min_x,
max_x,
min_y,
max_y);
if (body_.position.x < min_x || body_.position.x > max_x ||
body_.position.y < min_y || body_.position.y > max_y) {
desactivar();
}
void Bullet::update(float /*delta_time*/) {
// No-op: la desactivació per fora-de-zone viu a
// Systems::Collision::desactivateOutOfBoundsBullets() perquè així té accés
// al DebrisManager i pot generar el "trencament" visual de la bala alhora.
// El moviment l'integra PhysicsWorld; postUpdate sincronitza center_ i prev_position_.
}
void Bullet::postUpdate(float /*delta_time*/) {
// Sincronizar mirror desde body_ tras la integración del world.
// Captura la posició al final del frame anterior abans de sobreescriure center_;
// així el sistema de col·lisions pot fer swept (segment-vs-cercle) entre prev_position_
// i la nova center_, evitant tunneling a velocitats altes.
prev_position_ = center_;
center_ = body_.position;
// angle_ no cambia (las balas no rotan visualmente).
}
+6 -5
View File
@@ -17,7 +17,7 @@ class Bullet : public Entities::Entity {
explicit Bullet(Rendering::Renderer* renderer);
void init() override;
void disparar(const Vec2& position, float angle, uint8_t owner_id);
void fire(const Vec2& position, float angle, uint8_t owner_id);
void update(float delta_time) override;
void postUpdate(float delta_time) override;
void draw() const override;
@@ -30,19 +30,20 @@ class Bullet : public Entities::Entity {
return Defaults::Entities::BULLET_RADIUS;
}
[[nodiscard]] auto isCollidable() const -> bool override {
return is_active_ && grace_timer_ <= 0.0F;
return is_active_;
}
// Getters (API pública sin cambios)
[[nodiscard]] auto getOwnerId() const -> uint8_t { return owner_id_; }
[[nodiscard]] auto getGraceTimer() const -> float { return grace_timer_; }
// Posició al final del frame anterior, per a CCD segment-vs-cercle.
[[nodiscard]] auto getPrevPosition() const -> const Vec2& { return prev_position_; }
void desactivar();
private:
// Miembros específicos de Bullet (heredados: renderer_, shape_, center_, angle_, brightness_, body_).
// Inicializados en la declaración para que tanto el ctor por defecto como el que toma renderer
// dejen el objeto en estado coherente (proyectil inactivo, sin owner, sin grace timer).
// dejen el objeto en estado coherente (proyectil inactivo, sin owner).
bool is_active_{false};
uint8_t owner_id_{0}; // 0=P1, 1=P2
float grace_timer_{0.0F}; // Grace period timer (0.0 = vulnerable)
Vec2 prev_position_{}; // Posició al final del frame anterior (per a swept collision)
};
+112 -112
View File
@@ -8,7 +8,6 @@
#include <cstdlib>
#include <iostream>
#include "core/audio/audio.hpp"
#include "core/defaults.hpp"
#include "core/entities/entity.hpp"
#include "core/graphics/shape_loader.hpp"
@@ -42,7 +41,7 @@ namespace {
Enemy::Enemy(Rendering::Renderer* renderer)
: Entity(renderer),
tracking_strength_(Defaults::Enemies::Cuadrado::TRACKING_STRENGTH) {
tracking_strength_(Defaults::Enemies::Square::TRACKING_STRENGTH) {
brightness_ = Defaults::Brightness::ENEMIC;
// Configuración del cuerpo físico — defaults para enemy genérico.
@@ -59,43 +58,43 @@ void Enemy::init(EnemyType type, const Vec2* ship_pos) {
const char* shape_file = nullptr;
float base_speed = 0.0F;
float drotacio_min = 0.0F;
float drotacio_max = 0.0F;
float rotation_delta_min = 0.0F;
float rotation_delta_max = 0.0F;
float type_mass = Defaults::Enemies::Body::DEFAULT_MASS;
switch (type_) {
case EnemyType::PENTAGON:
shape_file = Defaults::Enemies::Pentagon::SHAPE_FILE;
base_speed = Defaults::Enemies::Pentagon::VELOCITAT;
drotacio_min = Defaults::Enemies::Pentagon::DROTACIO_MIN;
drotacio_max = Defaults::Enemies::Pentagon::DROTACIO_MAX;
base_speed = Defaults::Enemies::Pentagon::SPEED;
rotation_delta_min = Defaults::Enemies::Pentagon::ROTATION_DELTA_MIN;
rotation_delta_max = Defaults::Enemies::Pentagon::ROTATION_DELTA_MAX;
type_mass = Defaults::Enemies::Pentagon::MASS;
break;
case EnemyType::QUADRAT:
shape_file = Defaults::Enemies::Cuadrado::SHAPE_FILE;
base_speed = Defaults::Enemies::Cuadrado::VELOCITAT;
drotacio_min = Defaults::Enemies::Cuadrado::DROTACIO_MIN;
drotacio_max = Defaults::Enemies::Cuadrado::DROTACIO_MAX;
type_mass = Defaults::Enemies::Cuadrado::MASS;
case EnemyType::SQUARE:
shape_file = Defaults::Enemies::Square::SHAPE_FILE;
base_speed = Defaults::Enemies::Square::SPEED;
rotation_delta_min = Defaults::Enemies::Square::ROTATION_DELTA_MIN;
rotation_delta_max = Defaults::Enemies::Square::ROTATION_DELTA_MAX;
type_mass = Defaults::Enemies::Square::MASS;
tracking_timer_ = 0.0F;
break;
case EnemyType::MOLINILLO:
shape_file = Defaults::Enemies::Molinillo::SHAPE_FILE;
base_speed = Defaults::Enemies::Molinillo::VELOCITAT;
drotacio_min = Defaults::Enemies::Molinillo::DROTACIO_MIN;
drotacio_max = Defaults::Enemies::Molinillo::DROTACIO_MAX;
type_mass = Defaults::Enemies::Molinillo::MASS;
case EnemyType::PINWHEEL:
shape_file = Defaults::Enemies::Pinwheel::SHAPE_FILE;
base_speed = Defaults::Enemies::Pinwheel::SPEED;
rotation_delta_min = Defaults::Enemies::Pinwheel::ROTATION_DELTA_MIN;
rotation_delta_max = Defaults::Enemies::Pinwheel::ROTATION_DELTA_MAX;
type_mass = Defaults::Enemies::Pinwheel::MASS;
break;
default:
std::cerr << "[Enemy] Error: tipo desconocido ("
<< static_cast<int>(type_) << "), usando PENTAGON\n";
shape_file = Defaults::Enemies::Pentagon::SHAPE_FILE;
base_speed = Defaults::Enemies::Pentagon::VELOCITAT;
drotacio_min = Defaults::Enemies::Pentagon::DROTACIO_MIN;
drotacio_max = Defaults::Enemies::Pentagon::DROTACIO_MAX;
base_speed = Defaults::Enemies::Pentagon::SPEED;
rotation_delta_min = Defaults::Enemies::Pentagon::ROTATION_DELTA_MIN;
rotation_delta_max = Defaults::Enemies::Pentagon::ROTATION_DELTA_MAX;
break;
}
@@ -132,7 +131,7 @@ void Enemy::init(EnemyType type, const Vec2* ship_pos) {
const int RANGE_Y = static_cast<int>(max_y - min_y);
center_.x = static_cast<float>((std::rand() % RANGE_X) + static_cast<int>(min_x));
center_.y = static_cast<float>((std::rand() % RANGE_Y) + static_cast<int>(min_y));
std::cout << "[Enemy] Advertencia: spawn sin zona segura tras "
std::cout << "[Enemy] Advertencia: spawn sin zone segura tras "
<< Defaults::Enemies::Spawn::MAX_SPAWN_ATTEMPTS << " intentos\n";
}
} else {
@@ -153,28 +152,28 @@ void Enemy::init(EnemyType type, const Vec2* ship_pos) {
body_.clearAccumulators();
// Rotación visual aleatoria (independiente del body)
const float DROTACIO_RANGE = drotacio_max - drotacio_min;
drotacio_ = drotacio_min + ((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * DROTACIO_RANGE);
rotacio_ = 0.0F;
const float ROTATION_DELTA_RANGE = rotation_delta_max - rotation_delta_min;
rotation_delta_ = rotation_delta_min + ((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * ROTATION_DELTA_RANGE);
rotation_ = 0.0F;
// Estado de animación
animacio_ = EnemyAnimation();
animacio_.drotacio_base = drotacio_;
animacio_.drotacio_objetivo = drotacio_;
animacio_.drotacio_t = 1.0F;
animation_ = EnemyAnimation();
animation_.rotation_delta_base = rotation_delta_;
animation_.rotation_delta_target = rotation_delta_;
animation_.rotation_delta_t = 1.0F;
// Invulnerabilidad post-spawn
timer_invulnerabilitat_ = Defaults::Enemies::Spawn::INVULNERABILITY_DURATION;
invulnerability_timer_ = Defaults::Enemies::Spawn::INVULNERABILITY_DURATION;
brightness_ = Defaults::Enemies::Spawn::INVULNERABILITY_BRIGHTNESS_START;
// Timer para próximo cambio de dirección (Pentagon)
direction_change_timer_ = 0.0F;
esta_ = true;
is_active_ = true;
}
void Enemy::update(float delta_time) {
if (!esta_) {
if (!is_active_) {
return;
}
@@ -190,11 +189,11 @@ void Enemy::update(float delta_time) {
}
// Decremento de invulnerabilidad + LERP de brightness
if (timer_invulnerabilitat_ > 0.0F) {
timer_invulnerabilitat_ -= delta_time;
timer_invulnerabilitat_ = std::max(timer_invulnerabilitat_, 0.0F);
if (invulnerability_timer_ > 0.0F) {
invulnerability_timer_ -= delta_time;
invulnerability_timer_ = std::max(invulnerability_timer_, 0.0F);
const float T_INV = timer_invulnerabilitat_ / Defaults::Enemies::Spawn::INVULNERABILITY_DURATION;
const float T_INV = invulnerability_timer_ / Defaults::Enemies::Spawn::INVULNERABILITY_DURATION;
const float T = 1.0F - T_INV;
const float SMOOTH_T = T * T * (3.0F - (2.0F * T));
constexpr float START = Defaults::Enemies::Spawn::INVULNERABILITY_BRIGHTNESS_START;
@@ -210,11 +209,11 @@ void Enemy::update(float delta_time) {
case EnemyType::PENTAGON:
behaviorPentagon(delta_time);
break;
case EnemyType::QUADRAT:
behaviorQuadrat(delta_time);
case EnemyType::SQUARE:
behaviorSquare(delta_time);
break;
case EnemyType::MOLINILLO:
behaviorMolinillo(delta_time);
case EnemyType::PINWHEEL:
behaviorPinwheel(delta_time);
break;
}
}
@@ -223,18 +222,18 @@ void Enemy::update(float delta_time) {
updateAnimation(delta_time);
// Rotación visual (decoración, no afecta movimiento)
rotacio_ += drotacio_ * delta_time;
rotation_ += rotation_delta_ * delta_time;
}
void Enemy::postUpdate(float /*delta_time*/) {
// Sincronizar mirror tras la integración del world.
if (esta_) {
if (is_active_) {
center_ = body_.position;
}
}
void Enemy::draw() const {
if (!esta_ || !shape_) {
if (!is_active_ || !shape_) {
return;
}
const float SCALE = computeCurrentScale();
@@ -243,11 +242,11 @@ void Enemy::draw() const {
case EnemyType::PENTAGON:
color = Defaults::Palette::PENTAGON;
break;
case EnemyType::QUADRAT:
color = Defaults::Palette::QUADRAT;
case EnemyType::SQUARE:
color = Defaults::Palette::SQUARE;
break;
case EnemyType::MOLINILLO:
color = Defaults::Palette::MOLINILLO;
case EnemyType::PINWHEEL:
color = Defaults::Palette::PINWHEEL;
break;
}
@@ -261,11 +260,11 @@ void Enemy::draw() const {
}
}
Rendering::renderShape(renderer_, shape_, center_, rotacio_, SCALE, 1.0F, brightness_, color);
Rendering::renderShape(renderer_, shape_, center_, rotation_, SCALE, 1.0F, brightness_, color);
}
void Enemy::destruir() {
esta_ = false;
void Enemy::destroy() {
is_active_ = false;
body_.velocity = Vec2{};
body_.angular_velocity = 0.0F;
body_.radius = 0.0F; // No colisiona mientras está inactivo
@@ -274,10 +273,11 @@ void Enemy::destruir() {
last_hit_by_ = 0xFF;
}
void Enemy::herir(uint8_t shooter_id) {
void Enemy::hurt(uint8_t shooter_id) {
wounded_timer_ = Defaults::Enemies::Wounded::DURATION;
last_hit_by_ = shooter_id;
Audio::get()->playSound(Defaults::Sound::HIT, Audio::Group::GAME);
// El so HIT ara el reprodueix la bala quan es trenca en debris
// (Systems::Collision::breakBullet), no l'enemic en entrar a HURT.
}
void Enemy::applyImpulse(const Vec2& impulse) {
@@ -312,7 +312,7 @@ void Enemy::behaviorPentagon(float delta_time) {
if (RAND_VAL < Defaults::Enemies::Pentagon::ZIGZAG_PROB_PER_SECOND * delta_time) {
const float CURRENT_ANGLE = velocityToAngle(body_.velocity);
const float DELTA = (static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) *
Defaults::Enemies::Pentagon::CANVI_ANGLE_MAX;
Defaults::Enemies::Pentagon::ANGLE_CHANGE_MAX;
const float NEW_ANGLE = CURRENT_ANGLE + ((std::rand() % 2 == 0) ? DELTA : -DELTA);
const float SPEED = body_.velocity.length();
setVelocityFromAngle(NEW_ANGLE, SPEED);
@@ -320,12 +320,12 @@ void Enemy::behaviorPentagon(float delta_time) {
}
}
// QUADRAT: tracking discreto cada TRACKING_INTERVAL. Ajusta dirección
// SQUARE: tracking discreto cada TRACKING_INTERVAL. Ajusta dirección
// hacia el ship mezclando con tracking_strength_.
void Enemy::behaviorQuadrat(float delta_time) {
void Enemy::behaviorSquare(float delta_time) {
tracking_timer_ += delta_time;
if (tracking_timer_ >= Defaults::Enemies::Cuadrado::TRACKING_INTERVAL && ship_position_ != nullptr) {
if (tracking_timer_ >= Defaults::Enemies::Square::TRACKING_INTERVAL && ship_position_ != nullptr) {
tracking_timer_ = 0.0F;
const Vec2 TO_SHIP = *ship_position_ - center_;
@@ -348,89 +348,89 @@ void Enemy::behaviorQuadrat(float delta_time) {
}
}
// MOLINILLO: movimiento recto + boost de rotación visual cerca del ship.
// PINWHEEL: movimiento recto + boost de rotación visual cerca del ship.
// Sin tracking — solo cambios de dirección raros (igual que Pentagon pero
// con probabilidad mucho menor).
void Enemy::behaviorMolinillo(float /*delta_time*/) {
void Enemy::behaviorPinwheel(float /*delta_time*/) {
// Boost de rotación visual por proximidad al ship
if (ship_position_ != nullptr) {
const Vec2 TO_SHIP = *ship_position_ - center_;
const float DIST = TO_SHIP.length();
if (DIST < Defaults::Enemies::Molinillo::PROXIMITY_DISTANCE) {
drotacio_ = animacio_.drotacio_base * Defaults::Enemies::Molinillo::DROTACIO_PROXIMITY_MULTIPLIER;
if (DIST < Defaults::Enemies::Pinwheel::PROXIMITY_DISTANCE) {
rotation_delta_ = animation_.rotation_delta_base * Defaults::Enemies::Pinwheel::ROTATION_DELTA_PROXIMITY_MULTIPLIER;
} else {
drotacio_ = animacio_.drotacio_base;
rotation_delta_ = animation_.rotation_delta_base;
}
}
// Movimiento lineal puro: el world se encarga de integrar y rebotar.
}
void Enemy::updateAnimation(float delta_time) {
updatePalpitation(delta_time);
updatePulse(delta_time);
updateRotationAcceleration(delta_time);
}
void Enemy::updatePalpitation(float delta_time) {
if (animacio_.palpitacio_activa) {
animacio_.palpitacio_fase += 2.0F * Constants::PI * animacio_.palpitacio_frequencia * delta_time;
animacio_.palpitacio_temps_restant -= delta_time;
if (animacio_.palpitacio_temps_restant <= 0.0F) {
animacio_.palpitacio_activa = false;
void Enemy::updatePulse(float delta_time) {
if (animation_.pulse_active) {
animation_.pulse_phase += 2.0F * Constants::PI * animation_.pulse_frequency * delta_time;
animation_.pulse_time_remaining -= delta_time;
if (animation_.pulse_time_remaining <= 0.0F) {
animation_.pulse_active = false;
}
} else {
const float RAND_VAL = static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
const float TRIGGER_PROB = Defaults::Enemies::Animation::PALPITACIO_TRIGGER_PROB * delta_time;
const float TRIGGER_PROB = Defaults::Enemies::Animation::PULSE_TRIGGER_PROB * delta_time;
if (RAND_VAL < TRIGGER_PROB) {
animacio_.palpitacio_activa = true;
animacio_.palpitacio_fase = 0.0F;
animation_.pulse_active = true;
animation_.pulse_phase = 0.0F;
const float FREQ_RANGE = Defaults::Enemies::Animation::PALPITACIO_FREQ_MAX -
Defaults::Enemies::Animation::PALPITACIO_FREQ_MIN;
animacio_.palpitacio_frequencia = Defaults::Enemies::Animation::PALPITACIO_FREQ_MIN +
const float FREQ_RANGE = Defaults::Enemies::Animation::PULSE_FREQ_MAX -
Defaults::Enemies::Animation::PULSE_FREQ_MIN;
animation_.pulse_frequency = Defaults::Enemies::Animation::PULSE_FREQ_MIN +
((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * FREQ_RANGE);
const float AMP_RANGE = Defaults::Enemies::Animation::PALPITACIO_AMPLITUD_MAX -
Defaults::Enemies::Animation::PALPITACIO_AMPLITUD_MIN;
animacio_.palpitacio_amplitud = Defaults::Enemies::Animation::PALPITACIO_AMPLITUD_MIN +
const float AMP_RANGE = Defaults::Enemies::Animation::PULSE_AMPLITUD_MAX -
Defaults::Enemies::Animation::PULSE_AMPLITUD_MIN;
animation_.pulse_amplitude = Defaults::Enemies::Animation::PULSE_AMPLITUD_MIN +
((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * AMP_RANGE);
const float DUR_RANGE = Defaults::Enemies::Animation::PALPITACIO_DURACIO_MAX -
Defaults::Enemies::Animation::PALPITACIO_DURACIO_MIN;
animacio_.palpitacio_temps_restant = Defaults::Enemies::Animation::PALPITACIO_DURACIO_MIN +
const float DUR_RANGE = Defaults::Enemies::Animation::PULSE_DURATION_MAX -
Defaults::Enemies::Animation::PULSE_DURATION_MIN;
animation_.pulse_time_remaining = Defaults::Enemies::Animation::PULSE_DURATION_MIN +
((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * DUR_RANGE);
}
}
}
void Enemy::updateRotationAcceleration(float delta_time) {
if (animacio_.drotacio_t < 1.0F) {
animacio_.drotacio_t += delta_time / animacio_.drotacio_duracio;
if (animacio_.drotacio_t >= 1.0F) {
animacio_.drotacio_t = 1.0F;
animacio_.drotacio_base = animacio_.drotacio_objetivo;
drotacio_ = animacio_.drotacio_base;
if (animation_.rotation_delta_t < 1.0F) {
animation_.rotation_delta_t += delta_time / animation_.rotation_delta_duration;
if (animation_.rotation_delta_t >= 1.0F) {
animation_.rotation_delta_t = 1.0F;
animation_.rotation_delta_base = animation_.rotation_delta_target;
rotation_delta_ = animation_.rotation_delta_base;
} else {
const float T = animacio_.drotacio_t;
const float T = animation_.rotation_delta_t;
const float SMOOTH_T = T * T * (3.0F - (2.0F * T));
const float INITIAL = animacio_.drotacio_base;
const float TARGET = animacio_.drotacio_objetivo;
drotacio_ = INITIAL + ((TARGET - INITIAL) * SMOOTH_T);
const float INITIAL = animation_.rotation_delta_base;
const float TARGET = animation_.rotation_delta_target;
rotation_delta_ = INITIAL + ((TARGET - INITIAL) * SMOOTH_T);
}
} else {
const float RAND_VAL = static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
const float TRIGGER_PROB = Defaults::Enemies::Animation::ROTACIO_ACCEL_TRIGGER_PROB * delta_time;
const float TRIGGER_PROB = Defaults::Enemies::Animation::ROTATION_ACCEL_TRIGGER_PROB * delta_time;
if (RAND_VAL < TRIGGER_PROB) {
animacio_.drotacio_t = 0.0F;
animation_.rotation_delta_t = 0.0F;
const float MULT_RANGE = Defaults::Enemies::Animation::ROTACIO_ACCEL_MULTIPLIER_MAX -
Defaults::Enemies::Animation::ROTACIO_ACCEL_MULTIPLIER_MIN;
const float MULTIPLIER = Defaults::Enemies::Animation::ROTACIO_ACCEL_MULTIPLIER_MIN +
const float MULT_RANGE = Defaults::Enemies::Animation::ROTATION_ACCEL_MULTIPLIER_MAX -
Defaults::Enemies::Animation::ROTATION_ACCEL_MULTIPLIER_MIN;
const float MULTIPLIER = Defaults::Enemies::Animation::ROTATION_ACCEL_MULTIPLIER_MIN +
((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * MULT_RANGE);
animacio_.drotacio_objetivo = animacio_.drotacio_base * MULTIPLIER;
animation_.rotation_delta_target = animation_.rotation_delta_base * MULTIPLIER;
const float DUR_RANGE = Defaults::Enemies::Animation::ROTACIO_ACCEL_DURACIO_MAX -
Defaults::Enemies::Animation::ROTACIO_ACCEL_DURACIO_MIN;
animacio_.drotacio_duracio = Defaults::Enemies::Animation::ROTACIO_ACCEL_DURACIO_MIN +
const float DUR_RANGE = Defaults::Enemies::Animation::ROTATION_ACCEL_DURATION_MAX -
Defaults::Enemies::Animation::ROTATION_ACCEL_DURATION_MIN;
animation_.rotation_delta_duration = Defaults::Enemies::Animation::ROTATION_ACCEL_DURATION_MIN +
((static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX)) * DUR_RANGE);
}
}
@@ -438,15 +438,15 @@ void Enemy::updateRotationAcceleration(float delta_time) {
auto Enemy::computeCurrentScale() const -> float {
float scale = 1.0F;
if (timer_invulnerabilitat_ > 0.0F) {
const float T_INV = timer_invulnerabilitat_ / Defaults::Enemies::Spawn::INVULNERABILITY_DURATION;
if (invulnerability_timer_ > 0.0F) {
const float T_INV = invulnerability_timer_ / Defaults::Enemies::Spawn::INVULNERABILITY_DURATION;
const float T = 1.0F - T_INV;
const float SMOOTH_T = T * T * (3.0F - (2.0F * T));
constexpr float START = Defaults::Enemies::Spawn::INVULNERABILITY_SCALE_START;
constexpr float END = Defaults::Enemies::Spawn::INVULNERABILITY_SCALE_END;
scale = START + ((END - START) * SMOOTH_T);
} else if (animacio_.palpitacio_activa) {
scale += animacio_.palpitacio_amplitud * std::sin(animacio_.palpitacio_fase);
} else if (animation_.pulse_active) {
scale += animation_.pulse_amplitude * std::sin(animation_.pulse_phase);
}
return scale;
}
@@ -454,22 +454,22 @@ auto Enemy::computeCurrentScale() const -> float {
auto Enemy::getBaseVelocity() const -> float {
switch (type_) {
case EnemyType::PENTAGON:
return Defaults::Enemies::Pentagon::VELOCITAT;
case EnemyType::QUADRAT:
return Defaults::Enemies::Cuadrado::VELOCITAT;
case EnemyType::MOLINILLO:
return Defaults::Enemies::Molinillo::VELOCITAT;
return Defaults::Enemies::Pentagon::SPEED;
case EnemyType::SQUARE:
return Defaults::Enemies::Square::SPEED;
case EnemyType::PINWHEEL:
return Defaults::Enemies::Pinwheel::SPEED;
default:
return Defaults::Enemies::Pentagon::VELOCITAT;
return Defaults::Enemies::Pentagon::SPEED;
}
}
auto Enemy::getBaseRotation() const -> float {
return animacio_.drotacio_base != 0.0F ? animacio_.drotacio_base : drotacio_;
return animation_.rotation_delta_base != 0.0F ? animation_.rotation_delta_base : rotation_delta_;
}
void Enemy::setTrackingStrength(float strength) {
if (type_ == EnemyType::QUADRAT) {
if (type_ == EnemyType::SQUARE) {
tracking_strength_ = strength;
}
}
+31 -28
View File
@@ -13,24 +13,24 @@
// Tipo de enemy
enum class EnemyType : uint8_t {
PENTAGON = 0, // Pentágono esquivador (zigzag)
QUADRAT = 1, // Cuadrado perseguidor (tracks ship)
MOLINILLO = 2 // Molinillo agresivo (rápido, girando)
SQUARE = 1, // Square perseguidor (tracks ship)
PINWHEEL = 2 // Molinillo agresivo (rápido, girando)
};
// Estado de animación (palpitación + rotación acelerada)
struct EnemyAnimation {
// Palpitación (efecto respiración)
bool palpitacio_activa = false;
float palpitacio_fase = 0.0F;
float palpitacio_frequencia = 2.0F;
float palpitacio_amplitud = 0.15F;
float palpitacio_temps_restant = 0.0F;
bool pulse_active = false;
float pulse_phase = 0.0F;
float pulse_frequency = 2.0F;
float pulse_amplitude = 0.15F;
float pulse_time_remaining = 0.0F;
// Aceleración de rotación visual (modulación a largo plazo)
float drotacio_base = 0.0F;
float drotacio_objetivo = 0.0F;
float drotacio_t = 0.0F;
float drotacio_duracio = 0.0F;
float rotation_delta_base = 0.0F;
float rotation_delta_target = 0.0F;
float rotation_delta_t = 0.0F;
float rotation_delta_duration = 0.0F;
};
class Enemy : public Entities::Entity {
@@ -46,21 +46,24 @@ class Enemy : public Entities::Entity {
void draw() const override;
// Override: Interfaz de Entity
[[nodiscard]] auto isActive() const -> bool override { return esta_; }
[[nodiscard]] auto isActive() const -> bool override { return is_active_; }
// Override: Interfaz de colisión
[[nodiscard]] auto getCollisionRadius() const -> float override {
return Defaults::Entities::ENEMY_RADIUS;
}
// Mentre fa spawn (invulnerable) segueix col·lisionant: les bales el
// poden abatre i el cos físic rebota amb la nau. El damage a la nau
// segueix filtrat per `isInvulnerable()` al detectShipEnemy.
[[nodiscard]] auto isCollidable() const -> bool override {
return esta_ && timer_invulnerabilitat_ <= 0.0F;
return is_active_;
}
// Marcar destruido (desactiva el cuerpo físicamente: radius=0)
void destruir();
void destroy();
// Getters
[[nodiscard]] auto getRotationDelta() const -> float { return drotacio_; }
[[nodiscard]] auto getRotationDelta() const -> float { return rotation_delta_; }
[[nodiscard]] auto getVelocityVector() const -> Vec2 { return body_.velocity; }
// Set ship position reference for tracking behavior
@@ -76,18 +79,18 @@ class Enemy : public Entities::Entity {
// actual del body_.velocity.
void setVelocity(float speed);
void setRotation(float rot) {
drotacio_ = rot;
animacio_.drotacio_base = rot;
rotation_delta_ = rot;
animation_.rotation_delta_base = rot;
}
void setTrackingStrength(float strength);
// Invulnerabilidad
[[nodiscard]] auto isInvulnerable() const -> bool { return timer_invulnerabilitat_ > 0.0F; }
[[nodiscard]] auto getInvulnerabilityTime() const -> float { return timer_invulnerabilitat_; }
[[nodiscard]] auto isInvulnerable() const -> bool { return invulnerability_timer_ > 0.0F; }
[[nodiscard]] auto getInvulnerabilityTime() const -> float { return invulnerability_timer_; }
// Estado "herido": entre primer impacto de bala y explosión diferida.
// shooter_id: id del jugador que herí; 0xFF = sin atribución (cadena, etc.).
void herir(uint8_t shooter_id = 0xFF);
void hurt(uint8_t shooter_id = 0xFF);
[[nodiscard]] auto isWounded() const -> bool { return wounded_timer_ > 0.0F; }
[[nodiscard]] auto getWoundedTimer() const -> float { return wounded_timer_; }
[[nodiscard]] auto woundExpiredThisFrame() const -> bool { return wound_expired_this_frame_; }
@@ -101,12 +104,12 @@ class Enemy : public Entities::Entity {
// Miembros específicos (heredados: renderer_, shape_, center_, angle_, brightness_, body_).
// Inicializados en la declaración: el ctor por defecto deja al enemy en estado "inactivo
// como pentágono", coherente con lo que harán init() o el ctor con renderer al activarlo.
float drotacio_{0.0F}; // Velocidad angular visual (rad/s) — solo decoración, separada de body_.angular_velocity
float rotacio_{0.0F}; // Rotación visual acumulada (no afecta movimiento)
bool esta_{false};
float rotation_delta_{0.0F}; // Velocidad angular visual (rad/s) — solo decoración, separada de body_.angular_velocity
float rotation_{0.0F}; // Rotación visual acumulada (no afecta movimiento)
bool is_active_{false};
EnemyType type_{EnemyType::PENTAGON};
EnemyAnimation animacio_;
EnemyAnimation animation_;
// Comportamiento type-specific
float tracking_timer_{0.0F}; // Quadrat: tiempo desde último update de dirección
@@ -115,7 +118,7 @@ class Enemy : public Entities::Entity {
float direction_change_timer_{0.0F}; // Pentagon: tiempo para próximo cambio de dirección
// Invulnerabilidad post-spawn
float timer_invulnerabilitat_{0.0F};
float invulnerability_timer_{0.0F};
// Estado "herido": timer cuenta atrás; al cruzar 0 se marca expiración.
float wounded_timer_{0.0F};
@@ -124,11 +127,11 @@ class Enemy : public Entities::Entity {
// Métodos privados
void updateAnimation(float delta_time);
void updatePalpitation(float delta_time);
void updatePulse(float delta_time);
void updateRotationAcceleration(float delta_time);
void behaviorPentagon(float delta_time);
void behaviorQuadrat(float delta_time);
void behaviorMolinillo(float delta_time);
void behaviorSquare(float delta_time);
void behaviorPinwheel(float delta_time);
[[nodiscard]] auto computeCurrentScale() const -> float;
// Estático: solo opera sobre ship_pos pasado; no consulta estado del enemy.
static auto attemptSafeSpawn(const Vec2& ship_pos, float& out_x, float& out_y) -> bool;
+30 -5
View File
@@ -10,6 +10,7 @@
#include <cstdint>
#include <iostream>
#include "core/audio/audio.hpp"
#include "core/defaults.hpp"
#include "core/entities/entity.hpp"
#include "core/graphics/shape_loader.hpp"
@@ -43,10 +44,10 @@ void Ship::init(const Vec2* spawn_point, bool activar_invulnerabilitat) {
if (spawn_point != nullptr) {
center_ = *spawn_point;
} else {
float centre_x;
float centre_y;
Constants::getPlayAreaCenter(centre_x, centre_y);
center_ = {.x = centre_x, .y = centre_y};
float center_x;
float center_y;
Constants::getPlayAreaCenter(center_x, center_y);
center_ = {.x = center_x, .y = center_y};
}
// Reset orientación
@@ -62,6 +63,8 @@ void Ship::init(const Vec2* spawn_point, bool activar_invulnerabilitat) {
// Activar invulnerabilidad solo si es respawn
invulnerable_timer_ = activar_invulnerabilitat ? Defaults::Ship::INVULNERABILITY_DURATION : 0.0F;
is_hit_ = false;
hurt_timer_ = 0.0F;
touching_enemy_prev_frame_ = false;
}
void Ship::processInput(float delta_time, uint8_t player_id) {
@@ -115,6 +118,12 @@ void Ship::update(float delta_time) {
invulnerable_timer_ = std::max(invulnerable_timer_, 0.0F);
}
// Decrementar timer d'estat HURT (a 0 → torna a normal sense efecte extern)
if (hurt_timer_ > 0.0F) {
hurt_timer_ -= delta_time;
hurt_timer_ = std::max(hurt_timer_, 0.0F);
}
// El movimiento real lo hace PhysicsWorld::update().
// Aquí solo lógica de estado.
@@ -157,5 +166,21 @@ void Ship::draw() const {
const float VISUAL_PUSH = SPEED / Defaults::Ship::VISUAL_PUSH_DIVISOR;
const float SCALE = 1.0F + (VISUAL_PUSH / Defaults::Ship::VISUAL_SCALE_DIVISOR);
Rendering::renderShape(renderer_, shape_, center_, angle_, SCALE, 1.0F, brightness_, Defaults::Palette::SHIP);
// Parpelleig daurat mentre està ferida: alterna color normal ↔ color hurt
// a Hurt::BLINK_HZ (mateixa estètica que el wounded dels enemics).
SDL_Color color = color_normal_;
if (hurt_timer_ > 0.0F) {
const float CYCLE = 1.0F / Defaults::Ship::Hurt::BLINK_HZ;
const float T = std::fmod(hurt_timer_, CYCLE);
if (T < (CYCLE / 2.0F)) {
color = color_hurt_;
}
}
Rendering::renderShape(renderer_, shape_, center_, angle_, SCALE, 1.0F, brightness_, color);
}
void Ship::hurt() {
hurt_timer_ = Defaults::Ship::Hurt::DURATION;
Audio::get()->playSound(Defaults::Sound::HURT, Audio::Group::GAME);
}
+21
View File
@@ -53,10 +53,31 @@ class Ship : public Entities::Entity {
body_.velocity = Vec2{}; // Detener al morir
}
// Estat "ferit": primera col·lisió amb enemic dispara HURT; segona durant HURT mata.
void hurt();
[[nodiscard]] auto isHurt() const -> bool { return hurt_timer_ > 0.0F; }
[[nodiscard]] auto getHurtTimer() const -> float { return hurt_timer_; }
// Edge-trigger del contacte amb enemics: un impacte només compta a la transició
// no-tocant → tocant. Sense açò, el contacte continu durant el rebot frame-a-frame
// dispararia HURT i mort en frames consecutius.
[[nodiscard]] auto wasTouchingEnemyPrevFrame() const -> bool { return touching_enemy_prev_frame_; }
void setTouchingEnemyPrevFrame(bool touching) { touching_enemy_prev_frame_ = touching; }
private:
// Miembros específicos de Ship (heredados: renderer_, shape_, center_, angle_, brightness_, body_).
// Inicializados en la declaración: el ctor por defecto deja la nave "viva y sin invulnerabilidad",
// que es el estado coherente al que llevan tanto init() como el ctor con renderer.
bool is_hit_{false};
float invulnerable_timer_{0.0F}; // 0.0f = vulnerable, >0.0f = invulnerable
// Colors de la nau (propietats, prep per migració a YAML).
SDL_Color color_normal_{Defaults::Palette::SHIP};
SDL_Color color_hurt_{Defaults::Palette::WOUNDED};
// >0 → estat HURT (parpelleig color_normal_ ↔ color_hurt_).
float hurt_timer_{0.0F};
// Edge-trigger: true si el frame anterior la nau ja estava en contacte amb un enemic.
bool touching_enemy_prev_frame_{false};
};
+140 -84
View File
@@ -11,7 +11,9 @@
#include "core/audio/audio.hpp"
#include "core/input/input.hpp"
#include "core/locale/locale.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/service_menu.hpp"
#include "game/stage_system/stage_loader.hpp"
#include "game/systems/collision_system.hpp"
#include "game/systems/continue_system.hpp"
@@ -30,6 +32,7 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
floating_score_manager_(sdl.getRenderer()),
trail_manager_(sdl.getRenderer()),
text_(sdl.getRenderer()),
starfield_parallax_(sdl.getRenderer()),
playfield_(sdl.getRenderer()),
border_(sdl.getRenderer()) {
// Recuperar configuración de match des del context
@@ -37,9 +40,9 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
// Debug output de la configuración
std::cout << "[GameScene] Configuración de match - P1: "
<< (match_config_.jugador1_actiu ? "ACTIU" : "INACTIU")
<< (match_config_.player1_active ? "ACTIU" : "INACTIU")
<< ", P2: "
<< (match_config_.jugador2_actiu ? "ACTIU" : "INACTIU")
<< (match_config_.player2_active ? "ACTIU" : "INACTIU")
<< '\n';
// Consumir opciones (preparació per MODE_DEMO futur)
@@ -60,7 +63,7 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
// Basat en el codi Pascal original: line 376
std::srand(static_cast<unsigned>(std::time(nullptr)));
// Configurar el mundo físico con los límites de la zona de juego.
// Configurar el mundo físico con los límites de la zone de juego.
physics_world_.clear();
physics_world_.setBounds(Defaults::Zones::PLAYAREA);
@@ -75,18 +78,18 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
border_.bumpAt(hit.contact_point, STRENGTH);
});
// Fireworks generen un pulse a les línies V i H més properes del playfield.
firework_manager_.setSpawnCallback([this](Vec2 origen) {
playfield_.notifyFireworkSpawn(origen);
// Fireworks generen una ripple gran al playfield (ona d'aigua centrada al burst).
firework_manager_.setSpawnCallback([this](Vec2 origin) {
playfield_.notifyExplosion(origin);
});
// Explosions properes a una paret també generen bump (falloff lineal amb la distància).
debris_manager_.setExplosionCallback([this](Vec2 center) {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const float DIST_LEFT = std::abs(center.x - zona.x);
const float DIST_RIGHT = std::abs((zona.x + zona.w) - center.x);
const float DIST_TOP = std::abs(center.y - zona.y);
const float DIST_BOTTOM = std::abs((zona.y + zona.h) - center.y);
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
const float DIST_LEFT = std::abs(center.x - zone.x);
const float DIST_RIGHT = std::abs((zone.x + zone.w) - center.x);
const float DIST_TOP = std::abs(center.y - zone.y);
const float DIST_BOTTOM = std::abs((zone.y + zone.h) - center.y);
const float MIN_DIST = std::min({DIST_LEFT, DIST_RIGHT, DIST_TOP, DIST_BOTTOM});
if (MIN_DIST > Defaults::Border::EXPLOSION_FALLOFF_PX) {
return;
@@ -129,9 +132,9 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
// Inicialitzar naves segons configuración (solo jugadors active)
for (uint8_t i = 0; i < 2; i++) {
bool jugador_actiu = (i == 0) ? match_config_.jugador1_actiu : match_config_.jugador2_actiu;
bool player_active = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
if (jugador_actiu) {
if (player_active) {
// Jugador active: init normalment
Vec2 spawn_pos = getSpawnPoint(i);
ships_[i].init(&spawn_pos, false); // No invulnerability at start
@@ -180,6 +183,13 @@ void GameScene::handleEvent(const SDL_Event& event) {
}
void GameScene::update(float delta_time) {
// Pausa global: mentre el menu de servei esta obert, congelem la lògica
// de joc. El draw() segueix executant-se per a mantenir l'escena visible
// sota el menu.
if (const auto* menu = System::ServiceMenu::get(); menu != nullptr && menu->isOpen()) {
return;
}
// Orquestador delgado: cada paso vive en su propia función para
// mantener update() legible y reducir complejidad cognitiva.
stepPhysics(delta_time);
@@ -213,24 +223,45 @@ void GameScene::stepPhysics(float delta_time) {
bullet.postUpdate(delta_time);
}
trail_manager_.update(delta_time, ships_);
// Starfield: world_velocity = -mitjana_de_naus_actives. Si dues naus van en
// sentits oposats, es cancel·len → estrelles quietes (cap jugador "guanya").
// Si només n'hi ha una activa, segueix la seva velocitat.
Vec2 ship_vel_avg{.x = 0.0F, .y = 0.0F};
int n_active = 0;
for (const auto& ship : ships_) {
if (ship.isActive()) {
const Vec2 V = ship.getVelocityVector();
ship_vel_avg.x += V.x;
ship_vel_avg.y += V.y;
n_active++;
}
}
if (n_active > 0) {
ship_vel_avg.x /= static_cast<float>(n_active);
ship_vel_avg.y /= static_cast<float>(n_active);
}
starfield_parallax_.update(delta_time, Vec2{.x = -ship_vel_avg.x, .y = -ship_vel_avg.y});
playfield_.update(delta_time);
border_.update(delta_time);
// Notificar al playfield que la nau ha passat (per excitar línies properes).
for (const auto& ship : ships_) {
if (ship.isActive()) {
playfield_.notifyShipPass(ship.getCenter(), ship.getSpeed());
// Notificar al playfield que la nau es mou (genera ripples petites a cadència).
for (std::size_t id = 0; id < ships_.size(); id++) {
if (ships_[id].isActive()) {
playfield_.notifyShipMoving(static_cast<std::uint8_t>(id),
ships_[id].getCenter(),
ships_[id].getSpeed(),
delta_time);
}
}
}
void GameScene::stepShootingInput() {
auto* input = Input::get();
if (match_config_.jugador1_actiu &&
if (match_config_.player1_active &&
input->checkActionPlayer1(InputAction::SHOOT, Input::DO_NOT_ALLOW_REPEAT)) {
fireBullet(0);
}
if (match_config_.jugador2_actiu &&
if (match_config_.player2_active &&
input->checkActionPlayer2(InputAction::SHOOT, Input::DO_NOT_ALLOW_REPEAT)) {
fireBullet(1);
}
@@ -245,16 +276,16 @@ void GameScene::stepMidGameJoin() {
// Solo se permite join si hay al menos un jugador vivo (no se puede
// hacer join en pantalla vacía).
const bool ALGU_VIU =
(match_config_.jugador1_actiu && hit_timer_per_player_[0] != Defaults::Game::HIT_TIMER_INACTIVE_PLAYER) ||
(match_config_.jugador2_actiu && hit_timer_per_player_[1] != Defaults::Game::HIT_TIMER_INACTIVE_PLAYER);
(match_config_.player1_active && hit_timer_per_player_[0] != Defaults::Game::HIT_TIMER_INACTIVE_PLAYER) ||
(match_config_.player2_active && hit_timer_per_player_[1] != Defaults::Game::HIT_TIMER_INACTIVE_PLAYER);
if (!ALGU_VIU) {
return;
}
auto* input = Input::get();
for (uint8_t pid = 0; pid < 2; pid++) {
const bool ACTIU = (pid == 0) ? match_config_.jugador1_actiu
: match_config_.jugador2_actiu;
const bool ACTIU = (pid == 0) ? match_config_.player1_active
: match_config_.player2_active;
const bool MUERTO_SIN_VIDAS = hit_timer_per_player_[pid] == Defaults::Game::HIT_TIMER_INACTIVE_PLAYER;
if (ACTIU && !MUERTO_SIN_VIDAS) {
continue; // jugador ya está jugando
@@ -296,6 +327,7 @@ auto GameScene::stepContinueScreen(float delta_time) -> bool {
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_);
debris_manager_.update(delta_time);
firework_manager_.update(delta_time);
floating_score_manager_.update(delta_time);
@@ -321,6 +353,7 @@ auto GameScene::stepGameOver(float delta_time) -> bool {
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_);
debris_manager_.update(delta_time);
firework_manager_.update(delta_time);
floating_score_manager_.update(delta_time);
@@ -351,8 +384,8 @@ void GameScene::stepDeathSequence(float delta_time) {
// Sin vidas: marcar definitivamente muerto y comprobar transición a CONTINUE.
hit_timer_per_player_[i] = Defaults::Game::HIT_TIMER_INACTIVE_PLAYER;
const bool P1_DEAD = !match_config_.jugador1_actiu || lives_per_player_[0] <= 0;
const bool P2_DEAD = !match_config_.jugador2_actiu || lives_per_player_[1] <= 0;
const bool P1_DEAD = !match_config_.player1_active || lives_per_player_[0] <= 0;
const bool P2_DEAD = !match_config_.player2_active || lives_per_player_[1] <= 0;
if (P1_DEAD && P2_DEAD) {
game_over_state_ = GameOverState::CONTINUE;
continue_counter_ = Defaults::Game::CONTINUE_COUNT_START;
@@ -416,10 +449,10 @@ void GameScene::runStageInitHud(float delta_time) {
Defaults::Game::INIT_HUD_SHIP2_RATIO_INIT,
Defaults::Game::INIT_HUD_SHIP2_RATIO_END);
if (match_config_.jugador1_actiu && SHIP1_P < 1.0F) {
if (match_config_.player1_active && SHIP1_P < 1.0F) {
ships_[0].setCenter(Systems::InitHud::computeShipPosition(SHIP1_P, getSpawnPoint(0)));
}
if (match_config_.jugador2_actiu && SHIP2_P < 1.0F) {
if (match_config_.player2_active && SHIP2_P < 1.0F) {
ships_[1].setCenter(Systems::InitHud::computeShipPosition(SHIP2_P, getSpawnPoint(1)));
}
}
@@ -429,7 +462,7 @@ void GameScene::runStageLevelStart(float delta_time) {
// Ambas naves pueden moverse y disparar durante el intro.
for (uint8_t i = 0; i < 2; i++) {
const bool ACTIU = (i == 0) ? match_config_.jugador1_actiu : match_config_.jugador2_actiu;
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
if (ACTIU && hit_timer_per_player_[i] == 0.0F) {
ships_[i].processInput(delta_time, i);
ships_[i].update(delta_time);
@@ -438,6 +471,7 @@ void GameScene::runStageLevelStart(float delta_time) {
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_);
debris_manager_.update(delta_time);
firework_manager_.update(delta_time);
}
@@ -456,7 +490,7 @@ void GameScene::runStagePlaying(float delta_time) {
// Gameplay normal: ships activos + entidades + colisiones + efectos.
for (uint8_t i = 0; i < 2; i++) {
const bool ACTIU = (i == 0) ? match_config_.jugador1_actiu : match_config_.jugador2_actiu;
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
if (ACTIU && hit_timer_per_player_[i] == 0.0F) {
ships_[i].processInput(delta_time, i);
ships_[i].update(delta_time);
@@ -465,11 +499,15 @@ void GameScene::runStagePlaying(float delta_time) {
for (auto& enemy : enemies_) {
enemy.update(delta_time);
}
// Col·lisions primer, després desactivació per fora-de-zone: així una bala que
// el mateix frame xoca amb un enemic i alhora surt del PLAYAREA es compta com a
// impacte abans no se la trenqui per sortir.
runCollisionDetections();
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
runCollisionDetections();
Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_);
debris_manager_.update(delta_time);
firework_manager_.update(delta_time);
floating_score_manager_.update(delta_time);
@@ -478,7 +516,7 @@ void GameScene::runStagePlaying(float delta_time) {
void GameScene::runStageLevelCompleted(float delta_time) {
stage_manager_->update(delta_time);
for (uint8_t i = 0; i < 2; i++) {
const bool ACTIU = (i == 0) ? match_config_.jugador1_actiu : match_config_.jugador2_actiu;
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
if (ACTIU && hit_timer_per_player_[i] == 0.0F) {
ships_[i].processInput(delta_time, i);
ships_[i].update(delta_time);
@@ -487,6 +525,7 @@ void GameScene::runStageLevelCompleted(float delta_time) {
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
Systems::Collision::desactivateOutOfBoundsBullets(bullets_, debris_manager_);
debris_manager_.update(delta_time);
firework_manager_.update(delta_time);
floating_score_manager_.update(delta_time);
@@ -550,14 +589,15 @@ void GameScene::drawBullets() const {
void GameScene::drawActiveShipsAlive() const {
for (uint8_t i = 0; i < 2; i++) {
bool jugador_actiu = (i == 0) ? match_config_.jugador1_actiu : match_config_.jugador2_actiu;
if (jugador_actiu && hit_timer_per_player_[i] == 0.0F) {
bool player_active = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
if (player_active && hit_timer_per_player_[i] == 0.0F) {
ships_[i].draw();
}
}
}
void GameScene::drawContinueState() {
starfield_parallax_.draw();
border_.draw();
drawEnemies();
drawBullets();
@@ -569,6 +609,7 @@ void GameScene::drawContinueState() {
}
void GameScene::drawGameOverState() {
starfield_parallax_.draw();
border_.draw();
drawEnemies();
drawBullets();
@@ -576,15 +617,15 @@ void GameScene::drawGameOverState() {
firework_manager_.draw();
floating_score_manager_.draw();
const std::string GAME_OVER_TEXT = "GAME OVER";
const std::string GAME_OVER_TEXT = Locale::get().text("game_screen.game_over");
constexpr float SCALE = Defaults::Game::GameOverScreen::TEXT_SCALE;
constexpr float SPACING = Defaults::Game::GameOverScreen::TEXT_SPACING;
const SDL_FRect& play_area = Defaults::Zones::PLAYAREA;
float centre_x = play_area.x + (play_area.w / 2.0F);
float centre_y = play_area.y + (play_area.h / 2.0F);
float center_x = play_area.x + (play_area.w / 2.0F);
float center_y = play_area.y + (play_area.h / 2.0F);
text_.renderCentered(GAME_OVER_TEXT, {.x = centre_x, .y = centre_y}, SCALE, SPACING);
text_.renderCentered(GAME_OVER_TEXT, {.x = center_x, .y = center_y}, SCALE, SPACING);
drawScoreboard();
}
@@ -614,6 +655,8 @@ void GameScene::drawInitHudState() {
Defaults::Game::INIT_HUD_SHIP2_RATIO_INIT,
Defaults::Game::INIT_HUD_SHIP2_RATIO_END);
// Capa de fons més profunda: estrelles 2D (apareixen senceres des del frame 0).
starfield_parallax_.draw();
// Graella de fons al darrere (timer intern propi, cobreix tot l'INIT_HUD).
playfield_.draw();
@@ -629,16 +672,17 @@ void GameScene::drawInitHudState() {
Systems::InitHud::drawScoreboardAnimated(text_, buildScoreboard(), score_progress);
}
if (ship1_progress > 0.0F && match_config_.jugador1_actiu && ships_[0].isActive()) {
if (ship1_progress > 0.0F && match_config_.player1_active && ships_[0].isActive()) {
ships_[0].draw();
}
if (ship2_progress > 0.0F && match_config_.jugador2_actiu && ships_[1].isActive()) {
if (ship2_progress > 0.0F && match_config_.player2_active && ships_[1].isActive()) {
ships_[1].draw();
}
}
void GameScene::drawLevelStartState() {
starfield_parallax_.draw();
playfield_.draw();
border_.draw();
trail_manager_.draw();
@@ -652,6 +696,7 @@ void GameScene::drawLevelStartState() {
}
void GameScene::drawPlayingState() {
starfield_parallax_.draw();
playfield_.draw();
border_.draw();
trail_manager_.draw();
@@ -665,6 +710,7 @@ void GameScene::drawPlayingState() {
}
void GameScene::drawLevelCompletedState() {
starfield_parallax_.draw();
playfield_.draw();
border_.draw();
trail_manager_.draw();
@@ -673,7 +719,7 @@ void GameScene::drawLevelCompletedState() {
debris_manager_.draw();
firework_manager_.draw();
floating_score_manager_.draw();
drawStageMessage(StageSystem::Constants::MISSATGE_LEVEL_COMPLETED);
drawStageMessage(Locale::get().text("stage.completed"));
drawScoreboard();
}
@@ -686,30 +732,36 @@ void GameScene::tocado(uint8_t player_id) {
if (hit_timer_per_player_[player_id] == 0.0F) {
// *** PHASE 1: TRIGGER DEATH ***
// Capturar velocitat ABANS del markHit (que la reseteja a zero).
// Sense això, els debris no hereten cap inèrcia de la nau.
const Vec2 SHIP_VEL_PRE_DEATH = ships_[player_id].getVelocityVector();
const Vec2 SHIP_POS = ships_[player_id].getCenter();
const float SHIP_ANGLE = ships_[player_id].getAngle();
const float SHIP_BRIGHT = ships_[player_id].getBrightness();
// Mark ship as dead (stops rendering and input)
ships_[player_id].markHit();
// Create ship explosion
const Vec2& ship_pos = ships_[player_id].getCenter();
float ship_angle = ships_[player_id].getAngle();
Vec2 vel_nau = ships_[player_id].getVelocityVector();
// Reduir la velocity heretada per la ship segons defaults (més realista)
constexpr float INHERIT = Defaults::Physics::Debris::SHIP_VELOCITY_INHERITANCE;
Vec2 vel_nau_80 = {.x = vel_nau.x * INHERIT, .y = vel_nau.y * INHERIT};
const Vec2 INHERITED_VEL = SHIP_VEL_PRE_DEATH *
Defaults::Physics::Debris::SHIP_VELOCITY_INHERITANCE;
// Mateixa dispersió i efecte que els debris d'enemic (lifetime,
// friction, segment_multiplier alineats); només canvien sound i color.
debris_manager_.explode(
ships_[player_id].getShape(), // Ship shape (3 lines)
ship_pos, // Center position
ship_angle, // Ship orientation
1.0F, // Normal scale
Defaults::Physics::Debris::VELOCITAT_BASE, // 80 px/s
ships_[player_id].getBrightness(), // Heredar brightness
vel_nau_80, // Heredar 80% velocity
0.0F, // Nave: trayectorias rectas (sin drotacio)
0.0F, // Sin herencia visual (rotación aleatoria)
Defaults::Sound::EXPLOSION2, // Sonido alternativo para la explosión
Defaults::Palette::SHIP // Debris hereda color de la nave
);
ships_[player_id].getShape(),
SHIP_POS,
SHIP_ANGLE,
1.0F,
Defaults::Physics::Debris::SPEED_BASE,
SHIP_BRIGHT,
INHERITED_VEL,
0.0F, // sense herència angular
0.0F, // sin herencia visual
Defaults::Sound::EXPLOSION2,
Defaults::Palette::SHIP,
Defaults::Physics::Debris::ENEMY_LIFETIME,
Defaults::Physics::Debris::ENEMY_FRICTION,
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER);
// Start death timer (non-zero to avoid re-triggering)
hit_timer_per_player_[player_id] = Defaults::Game::HIT_TIMER_TRIGGER_DEATH;
@@ -726,20 +778,20 @@ void GameScene::drawScoreboard() {
const float SCALE = Defaults::Hud::SCOREBOARD_TEXT_SCALE;
const float SPACING = Defaults::Hud::SCOREBOARD_TEXT_SPACING;
// Calcular centro de la zona del marcador
// Calcular centro de la zone del marcador
const SDL_FRect& scoreboard_zone = Defaults::Zones::SCOREBOARD;
float centre_x = scoreboard_zone.w / 2.0F;
float centre_y = scoreboard_zone.y + (scoreboard_zone.h / 2.0F);
float center_x = scoreboard_zone.w / 2.0F;
float center_y = scoreboard_zone.y + (scoreboard_zone.h / 2.0F);
// Renderizar centrat
text_.renderCentered(text, {.x = centre_x, .y = centre_y}, SCALE, SPACING);
text_.renderCentered(text, {.x = center_x, .y = center_y}, SCALE, SPACING);
}
auto GameScene::buildScoreboard() const -> std::string {
// Puntuación P1 (6 dígits) - mostrar zeros si inactiu
std::string score_p1;
std::string vides_p1;
if (match_config_.jugador1_actiu) {
if (match_config_.player1_active) {
score_p1 = std::to_string(score_per_player_[0]);
score_p1 = std::string(6 - std::min(6, static_cast<int>(score_p1.length())), '0') + score_p1;
vides_p1 = (lives_per_player_[0] < 10)
@@ -758,7 +810,7 @@ auto GameScene::buildScoreboard() const -> std::string {
// Puntuación P2 (6 dígits) - mostrar zeros si inactiu
std::string score_p2;
std::string vides_p2;
if (match_config_.jugador2_actiu) {
if (match_config_.player2_active) {
score_p2 = std::to_string(score_per_player_[1]);
score_p2 = std::string(6 - std::min(6, static_cast<int>(score_p2.length())), '0') + score_p2;
vides_p2 = (lives_per_player_[1] < 10)
@@ -771,7 +823,7 @@ auto GameScene::buildScoreboard() const -> std::string {
// Format: "123456 03 LEVEL 01 654321 02"
// Nota: dos espais entre seccions, mantenir ambdós slots siempre visibles
return score_p1 + " " + vides_p1 + " LEVEL " + stage_str + " " + score_p2 + " " + vides_p2;
return score_p1 + " " + vides_p1 + " " + Locale::get().text("hud.level") + stage_str + " " + score_p2 + " " + vides_p2;
}
// [NEW] Stage system helper methods
@@ -836,9 +888,10 @@ void GameScene::drawStageMessage(const std::string& message) {
float x = play_area.x + ((play_area.w - full_text_width) / 2.0F);
float y = play_area.y + (play_area.h * Defaults::Game::STAGE_MESSAGE_Y_RATIO) - (text_height / 2.0F);
// Render only the partial message (typewriter effect)
// Render only the partial message (typewriter effect) amb el color
// ambre neon del "PRESS START" del títol — unifica el feel dels missatges.
Vec2 pos = {.x = x, .y = y};
text_.render(partial_message, pos, scale, SPACING);
text_.render(partial_message, pos, scale, SPACING, 1.0F, Defaults::Title::Colors::PRESS_START);
}
// ========================================
@@ -846,7 +899,7 @@ void GameScene::drawStageMessage(const std::string& message) {
// ========================================
auto GameScene::getSpawnPoint(uint8_t player_id) const -> Vec2 {
const SDL_FRect& zona = Defaults::Zones::PLAYAREA;
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
float x_ratio;
if (match_config_.isSinglePlayer()) {
@@ -860,8 +913,8 @@ auto GameScene::getSpawnPoint(uint8_t player_id) const -> Vec2 {
}
return {
.x = zona.x + (zona.w * x_ratio),
.y = zona.y + (zona.h * Defaults::Game::SPAWN_Y_RATIO)};
.x = zone.x + (zone.w * x_ratio),
.y = zone.y + (zone.h * Defaults::Game::SPAWN_Y_RATIO)};
}
void GameScene::fireBullet(uint8_t player_id) {
@@ -883,15 +936,15 @@ void GameScene::fireBullet(uint8_t player_id) {
float sin_a = std::sin(ship_angle);
float tip_x = (LOCAL_TIP_X * cos_a) - (LOCAL_TIP_Y * sin_a) + ship_centre.x;
float tip_y = (LOCAL_TIP_X * sin_a) + (LOCAL_TIP_Y * cos_a) + ship_centre.y;
Vec2 posicio_dispar = {.x = tip_x, .y = tip_y};
Vec2 fire_position = {.x = tip_x, .y = tip_y};
// Buscar primera bullet inactiva en el pool del player.
// El pool global té MAX_BALES slots per jugador (P1=[0..MAX-1], P2=[MAX..2*MAX-1]).
constexpr int SLOTS_PER_PLAYER = Defaults::Entities::MAX_BALES;
// El pool global té MAX_BULLETS slots per jugador (P1=[0..MAX-1], P2=[MAX..2*MAX-1]).
constexpr int SLOTS_PER_PLAYER = Defaults::Entities::MAX_BULLETS;
const int START_IDX = player_id * SLOTS_PER_PLAYER;
for (int i = START_IDX; i < START_IDX + SLOTS_PER_PLAYER; i++) {
if (!bullets_[i].isActive()) {
bullets_[i].disparar(posicio_dispar, ship_angle, player_id);
bullets_[i].fire(fire_position, ship_angle, player_id);
break;
}
}
@@ -902,14 +955,14 @@ void GameScene::drawContinue() {
constexpr float SPACING = 4.0F;
// "CONTINUE" text (using constants)
const std::string CONTINUE_TEXT = "CONTINUE";
const std::string CONTINUE_TEXT = Locale::get().text("game_screen.continue");
float escala_continue = Defaults::Game::ContinueScreen::CONTINUE_TEXT_SCALE;
float y_ratio_continue = Defaults::Game::ContinueScreen::CONTINUE_TEXT_Y_RATIO;
float centre_x = play_area.x + (play_area.w / 2.0F);
float center_x = play_area.x + (play_area.w / 2.0F);
float centre_y_continue = play_area.y + (play_area.h * y_ratio_continue);
text_.renderCentered(CONTINUE_TEXT, {.x = centre_x, .y = centre_y_continue}, escala_continue, SPACING);
text_.renderCentered(CONTINUE_TEXT, {.x = center_x, .y = centre_y_continue}, escala_continue, SPACING);
// Countdown number (using constants)
const std::string COUNTER_STR = std::to_string(continue_counter_);
@@ -918,26 +971,29 @@ void GameScene::drawContinue() {
float centre_y_counter = play_area.y + (play_area.h * y_ratio_counter);
text_.renderCentered(COUNTER_STR, {.x = centre_x, .y = centre_y_counter}, escala_counter, SPACING);
text_.renderCentered(COUNTER_STR, {.x = center_x, .y = centre_y_counter}, escala_counter, SPACING);
// "CONTINUES LEFT" (conditional + using constants)
if (!Defaults::Game::INFINITE_CONTINUES) {
const std::string CONTINUES_TEXT = "CONTINUES LEFT: " + std::to_string(Defaults::Game::MAX_CONTINUES - continues_used_);
const std::string CONTINUES_TEXT = localeSubstitute(
Locale::get().text("game_screen.continues_left"),
"{n}",
std::to_string(Defaults::Game::MAX_CONTINUES - continues_used_));
float escala_info = Defaults::Game::ContinueScreen::INFO_TEXT_SCALE;
float y_ratio_info = Defaults::Game::ContinueScreen::INFO_TEXT_Y_RATIO;
float centre_y_info = play_area.y + (play_area.h * y_ratio_info);
text_.renderCentered(CONTINUES_TEXT, {.x = centre_x, .y = centre_y_info}, escala_info, SPACING);
text_.renderCentered(CONTINUES_TEXT, {.x = center_x, .y = centre_y_info}, escala_info, SPACING);
}
}
void GameScene::joinPlayer(uint8_t player_id) {
// Activate player
if (player_id == 0) {
match_config_.jugador1_actiu = true;
match_config_.player1_active = true;
} else {
match_config_.jugador2_actiu = true;
match_config_.player2_active = true;
}
// Reset stats
+5 -1
View File
@@ -10,6 +10,7 @@
#include "core/graphics/border.hpp"
#include "core/graphics/playfield.hpp"
#include "core/graphics/starfield_parallax.hpp"
#include "core/graphics/vector_text.hpp"
#include "core/physics/physics_world.hpp"
#include "core/rendering/sdl_manager.hpp"
@@ -66,7 +67,7 @@ class GameScene final : public Scene {
std::array<Enemy, Constants::MAX_ORNIS> enemies_;
// 6 balas: P1=[0,1,2], P2=[3,4,5]. El cast a size_t evita la
// widening conversion implícita que detecta clang-tidy.
std::array<Bullet, static_cast<std::size_t>(Constants::MAX_BALES) * 2> bullets_;
std::array<Bullet, static_cast<std::size_t>(Constants::MAX_BULLETS) * 2> bullets_;
std::array<float, 2> hit_timer_per_player_; // Death timers per player (seconds)
// Lives and game over system
@@ -82,6 +83,9 @@ class GameScene final : public Scene {
// Text vectorial
Graphics::VectorText text_;
// Capa més profunda del fons: estrelles 2D amb parallax (estàtiques de moment).
Graphics::StarfieldParallax starfield_parallax_;
// Fons del playfield (graella + futures capes)
Graphics::Playfield playfield_;
+96 -97
View File
@@ -20,18 +20,18 @@ using SceneManager::SceneContext;
using SceneType = SceneContext::SceneType;
using Option = SceneContext::Option;
// Helper: calcular el progrés individual de una lletra
// Helper: calcular el progrés individual de una letter
// en función del progrés global (efecte seqüencial)
static auto computeLetterProgress(size_t letra_index, size_t num_letras, float global_progress, float threshold) -> float {
if (num_letras == 0) {
static auto computeLetterProgress(size_t letter_index, size_t num_letters, float global_progress, float threshold) -> float {
if (num_letters == 0) {
return 1.0F;
}
// Calcular time per lletra
float duration_per_letra = 1.0F / static_cast<float>(num_letras);
float step = threshold * duration_per_letra;
float start = static_cast<float>(letra_index) * step;
float end = start + duration_per_letra;
// Calcular time per letter
float duration_per_letter = 1.0F / static_cast<float>(num_letters);
float step = threshold * duration_per_letter;
float start = static_cast<float>(letter_index) * step;
float end = start + duration_per_letter;
// Interpolar progrés
if (global_progress < start) {
@@ -47,15 +47,14 @@ LogoScene::LogoScene(SDLManager& sdl, SceneContext& context)
: sdl_(sdl),
context_(context),
debris_manager_(std::make_unique<Effects::DebrisManager>(sdl.getRenderer()))
{
debris_manager_(std::make_unique<Effects::DebrisManager>(sdl.getRenderer())) {
std::cout << "SceneType Logo: Inicialitzant...\n";
// Consumir opciones (LOGO no processa opciones actualment)
auto option = context_.consumeOption();
(void)option; // Suprimir warning
so_reproduit_.fill(false); // Inicialitzar seguiment de sons
sound_played_.fill(false); // Inicialitzar seguiment de sons
initLetters();
}
@@ -91,7 +90,7 @@ void LogoScene::initLetters() {
"logo/letra_s.shp"};
// Pas 1: Carregar todas las formes i calcular amplades
float ancho_total = 0.0F;
float total_width = 0.0F;
for (const auto& file : archivos) {
auto shape = ShapeLoader::load(file);
@@ -111,66 +110,66 @@ void LogoScene::initLetters() {
}
}
float ancho_sin_escalar = max_x - min_x;
float width_unscaled = max_x - min_x;
// IMPORTANT: Escalar ancho i offset con ESCALA_FINAL
// IMPORTANT: Escalar ancho i offset con FINAL_SCALE
// per que las posicions finals coincideixin con la mida real de las lletres
float ancho = ancho_sin_escalar * ESCALA_FINAL;
float offset_centre = (shape->getCenter().x - min_x) * ESCALA_FINAL;
float width = width_unscaled * FINAL_SCALE;
float center_offset = (shape->getCenter().x - min_x) * FINAL_SCALE;
lletres_.push_back({shape,
letters_.push_back({shape,
{.x = 0.0F, .y = 0.0F}, // Posición es calcularà después
ancho,
offset_centre});
width,
center_offset});
ancho_total += ancho;
total_width += width;
}
// Pas 2: Añadir espaiat entre lletres
ancho_total += ESPAI_ENTRE_LLETRES * (lletres_.size() - 1);
total_width += LETTER_SPACING * (letters_.size() - 1);
// Pas 3: Calcular posición inicial (centrat horitzontal)
constexpr auto PANTALLA_ANCHO = static_cast<float>(Defaults::Game::WIDTH);
constexpr auto SCREEN_WIDTH = static_cast<float>(Defaults::Game::WIDTH);
constexpr auto PANTALLA_ALTO = static_cast<float>(Defaults::Game::HEIGHT);
float x_inicial = (PANTALLA_ANCHO - ancho_total) / 2.0F;
float x_inicial = (SCREEN_WIDTH - total_width) / 2.0F;
float y_centre = PANTALLA_ALTO / 2.0F;
// Pas 4: Assignar posicions a cada lletra
// Pas 4: Assignar posicions a cada letter
float x_actual = x_inicial;
for (auto& lletra : lletres_) {
for (auto& letter : letters_) {
// Posicionar el centro de la shape (shape_centre) en pantalla
// Usar offset_centre en lloc de ancho/2 perquè shape_centre
// Usar center_offset en lloc de ancho/2 perquè shape_centre
// pot no estar exactament al mig del bounding box
lletra.position.x = x_actual + lletra.offset_centre;
lletra.position.y = y_centre;
letter.position.x = x_actual + letter.center_offset;
letter.position.y = y_centre;
// Avançar para següent lletra
x_actual += lletra.ancho + ESPAI_ENTRE_LLETRES;
// Avançar para següent letter
x_actual += letter.width + LETTER_SPACING;
}
std::cout << "[LogoScene] " << lletres_.size()
<< " lletres carregades, ancho total: " << ancho_total << " px\n";
std::cout << "[LogoScene] " << letters_.size()
<< " lletres carregades, ancho total: " << total_width << " px\n";
}
void LogoScene::changeState(AnimationState nou_estat) {
estat_actual_ = nou_estat;
temps_estat_actual_ = 0.0F; // Reset time
current_state_ = nou_estat;
temps_current_state_ = 0.0F; // Reset time
// Inicialitzar state de explosión
if (nou_estat == AnimationState::EXPLOSION) {
lletra_explosio_index_ = 0;
temps_des_ultima_explosio_ = 0.0F;
letter_explosion_index_ = 0;
time_since_last_explosion_ = 0.0F;
// Generar ordre aleatori de explosions
ordre_explosio_.clear();
for (size_t i = 0; i < lletres_.size(); i++) {
ordre_explosio_.push_back(i);
explosion_order_.clear();
for (size_t i = 0; i < letters_.size(); i++) {
explosion_order_.push_back(i);
}
std::random_device rd;
std::mt19937 g(rd());
std::shuffle(ordre_explosio_.begin(), ordre_explosio_.end(), g);
std::shuffle(explosion_order_.begin(), explosion_order_.end(), g);
} else if (nou_estat == AnimationState::POST_EXPLOSION) {
Audio::get()->playMusic("title.ogg");
}
@@ -180,35 +179,35 @@ void LogoScene::changeState(AnimationState nou_estat) {
}
auto LogoScene::allLettersComplete() const -> bool {
// Cuando global_progress = 1.0, todas las lletres tenen letra_progress = 1.0
return temps_estat_actual_ >= DURACIO_ZOOM;
// Cuando global_progress = 1.0, todas las lletres tenen letter_progress = 1.0
return temps_current_state_ >= DURATION_ZOOM;
}
void LogoScene::updateExplosions(float delta_time) {
temps_des_ultima_explosio_ += delta_time;
time_since_last_explosion_ += delta_time;
// Comprovar si es el moment de explode la següent lletra
if (temps_des_ultima_explosio_ >= DELAY_ENTRE_EXPLOSIONS) {
if (lletra_explosio_index_ < lletres_.size()) {
// Explotar lletra actual (en ordre aleatori)
size_t index_actual = ordre_explosio_[lletra_explosio_index_];
const auto& lletra = lletres_[index_actual];
// Comprovar si es el moment de explode la següent letter
if (time_since_last_explosion_ >= DELAY_ENTRE_EXPLOSIONS) {
if (letter_explosion_index_ < letters_.size()) {
// Explotar letter actual (en ordre aleatori)
size_t index_actual = explosion_order_[letter_explosion_index_];
const auto& letter = letters_[index_actual];
debris_manager_->explode(
lletra.shape, // Forma a explode
lletra.position, // Posición
letter.shape, // Forma a explode
letter.position, // Posición
0.0F, // Angle (sin rotación)
ESCALA_FINAL, // Escala (lletres a scale final)
VELOCITAT_EXPLOSIO, // Velocidad base
FINAL_SCALE, // Escala (lletres a scale final)
SPEED_EXPLOSIO, // Velocidad base
1.0F, // Brightness màxim (per defecte)
{.x = 0.0F, .y = 0.0F} // Sin velocity (per defecte)
);
std::cout << "[LogoScene] Explota lletra " << lletra_explosio_index_ << "\n";
std::cout << "[LogoScene] Explota letter " << letter_explosion_index_ << "\n";
// Passar a la següent lletra
lletra_explosio_index_++;
temps_des_ultima_explosio_ = 0.0F;
// Passar a la següent letter
letter_explosion_index_++;
time_since_last_explosion_ = 0.0F;
} else {
// Todas las lletres han explotat, transición a POST_EXPLOSION
changeState(AnimationState::POST_EXPLOSION);
@@ -217,31 +216,31 @@ void LogoScene::updateExplosions(float delta_time) {
}
void LogoScene::update(float delta_time) {
temps_estat_actual_ += delta_time;
temps_current_state_ += delta_time;
switch (estat_actual_) {
switch (current_state_) {
case AnimationState::PRE_ANIMATION:
if (temps_estat_actual_ >= DURACIO_PRE) {
if (temps_current_state_ >= DURATION_PRE) {
changeState(AnimationState::ANIMATION);
}
break;
case AnimationState::ANIMATION: {
// Reproduir so per cada lletra cuando comença a aparèixer
float global_progress = std::min(temps_estat_actual_ / DURACIO_ZOOM, 1.0F);
// Reproduir so per cada letter cuando comença a aparèixer
float global_progress = std::min(temps_current_state_ / DURATION_ZOOM, 1.0F);
for (size_t i = 0; i < lletres_.size() && i < so_reproduit_.size(); i++) {
if (!so_reproduit_[i]) {
float letra_progress = computeLetterProgress(
for (size_t i = 0; i < letters_.size() && i < sound_played_.size(); i++) {
if (!sound_played_[i]) {
float letter_progress = computeLetterProgress(
i,
lletres_.size(),
letters_.size(),
global_progress,
THRESHOLD_LETRA);
LETTER_THRESHOLD);
// Reproduir so cuando la lletra comença a aparèixer (progress > 0)
if (letra_progress > 0.0F) {
// Reproduir so cuando la letter comença a aparèixer (progress > 0)
if (letter_progress > 0.0F) {
Audio::get()->playSound(Defaults::Sound::LOGO, Audio::Group::GAME);
so_reproduit_[i] = true;
sound_played_[i] = true;
}
}
}
@@ -253,7 +252,7 @@ void LogoScene::update(float delta_time) {
}
case AnimationState::POST_ANIMATION:
if (temps_estat_actual_ >= DURACIO_POST_ANIMATION) {
if (temps_current_state_ >= DURATION_POST_ANIMATION) {
changeState(AnimationState::EXPLOSION);
}
break;
@@ -263,7 +262,7 @@ void LogoScene::update(float delta_time) {
break;
case AnimationState::POST_EXPLOSION:
if (temps_estat_actual_ >= DURACIO_POST_EXPLOSION) {
if (temps_current_state_ >= DURATION_POST_EXPLOSION) {
// Transición a pantalla de título
context_.setNextScene(SceneType::TITLE);
}
@@ -283,47 +282,47 @@ void LogoScene::draw() {
// Director ha hecho el clear; aquí solo pintamos lo de la escena.
// PRE_ANIMATION: Solo pantalla negra (no se pinta nada).
if (estat_actual_ == AnimationState::PRE_ANIMATION) {
if (current_state_ == AnimationState::PRE_ANIMATION) {
return;
}
// ANIMATION o POST_ANIMATION: Dibuixar lletres con animación
if (estat_actual_ == AnimationState::ANIMATION ||
estat_actual_ == AnimationState::POST_ANIMATION) {
if (current_state_ == AnimationState::ANIMATION ||
current_state_ == AnimationState::POST_ANIMATION) {
float global_progress =
(estat_actual_ == AnimationState::ANIMATION)
? std::min(temps_estat_actual_ / DURACIO_ZOOM, 1.0F)
(current_state_ == AnimationState::ANIMATION)
? std::min(temps_current_state_ / DURATION_ZOOM, 1.0F)
: 1.0F; // POST: mantenir al 100%
const Vec2 ORIGEN_ZOOM = {.x = ORIGEN_ZOOM_X, .y = ORIGEN_ZOOM_Y};
const Vec2 ZOOM_ORIGIN = {.x = ZOOM_ORIGIN_X, .y = ZOOM_ORIGIN_Y};
for (size_t i = 0; i < lletres_.size(); i++) {
const auto& lletra = lletres_[i];
for (size_t i = 0; i < letters_.size(); i++) {
const auto& letter = letters_[i];
float letra_progress = computeLetterProgress(
float letter_progress = computeLetterProgress(
i,
lletres_.size(),
letters_.size(),
global_progress,
THRESHOLD_LETRA);
LETTER_THRESHOLD);
if (letra_progress <= 0.0F) {
if (letter_progress <= 0.0F) {
continue;
}
Vec2 pos_actual;
pos_actual.x =
ORIGEN_ZOOM.x + ((lletra.position.x - ORIGEN_ZOOM.x) * letra_progress);
ZOOM_ORIGIN.x + ((letter.position.x - ZOOM_ORIGIN.x) * letter_progress);
pos_actual.y =
ORIGEN_ZOOM.y + ((lletra.position.y - ORIGEN_ZOOM.y) * letra_progress);
ZOOM_ORIGIN.y + ((letter.position.y - ZOOM_ORIGIN.y) * letter_progress);
float t = letra_progress;
float t = letter_progress;
float ease_factor = 1.0F - ((1.0F - t) * (1.0F - t));
float current_scale =
ESCALA_INICIAL + ((ESCALA_FINAL - ESCALA_INICIAL) * ease_factor);
INITIAL_SCALE + ((FINAL_SCALE - INITIAL_SCALE) * ease_factor);
Rendering::renderShape(
sdl_.getRenderer(),
lletra.shape,
letter.shape,
pos_actual,
0.0F,
current_scale,
@@ -332,24 +331,24 @@ void LogoScene::draw() {
}
// EXPLOSION: Dibuixar solo lletres que aún no han explotat
if (estat_actual_ == AnimationState::EXPLOSION) {
if (current_state_ == AnimationState::EXPLOSION) {
// Crear conjunt de lletres ya explotades
std::set<size_t> explotades;
for (size_t i = 0; i < lletra_explosio_index_; i++) {
explotades.insert(ordre_explosio_[i]);
for (size_t i = 0; i < letter_explosion_index_; i++) {
explotades.insert(explosion_order_[i]);
}
// Dibuixar solo lletres que NO han explotat
for (size_t i = 0; i < lletres_.size(); i++) {
for (size_t i = 0; i < letters_.size(); i++) {
if (!explotades.contains(i)) {
const auto& lletra = lletres_[i];
const auto& letter = letters_[i];
Rendering::renderShape(
sdl_.getRenderer(),
lletra.shape,
lletra.position,
letter.shape,
letter.position,
0.0F,
ESCALA_FINAL,
FINAL_SCALE,
1.0F);
}
}
+22 -22
View File
@@ -42,46 +42,46 @@ class LogoScene final : public Scene {
SDLManager& sdl_;
SceneManager::SceneContext& context_;
AnimationState estat_actual_{AnimationState::PRE_ANIMATION}; // Estat actual de la màquina
AnimationState current_state_{AnimationState::PRE_ANIMATION}; // Estat actual de la màquina
float
temps_estat_actual_{0.0F}; // Temps en l'state actual (reset en cada transición)
temps_current_state_{0.0F}; // Temps en l'state actual (reset en cada transición)
// Gestor de fragments de explosions
std::unique_ptr<Effects::DebrisManager> debris_manager_;
// Seguiment de explosions seqüencials
size_t lletra_explosio_index_{0}; // Índex de la següent lletra a explode
float temps_des_ultima_explosio_{0.0F}; // Temps desde l'última explosión
std::vector<size_t> ordre_explosio_; // Ordre aleatori de índexs de lletres
size_t letter_explosion_index_{0}; // Índex de la següent letter a explode
float time_since_last_explosion_{0.0F}; // Temps desde l'última explosión
std::vector<size_t> explosion_order_; // Ordre aleatori de índexs de lletres
// Estructura para cada lletra del logo
struct LetraLogo {
// Estructura para cada letter del logo
struct LogoLetter {
std::shared_ptr<Graphics::Shape> shape;
Vec2 position; // Posición final en pantalla
float ancho; // Ancho del bounding box
float offset_centre; // Distancia de min_x a shape_centre.x
float width; // Ancho del bounding box
float center_offset; // Distancia de min_x a shape_centre.x
};
std::vector<LetraLogo> lletres_; // 9 lletres: J-A-I-L-G-A-M-E-S
std::vector<LogoLetter> letters_; // 9 lletres: J-A-I-L-G-A-M-E-S
// Seguiment de sons de lletres (evitar reproduccions repetides)
std::array<bool, 9> so_reproduit_; // Track si cada lletra ya ha reproduit el so
std::array<bool, 9> sound_played_; // Track si cada letter ya ha reproduit el so
// Constants de animación
static constexpr float DURACIO_PRE = 1.5F; // Duració PRE_ANIMATION (pantalla negra)
static constexpr float DURACIO_ZOOM = 4.0F; // Duració del zoom (segons)
static constexpr float DURACIO_POST_ANIMATION = 3.0F; // Duració POST_ANIMATION (logo complet)
static constexpr float DURACIO_POST_EXPLOSION = 3.0F; // Duració POST_EXPLOSION (espera final)
static constexpr float DURATION_PRE = 1.5F; // Duració PRE_ANIMATION (pantalla negra)
static constexpr float DURATION_ZOOM = 4.0F; // Duració del zoom (segons)
static constexpr float DURATION_POST_ANIMATION = 3.0F; // Duració POST_ANIMATION (logo complet)
static constexpr float DURATION_POST_EXPLOSION = 3.0F; // Duració POST_EXPLOSION (espera final)
static constexpr float DELAY_ENTRE_EXPLOSIONS = 0.1F; // Temps entre explosions de lletres
static constexpr float VELOCITAT_EXPLOSIO = 240.0F; // Velocidad base fragments (px/s)
static constexpr float ESCALA_INICIAL = 0.1F; // Escala inicial (10%)
static constexpr float ESCALA_FINAL = 0.8F; // Escala final (80%)
static constexpr float ESPAI_ENTRE_LLETRES = 10.0F; // Espaiat entre lletres
static constexpr float SPEED_EXPLOSIO = 240.0F; // Velocidad base fragments (px/s)
static constexpr float INITIAL_SCALE = 0.1F; // Escala inicial (10%)
static constexpr float FINAL_SCALE = 0.8F; // Escala final (80%)
static constexpr float LETTER_SPACING = 10.0F; // Espaiat entre lletres
// Constants de animación seqüencial
static constexpr float THRESHOLD_LETRA = 0.6F; // Umbral per activar següent lletra (0.0-1.0)
static constexpr float ORIGEN_ZOOM_X = Defaults::Game::WIDTH * 0.5F; // Vec2 inicial X del zoom
static constexpr float ORIGEN_ZOOM_Y = Defaults::Game::HEIGHT * 0.4F; // Vec2 inicial Y del zoom
static constexpr float LETTER_THRESHOLD = 0.6F; // Umbral per activar següent letter (0.0-1.0)
static constexpr float ZOOM_ORIGIN_X = Defaults::Game::WIDTH * 0.5F; // Vec2 inicial X del zoom
static constexpr float ZOOM_ORIGIN_Y = Defaults::Game::HEIGHT * 0.4F; // Vec2 inicial Y del zoom
// Métodos privats
void initLetters();
File diff suppressed because it is too large Load Diff
+86 -77
View File
@@ -1,6 +1,12 @@
// title_scene.hpp - Pantalla de título del juego
// Muestra message "PRESS BUTTON TO PLAY" y copyright
// title_scene.hpp - Escena de títol en 3D real
// © 2026 JailDesigner
//
// Clon de `TitleScene` (2D) que substitueix `Graphics::Starfield` per
// `Graphics::Starfield` i `Title::ShipAnimator` per `Title::ShipAnimator`,
// afegint una `Graphics::Camera3D` que projecta l'escena en perspectiva real.
// Tot el bloc d'overlay 2D (logo "ORNI ATTACK!", "PRESS START TO PLAY", peu
// "JAILGAMES + copyright") es manté idèntic.
//
#pragma once
@@ -11,125 +17,128 @@
#include <memory>
#include <vector>
#include "core/graphics/camera3d.hpp"
#include "core/graphics/shape.hpp"
#include "core/graphics/starfield.hpp"
#include "core/graphics/vector_text.hpp"
#include "core/input/input_types.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/system/game_config.hpp"
#include "core/system/scene.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/game_config.hpp"
#include "core/types.hpp"
#include "game/title/ship_animator.hpp"
// Botones para INICIAR PARTIDA desde MAIN (solo START)
static constexpr std::array<InputAction, 1> START_GAME_BUTTONS = {
InputAction::START};
class TitleScene final : public Scene {
public:
explicit TitleScene(SDLManager& sdl, SceneManager::SceneContext& context);
~TitleScene() override; // Destructor per aturar música
~TitleScene() override;
// Scene interface
void handleEvent(const SDL_Event& event) override;
void update(float delta_time) override;
void draw() override;
[[nodiscard]] auto isFinished() const -> bool override;
private:
// Màquina de estats per la pantalla de título
enum class TitleState : std::uint8_t {
STARFIELD_FADE_IN, // Fade-in del starfield (3.0s)
STARFIELD, // Pantalla con camp de estrelles (4.0s)
MAIN, // Pantalla de título con text (indefinit, hasta START)
PLAYER_JOIN_PHASE, // Fase de unió de jugadors: fade-out música + text parpellejant (2.5s)
BLACK_SCREEN // Pantalla negra de transición (2.0s)
STARFIELD_FADE_IN,
STARFIELD,
MAIN,
PLAYER_JOIN_PHASE,
BLACK_SCREEN,
};
// Estructura per emmagatzemar informació de cada lletra del título
struct LetraLogo {
std::shared_ptr<Graphics::Shape> shape; // Forma vectorial de la lletra
Vec2 position; // Posición en pantalla
float ancho; // Amplada scaled
float altura; // Altura scaled
float offset_centre; // Offset del centro per posicionament
struct LogoLetter {
std::shared_ptr<Graphics::Shape> shape;
Vec2 position;
float width;
float height;
float center_offset;
};
SDLManager& sdl_;
SceneManager::SceneContext& context_;
GameConfig::MatchConfig match_config_; // Configuración de jugadors active
Graphics::VectorText text_; // Sistema de text vectorial
std::unique_ptr<Graphics::Starfield> starfield_; // Camp de estrelles de fons
std::unique_ptr<Title::ShipAnimator> ship_animator_; // Naves 3D flotantes
TitleState estat_actual_{TitleState::STARFIELD_FADE_IN}; // Estat actual de la màquina
float temps_acumulat_{0.0F}; // Temps acumulat per l'state INIT
GameConfig::MatchConfig match_config_;
Graphics::VectorText text_;
std::unique_ptr<Graphics::Camera3D> camera_;
std::unique_ptr<Graphics::Starfield> starfield_;
std::unique_ptr<Title::ShipAnimator> ship_animator_;
// Lletres del título "ORNI ATTACK!"
std::vector<LetraLogo> lletres_orni_; // Lletres de "ORNI" (línia 1)
std::vector<LetraLogo> lletres_attack_; // Lletres de "ATTACK!" (línia 2)
float y_attack_dinamica_; // Posición Y calculada dinàmicament per "ATTACK!"
// Destell que tapa el "pop" final de cada nau quan arriba al VP.
// Pool fix de 2 (una per nau). Anima escala 0→max→0.
struct Flash {
Vec2 position{};
float timer{0.0F};
bool active{false};
};
std::array<Flash, 2> flashes_{};
std::shared_ptr<Graphics::Shape> flash_shape_;
// Logo "JAILGAMES" pequeño sobre el copyright (esquinas inferiores del título).
std::vector<LetraLogo> lletres_jailgames_;
void triggerFlash(Vec2 pos);
void updateFlashes(float delta_time);
void drawFlashes();
TitleState current_state_{TitleState::STARFIELD_FADE_IN};
float temps_acumulat_{0.0F};
// Estat de animación del logo
float temps_animacio_{0.0F}; // Temps acumulat per animación orbital
std::vector<Vec2> posicions_originals_orni_; // Posicions originals de "ORNI"
std::vector<Vec2> posicions_originals_attack_; // Posicions originals de "ATTACK!"
std::vector<LogoLetter> letters_orni_;
std::vector<LogoLetter> letters_attack_;
float dynamic_attack_y_{0.0F};
// Estat de arrencada de l'animación
float temps_estat_main_{0.0F}; // Temps acumulat en state MAIN
bool animacio_activa_{false}; // Flag: true cuando animación está activa
float factor_lerp_{0.0F}; // Factor de lerp actual (0.0 → 1.0)
std::vector<LogoLetter> letters_jailgames_;
// Constants
static constexpr float BRIGHTNESS_STARFIELD = 1.2F; // Brightness del starfield (>1.0 = més brillant)
static constexpr float DURACIO_FADE_IN = 3.0F; // Duració del fade-in del starfield (1.5 segons)
static constexpr float DURACIO_INIT = 4.0F; // Duració de l'state INIT (2 segons)
static constexpr float DURACIO_TRANSITION = 2.5F; // Duració de la transición (1.5 segons)
static constexpr float ESPAI_ENTRE_LLETRES = 10.0F; // Espai entre lletres
static constexpr float BLINK_FREQUENCY = 3.0F; // Freqüència de parpelleig (3 Hz)
static constexpr float DURACIO_BLACK_SCREEN = 2.0F; // Duració pantalla negra (2 segons)
static constexpr int MUSIC_FADE = 1500; // Duracio del fade de la musica del titol al començar a jugar
float animation_time_{0.0F};
std::vector<Vec2> original_positions_orni_;
std::vector<Vec2> original_positions_attack_;
// Constants de animación del logo
static constexpr float ORBIT_AMPLITUDE_X = 4.0F; // Amplitud oscil·lació horitzontal (píxels)
static constexpr float ORBIT_AMPLITUDE_Y = 3.0F; // Amplitud oscil·lació vertical (píxels)
static constexpr float ORBIT_FREQUENCY_X = 0.8F; // Velocidad oscil·lació horitzontal (Hz)
static constexpr float ORBIT_FREQUENCY_Y = 1.2F; // Velocidad oscil·lació vertical (Hz)
static constexpr float ORBIT_PHASE_OFFSET = 1.57F; // Desfasament entre X i Y (90° per circular)
float state_time_main_{0.0F};
bool animation_active_{false};
float lerp_factor_{0.0F};
// Constants de ombra del logo
static constexpr float SHADOW_DELAY = 0.5F; // Retard temporal de l'ombra (segons)
static constexpr float SHADOW_BRIGHTNESS = 0.4F; // Multiplicador de brightness de l'ombra (0.0-1.0)
static constexpr float SHADOW_OFFSET_X = 2.0F; // Offset espacial X fix (píxels)
static constexpr float SHADOW_OFFSET_Y = 2.0F; // Offset espacial Y fix (píxels)
// Progresos de la intro coreografiada al state MAIN.
float intro_logo_progress_{0.0F};
float intro_jailgames_progress_{0.0F};
float intro_copyright_progress_{0.0F};
bool press_start_visible_{false};
bool ships_intro_launched_{false};
// Temporització de l'arrencada de l'animación
static constexpr float DELAY_INICI_ANIMACIO = 10.0F; // 10s estàtic antes de animar
static constexpr float DURACIO_LERP = 2.0F; // 2s per arribar a amplitud completa
static constexpr float BRIGHTNESS_STARFIELD = 1.2F;
static constexpr float DURATION_FADE_IN = 3.0F;
static constexpr float DURATION_INIT = 4.0F;
static constexpr float DURATION_TRANSITION = 2.5F;
static constexpr float LETTER_SPACING = 10.0F;
static constexpr float BLINK_FREQUENCY = 3.0F;
static constexpr float DURATION_BLACK_SCREEN = 2.0F;
static constexpr int MUSIC_FADE = 1500;
// Métodos privats
void updateLogoAnimation(float delta_time); // Actualitza l'animación orbital del logo
// Estático: solo consulta Input (singleton), no estado de la escena.
static constexpr float ORBIT_AMPLITUDE_X = 4.0F;
static constexpr float ORBIT_AMPLITUDE_Y = 3.0F;
static constexpr float ORBIT_FREQUENCY_X = 0.8F;
static constexpr float ORBIT_FREQUENCY_Y = 1.2F;
static constexpr float ORBIT_PHASE_OFFSET = 1.57F;
static constexpr float SHADOW_DELAY = 0.5F;
static constexpr float SHADOW_BRIGHTNESS = 0.4F;
static constexpr float SHADOW_OFFSET_X = 2.0F;
static constexpr float SHADOW_OFFSET_Y = 2.0F;
static constexpr float DURATION_LERP = 2.0F;
// Càmera 3D: FOV vertical en radians.
static constexpr float CAMERA_FOV_Y_RAD = 1.0472F; // 60°
void updateLogoAnimation(float delta_time);
static auto checkSkipButtonPressed() -> bool;
auto checkStartGameButtonPressed() -> bool;
void initTitle(); // Carrega i posiciona las lletres del título
void inicialitzarJailgames(); // Carrega i posiciona el logo JAILGAMES pequeño
void dibuixarPeuTitol(float spacing) const; // Logo JAILGAMES + línia de copyright
void initTitle();
void inicialitzarJailgames();
void dibuixarPeuTitol(float spacing) const;
// Sub-pasos de update() (extreure cada state per reduir complexitat).
void updateStarfieldFadeInState(float delta_time);
void updateStarfieldState(float delta_time);
void updateMainState(float delta_time);
void updatePlayerJoinPhaseState(float delta_time);
void updateBlackScreenState(float delta_time);
// Handlers de input globals (independents de l'state actual).
void handleSkipInput();
void handleStartInput();
// Helper compartit: dispara l'animación de salida per las naves del player que
// acaba de fer un join "en aquest frame" (jugadorX_actiu == true && !prev).
void triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active,
const char* log_prefix);
void triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix);
};
-547
View File
@@ -1,547 +0,0 @@
// title_scene_3d.cpp - Implementació de l'escena de títol 3D real
// © 2026 JailDesigner
#include "title_scene_3d.hpp"
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <iostream>
#include <numbers>
#include <string>
#include "core/audio/audio.hpp"
#include "core/defaults.hpp"
#include "core/graphics/shape_loader.hpp"
#include "core/input/input.hpp"
#include "core/rendering/shape_renderer.hpp"
#include "core/system/scene_context.hpp"
#include "project.h"
using SceneManager::SceneContext;
using SceneType = SceneContext::SceneType;
using Option = SceneContext::Option;
namespace {
// Botons per iniciar partida des de MAIN (només START). Duplicat del que viu
// al `title_scene.hpp` perquè no volem un acoblament entre la versió 2D i la
// 3D mentre conviuen.
constexpr std::array<InputAction, 1> START_GAME_BUTTONS_3D = {InputAction::START};
} // namespace
TitleScene3D::TitleScene3D(SDLManager& sdl, SceneContext& context)
: sdl_(sdl),
context_(context),
text_(sdl.getRenderer()) {
std::cout << "SceneType Titol3D: Inicialitzant...\n";
match_config_.jugador1_actiu = false;
match_config_.jugador2_actiu = false;
match_config_.mode = GameConfig::Mode::NORMAL;
auto option = context_.consumeOption();
if (option == Option::JUMP_TO_TITLE_MAIN) {
std::cout << "SceneType Titol3D: Opció JUMP_TO_TITLE_MAIN activada\n";
estat_actual_ = TitleState::MAIN;
temps_estat_main_ = 0.0F;
}
// Càmera 3D: posicionada a l'origen, mirant cap a +Z, amb Y cap amunt.
camera_ = std::make_unique<Graphics::Camera3D>(
Vec3{.x = 0.0F, .y = 0.0F, .z = 0.0F},
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F},
Vec3{.x = 0.0F, .y = 1.0F, .z = 0.0F},
CAMERA_FOV_Y_RAD,
static_cast<float>(Defaults::Game::WIDTH),
static_cast<float>(Defaults::Game::HEIGHT));
starfield_ = std::make_unique<Graphics::Starfield3D>(
sdl_.getRenderer(),
camera_.get(),
200);
if (estat_actual_ == TitleState::MAIN) {
starfield_->setBrightness(BRIGHTNESS_STARFIELD);
} else {
starfield_->setBrightness(0.0F);
}
ship_animator_ = std::make_unique<Title::ShipAnimator3D>(sdl_.getRenderer(), camera_.get());
ship_animator_->init();
if (estat_actual_ == TitleState::MAIN) {
ship_animator_->setVisible(true);
ship_animator_->startEntryAnimation();
} else {
ship_animator_->setVisible(false);
}
initTitle();
inicialitzarJailgames();
if (Audio::getMusicState() != Audio::MusicState::PLAYING) {
Audio::get()->playMusic("title.ogg");
}
}
TitleScene3D::~TitleScene3D() {
Audio::get()->stopMusic();
}
void TitleScene3D::initTitle() {
using namespace Graphics;
const std::vector<std::string> FITXERS_ORNI = {
"title/letra_o.shp",
"title/letra_r.shp",
"title/letra_n.shp",
"title/letra_i.shp"};
float ancho_total_orni = 0.0F;
for (const auto& file : FITXERS_ORNI) {
auto shape = ShapeLoader::load(file);
if (!shape || !shape->isValid()) {
std::cerr << "[TitleScene3D] Error carregant " << file << '\n';
continue;
}
float min_x = FLT_MAX;
float max_x = -FLT_MAX;
float min_y = FLT_MAX;
float max_y = -FLT_MAX;
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_x = std::min(min_x, point.x);
max_x = std::max(max_x, point.x);
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
const float ANCHO = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
const float ALTURA = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE;
const float OFFSET_CENTRE = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
lletres_orni_.push_back({shape, {.x = 0.0F, .y = 0.0F}, ANCHO, ALTURA, OFFSET_CENTRE});
ancho_total_orni += ANCHO;
}
ancho_total_orni += ESPAI_ENTRE_LLETRES * static_cast<float>(lletres_orni_.size() - 1);
float x_actual = (Defaults::Game::WIDTH - ancho_total_orni) / 2.0F;
for (auto& lletra : lletres_orni_) {
lletra.position.x = x_actual + lletra.offset_centre;
lletra.position.y = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS;
x_actual += lletra.ancho + ESPAI_ENTRE_LLETRES;
}
const float ALTURA_ORNI = lletres_orni_.empty() ? 50.0F : lletres_orni_[0].altura;
const float Y_ORNI = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS;
const float SEPARACION = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_LINE_SPACING;
y_attack_dinamica_ = Y_ORNI + ALTURA_ORNI + SEPARACION;
const std::vector<std::string> FITXERS_ATTACK = {
"title/letra_a.shp",
"title/letra_t.shp",
"title/letra_t.shp",
"title/letra_a.shp",
"title/letra_c.shp",
"title/letra_k.shp",
"title/letra_exclamacion.shp"};
float ancho_total_attack = 0.0F;
for (const auto& file : FITXERS_ATTACK) {
auto shape = ShapeLoader::load(file);
if (!shape || !shape->isValid()) {
std::cerr << "[TitleScene3D] Error carregant " << file << '\n';
continue;
}
float min_x = FLT_MAX;
float max_x = -FLT_MAX;
float min_y = FLT_MAX;
float max_y = -FLT_MAX;
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_x = std::min(min_x, point.x);
max_x = std::max(max_x, point.x);
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
const float ANCHO = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
const float ALTURA = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE;
const float OFFSET_CENTRE = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
lletres_attack_.push_back({shape, {.x = 0.0F, .y = 0.0F}, ANCHO, ALTURA, OFFSET_CENTRE});
ancho_total_attack += ANCHO;
}
ancho_total_attack += ESPAI_ENTRE_LLETRES * static_cast<float>(lletres_attack_.size() - 1);
x_actual = (Defaults::Game::WIDTH - ancho_total_attack) / 2.0F;
for (auto& lletra : lletres_attack_) {
lletra.position.x = x_actual + lletra.offset_centre;
lletra.position.y = y_attack_dinamica_;
x_actual += lletra.ancho + ESPAI_ENTRE_LLETRES;
}
posicions_originals_orni_.clear();
for (const auto& lletra : lletres_orni_) {
posicions_originals_orni_.push_back(lletra.position);
}
posicions_originals_attack_.clear();
for (const auto& lletra : lletres_attack_) {
posicions_originals_attack_.push_back(lletra.position);
}
}
void TitleScene3D::inicialitzarJailgames() {
using namespace Graphics;
const std::vector<std::string> FITXERS = {
"logo/letra_j.shp",
"logo/letra_a.shp",
"logo/letra_i.shp",
"logo/letra_l.shp",
"logo/letra_g.shp",
"logo/letra_a.shp",
"logo/letra_m.shp",
"logo/letra_e.shp",
"logo/letra_s.shp"};
constexpr float SCALE = Defaults::Title::Layout::JAILGAMES_SCALE;
float ancho_total = 0.0F;
float altura_max = 0.0F;
for (const auto& file : FITXERS) {
auto shape = ShapeLoader::load(file);
if (!shape || !shape->isValid()) {
std::cerr << "[TitleScene3D] Error carregant " << file << '\n';
continue;
}
float min_x = FLT_MAX;
float max_x = -FLT_MAX;
float min_y = FLT_MAX;
float max_y = -FLT_MAX;
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_x = std::min(min_x, point.x);
max_x = std::max(max_x, point.x);
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
const float ANCHO = (max_x - min_x) * SCALE;
const float ALTURA = (max_y - min_y) * SCALE;
const float OFFSET_CENTRE = (shape->getCenter().x - min_x) * SCALE;
lletres_jailgames_.push_back({shape, {.x = 0.0F, .y = 0.0F}, ANCHO, ALTURA, OFFSET_CENTRE});
ancho_total += ANCHO;
altura_max = std::max(altura_max, ALTURA);
}
constexpr float ESPAI_JAILGAMES = ESPAI_ENTRE_LLETRES * SCALE;
if (!lletres_jailgames_.empty()) {
ancho_total += ESPAI_JAILGAMES * static_cast<float>(lletres_jailgames_.size() - 1);
}
const float Y_COPY = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS;
const float GAP = Defaults::Game::HEIGHT * Defaults::Title::Layout::JAILGAMES_COPYRIGHT_GAP;
const float Y_CENTRE = Y_COPY - GAP - (altura_max / 2.0F);
const float X_INICIAL = (Defaults::Game::WIDTH - ancho_total) / 2.0F;
float x_actual = X_INICIAL;
for (auto& lletra : lletres_jailgames_) {
lletra.position.x = x_actual + lletra.offset_centre;
lletra.position.y = Y_CENTRE;
x_actual += lletra.ancho + ESPAI_JAILGAMES;
}
}
void TitleScene3D::dibuixarPeuTitol(float spacing) const {
for (const auto& lletra : lletres_jailgames_) {
Rendering::renderShape(sdl_.getRenderer(), lletra.shape, lletra.position, 0.0F, Defaults::Title::Layout::JAILGAMES_SCALE, 1.0F);
}
std::string copyright = Project::COPYRIGHT;
for (char& c : copyright) {
if (c >= 'a' && c <= 'z') {
c = static_cast<char>(c - 32);
}
}
const float Y_COPY = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS;
const float CENTRE_X = Defaults::Game::WIDTH / 2.0F;
text_.renderCentered(copyright, {.x = CENTRE_X, .y = Y_COPY}, Defaults::Title::Layout::COPYRIGHT_SCALE, spacing);
}
auto TitleScene3D::isFinished() const -> bool {
// Aquesta escena és la destinació d'un setNextScene(TITLE) quan ORNI_TITLE_3D
// està activat; mentre el context continue marcant TITLE com a destí actual,
// l'escena resta viva. També accepta TITLE_3D explícit.
const SceneType NEXT = context_.nextScene();
return NEXT != SceneType::TITLE && NEXT != SceneType::TITLE_3D;
}
void TitleScene3D::update(float delta_time) {
if (starfield_) {
starfield_->update(delta_time);
}
if (ship_animator_ &&
(estat_actual_ == TitleState::STARFIELD_FADE_IN ||
estat_actual_ == TitleState::STARFIELD ||
estat_actual_ == TitleState::MAIN ||
estat_actual_ == TitleState::PLAYER_JOIN_PHASE)) {
ship_animator_->update(delta_time);
}
switch (estat_actual_) {
case TitleState::STARFIELD_FADE_IN:
updateStarfieldFadeInState(delta_time);
break;
case TitleState::STARFIELD:
updateStarfieldState(delta_time);
break;
case TitleState::MAIN:
updateMainState(delta_time);
break;
case TitleState::PLAYER_JOIN_PHASE:
updatePlayerJoinPhaseState(delta_time);
break;
case TitleState::BLACK_SCREEN:
updateBlackScreenState(delta_time);
break;
}
handleSkipInput();
handleStartInput();
}
void TitleScene3D::updateStarfieldFadeInState(float delta_time) {
temps_acumulat_ += delta_time;
const float PROGRESS = std::min(1.0F, temps_acumulat_ / DURACIO_FADE_IN);
starfield_->setBrightness(PROGRESS * BRIGHTNESS_STARFIELD);
if (temps_acumulat_ >= DURACIO_FADE_IN) {
estat_actual_ = TitleState::STARFIELD;
temps_acumulat_ = 0.0F;
starfield_->setBrightness(BRIGHTNESS_STARFIELD);
}
}
void TitleScene3D::updateStarfieldState(float delta_time) {
temps_acumulat_ += delta_time;
if (temps_acumulat_ >= DURACIO_INIT) {
estat_actual_ = TitleState::MAIN;
temps_estat_main_ = 0.0F;
animacio_activa_ = false;
factor_lerp_ = 0.0F;
}
}
void TitleScene3D::updateMainState(float delta_time) {
temps_estat_main_ += delta_time;
if (temps_estat_main_ >= Defaults::Title::Ships::ENTRANCE_DELAY &&
ship_animator_ && !ship_animator_->isVisible()) {
ship_animator_->setVisible(true);
ship_animator_->startEntryAnimation();
}
if (temps_estat_main_ < DELAY_INICI_ANIMACIO) {
factor_lerp_ = 0.0F;
animacio_activa_ = false;
} else if (temps_estat_main_ < DELAY_INICI_ANIMACIO + DURACIO_LERP) {
const float TEMPS_LERP = temps_estat_main_ - DELAY_INICI_ANIMACIO;
factor_lerp_ = TEMPS_LERP / DURACIO_LERP;
animacio_activa_ = true;
} else {
factor_lerp_ = 1.0F;
animacio_activa_ = true;
}
updateLogoAnimation(delta_time);
}
void TitleScene3D::updatePlayerJoinPhaseState(float delta_time) {
temps_acumulat_ += delta_time;
updateLogoAnimation(delta_time);
const bool P1_ABANS = match_config_.jugador1_actiu;
const bool P2_ABANS = match_config_.jugador2_actiu;
if (checkStartGameButtonPressed()) {
context_.setMatchConfig(match_config_);
triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "late join - ");
Audio::get()->playSound(Defaults::Sound::START, Audio::Group::GAME);
temps_acumulat_ = 0.0F;
}
if (temps_acumulat_ >= DURACIO_TRANSITION) {
estat_actual_ = TitleState::BLACK_SCREEN;
temps_acumulat_ = 0.0F;
}
}
void TitleScene3D::updateBlackScreenState(float delta_time) {
temps_acumulat_ += delta_time;
if (temps_acumulat_ >= DURACIO_BLACK_SCREEN) {
context_.setNextScene(SceneType::GAME);
}
}
void TitleScene3D::handleSkipInput() {
if (estat_actual_ != TitleState::STARFIELD_FADE_IN && estat_actual_ != TitleState::STARFIELD) {
return;
}
if (!checkSkipButtonPressed()) {
return;
}
estat_actual_ = TitleState::MAIN;
starfield_->setBrightness(BRIGHTNESS_STARFIELD);
temps_estat_main_ = 0.0F;
}
void TitleScene3D::handleStartInput() {
if (estat_actual_ != TitleState::MAIN) {
return;
}
const bool P1_ABANS = match_config_.jugador1_actiu;
const bool P2_ABANS = match_config_.jugador2_actiu;
if (!checkStartGameButtonPressed()) {
return;
}
if (ship_animator_ && !ship_animator_->isVisible()) {
ship_animator_->setVisible(true);
ship_animator_->skipToFloatingState();
}
context_.setMatchConfig(match_config_);
estat_actual_ = TitleState::PLAYER_JOIN_PHASE;
temps_acumulat_ = 0.0F;
triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "");
Audio::get()->fadeOutMusic(MUSIC_FADE);
Audio::get()->playSound(Defaults::Sound::START, Audio::Group::GAME);
}
void TitleScene3D::triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix) {
if (ship_animator_ == nullptr) {
return;
}
if (match_config_.jugador1_actiu && !p1_was_active) {
ship_animator_->triggerExitAnimationForPlayer(1);
std::cout << "[TitleScene3D] P1 " << log_prefix << "ship exiting\n";
}
if (match_config_.jugador2_actiu && !p2_was_active) {
ship_animator_->triggerExitAnimationForPlayer(2);
std::cout << "[TitleScene3D] P2 " << log_prefix << "ship exiting\n";
}
}
void TitleScene3D::updateLogoAnimation(float delta_time) {
if (!animacio_activa_) {
return;
}
temps_animacio_ += delta_time * factor_lerp_;
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float OFFSET_X = ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * temps_animacio_);
const float OFFSET_Y = ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * temps_animacio_) + ORBIT_PHASE_OFFSET);
for (std::size_t i = 0; i < lletres_orni_.size(); ++i) {
lletres_orni_[i].position.x = posicions_originals_orni_[i].x + std::round(OFFSET_X);
lletres_orni_[i].position.y = posicions_originals_orni_[i].y + std::round(OFFSET_Y);
}
for (std::size_t i = 0; i < lletres_attack_.size(); ++i) {
lletres_attack_[i].position.x = posicions_originals_attack_[i].x + std::round(OFFSET_X);
lletres_attack_[i].position.y = posicions_originals_attack_[i].y + std::round(OFFSET_Y);
}
}
void TitleScene3D::draw() {
if (starfield_ && estat_actual_ != TitleState::BLACK_SCREEN) {
starfield_->draw();
}
if (ship_animator_ &&
(estat_actual_ == TitleState::STARFIELD_FADE_IN ||
estat_actual_ == TitleState::STARFIELD ||
estat_actual_ == TitleState::MAIN ||
estat_actual_ == TitleState::PLAYER_JOIN_PHASE)) {
ship_animator_->draw();
}
if (estat_actual_ == TitleState::STARFIELD_FADE_IN || estat_actual_ == TitleState::STARFIELD) {
return;
}
if (estat_actual_ != TitleState::MAIN && estat_actual_ != TitleState::PLAYER_JOIN_PHASE) {
return;
}
if (animacio_activa_) {
float temps_shadow = std::max(0.0F, temps_animacio_ - SHADOW_DELAY);
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float SHADOW_OX = (ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * temps_shadow)) + SHADOW_OFFSET_X;
const float SHADOW_OY = (ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * temps_shadow) + ORBIT_PHASE_OFFSET)) + SHADOW_OFFSET_Y;
for (std::size_t i = 0; i < lletres_orni_.size(); ++i) {
const Vec2 POS_SHADOW{
.x = posicions_originals_orni_[i].x + std::round(SHADOW_OX),
.y = posicions_originals_orni_[i].y + std::round(SHADOW_OY),
};
Rendering::renderShape(sdl_.getRenderer(), lletres_orni_[i].shape, POS_SHADOW, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F, SHADOW_BRIGHTNESS);
}
for (std::size_t i = 0; i < lletres_attack_.size(); ++i) {
const Vec2 POS_SHADOW{
.x = posicions_originals_attack_[i].x + std::round(SHADOW_OX),
.y = posicions_originals_attack_[i].y + std::round(SHADOW_OY),
};
Rendering::renderShape(sdl_.getRenderer(), lletres_attack_[i].shape, POS_SHADOW, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F, SHADOW_BRIGHTNESS);
}
}
for (const auto& lletra : lletres_orni_) {
Rendering::renderShape(sdl_.getRenderer(), lletra.shape, lletra.position, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F);
}
for (const auto& lletra : lletres_attack_) {
Rendering::renderShape(sdl_.getRenderer(), lletra.shape, lletra.position, 0.0F, Defaults::Title::Layout::LOGO_SCALE, 1.0F);
}
const float SPACING = Defaults::Title::Layout::TEXT_SPACING;
bool mostrar_text = true;
if (estat_actual_ == TitleState::PLAYER_JOIN_PHASE) {
const float FASE = temps_acumulat_ * BLINK_FREQUENCY * 2.0F * std::numbers::pi_v<float>;
mostrar_text = (std::sin(FASE) > 0.0F);
}
if (mostrar_text) {
const std::string MAIN_TEXT = "PRESS START TO PLAY";
const float MAIN_SCALE = Defaults::Title::Layout::PRESS_START_SCALE;
const float CENTRE_X = Defaults::Game::WIDTH / 2.0F;
const float CENTRE_Y = Defaults::Game::HEIGHT * Defaults::Title::Layout::PRESS_START_POS;
text_.renderCentered(MAIN_TEXT, {.x = CENTRE_X, .y = CENTRE_Y}, MAIN_SCALE, SPACING);
}
dibuixarPeuTitol(SPACING);
}
auto TitleScene3D::checkSkipButtonPressed() -> bool {
return Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS);
}
auto TitleScene3D::checkStartGameButtonPressed() -> bool {
auto* input = Input::get();
bool any_pressed = false;
for (auto action : START_GAME_BUTTONS_3D) {
if (input->checkActionPlayer1(action, Input::DO_NOT_ALLOW_REPEAT)) {
if (!match_config_.jugador1_actiu) {
match_config_.jugador1_actiu = true;
any_pressed = true;
}
}
if (input->checkActionPlayer2(action, Input::DO_NOT_ALLOW_REPEAT)) {
if (!match_config_.jugador2_actiu) {
match_config_.jugador2_actiu = true;
any_pressed = true;
}
}
}
return any_pressed;
}
void TitleScene3D::handleEvent(const SDL_Event& event) {
(void)event;
}
-126
View File
@@ -1,126 +0,0 @@
// title_scene_3d.hpp - Variant 3D real de l'escena de títol
// © 2026 JailDesigner
//
// Clon de `TitleScene` (2D) que substitueix `Graphics::Starfield` per
// `Graphics::Starfield3D` i `Title::ShipAnimator` per `Title::ShipAnimator3D`,
// afegint una `Graphics::Camera3D` que projecta l'escena en perspectiva real.
// Tot el bloc d'overlay 2D (logo "ORNI ATTACK!", "PRESS START TO PLAY", peu
// "JAILGAMES + copyright") es manté idèntic.
//
// Trigger: env var `ORNI_TITLE_3D=1` interceptada al `Director::buildScene`,
// o transicions explícites a `SceneType::TITLE_3D`.
#pragma once
#include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <memory>
#include <vector>
#include "core/graphics/camera3d.hpp"
#include "core/graphics/shape.hpp"
#include "core/graphics/starfield3d.hpp"
#include "core/graphics/vector_text.hpp"
#include "core/input/input_types.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/system/game_config.hpp"
#include "core/system/scene.hpp"
#include "core/system/scene_context.hpp"
#include "core/types.hpp"
#include "game/title/ship_animator3d.hpp"
class TitleScene3D final : public Scene {
public:
explicit TitleScene3D(SDLManager& sdl, SceneManager::SceneContext& context);
~TitleScene3D() override;
void handleEvent(const SDL_Event& event) override;
void update(float delta_time) override;
void draw() override;
[[nodiscard]] auto isFinished() const -> bool override;
private:
enum class TitleState : std::uint8_t {
STARFIELD_FADE_IN,
STARFIELD,
MAIN,
PLAYER_JOIN_PHASE,
BLACK_SCREEN,
};
struct LetraLogo {
std::shared_ptr<Graphics::Shape> shape;
Vec2 position;
float ancho;
float altura;
float offset_centre;
};
SDLManager& sdl_;
SceneManager::SceneContext& context_;
GameConfig::MatchConfig match_config_;
Graphics::VectorText text_;
std::unique_ptr<Graphics::Camera3D> camera_;
std::unique_ptr<Graphics::Starfield3D> starfield_;
std::unique_ptr<Title::ShipAnimator3D> ship_animator_;
TitleState estat_actual_{TitleState::STARFIELD_FADE_IN};
float temps_acumulat_{0.0F};
std::vector<LetraLogo> lletres_orni_;
std::vector<LetraLogo> lletres_attack_;
float y_attack_dinamica_{0.0F};
std::vector<LetraLogo> lletres_jailgames_;
float temps_animacio_{0.0F};
std::vector<Vec2> posicions_originals_orni_;
std::vector<Vec2> posicions_originals_attack_;
float temps_estat_main_{0.0F};
bool animacio_activa_{false};
float factor_lerp_{0.0F};
static constexpr float BRIGHTNESS_STARFIELD = 1.2F;
static constexpr float DURACIO_FADE_IN = 3.0F;
static constexpr float DURACIO_INIT = 4.0F;
static constexpr float DURACIO_TRANSITION = 2.5F;
static constexpr float ESPAI_ENTRE_LLETRES = 10.0F;
static constexpr float BLINK_FREQUENCY = 3.0F;
static constexpr float DURACIO_BLACK_SCREEN = 2.0F;
static constexpr int MUSIC_FADE = 1500;
static constexpr float ORBIT_AMPLITUDE_X = 4.0F;
static constexpr float ORBIT_AMPLITUDE_Y = 3.0F;
static constexpr float ORBIT_FREQUENCY_X = 0.8F;
static constexpr float ORBIT_FREQUENCY_Y = 1.2F;
static constexpr float ORBIT_PHASE_OFFSET = 1.57F;
static constexpr float SHADOW_DELAY = 0.5F;
static constexpr float SHADOW_BRIGHTNESS = 0.4F;
static constexpr float SHADOW_OFFSET_X = 2.0F;
static constexpr float SHADOW_OFFSET_Y = 2.0F;
static constexpr float DELAY_INICI_ANIMACIO = 10.0F;
static constexpr float DURACIO_LERP = 2.0F;
// Càmera 3D: FOV vertical en radians.
static constexpr float CAMERA_FOV_Y_RAD = 1.0472F; // 60°
void updateLogoAnimation(float delta_time);
static auto checkSkipButtonPressed() -> bool;
auto checkStartGameButtonPressed() -> bool;
void initTitle();
void inicialitzarJailgames();
void dibuixarPeuTitol(float spacing) const;
void updateStarfieldFadeInState(float delta_time);
void updateStarfieldState(float delta_time);
void updateMainState(float delta_time);
void updatePlayerJoinPhaseState(float delta_time);
void updateBlackScreenState(float delta_time);
void handleSkipInput();
void handleStartInput();
void triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix);
};
+28 -28
View File
@@ -16,13 +16,13 @@
namespace StageSystem {
SpawnController::SpawnController() = default;
SpawnController::SpawnController() = default;
void SpawnController::configure(const StageConfig* config) {
void SpawnController::configure(const StageConfig* config) {
config_ = config;
}
}
void SpawnController::start() {
void SpawnController::start() {
if (config_ == nullptr) {
std::cerr << "[SpawnController] Error: config_ es null" << '\n';
return;
@@ -33,15 +33,15 @@ void SpawnController::start() {
std::cout << "[SpawnController] Stage " << static_cast<int>(config_->stage_id)
<< ": generats " << spawn_queue_.size() << " spawn events" << '\n';
}
}
void SpawnController::reset() {
void SpawnController::reset() {
spawn_queue_.clear();
temps_transcorregut_ = 0.0F;
index_spawn_actual_ = 0;
}
}
void SpawnController::update(float delta_time, std::array<Enemy, 15>& orni_array, bool pausar) {
void SpawnController::update(float delta_time, std::array<Enemy, 15>& orni_array, bool pausar) {
if ((config_ == nullptr) || spawn_queue_.empty()) {
return;
}
@@ -80,20 +80,20 @@ void SpawnController::update(float delta_time, std::array<Enemy, 15>& orni_array
break;
}
}
}
}
auto SpawnController::allEnemiesSpawned() const -> bool {
auto SpawnController::allEnemiesSpawned() const -> bool {
return index_spawn_actual_ >= spawn_queue_.size();
}
}
auto SpawnController::allEnemiesDestroyed(const std::array<Enemy, 15>& orni_array) const -> bool {
auto SpawnController::allEnemiesDestroyed(const std::array<Enemy, 15>& orni_array) const -> bool {
if (!allEnemiesSpawned()) {
return false;
}
return std::ranges::all_of(orni_array, [](const Enemy& enemy) { return !enemy.isActive(); });
}
}
auto SpawnController::getAliveEnemyCount(const std::array<Enemy, 15>& orni_array) -> uint8_t {
auto SpawnController::getAliveEnemyCount(const std::array<Enemy, 15>& orni_array) -> uint8_t {
uint8_t count = 0;
for (const auto& enemy : orni_array) {
if (enemy.isActive()) {
@@ -101,13 +101,13 @@ auto SpawnController::getAliveEnemyCount(const std::array<Enemy, 15>& orni_array
}
}
return count;
}
}
auto SpawnController::countSpawnedEnemies() const -> uint8_t {
auto SpawnController::countSpawnedEnemies() const -> uint8_t {
return static_cast<uint8_t>(index_spawn_actual_);
}
}
void SpawnController::generateSpawnEvents() {
void SpawnController::generateSpawnEvents() {
if (config_ == nullptr) {
return;
}
@@ -120,9 +120,9 @@ void SpawnController::generateSpawnEvents() {
spawn_queue_.push_back({spawn_time, type, false});
}
}
}
auto SpawnController::selectRandomType() const -> EnemyType {
auto SpawnController::selectRandomType() const -> EnemyType {
if (config_ == nullptr) {
return EnemyType::PENTAGON;
}
@@ -134,20 +134,20 @@ auto SpawnController::selectRandomType() const -> EnemyType {
return EnemyType::PENTAGON;
}
if (rand_val < config_->distribucio.pentagon + config_->distribucio.cuadrado) {
return EnemyType::QUADRAT;
return EnemyType::SQUARE;
}
return EnemyType::PINWHEEL;
}
return EnemyType::MOLINILLO;
}
void SpawnController::spawnEnemy(Enemy& enemy, EnemyType type, const Vec2* ship_pos) {
void SpawnController::spawnEnemy(Enemy& enemy, EnemyType type, const Vec2* ship_pos) {
// Initialize enemy (with safe spawn if ship_pos provided)
enemy.init(type, ship_pos);
// Apply difficulty multipliers
applyMultipliers(enemy);
}
}
void SpawnController::applyMultipliers(Enemy& enemy) const {
void SpawnController::applyMultipliers(Enemy& enemy) const {
if (config_ == nullptr) {
return;
}
@@ -160,8 +160,8 @@ void SpawnController::applyMultipliers(Enemy& enemy) const {
float base_rot = enemy.getBaseRotation();
enemy.setRotation(base_rot * config_->multiplicadors.rotation);
// Apply tracking strength (only affects QUADRAT)
// Apply tracking strength (only affects SQUARE)
enemy.setTrackingStrength(config_->multiplicadors.tracking_strength);
}
}
} // namespace StageSystem
+1 -22
View File
@@ -3,7 +3,6 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
@@ -36,7 +35,7 @@ namespace StageSystem {
struct MultiplicadorsDificultat {
float velocity; // 0.5-2.0 típic
float rotation; // 0.5-2.0 típic
float tracking_strength; // 0.0-1.5 (aplicat a Cuadrado)
float tracking_strength; // 0.0-1.5 (aplicat a Square)
};
// Metadades del file YAML
@@ -78,24 +77,4 @@ namespace StageSystem {
}
};
// Constants per messages de transición
namespace Constants {
// Pool de messages per start de level (selecció aleatòria)
inline constexpr std::array<const char*, 12> MISSATGES_LEVEL_START = {
"ORNI ALERT!",
"INCOMING ORNIS!",
"ROLLING THREAT!",
"ENEMY WAVE!",
"WAVE OF ORNIS DETECTED!",
"NEXT SWARM APPROACHING!",
"BRACE FOR THE NEXT WAVE!",
"ANOTHER ATTACK INCOMING!",
"SENSORS DETECT HOSTILE ORNIS...",
"UNIDENTIFIED ROLLING OBJECTS INBOUND!",
"ENEMY FORCES MOBILIZING!",
"PREPARE FOR IMPACT!"};
constexpr const char* MISSATGE_LEVEL_COMPLETED = "GOOD JOB COMMANDER!";
} // namespace Constants
} // namespace StageSystem
+29 -29
View File
@@ -10,28 +10,28 @@
#include "core/audio/audio.hpp"
#include "core/defaults.hpp"
#include "core/locale/locale.hpp"
#include "stage_config.hpp"
namespace StageSystem {
StageManager::StageManager(const StageSystemConfig* config)
: config_(config)
{
StageManager::StageManager(const StageSystemConfig* config)
: config_(config) {
if (config_ == nullptr) {
std::cerr << "[StageManager] Error: config es null" << '\n';
}
}
}
void StageManager::init() {
void StageManager::init() {
stage_actual_ = 1;
loadStage(stage_actual_);
changeState(EstatStage::INIT_HUD);
std::cout << "[StageManager] Inicialitzat a stage " << static_cast<int>(stage_actual_)
<< '\n';
}
}
void StageManager::update(float delta_time, bool pause_spawn) {
void StageManager::update(float delta_time, bool pause_spawn) {
switch (estat_) {
case EstatStage::INIT_HUD:
processInitHud(delta_time);
@@ -49,25 +49,25 @@ void StageManager::update(float delta_time, bool pause_spawn) {
processLevelCompleted(delta_time);
break;
}
}
}
void StageManager::markStageCompleted() {
void StageManager::markStageCompleted() {
std::cout << "[StageManager] Stage " << static_cast<int>(stage_actual_) << " completat!"
<< '\n';
changeState(EstatStage::LEVEL_COMPLETED);
}
}
auto StageManager::isGameComplete() const -> bool {
auto StageManager::isGameComplete() const -> bool {
return stage_actual_ >= config_->metadata.total_stages &&
estat_ == EstatStage::LEVEL_COMPLETED &&
timer_transicio_ <= 0.0F;
}
}
auto StageManager::getCurrentConfig() const -> const StageConfig* {
auto StageManager::getCurrentConfig() const -> const StageConfig* {
return config_->findStage(stage_actual_);
}
}
void StageManager::changeState(EstatStage nou_estat) {
void StageManager::changeState(EstatStage nou_estat) {
estat_ = nou_estat;
// Set timer based on state type
@@ -81,8 +81,9 @@ void StageManager::changeState(EstatStage nou_estat) {
// Select random message when entering LEVEL_START
if (nou_estat == EstatStage::LEVEL_START) {
size_t index = static_cast<size_t>(std::rand()) % Constants::MISSATGES_LEVEL_START.size();
missatge_level_start_actual_ = Constants::MISSATGES_LEVEL_START[index];
const std::size_t POOL = Locale::get().count("stage.start");
const std::size_t INDEX = (POOL == 0) ? 0 : static_cast<std::size_t>(std::rand()) % POOL;
missatge_level_start_actual_ = Locale::get().text("stage.start." + std::to_string(INDEX));
// [NOU] Iniciar música al entrar en LEVEL_START (después de INIT_HUD)
// Solo si no está sonant ya (per evitar reset en loops posteriors)
@@ -107,34 +108,33 @@ void StageManager::changeState(EstatStage nou_estat) {
break;
}
std::cout << '\n';
}
}
void StageManager::processInitHud(float delta_time) {
void StageManager::processInitHud(float delta_time) {
timer_transicio_ -= delta_time;
if (timer_transicio_ <= 0.0F) {
changeState(EstatStage::LEVEL_START);
}
}
}
void StageManager::processLevelStart(float delta_time) {
void StageManager::processLevelStart(float delta_time) {
timer_transicio_ -= delta_time;
if (timer_transicio_ <= 0.0F) {
changeState(EstatStage::PLAYING);
}
}
void StageManager::processPlaying(float delta_time, bool pause_spawn) {
}
void StageManager::processPlaying(float delta_time, bool pause_spawn) {
// Update spawn controller (pauses when pause_spawn = true)
// Note: The actual enemy array update happens in GameScene::update()
// This is just for internal timekeeping
(void)delta_time; // Spawn controller is updated externally
(void)pause_spawn; // Passed to spawn_controller_.update() by GameScene
}
}
void StageManager::processLevelCompleted(float delta_time) {
void StageManager::processLevelCompleted(float delta_time) {
timer_transicio_ -= delta_time;
if (timer_transicio_ <= 0.0F) {
@@ -152,9 +152,9 @@ void StageManager::processLevelCompleted(float delta_time) {
loadStage(stage_actual_);
changeState(EstatStage::LEVEL_START);
}
}
}
void StageManager::loadStage(uint8_t stage_id) {
void StageManager::loadStage(uint8_t stage_id) {
const StageConfig* stage_config = config_->findStage(stage_id);
if (stage_config == nullptr) {
std::cerr << "[StageManager] Error: no es pot trobar stage " << static_cast<int>(stage_id)
@@ -168,6 +168,6 @@ void StageManager::loadStage(uint8_t stage_id) {
std::cout << "[StageManager] Carregat stage " << static_cast<int>(stage_id) << ": "
<< static_cast<int>(stage_config->total_enemies) << " enemigos" << '\n';
}
}
} // namespace StageSystem
+118 -35
View File
@@ -7,6 +7,7 @@
#include "core/audio/audio.hpp"
#include "core/physics/collision.hpp"
#include "core/types.hpp"
#include "game/constants.hpp"
namespace Systems::Collision {
@@ -19,10 +20,10 @@ namespace Systems::Collision {
switch (type) {
case EnemyType::PENTAGON:
return Defaults::Enemies::Scoring::PENTAGON_SCORE;
case EnemyType::QUADRAT:
return Defaults::Enemies::Scoring::QUADRAT_SCORE;
case EnemyType::MOLINILLO:
return Defaults::Enemies::Scoring::MOLINILLO_SCORE;
case EnemyType::SQUARE:
return Defaults::Enemies::Scoring::SQUARE_SCORE;
case EnemyType::PINWHEEL:
return Defaults::Enemies::Scoring::PINWHEEL_SCORE;
}
return 0;
}
@@ -31,10 +32,10 @@ namespace Systems::Collision {
switch (type) {
case EnemyType::PENTAGON:
return Defaults::Palette::PENTAGON;
case EnemyType::QUADRAT:
return Defaults::Palette::QUADRAT;
case EnemyType::MOLINILLO:
return Defaults::Palette::MOLINILLO;
case EnemyType::SQUARE:
return Defaults::Palette::SQUARE;
case EnemyType::PINWHEEL:
return Defaults::Palette::PINWHEEL;
}
return SDL_Color{};
}
@@ -57,16 +58,16 @@ namespace Systems::Collision {
}
ctx.floating_score_manager.crear(POINTS, ENEMY_POS);
enemy.destruir();
enemy.destroy();
constexpr float VELOCITAT_EXPLOSIO = 80.0F; // px/s (explosión suave)
constexpr float SPEED_EXPLOSIO = 80.0F; // px/s (explosión suave)
const Vec2 INHERITED_VEL = ENEMY_VEL * Defaults::Physics::Debris::ENEMY_VELOCITY_INHERITANCE;
ctx.debris_manager.explode(
SHAPE,
ENEMY_POS,
0.0F, // angle (rotación interna del enemy)
1.0F, // escala
VELOCITAT_EXPLOSIO,
SPEED_EXPLOSIO,
BRIGHTNESS,
INHERITED_VEL,
0.0F, // sense herència angular: evita que els 5 trossos curvin en bloc
@@ -78,8 +79,37 @@ namespace Systems::Collision {
Defaults::Physics::Debris::ENEMY_SEGMENT_MULTIPLIER);
// Firework burst radial des del centro de l'enemic (efecte adicional al debris).
// No heretem color: el burst usa el blanc per defecte per a un feel més lluminós.
ctx.firework_manager.spawn(ENEMY_POS);
// Línia blanca + halo daurat (WOUNDED) per a feel d'espurnes.
ctx.firework_manager.spawn(ENEMY_POS,
Defaults::FX::Firework::DEFAULT_COLOR,
Defaults::FX::Firework::SPEED,
Defaults::FX::Firework::N_POINTS,
Defaults::FX::Firework::INITIAL_BRIGHTNESS,
/*glow=*/true,
Defaults::Palette::WOUNDED);
}
// Trenca una bala en debris (8 fragments de l'octàgon) + so HIT + desactiva.
// S'invoca des de qualsevol desactivació de bala (impacte amb enemic, amb jugador,
// o sortida del PLAYAREA) per a un feedback visual i sonor consistent.
void breakBullet(Effects::DebrisManager& debris_manager, Bullet& bullet) {
constexpr float DEBRIS_VELOCITY = 60.0F;
debris_manager.explode(
bullet.getShape(),
bullet.getCenter(),
bullet.getAngle(),
1.0F, // scale
DEBRIS_VELOCITY,
bullet.getBrightness(),
Vec2{}, // sense herència de velocitat (fragments radials)
0.0F, // sense velocity angular heretada
0.0F, // sense rotació visual heretada
Defaults::Sound::HIT,
Defaults::Palette::BULLET,
Defaults::Physics::Debris::TEMPS_VIDA,
Defaults::Physics::Debris::ACCELERACIO,
1); // sense duplicat de segments
bullet.desactivar();
}
} // anonymous namespace
@@ -87,8 +117,11 @@ namespace Systems::Collision {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_BULLET_ENEMY_AMPLIFIER;
for (auto& bullet : ctx.bullets) {
if (!bullet.isActive()) {
continue;
}
for (auto& enemy : ctx.enemies) {
if (!Physics::checkCollision(bullet, enemy, AMPLIFIER)) {
if (!Physics::checkCollisionSwept(bullet.getPrevPosition(), bullet.getCenter(), Defaults::Entities::BULLET_RADIUS, enemy, AMPLIFIER)) {
continue;
}
@@ -108,10 +141,10 @@ namespace Systems::Collision {
explodeNow(ctx, enemy, SHOOTER);
} else {
// Primer impacto → entra en estado herido (explosión diferida).
enemy.herir(SHOOTER);
enemy.hurt(SHOOTER);
}
bullet.desactivar();
breakBullet(ctx.debris_manager, bullet);
break; // Una bala impacta a un enemy y muere
}
}
@@ -144,14 +177,14 @@ namespace Systems::Collision {
if (A_WOUNDED == B_WOUNDED) {
continue; // ambos sanos o ambos heridos: nada que propagar
}
if (!Physics::checkCollision(a, b, 1.0F)) {
if (!Physics::checkCollision(a, b, Defaults::Game::COLLISION_WOUNDED_CHAIN_AMPLIFIER)) {
continue;
}
// El sano queda herido, propagando el shooter original.
if (A_WOUNDED) {
b.herir(a.getLastHitBy());
b.hurt(a.getLastHitBy());
} else {
a.herir(b.getLastHitBy());
a.hurt(b.getLastHitBy());
}
}
}
@@ -161,22 +194,46 @@ namespace Systems::Collision {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_SHIP_ENEMY_AMPLIFIER;
for (uint8_t i = 0; i < 2; i++) {
// Skip si ya tocado / muerto / invulnerable
// Skip si ya tocado / muerto / invulnerable. NO actualitzem el flag de contacte:
// mentre estem inactius no hi ha "frame anterior" rellevant, i el respawn ja el resetea.
if (ctx.hit_timer_per_player[i] > 0.0F ||
!ctx.ships[i].isActive() ||
ctx.ships[i].isInvulnerable()) {
continue;
}
for (const auto& enemy : ctx.enemies) {
// Comprovem si la nau toca QUALSEVOL enemic vulnerable aquest frame.
Enemy* touched_enemy = nullptr;
for (auto& enemy : ctx.enemies) {
if (enemy.isInvulnerable()) {
continue;
}
if (Physics::checkCollision(ctx.ships[i], enemy, AMPLIFIER)) {
touched_enemy = &enemy;
break;
}
}
const bool TOUCHING_NOW = touched_enemy != nullptr;
// Edge-trigger: només compta com a impacte la transició no-tocant → tocant.
// Així el contacte continu durant el rebot frame-a-frame no dispara HURT i mort
// en frames consecutius.
const bool RISING_EDGE = TOUCHING_NOW && !ctx.ships[i].wasTouchingEnemyPrevFrame();
if (RISING_EDGE) {
if (ctx.ships[i].isHurt()) {
// Segon impacte durant HURT → mort. Aplica un impuls afegit
// perquè l'enemic surti disparat (feedback visible).
const Vec2 SHIP_VEL = ctx.ships[i].getVelocityVector();
const Vec2 IMPULSE = SHIP_VEL * (Defaults::Ship::MASS * Defaults::Physics::Ship::DEATH_IMPACT_MOMENTUM_FACTOR);
touched_enemy->applyImpulse(IMPULSE);
ctx.on_player_hit(i);
break; // Solo una colisión por player por frame
} else {
// Primer impacte → estat HURT (rebot físic ja resolt per PhysicsWorld;
// l'enemic no rep dany per decisió de disseny).
ctx.ships[i].hurt();
}
}
ctx.ships[i].setTouchingEnemyPrevFrame(TOUCHING_NOW);
}
}
@@ -188,40 +245,44 @@ namespace Systems::Collision {
constexpr float AMPLIFIER = Defaults::Game::COLLISION_BULLET_PLAYER_AMPLIFIER;
for (auto& bullet : ctx.bullets) {
if (!bullet.isActive() || bullet.getGraceTimer() > 0.0F) {
if (!bullet.isActive()) {
continue;
}
const uint8_t BULLET_OWNER = bullet.getOwnerId();
for (uint8_t player_id = 0; player_id < 2; player_id++) {
// Una bala mai no impacta al seu propi shooter: les bales d'aquest joc no
// reboten ni el shooter pot atrapar-les, així que la prevenció és per disseny.
if (BULLET_OWNER == player_id) {
continue;
}
if (ctx.hit_timer_per_player[player_id] > 0.0F ||
!ctx.ships[player_id].isActive() ||
ctx.ships[player_id].isInvulnerable()) {
continue;
}
const bool JUGADOR_ACTIU = (player_id == 0)
? ctx.match_config.jugador1_actiu
: ctx.match_config.jugador2_actiu;
? ctx.match_config.player1_active
: ctx.match_config.player2_active;
if (!JUGADOR_ACTIU) {
continue;
}
if (!Physics::checkCollision(bullet, ctx.ships[player_id], AMPLIFIER)) {
if (!Physics::checkCollisionSwept(bullet.getPrevPosition(), bullet.getCenter(), Defaults::Entities::BULLET_RADIUS, ctx.ships[player_id], AMPLIFIER)) {
continue;
}
// *** FRIENDLY FIRE HIT ***
if (BULLET_OWNER == player_id) {
// Self-hit: víctima pierde 1 vida.
ctx.on_player_hit(player_id);
} else {
// Teammate hit: víctima pierde 1, atacante gana 1.
// *** TEAMMATE HIT (friendly fire) ***
// Víctima perd 1 vida, atacant en guanya 1. Apliquem l'impuls
// de la bala a la nau ABANS de on_player_hit perquè tocado()
// captura la velocitat per als debris (si no, queden quiets).
const Vec2 BULLET_IMPULSE = bullet.getBody().velocity *
(bullet.getBody().mass * Defaults::Physics::Bullet::IMPACT_MOMENTUM_FACTOR);
ctx.ships[player_id].getBody().applyImpulse(BULLET_IMPULSE);
ctx.on_player_hit(player_id);
ctx.lives_per_player[BULLET_OWNER]++;
}
Audio::get()->playSound(Defaults::Sound::FRIENDLY_FIRE_HIT, Audio::Group::GAME);
bullet.desactivar();
breakBullet(ctx.debris_manager, bullet);
break; // Una bullet solo impacta una vez por frame
}
}
@@ -235,4 +296,26 @@ namespace Systems::Collision {
detectBulletPlayer(ctx);
}
void desactivateOutOfBoundsBullets(
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS) * 2>& bullets,
Effects::DebrisManager& debris_manager) {
float min_x;
float max_x;
float min_y;
float max_y;
Constants::getPlayAreaBounds(min_x, max_x, min_y, max_y);
constexpr float R = Defaults::Entities::BULLET_RADIUS;
for (auto& bullet : bullets) {
if (!bullet.isActive()) {
continue;
}
const Vec2& pos = bullet.getCenter();
if (pos.x < min_x + R || pos.x > max_x - R ||
pos.y < min_y + R || pos.y > max_y - R) {
breakBullet(debris_manager, bullet);
}
}
}
} // namespace Systems::Collision
+8 -1
View File
@@ -32,7 +32,7 @@ namespace Systems::Collision {
struct Context {
std::array<Ship, 2>& ships;
std::array<Enemy, Defaults::Entities::MAX_ORNIS>& enemies;
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BALES) * 2>& bullets;
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS) * 2>& bullets;
std::array<float, 2>& hit_timer_per_player;
std::array<int, 2>& score_per_player;
std::array<int, 2>& lives_per_player;
@@ -70,4 +70,11 @@ namespace Systems::Collision {
// Las tres en orden lógico del frame.
void detectAll(Context& ctx);
// Desactiva les bales que han sortit del PLAYAREA, generant debris visual
// (8 fragments de l'octàgon) i el so HIT. Cal cridar-la després de detectAll()
// perquè una bala que el mateix frame xoca i alhora surt es comptabilitzi com a impacte.
void desactivateOutOfBoundsBullets(
std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS) * 2>& bullets,
Effects::DebrisManager& debris_manager);
} // namespace Systems::Collision
+13 -13
View File
@@ -11,34 +11,34 @@
#include "game/scenes/game_scene.hpp" // GameOverState (definición completa)
namespace Systems::ContinueScreen {
namespace {
namespace {
// Si el countdown ha bajado de 0, transiciona a GAME_OVER con su timer.
void checkAndApplyTimeout(Context& ctx) {
// Si el countdown ha bajado de 0, transiciona a GAME_OVER con su timer.
void checkAndApplyTimeout(Context& ctx) {
if (ctx.counter < 0) {
ctx.state = GameOverState::GAME_OVER;
ctx.game_over_timer = Defaults::Game::GAME_OVER_DURATION;
}
}
}
void revivePlayer(Context& ctx, uint8_t player_id) {
void revivePlayer(Context& ctx, uint8_t player_id) {
ctx.score_per_player[player_id] = 0;
ctx.lives_per_player[player_id] = Defaults::Game::STARTING_LIVES;
ctx.hit_timer_per_player[player_id] = 0.0F;
if (player_id == 0) {
ctx.match_config.jugador1_actiu = true;
ctx.match_config.player1_active = true;
} else {
ctx.match_config.jugador2_actiu = true;
ctx.match_config.player2_active = true;
}
const Vec2 SPAWN = ctx.get_spawn_point(player_id);
ctx.ships[player_id].init(&SPAWN, /*activar_invulnerabilitat=*/true);
}
}
} // namespace
} // namespace
void update(Context& ctx, float delta_time) {
void update(Context& ctx, float delta_time) {
ctx.tick_timer -= delta_time;
if (ctx.tick_timer > 0.0F) {
return;
@@ -52,9 +52,9 @@ void update(Context& ctx, float delta_time) {
if (ctx.state == GameOverState::CONTINUE) {
Audio::get()->playSound(Defaults::Sound::CONTINUE, Audio::Group::GAME);
}
}
}
void processInput(Context& ctx) {
void processInput(Context& ctx) {
auto* input = Input::get();
const bool P1_START = input->checkActionPlayer1(InputAction::START, Input::DO_NOT_ALLOW_REPEAT);
@@ -104,6 +104,6 @@ void processInput(Context& ctx) {
ctx.tick_timer = Defaults::Game::CONTINUE_TICK_DURATION;
}
}
}
} // namespace Systems::ContinueScreen
+1 -1
View File
@@ -31,7 +31,7 @@ namespace Systems::InitHud {
auto computeShipPosition(float progress, const Vec2& final_position) -> Vec2 {
const float EASED = Easing::easeOutQuad(progress);
const SDL_FRect& zone = Defaults::Zones::PLAYAREA;
// Y inicial: bajo la zona de juego (sale desde fuera).
// Y inicial: bajo la zone de juego (sale desde fuera).
const float Y_INI = zone.y + zone.h + Defaults::Hud::InitAnim::SHIP_SPAWN_Y_OFFSET;
const float Y_ANIM = Y_INI + ((final_position.y - Y_INI) * EASED);
return Vec2{.x = final_position.x, .y = Y_ANIM};
+18 -18
View File
@@ -4,7 +4,7 @@
// Cubre la animación INIT_HUD del comienzo de cada partida/stage:
// 1. Crecimiento de los marcos del PLAYAREA con efecto pincel en 3 fases.
// 2. Marcador subiendo desde abajo.
// 3. Naves entrando desde la zona inferior hacia su spawn.
// 3. Naves entrando desde la zone inferior hacia su spawn.
//
// Todas las funciones son puras (sin estado interno propio). GameScene aporta
// el contexto que necesitan: posiciones finales, texto del scoreboard y el
@@ -21,28 +21,28 @@
namespace Systems::InitHud {
// Convierte un progreso global 0..1 al sub-progreso de un elemento que solo
// se anima en la ventana [ratio_init, ratio_end].
// < ratio_init → 0.0 (no empezó)
// > ratio_end → 1.0 (terminó)
// en rango → interpolación lineal 0..1
[[nodiscard]] auto computeRangeProgress(float global_progress,
// Convierte un progreso global 0..1 al sub-progreso de un elemento que solo
// se anima en la ventana [ratio_init, ratio_end].
// < ratio_init → 0.0 (no empezó)
// > ratio_end → 1.0 (terminó)
// en rango → interpolación lineal 0..1
[[nodiscard]] auto computeRangeProgress(float global_progress,
float ratio_init,
float ratio_end) -> float;
// Calcula posición Y animada de una nave durante INIT_HUD. La nave sube
// desde 50 px bajo el PLAYAREA hasta `final_position` con easing.
[[nodiscard]] auto computeShipPosition(float progress, const Vec2& final_position) -> Vec2;
// Calcula posición Y animada de una nave durante INIT_HUD. La nave sube
// desde 50 px bajo el PLAYAREA hasta `final_position` con easing.
[[nodiscard]] auto computeShipPosition(float progress, const Vec2& final_position) -> Vec2;
// Dibuja los 4 lados del PLAYAREA con efecto pincel en 3 fases:
// 0..33% → línea superior crece desde el centro hacia los lados.
// 33..66% → líneas verticales bajan por los laterales.
// 66..100% → línea inferior crece desde los lados hacia el centro.
void drawBordersAnimated(Rendering::Renderer* renderer, float progress);
// Dibuja los 4 lados del PLAYAREA con efecto pincel en 3 fases:
// 0..33% → línea superior crece desde el centro hacia los lados.
// 33..66% → líneas verticales bajan por los laterales.
// 66..100% → línea inferior crece desde los lados hacia el centro.
void drawBordersAnimated(Rendering::Renderer* renderer, float progress);
// Dibuja el scoreboard centrado, subiendo desde fuera de la pantalla
// hasta su posición final con easing.
void drawScoreboardAnimated(const Graphics::VectorText& text,
// Dibuja el scoreboard centrado, subiendo desde fuera de la pantalla
// hasta su posición final con easing.
void drawScoreboardAnimated(const Graphics::VectorText& text,
const std::string& scoreboard_text,
float progress);
+245 -216
View File
@@ -1,45 +1,140 @@
// ship_animator.cpp - Implementació del sistema de animación de naves
// ship_animator.cpp - Implementació de l'animador de naus 3D
// © 2026 JailDesigner
#include "ship_animator.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include "core/defaults.hpp"
#include "core/graphics/shape_loader.hpp"
#include "core/math/easing.hpp"
#include "core/rendering/shape_renderer.hpp"
namespace Title {
ShipAnimator::ShipAnimator(Rendering::Renderer* renderer)
: renderer_(renderer) {
}
namespace {
void ShipAnimator::init() {
// Carregar formes de naves con perspectiva pre-calculada
auto forma_p1 = Graphics::ShapeLoader::load("ship_perspective.shp"); // Perspectiva izquierda
auto forma_p2 = Graphics::ShapeLoader::load("ship2_perspective.shp"); // Perspectiva derecha
// Profunditat d'extrusió de la silueta 2D de la nau (en unitats mundials).
// 0.0F → emet només la silueta plana. >0 emet volum extrudit.
constexpr float SHIP_EXTRUSION_DEPTH = 1.0F;
// VP lògic per definir forward_dir / direcció del path. Tots els paths
// s'allunyen cap a aquest punt; les naus exiting continuen MÉS ENLLÀ
// (vegeu SHIP_EXIT_TRAVEL) per no desaparèixer en arribar al VP.
constexpr float SHIP_EXIT_Z = 800.0F;
constexpr Vec3 VANISHING_POINT{.x = 0.0F, .y = 0.0F, .z = SHIP_EXIT_Z};
// Profunditat addicional darrere del VP cap a la qual les naus exiting
// convergeixen. Així P1 (X<0) i P2 (X>0) mantenen sempre els seus
// hemisferis i no es creuen al passar pel VP — totes dues acaben al
// centre projectat (640, 360) sense travessar-lo.
constexpr float SHIP_EXIT_OVERFLOW = 700.0F;
// Directors VP → origen de cada nau, normalitzats. P1 ve des de "les 7"
// del rellotge (baix-esquerra), P2 des de "les 5" (baix-dreta). Els
// components estan calibrats perquè a TARGET_DIST el pixel projectat
// caigui aprox sota la "P de PRESS" / "Y de PLAY" del text del títol.
constexpr Vec3 PATH_DIR_P1{.x = -0.0887F, .y = -0.0683F, .z = -0.9938F};
constexpr Vec3 PATH_DIR_P2{.x = +0.0887F, .y = -0.0683F, .z = -0.9938F};
// Distàncies des del VP al llarg del path (unitats mundials).
// Reduïm TARGET_DIST per acostar el descans al VP (puja en pantalla,
// s'allunya de PRESS START); compensem amb SHIP_FLOAT_SCALE més gran.
constexpr float TARGET_DIST = 480.0F; // Descans a Z≈323 → pixel ≈ (558, 423)
constexpr float ENTRY_DIST = 770.0F; // Inicial a Z≈35 → fora pantalla baix-esq.
// Pitch addicional sobre el look-at pur per fer que el dors de la nau
// s'incline cap a la càmera (~-14° afegits). Amb forward quasi paral·lel
// a +Z, el pitch look-at és ~-94°; afegint això queda al voltant de -108°,
// que és l'angle visualment validat com a "bo" per l'usuari.
constexpr float PITCH_LIFT_RAD = -0.25F;
// Look-at: calcula pitch+yaw que duen (0,-1,0) local a forward_dir mundial.
// Requereix l'ordre de rotació X→Y→Z al applyTransform de wireframe3d.
// Si forward és quasi vertical (sin(pitch) ≈ 0), retorna yaw=0 (qualsevol).
auto computePitchYawForLookAt(const Vec3& forward_dir) -> Vec2 {
const float DY = std::clamp(forward_dir.y, -1.0F, 1.0F);
const float PITCH_LOOKAT = -std::acos(-DY); // ∈ [-π, 0]
const float SIN_PITCH = std::sin(PITCH_LOOKAT);
float yaw = 0.0F;
if (std::abs(SIN_PITCH) >= 1.0E-5F) {
const float SY = -forward_dir.x / SIN_PITCH;
const float CY = -forward_dir.z / SIN_PITCH;
yaw = std::atan2(SY, CY);
}
return Vec2{.x = PITCH_LOOKAT + PITCH_LIFT_RAD, .y = yaw};
}
auto safeNormalize(const Vec3& v, const Vec3& fallback) -> Vec3 {
return v.lengthSquared() > 0.0F ? v.normalized() : fallback;
}
auto entryForward(const TitleShip& ship) -> Vec3 {
return safeNormalize(ship.target_position - ship.initial_position,
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F});
}
auto floatingForward(const Vec3& target) -> Vec3 {
return safeNormalize(VANISHING_POINT - target,
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F});
}
auto exitForward(const Vec3& current) -> Vec3 {
return safeNormalize(VANISHING_POINT - current,
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F});
}
// Mida visual i animació.
constexpr float SHIP_FLOAT_SCALE = 2.0F;
constexpr float SHIP_ENTRY_SCALE = 2.0F; // Mida mundial idèntica; la perspectiva fa la resta
// ENTRY_DURATION viu a Defaults::Title::Ships::ENTRY_DURATION (compartit
// amb title_scene.cpp per calcular el threshold T_SHIPS_LANDED).
constexpr float ENTRY_DURATION = Defaults::Title::Ships::ENTRY_DURATION;
constexpr float EXIT_DURATION = Defaults::Title::Ships::EXIT_DURATION;
// Oscil·lació en unitats mundials (al voltant del target_position).
constexpr float FLOAT_AMPLITUDE_X = 1.5F;
constexpr float FLOAT_AMPLITUDE_Y = 1.0F;
constexpr float FLOAT_FREQUENCY_X_BASE = 0.5F;
constexpr float FLOAT_FREQUENCY_Y_BASE = 0.7F;
constexpr float FLOAT_PHASE_OFFSET = 1.57F;
constexpr float P1_FREQUENCY_MULTIPLIER = 0.88F;
constexpr float P2_FREQUENCY_MULTIPLIER = 1.12F;
constexpr float P1_ENTRY_DELAY = 0.0F;
constexpr float P2_ENTRY_DELAY = 0.5F;
} // namespace
ShipAnimator::ShipAnimator(Rendering::Renderer* renderer,
const Graphics::Camera3D* camera)
: renderer_(renderer),
camera_(camera) {
}
void ShipAnimator::init() {
auto shape_p1 = Graphics::ShapeLoader::load("ship.shp");
auto shape_p2 = Graphics::ShapeLoader::load("ship2.shp");
// Configurar ship P1
ships_[0].player_id = 1;
ships_[0].shape = forma_p1;
if (shape_p1 && shape_p1->isValid()) {
ships_[0].mesh = Graphics::extrudeShape2D(*shape_p1, SHIP_EXTRUSION_DEPTH);
}
configureShipP1(ships_[0]);
// Configurar ship P2
ships_[1].player_id = 2;
ships_[1].shape = forma_p2;
if (shape_p2 && shape_p2->isValid()) {
ships_[1].mesh = Graphics::extrudeShape2D(*shape_p2, SHIP_EXTRUSION_DEPTH);
}
configureShipP2(ships_[1]);
}
}
void ShipAnimator::update(float delta_time) {
// Dispatcher segons state de cada ship
void ShipAnimator::update(float delta_time) {
for (auto& ship : ships_) {
if (!ship.visible) {
continue;
}
switch (ship.state) {
case ShipState::ENTERING:
updateEntering(ship, delta_time);
@@ -47,281 +142,215 @@ void ShipAnimator::update(float delta_time) {
case ShipState::FLOATING:
updateFloating(ship, delta_time);
break;
case ShipState::EXITING:
case ShipState::EXITING: {
updateExiting(ship, delta_time);
// Transició a invisible: la nau acaba d'arribar al VP.
if (!ship.visible && on_ship_disappear_) {
on_ship_disappear_(ship.player_id);
}
break;
}
}
}
}
}
void ShipAnimator::draw() const {
for (const auto& ship : ships_) {
void ShipAnimator::draw() const {
if (camera_ == nullptr || renderer_ == nullptr) {
return;
}
for (std::size_t i = 0; i < ships_.size(); ++i) {
const auto& ship = ships_[i];
if (!ship.visible) {
continue;
}
// Renderizar ship (perspectiva ya incorporada a la shape)
Rendering::renderShape(
renderer_,
ship.shape,
ship.current_position,
0.0F, // angle (rotación 2D no utilitzada)
ship.current_scale,
1.0F, // progress (siempre visible)
1.0F // brightness (brightness màxima)
);
const Vec2 EULER = computePitchYawForLookAt(ship.forward_dir);
const Graphics::Transform3D TRANSFORM{
.position = ship.current_position,
.rotation_euler = Vec3{.x = EULER.x, .y = EULER.y, .z = 0.0F},
.scale = ship.current_scale,
};
const SDL_Color SHIP_COLOR = (i == 0)
? Defaults::Title::Colors::SHIP_P1
: Defaults::Title::Colors::SHIP_P2;
Graphics::drawWireframe(renderer_, *camera_, ship.mesh, TRANSFORM, 1.0F, SHIP_COLOR);
}
}
}
void ShipAnimator::startEntryAnimation() {
using namespace Defaults::Title::Ships;
// Configurar ship P1 para l'animación de entrada
ships_[0].state = ShipState::ENTERING;
ships_[0].state_time = 0.0F;
ships_[0].initial_position = computeOffscreenPosition(CLOCK_8_ANGLE);
ships_[0].current_position = ships_[0].initial_position;
ships_[0].current_scale = ships_[0].initial_scale;
// Configurar ship P2 para l'animación de entrada
ships_[1].state = ShipState::ENTERING;
ships_[1].state_time = 0.0F;
ships_[1].initial_position = computeOffscreenPosition(CLOCK_4_ANGLE);
ships_[1].current_position = ships_[1].initial_position;
ships_[1].current_scale = ships_[1].initial_scale;
}
void ShipAnimator::triggerExitAnimation() {
// Configurar ambdues naves para l'animación de salida
void ShipAnimator::startEntryAnimation() {
for (auto& ship : ships_) {
ship.state = ShipState::ENTERING;
ship.state_time = 0.0F;
ship.current_position = ship.initial_position;
ship.current_scale = ship.initial_scale;
ship.forward_dir = entryForward(ship);
}
}
void ShipAnimator::triggerExitAnimation() {
for (auto& ship : ships_) {
// Canviar state a EXITING
ship.state = ShipState::EXITING;
ship.state_time = 0.0F;
// Preservar posición actual (pot estar a mig camí si START es prem durante ENTERING)
ship.initial_position = ship.current_position;
// La scale objetivo es preserva para calcular la interpolació
// (current_scale pot ser diferent si está en ENTERING)
ship.forward_dir = exitForward(ship.current_position);
}
}
}
void ShipAnimator::skipToFloatingState() {
// Posar ambdues naves directament en state FLOATING
void ShipAnimator::triggerExitAnimationForPlayer(int player_id) {
for (auto& ship : ships_) {
if (ship.player_id == player_id) {
ship.state = ShipState::EXITING;
ship.state_time = 0.0F;
ship.initial_position = ship.current_position;
ship.forward_dir = exitForward(ship.current_position);
break;
}
}
}
void ShipAnimator::skipToFloatingState() {
for (auto& ship : ships_) {
ship.state = ShipState::FLOATING;
ship.state_time = 0.0F;
ship.oscillation_phase = 0.0F;
// Posar en posición objetivo (sin animación)
ship.current_position = ship.target_position;
ship.current_scale = ship.target_scale;
// NO establir visibilitat aquí - ya ho hace el caller
// (evita fer visibles ambdues naves cuando solo una ha premut START)
}
}
auto ShipAnimator::isVisible() const -> bool {
// Retorna true si almenys una ship es visible
return std::ranges::any_of(ships_, [](const TitleShip& ship) { return ship.visible; });
}
void ShipAnimator::triggerExitAnimationForPlayer(int player_id) {
// Trobar la ship del player especificat
for (auto& ship : ships_) {
if (ship.player_id == player_id) {
// Canviar state a EXITING solo per esta ship
ship.state = ShipState::EXITING;
ship.state_time = 0.0F;
// Preservar posición actual (pot estar a mig camí si START es prem durante ENTERING)
ship.initial_position = ship.current_position;
// La scale objetivo es preserva para calcular la interpolació
// (current_scale pot ser diferent si está en ENTERING)
break; // Solo una ship per player
ship.forward_dir = floatingForward(ship.target_position);
}
}
}
void ShipAnimator::setVisible(bool visible) {
void ShipAnimator::setVisible(bool visible) {
for (auto& ship : ships_) {
ship.visible = visible;
}
}
}
auto ShipAnimator::isAnimationComplete() const -> bool {
// Comprovar si todas las naves són invisibles (han completat l'animación de salida)
return std::ranges::all_of(ships_, [](const TitleShip& ship) { return !ship.visible; });
}
auto ShipAnimator::isVisible() const -> bool {
return std::ranges::any_of(ships_,
[](const TitleShip& s) { return s.visible; });
}
// Métodos de animación (stubs)
void ShipAnimator::updateEntering(TitleShip& ship, float delta_time) {
using namespace Defaults::Title::Ships;
auto ShipAnimator::isAnimationComplete() const -> bool {
return std::ranges::all_of(ships_,
[](const TitleShip& s) { return !s.visible; });
}
void ShipAnimator::updateEntering(TitleShip& ship, float delta_time) {
ship.state_time += delta_time;
// Esperar al delay antes de començar l'animación
if (ship.state_time < ship.entry_delay) {
// Aún en delay: la ship es queda fuera de pantalla (posición inicial)
ship.current_position = ship.initial_position;
ship.current_scale = ship.initial_scale;
return;
}
const float ELAPSED = ship.state_time - ship.entry_delay;
const float PROGRESS = std::min(1.0F, ELAPSED / ENTRY_DURATION);
const float EASED = Easing::easeOutQuad(PROGRESS);
// Cálculo del progrés (restant el delay)
float elapsed = ship.state_time - ship.entry_delay;
float progress = std::min(1.0F, elapsed / ENTRY_DURATION);
// Acumula la fase d'oscil·lació també durant ENTERING; sense això,
// al passar a FLOATING la posició salta d'amplitud_y de cop perquè
// l'offset Y comença a sin(π/2) = 1. Acumulant-la abans, la nau
// ja oscil·la mentre s'aproxima i la transició és contínua.
ship.oscillation_phase += delta_time;
// Aplicar easing (ease_out_quad per arribada suau)
float eased_progress = Easing::easeOutQuad(progress);
const float INTERP_X = Easing::lerp(ship.initial_position.x, ship.target_position.x, EASED);
const float INTERP_Y = Easing::lerp(ship.initial_position.y, ship.target_position.y, EASED);
const float INTERP_Z = Easing::lerp(ship.initial_position.z, ship.target_position.z, EASED);
// Lerp posición (inicial → objetivo)
ship.current_position.x = Easing::lerp(ship.initial_position.x, ship.target_position.x, eased_progress);
ship.current_position.y = Easing::lerp(ship.initial_position.y, ship.target_position.y, eased_progress);
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float OFFSET_X = ship.amplitude_x *
std::sin(TWO_PI * ship.frequency_x * ship.oscillation_phase);
const float OFFSET_Y = ship.amplitude_y *
std::sin((TWO_PI * ship.frequency_y * ship.oscillation_phase) + FLOAT_PHASE_OFFSET);
// Lerp scale (grande → normal)
ship.current_scale = Easing::lerp(ship.initial_scale, ship.target_scale, eased_progress);
ship.current_position.x = INTERP_X + OFFSET_X;
ship.current_position.y = INTERP_Y + OFFSET_Y;
ship.current_position.z = INTERP_Z;
ship.current_scale = Easing::lerp(ship.initial_scale, ship.target_scale, EASED);
// Transicionar a FLOATING cuando completi
if (elapsed >= ENTRY_DURATION) {
if (ELAPSED >= ENTRY_DURATION) {
ship.state = ShipState::FLOATING;
ship.state_time = 0.0F;
ship.oscillation_phase = 0.0F; // Reiniciar fase de oscil·lació
// No resetegem oscillation_phase: així updateFloating continua
// l'oscil·lació iniciada durant ENTERING sense salt.
ship.forward_dir = floatingForward(ship.target_position);
}
}
}
void ShipAnimator::updateFloating(TitleShip& ship, float delta_time) {
using namespace Defaults::Title::Ships;
// Actualitzar time i fase de oscil·lació
void ShipAnimator::updateFloating(TitleShip& ship, float delta_time) {
ship.state_time += delta_time;
ship.oscillation_phase += delta_time;
// Oscil·lació sinusoïdal X/Y (parámetros específics per ship)
float offset_x = ship.amplitude_x * std::sin(2.0F * Defaults::Math::PI * ship.frequency_x * ship.oscillation_phase);
float offset_y = ship.amplitude_y * std::sin((2.0F * Defaults::Math::PI * ship.frequency_y * ship.oscillation_phase) + FLOAT_PHASE_OFFSET);
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float OFFSET_X = ship.amplitude_x *
std::sin(TWO_PI * ship.frequency_x * ship.oscillation_phase);
const float OFFSET_Y = ship.amplitude_y *
std::sin((TWO_PI * ship.frequency_y * ship.oscillation_phase) + FLOAT_PHASE_OFFSET);
// Aplicar oscil·lació a la posición objetivo
ship.current_position.x = ship.target_position.x + offset_x;
ship.current_position.y = ship.target_position.y + offset_y;
// Escala constant (sin "breathing" per ara)
ship.current_position.x = ship.target_position.x + OFFSET_X;
ship.current_position.y = ship.target_position.y + OFFSET_Y;
ship.current_position.z = ship.target_position.z;
ship.current_scale = ship.target_scale;
}
void ShipAnimator::updateExiting(TitleShip& ship, float delta_time) {
using namespace Defaults::Title::Ships;
}
void ShipAnimator::updateExiting(TitleShip& ship, float delta_time) {
ship.state_time += delta_time;
const float PROGRESS = std::min(1.0F, ship.state_time / EXIT_DURATION);
const float EASED = Easing::easeInQuad(PROGRESS);
// Calcular progrés (0.0 → 1.0)
float progress = std::min(1.0F, ship.state_time / EXIT_DURATION);
// Destí: punt fix a (VP.x, VP.y, VP.z + OVERFLOW). Cada nau s'apropa
// al centre projectat des del seu costat sense creuar el VP.
const Vec3 EXIT_DEST{
.x = VANISHING_POINT.x,
.y = VANISHING_POINT.y,
.z = VANISHING_POINT.z + SHIP_EXIT_OVERFLOW,
};
ship.current_position.x = Easing::lerp(ship.initial_position.x, EXIT_DEST.x, EASED);
ship.current_position.y = Easing::lerp(ship.initial_position.y, EXIT_DEST.y, EASED);
ship.current_position.z = Easing::lerp(ship.initial_position.z, EXIT_DEST.z, EASED);
ship.current_scale = ship.target_scale; // L'escala visual baixa via la perspectiva
// Aplicar easing (ease_in_quad per aceleración hacia el point de fuga)
float eased_progress = Easing::easeInQuad(progress);
// Vec2 de fuga (centro del starfield)
constexpr Vec2 VANISHING_POINT{.x = VANISHING_POINT_X, .y = VANISHING_POINT_Y};
// Lerp posición hacia el point de fuga (preservar posición inicial actual)
// Nota: initial_position conté la posición on estava cuando es va activar EXITING
ship.current_position.x = Easing::lerp(ship.initial_position.x, VANISHING_POINT.x, eased_progress);
ship.current_position.y = Easing::lerp(ship.initial_position.y, VANISHING_POINT.y, eased_progress);
// Escala redueix a 0 (simula Z → infinit)
ship.current_scale = ship.target_scale * (1.0F - eased_progress);
// Marcar invisible cuando l'animación completi
if (progress >= 1.0F) {
if (PROGRESS >= 1.0F) {
ship.visible = false;
}
}
}
// Configuración
void ShipAnimator::configureShipP1(TitleShip& ship) {
using namespace Defaults::Title::Ships;
// Estat inicial: FLOATING (per test estàtic)
void ShipAnimator::configureShipP1(TitleShip& ship) {
ship.state = ShipState::FLOATING;
ship.state_time = 0.0F;
// Posicions (clock 8, bottom-left)
ship.target_position = {.x = p1TargetX(), .y = p1TargetY()};
// Calcular posición inicial (fuera de pantalla)
ship.initial_position = computeOffscreenPosition(CLOCK_8_ANGLE);
ship.current_position = ship.initial_position; // Començar fuera de pantalla
// Escales
ship.target_scale = FLOATING_SCALE;
ship.current_scale = FLOATING_SCALE;
ship.initial_scale = ENTRY_SCALE_START;
// Flotació
// Target i initial sobre el path VP → "les 7" del rellotge (P1).
ship.target_position = VANISHING_POINT + (PATH_DIR_P1 * TARGET_DIST);
ship.initial_position = VANISHING_POINT + (PATH_DIR_P1 * ENTRY_DIST);
ship.current_position = ship.initial_position;
ship.target_scale = SHIP_FLOAT_SCALE;
ship.current_scale = SHIP_FLOAT_SCALE;
ship.initial_scale = SHIP_ENTRY_SCALE;
ship.oscillation_phase = 0.0F;
// Parámetros de entrada
ship.entry_delay = P1_ENTRY_DELAY;
// Parámetros de oscil·lació específics P1
ship.amplitude_x = FLOAT_AMPLITUDE_X;
ship.amplitude_y = FLOAT_AMPLITUDE_Y;
ship.frequency_x = FLOAT_FREQUENCY_X_BASE * P1_FREQUENCY_MULTIPLIER;
ship.frequency_y = FLOAT_FREQUENCY_Y_BASE * P1_FREQUENCY_MULTIPLIER;
// Visibilitat
ship.forward_dir = entryForward(ship);
ship.visible = true;
}
}
void ShipAnimator::configureShipP2(TitleShip& ship) {
using namespace Defaults::Title::Ships;
// Estat inicial: FLOATING (per test estàtic)
void ShipAnimator::configureShipP2(TitleShip& ship) {
ship.state = ShipState::FLOATING;
ship.state_time = 0.0F;
// Posicions (clock 4, bottom-right)
ship.target_position = {.x = p2TargetX(), .y = p2TargetY()};
// Calcular posición inicial (fuera de pantalla)
ship.initial_position = computeOffscreenPosition(CLOCK_4_ANGLE);
ship.current_position = ship.initial_position; // Començar fuera de pantalla
// Escales
ship.target_scale = FLOATING_SCALE;
ship.current_scale = FLOATING_SCALE;
ship.initial_scale = ENTRY_SCALE_START;
// Flotació
// Target i initial sobre el path VP → "les 5" del rellotge (P2).
ship.target_position = VANISHING_POINT + (PATH_DIR_P2 * TARGET_DIST);
ship.initial_position = VANISHING_POINT + (PATH_DIR_P2 * ENTRY_DIST);
ship.current_position = ship.initial_position;
ship.target_scale = SHIP_FLOAT_SCALE;
ship.current_scale = SHIP_FLOAT_SCALE;
ship.initial_scale = SHIP_ENTRY_SCALE;
ship.oscillation_phase = 0.0F;
// Parámetros de entrada
ship.entry_delay = P2_ENTRY_DELAY;
// Parámetros de oscil·lació específics P2
ship.amplitude_x = FLOAT_AMPLITUDE_X;
ship.amplitude_y = FLOAT_AMPLITUDE_Y;
ship.frequency_x = FLOAT_FREQUENCY_X_BASE * P2_FREQUENCY_MULTIPLIER;
ship.frequency_y = FLOAT_FREQUENCY_Y_BASE * P2_FREQUENCY_MULTIPLIER;
// Visibilitat
ship.forward_dir = entryForward(ship);
ship.visible = true;
}
auto ShipAnimator::computeOffscreenPosition(float angle_rellotge) -> Vec2 {
using namespace Defaults::Title::Ships;
// Convertir angle del rellotge a radians (per exemple: 240° per clock 8)
// Calcular posición en direcció radial des del centro, pero més lluny
// ENTRY_OFFSET es calcula automàticament: (SHIP_MAX_RADIUS * ENTRY_SCALE_START) + ENTRY_OFFSET_MARGIN
float extended_radius = CLOCK_RADIUS + ENTRY_OFFSET;
float x = (Defaults::Game::WIDTH / 2.0F) + (extended_radius * std::cos(angle_rellotge));
float y = (Defaults::Game::HEIGHT / 2.0F) + (extended_radius * std::sin(angle_rellotge));
return {.x = x, .y = y};
}
}
} // namespace Title
+47 -59
View File
@@ -1,104 +1,92 @@
// ship_animator.hpp - Sistema de animación de naves para l'escena de título
// ship_animator.hpp - Sistema d'animació de naus 3D per a l'escena de títol
// © 2026 JailDesigner
//
// Manté la mateixa màquina d'estats
// (ENTERING → FLOATING → EXITING) però treballa amb posicions Vec3 i emet
// wireframes a través d'una `Camera3D`. La geometria s'extrau de `ship.shp`
// (P1) i `ship2.shp` (P2) per extrusió en Z.
#pragma once
#include "core/rendering/render_context.hpp"
#include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <memory>
#include <functional>
#include "core/graphics/shape.hpp"
#include "core/graphics/camera3d.hpp"
#include "core/graphics/wireframe3d.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Title {
// Estats de l'animación de la ship
enum class ShipState : std::uint8_t {
ENTERING, // Entrant desde fuera de pantalla
FLOATING, // Flotante en posición estàtica
EXITING // Volant hacia el point de fuga
};
enum class ShipState : std::uint8_t {
ENTERING,
FLOATING,
EXITING,
};
// Dades de una ship individual al título.
// Todos los miembros tienen inicializador por defecto: ShipAnimator::ships_
// es un std::array<TitleShip, 2> y sin estos defaults los campos primitivos
// quedarían indeterminados al instanciar el animador.
struct TitleShip {
// Identificació
int player_id{0}; // 1 o 2
// Estat
struct TitleShip {
int player_id{0};
ShipState state{ShipState::ENTERING};
float state_time{0.0F}; // Temps acumulat en l'state actual
float state_time{0.0F};
// Posicions
Vec2 initial_position{}; // Posición de start (fuera de pantalla per ENTERING)
Vec2 target_position{}; // Posición objetivo (rellotge 8 o 4)
Vec2 current_position{}; // Posición interpolada actual
Vec3 initial_position{};
Vec3 target_position{};
Vec3 current_position{};
// Escales (simulació eix Z)
float initial_scale{1.0F}; // Escala de start (més grande = més a prop)
float target_scale{1.0F}; // Escala objetivo (mida flotació)
float current_scale{1.0F}; // Escala interpolada actual
float initial_scale{1.0F};
float target_scale{1.0F};
float current_scale{1.0F};
// Flotació
float oscillation_phase{0.0F}; // Acumulador de fase per movement sinusoïdal
float oscillation_phase{0.0F};
float entry_delay{0.0F};
// Parámetros de entrada
float entry_delay{0.0F}; // Delay antes de entrar (0.0 per P1, 0.5 per P2)
// Parámetros de oscil·lació per ship
float amplitude_x{0.0F};
float amplitude_y{0.0F};
float frequency_x{0.0F};
float frequency_y{0.0F};
// Forma
std::shared_ptr<Graphics::Shape> shape;
// Visibilitat
Graphics::Mesh3D mesh;
// Vector mundial cap a on apunta el front del shape. Recalculat a cada
// transició d'estat perquè draw() oriente la nau (look-at) en la
// direcció del seu path actual.
Vec3 forward_dir{.x = 0.0F, .y = 0.0F, .z = 1.0F};
bool visible{false};
};
};
// Gestor de animación de naves para l'escena de título
class ShipAnimator {
class ShipAnimator {
public:
explicit ShipAnimator(Rendering::Renderer* renderer);
ShipAnimator(Rendering::Renderer* renderer, const Graphics::Camera3D* camera);
// Cicle de vida
void init();
void update(float delta_time);
void draw() const;
// Control de state (cridat per TitleScene)
void startEntryAnimation();
void triggerExitAnimation(); // Anima todas las naves
void triggerExitAnimationForPlayer(int player_id); // Anima solo una ship (P1=1, P2=2)
void skipToFloatingState(); // Salta directament a FLOATING sin animación
void triggerExitAnimation();
void triggerExitAnimationForPlayer(int player_id);
void skipToFloatingState();
// Control de visibilitat
void setVisible(bool visible);
[[nodiscard]] auto isAnimationComplete() const -> bool;
[[nodiscard]] auto isVisible() const -> bool; // Comprova si alguna ship es visible
[[nodiscard]] auto isVisible() const -> bool;
// Callback disparat quan una nau acaba l'EXITING (es torna invisible
// al VP). Útil per a un destell que tapi el pop final.
using ShipDisappearCallback = std::function<void(int player_id)>;
void setOnShipDisappear(ShipDisappearCallback cb) { on_ship_disappear_ = std::move(cb); }
private:
Rendering::Renderer* renderer_;
std::array<TitleShip, 2> ships_; // Naves P1 i P2
const Graphics::Camera3D* camera_;
std::array<TitleShip, 2> ships_;
ShipDisappearCallback on_ship_disappear_;
// Métodos de animación. Estáticos: solo modifican el TitleShip pasado,
// sin tocar otros miembros del ShipAnimator.
static void updateEntering(TitleShip& ship, float delta_time);
static void updateFloating(TitleShip& ship, float delta_time);
static void updateExiting(TitleShip& ship, float delta_time);
// Configuración (también estáticos: trabajan sobre el ship pasado).
static void configureShipP1(TitleShip& ship);
static void configureShipP2(TitleShip& ship);
[[nodiscard]] static auto computeOffscreenPosition(float angle_rellotge) -> Vec2;
};
};
} // namespace Title
-328
View File
@@ -1,328 +0,0 @@
// ship_animator3d.cpp - Implementació de l'animador de naus 3D
// © 2026 JailDesigner
#include "ship_animator3d.hpp"
#include <algorithm>
#include <cmath>
#include "core/defaults.hpp"
#include "core/graphics/shape_loader.hpp"
#include "core/math/easing.hpp"
namespace Title {
namespace {
// Profunditat d'extrusió de la silueta 2D de la nau (en unitats mundials).
// 0.0F → emet només la silueta plana. >0 emet volum extrudit.
constexpr float SHIP_EXTRUSION_DEPTH = 1.0F;
// Punt de fuga (al fons, centre projectat). Tots els paths convergeixen aquí.
constexpr float SHIP_EXIT_Z = 800.0F;
constexpr Vec3 VANISHING_POINT{.x = 0.0F, .y = 0.0F, .z = SHIP_EXIT_Z};
// Directors VP → origen de cada nau, normalitzats. P1 ve des de "les 7"
// del rellotge (baix-esquerra), P2 des de "les 5" (baix-dreta). Els
// components estan calibrats perquè a TARGET_DIST el pixel projectat
// caigui aprox sota la "P de PRESS" / "Y de PLAY" del text del títol.
constexpr Vec3 PATH_DIR_P1{.x = -0.0887F, .y = -0.0683F, .z = -0.9938F};
constexpr Vec3 PATH_DIR_P2{.x = +0.0887F, .y = -0.0683F, .z = -0.9938F};
// Distàncies des del VP al llarg del path (unitats mundials).
constexpr float TARGET_DIST = 563.5F; // Descans a Z≈240 → pixel ≈ (510, 460)
constexpr float ENTRY_DIST = 750.0F; // Inicial a Z≈54 → fora pantalla baix-esq.
// Pitch addicional sobre el look-at pur per fer que el dors de la nau
// s'incline cap a la càmera (~-14° afegits). Amb forward quasi paral·lel
// a +Z, el pitch look-at és ~-94°; afegint això queda al voltant de -108°,
// que és l'angle visualment validat com a "bo" per l'usuari.
constexpr float PITCH_LIFT_RAD = -0.25F;
// Look-at: calcula pitch+yaw que duen (0,-1,0) local a forward_dir mundial.
// Requereix l'ordre de rotació X→Y→Z al applyTransform de wireframe3d.
// Si forward és quasi vertical (sin(pitch) ≈ 0), retorna yaw=0 (qualsevol).
auto computePitchYawForLookAt(const Vec3& forward_dir) -> Vec2 {
const float DY = std::clamp(forward_dir.y, -1.0F, 1.0F);
const float PITCH_LOOKAT = -std::acos(-DY); // ∈ [-π, 0]
const float SIN_PITCH = std::sin(PITCH_LOOKAT);
float yaw = 0.0F;
if (std::abs(SIN_PITCH) >= 1.0E-5F) {
const float SY = -forward_dir.x / SIN_PITCH;
const float CY = -forward_dir.z / SIN_PITCH;
yaw = std::atan2(SY, CY);
}
return Vec2{.x = PITCH_LOOKAT + PITCH_LIFT_RAD, .y = yaw};
}
auto safeNormalize(const Vec3& v, const Vec3& fallback) -> Vec3 {
return v.lengthSquared() > 0.0F ? v.normalized() : fallback;
}
auto entryForward(const TitleShip3D& ship) -> Vec3 {
return safeNormalize(ship.target_position - ship.initial_position,
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F});
}
auto floatingForward(const Vec3& target) -> Vec3 {
return safeNormalize(VANISHING_POINT - target,
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F});
}
auto exitForward(const Vec3& current) -> Vec3 {
return safeNormalize(VANISHING_POINT - current,
Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F});
}
// Mida visual i animació.
constexpr float SHIP_FLOAT_SCALE = 1.1F;
constexpr float SHIP_ENTRY_SCALE = 1.1F; // Mida mundial idèntica; la perspectiva fa la resta
constexpr float ENTRY_DURATION = 2.0F;
constexpr float EXIT_DURATION = 1.0F;
// Oscil·lació en unitats mundials (al voltant del target_position).
constexpr float FLOAT_AMPLITUDE_X = 1.5F;
constexpr float FLOAT_AMPLITUDE_Y = 1.0F;
constexpr float FLOAT_FREQUENCY_X_BASE = 0.5F;
constexpr float FLOAT_FREQUENCY_Y_BASE = 0.7F;
constexpr float FLOAT_PHASE_OFFSET = 1.57F;
constexpr float P1_FREQUENCY_MULTIPLIER = 0.88F;
constexpr float P2_FREQUENCY_MULTIPLIER = 1.12F;
constexpr float P1_ENTRY_DELAY = 0.0F;
constexpr float P2_ENTRY_DELAY = 0.5F;
} // namespace
ShipAnimator3D::ShipAnimator3D(Rendering::Renderer* renderer,
const Graphics::Camera3D* camera)
: renderer_(renderer),
camera_(camera) {
}
void ShipAnimator3D::init() {
auto shape_p1 = Graphics::ShapeLoader::load("ship.shp");
auto shape_p2 = Graphics::ShapeLoader::load("ship2.shp");
ships_[0].player_id = 1;
if (shape_p1 && shape_p1->isValid()) {
ships_[0].mesh = Graphics::extrudeShape2D(*shape_p1, SHIP_EXTRUSION_DEPTH);
}
configureShipP1(ships_[0]);
ships_[1].player_id = 2;
if (shape_p2 && shape_p2->isValid()) {
ships_[1].mesh = Graphics::extrudeShape2D(*shape_p2, SHIP_EXTRUSION_DEPTH);
}
configureShipP2(ships_[1]);
}
void ShipAnimator3D::update(float delta_time) {
for (auto& ship : ships_) {
if (!ship.visible) {
continue;
}
switch (ship.state) {
case ShipState3D::ENTERING:
updateEntering(ship, delta_time);
break;
case ShipState3D::FLOATING:
updateFloating(ship, delta_time);
break;
case ShipState3D::EXITING:
updateExiting(ship, delta_time);
break;
}
}
}
void ShipAnimator3D::draw() const {
if (camera_ == nullptr || renderer_ == nullptr) {
return;
}
for (const auto& ship : ships_) {
if (!ship.visible) {
continue;
}
const Vec2 EULER = computePitchYawForLookAt(ship.forward_dir);
const Graphics::Transform3D TRANSFORM{
.position = ship.current_position,
.rotation_euler = Vec3{.x = EULER.x, .y = EULER.y, .z = 0.0F},
.scale = ship.current_scale,
};
Graphics::drawWireframe(renderer_, *camera_, ship.mesh, TRANSFORM, 1.0F);
}
}
void ShipAnimator3D::startEntryAnimation() {
for (auto& ship : ships_) {
ship.state = ShipState3D::ENTERING;
ship.state_time = 0.0F;
ship.current_position = ship.initial_position;
ship.current_scale = ship.initial_scale;
ship.forward_dir = entryForward(ship);
}
}
void ShipAnimator3D::triggerExitAnimation() {
for (auto& ship : ships_) {
ship.state = ShipState3D::EXITING;
ship.state_time = 0.0F;
ship.initial_position = ship.current_position;
ship.forward_dir = exitForward(ship.current_position);
}
}
void ShipAnimator3D::triggerExitAnimationForPlayer(int player_id) {
for (auto& ship : ships_) {
if (ship.player_id == player_id) {
ship.state = ShipState3D::EXITING;
ship.state_time = 0.0F;
ship.initial_position = ship.current_position;
ship.forward_dir = exitForward(ship.current_position);
break;
}
}
}
void ShipAnimator3D::skipToFloatingState() {
for (auto& ship : ships_) {
ship.state = ShipState3D::FLOATING;
ship.state_time = 0.0F;
ship.oscillation_phase = 0.0F;
ship.current_position = ship.target_position;
ship.current_scale = ship.target_scale;
ship.forward_dir = floatingForward(ship.target_position);
}
}
void ShipAnimator3D::setVisible(bool visible) {
for (auto& ship : ships_) {
ship.visible = visible;
}
}
auto ShipAnimator3D::isVisible() const -> bool {
return std::ranges::any_of(ships_,
[](const TitleShip3D& s) { return s.visible; });
}
auto ShipAnimator3D::isAnimationComplete() const -> bool {
return std::ranges::all_of(ships_,
[](const TitleShip3D& s) { return !s.visible; });
}
void ShipAnimator3D::updateEntering(TitleShip3D& ship, float delta_time) {
ship.state_time += delta_time;
if (ship.state_time < ship.entry_delay) {
ship.current_position = ship.initial_position;
ship.current_scale = ship.initial_scale;
return;
}
const float ELAPSED = ship.state_time - ship.entry_delay;
const float PROGRESS = std::min(1.0F, ELAPSED / ENTRY_DURATION);
const float EASED = Easing::easeOutQuad(PROGRESS);
// Acumula la fase d'oscil·lació també durant ENTERING; sense això,
// al passar a FLOATING la posició salta d'amplitud_y de cop perquè
// l'offset Y comença a sin(π/2) = 1. Acumulant-la abans, la nau
// ja oscil·la mentre s'aproxima i la transició és contínua.
ship.oscillation_phase += delta_time;
const float INTERP_X = Easing::lerp(ship.initial_position.x, ship.target_position.x, EASED);
const float INTERP_Y = Easing::lerp(ship.initial_position.y, ship.target_position.y, EASED);
const float INTERP_Z = Easing::lerp(ship.initial_position.z, ship.target_position.z, EASED);
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float OFFSET_X = ship.amplitude_x *
std::sin(TWO_PI * ship.frequency_x * ship.oscillation_phase);
const float OFFSET_Y = ship.amplitude_y *
std::sin((TWO_PI * ship.frequency_y * ship.oscillation_phase) + FLOAT_PHASE_OFFSET);
ship.current_position.x = INTERP_X + OFFSET_X;
ship.current_position.y = INTERP_Y + OFFSET_Y;
ship.current_position.z = INTERP_Z;
ship.current_scale = Easing::lerp(ship.initial_scale, ship.target_scale, EASED);
if (ELAPSED >= ENTRY_DURATION) {
ship.state = ShipState3D::FLOATING;
ship.state_time = 0.0F;
// No resetegem oscillation_phase: així updateFloating continua
// l'oscil·lació iniciada durant ENTERING sense salt.
ship.forward_dir = floatingForward(ship.target_position);
}
}
void ShipAnimator3D::updateFloating(TitleShip3D& ship, float delta_time) {
ship.state_time += delta_time;
ship.oscillation_phase += delta_time;
const float TWO_PI = 2.0F * Defaults::Math::PI;
const float OFFSET_X = ship.amplitude_x *
std::sin(TWO_PI * ship.frequency_x * ship.oscillation_phase);
const float OFFSET_Y = ship.amplitude_y *
std::sin((TWO_PI * ship.frequency_y * ship.oscillation_phase) + FLOAT_PHASE_OFFSET);
ship.current_position.x = ship.target_position.x + OFFSET_X;
ship.current_position.y = ship.target_position.y + OFFSET_Y;
ship.current_position.z = ship.target_position.z;
ship.current_scale = ship.target_scale;
}
void ShipAnimator3D::updateExiting(TitleShip3D& ship, float delta_time) {
ship.state_time += delta_time;
const float PROGRESS = std::min(1.0F, ship.state_time / EXIT_DURATION);
const float EASED = Easing::easeInQuad(PROGRESS);
// Vola cap al centre projectat (x=0, y=0) i a Z gran (lluny).
ship.current_position.x = Easing::lerp(ship.initial_position.x, 0.0F, EASED);
ship.current_position.y = Easing::lerp(ship.initial_position.y, 0.0F, EASED);
ship.current_position.z = Easing::lerp(ship.initial_position.z, SHIP_EXIT_Z, EASED);
ship.current_scale = ship.target_scale; // L'escala visual baixa via la perspectiva
if (PROGRESS >= 1.0F) {
ship.visible = false;
}
}
void ShipAnimator3D::configureShipP1(TitleShip3D& ship) {
ship.state = ShipState3D::FLOATING;
ship.state_time = 0.0F;
// Target i initial sobre el path VP → "les 7" del rellotge (P1).
ship.target_position = VANISHING_POINT + (PATH_DIR_P1 * TARGET_DIST);
ship.initial_position = VANISHING_POINT + (PATH_DIR_P1 * ENTRY_DIST);
ship.current_position = ship.initial_position;
ship.target_scale = SHIP_FLOAT_SCALE;
ship.current_scale = SHIP_FLOAT_SCALE;
ship.initial_scale = SHIP_ENTRY_SCALE;
ship.oscillation_phase = 0.0F;
ship.entry_delay = P1_ENTRY_DELAY;
ship.amplitude_x = FLOAT_AMPLITUDE_X;
ship.amplitude_y = FLOAT_AMPLITUDE_Y;
ship.frequency_x = FLOAT_FREQUENCY_X_BASE * P1_FREQUENCY_MULTIPLIER;
ship.frequency_y = FLOAT_FREQUENCY_Y_BASE * P1_FREQUENCY_MULTIPLIER;
ship.forward_dir = entryForward(ship);
ship.visible = true;
}
void ShipAnimator3D::configureShipP2(TitleShip3D& ship) {
ship.state = ShipState3D::FLOATING;
ship.state_time = 0.0F;
// Target i initial sobre el path VP → "les 5" del rellotge (P2).
ship.target_position = VANISHING_POINT + (PATH_DIR_P2 * TARGET_DIST);
ship.initial_position = VANISHING_POINT + (PATH_DIR_P2 * ENTRY_DIST);
ship.current_position = ship.initial_position;
ship.target_scale = SHIP_FLOAT_SCALE;
ship.current_scale = SHIP_FLOAT_SCALE;
ship.initial_scale = SHIP_ENTRY_SCALE;
ship.oscillation_phase = 0.0F;
ship.entry_delay = P2_ENTRY_DELAY;
ship.amplitude_x = FLOAT_AMPLITUDE_X;
ship.amplitude_y = FLOAT_AMPLITUDE_Y;
ship.frequency_x = FLOAT_FREQUENCY_X_BASE * P2_FREQUENCY_MULTIPLIER;
ship.frequency_y = FLOAT_FREQUENCY_Y_BASE * P2_FREQUENCY_MULTIPLIER;
ship.forward_dir = entryForward(ship);
ship.visible = true;
}
} // namespace Title
-85
View File
@@ -1,85 +0,0 @@
// ship_animator3d.hpp - Sistema d'animació de naus 3D per a l'escena de títol
// © 2026 JailDesigner
//
// Equivalent 3D del `Title::ShipAnimator`. Manté la mateixa màquina d'estats
// (ENTERING → FLOATING → EXITING) però treballa amb posicions Vec3 i emet
// wireframes a través d'una `Camera3D`. La geometria s'extrau de `ship.shp`
// (P1) i `ship2.shp` (P2) per extrusió en Z.
#pragma once
#include <array>
#include <cstdint>
#include "core/graphics/camera3d.hpp"
#include "core/graphics/wireframe3d.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Title {
enum class ShipState3D : std::uint8_t {
ENTERING,
FLOATING,
EXITING,
};
struct TitleShip3D {
int player_id{0};
ShipState3D state{ShipState3D::ENTERING};
float state_time{0.0F};
Vec3 initial_position{};
Vec3 target_position{};
Vec3 current_position{};
float initial_scale{1.0F};
float target_scale{1.0F};
float current_scale{1.0F};
float oscillation_phase{0.0F};
float entry_delay{0.0F};
float amplitude_x{0.0F};
float amplitude_y{0.0F};
float frequency_x{0.0F};
float frequency_y{0.0F};
Graphics::Mesh3D mesh;
// Vector mundial cap a on apunta el front del shape. Recalculat a cada
// transició d'estat perquè draw() oriente la nau (look-at) en la
// direcció del seu path actual.
Vec3 forward_dir{.x = 0.0F, .y = 0.0F, .z = 1.0F};
bool visible{false};
};
class ShipAnimator3D {
public:
ShipAnimator3D(Rendering::Renderer* renderer, const Graphics::Camera3D* camera);
void init();
void update(float delta_time);
void draw() const;
void startEntryAnimation();
void triggerExitAnimation();
void triggerExitAnimationForPlayer(int player_id);
void skipToFloatingState();
void setVisible(bool visible);
[[nodiscard]] auto isAnimationComplete() const -> bool;
[[nodiscard]] auto isVisible() const -> bool;
private:
Rendering::Renderer* renderer_;
const Graphics::Camera3D* camera_;
std::array<TitleShip3D, 2> ships_;
static void updateEntering(TitleShip3D& ship, float delta_time);
static void updateFloating(TitleShip3D& ship, float delta_time);
static void updateExiting(TitleShip3D& ship, float delta_time);
static void configureShipP1(TitleShip3D& ship);
static void configureShipP2(TitleShip3D& ship);
};
} // namespace Title

Some files were not shown because too many files have changed in this diff Show More