Compare commits
38 Commits
18c29fed1e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d6a9e740 | |||
| e1921858ed | |||
| 14cb96345c | |||
| d28feb3276 | |||
| a7a3ae9055 | |||
| 5ec4bf926b | |||
| 0e04617968 | |||
| b450e2af21 | |||
| a65756f298 | |||
| dac312ef2b | |||
| 953d6af701 | |||
| c14e305a4a | |||
| fb036dede5 | |||
| 3c9dc0ff65 | |||
| d3ca238a51 | |||
| a729dc089e | |||
| 18463774e9 | |||
| 694243ac89 | |||
| 60ddc9ddd3 | |||
| 70946fc720 | |||
| fb82ac06d0 | |||
| 494f906435 | |||
| 50c869536d | |||
| 0b99911f5d | |||
| 33d5eedec8 | |||
| 744268fedd | |||
| 06aa63f548 | |||
| a9943ce8bf | |||
| b46af7ab1d | |||
| ab22d4c34f | |||
| e2d6ea64a4 | |||
| 322744c296 | |||
| cf3e897752 | |||
| 4efb60bb5b | |||
| afd7fdf18d | |||
| 5b9eb5f0e3 | |||
| 09c8d3c0ef | |||
| a6c2c78cb5 |
168
.copilot-rules.md
Normal file
168
.copilot-rules.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# Copilot Rules — Spacetris (SDL3 / C++20)
|
||||||
|
|
||||||
|
These rules define **non-negotiable constraints** for all AI-assisted changes.
|
||||||
|
They exist to preserve determinism, performance, and architecture.
|
||||||
|
|
||||||
|
If these rules conflict with `.github/copilot-instructions.md`,
|
||||||
|
**follow `.github/copilot-instructions.md`.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Constraints (Non-Negotiable)
|
||||||
|
|
||||||
|
- Language: **C++20**
|
||||||
|
- Runtime: **SDL3** + **SDL3_ttf**
|
||||||
|
- Build system: **CMake**
|
||||||
|
- Dependencies via **vcpkg**
|
||||||
|
- Assets must use **relative paths only**
|
||||||
|
- Deterministic gameplay logic is mandatory
|
||||||
|
|
||||||
|
Do not rewrite or refactor working systems unless explicitly requested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo Layout & Responsibilities
|
||||||
|
|
||||||
|
- Core gameplay loop/state: `src/Game.*`
|
||||||
|
- Entry point: `src/main.cpp`
|
||||||
|
- Text/TTF: `src/Font.*`
|
||||||
|
- Audio: `src/Audio.*`, `src/SoundEffect.*`
|
||||||
|
- Effects: `src/LineEffect.*`, `src/Starfield*.cpp`
|
||||||
|
- High scores: `src/Scores.*`
|
||||||
|
- Packaging: `build-production.ps1`
|
||||||
|
|
||||||
|
When adding a module:
|
||||||
|
- Place it under `src/` (or an established subfolder)
|
||||||
|
- Register it in `CMakeLists.txt`
|
||||||
|
- Avoid circular includes
|
||||||
|
- Keep headers minimal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Verification
|
||||||
|
|
||||||
|
Prefer existing scripts:
|
||||||
|
|
||||||
|
- Debug: `cmake --build build-msvc --config Debug`
|
||||||
|
- Release:
|
||||||
|
- Configure: `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release`
|
||||||
|
- Build: `cmake --build build-release --config Release`
|
||||||
|
- Packaging (Windows): `./build-production.ps1`
|
||||||
|
|
||||||
|
Before finalizing changes:
|
||||||
|
- Debug build must succeed
|
||||||
|
- Packaging must succeed if assets or DLLs are touched
|
||||||
|
|
||||||
|
Do not introduce new build steps unless required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding & Architecture Rules
|
||||||
|
|
||||||
|
- Match local file style (naming, braces, spacing)
|
||||||
|
- Avoid large refactors
|
||||||
|
- Prefer small, testable helpers
|
||||||
|
- Avoid floating-point math in core gameplay state
|
||||||
|
- Game logic must be deterministic
|
||||||
|
- Rendering code must not mutate game state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rendering & Performance Rules
|
||||||
|
|
||||||
|
- Do not allocate memory per frame
|
||||||
|
- Do not load assets during rendering
|
||||||
|
- No blocking calls in render loop
|
||||||
|
- Visual effects must be time-based (`deltaTime`)
|
||||||
|
- Rendering must not contain gameplay logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threading Rules
|
||||||
|
|
||||||
|
- SDL main thread:
|
||||||
|
- Rendering
|
||||||
|
- Input
|
||||||
|
- Game simulation
|
||||||
|
- Networking must be **non-blocking** from the SDL main loop
|
||||||
|
- Either run networking on a separate thread, or poll ENet frequently with a 0 timeout
|
||||||
|
- Never wait/spin for remote inputs on the render thread
|
||||||
|
- Cross-thread communication via queues or buffers only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets, Fonts, and Paths
|
||||||
|
|
||||||
|
- Runtime expects adjacent `assets/` directory
|
||||||
|
- `FreeSans.ttf` must remain at repo root
|
||||||
|
- New assets:
|
||||||
|
- Go under `assets/`
|
||||||
|
- Must be included in `build-production.ps1`
|
||||||
|
|
||||||
|
Never hardcode machine-specific paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Partner (COOPERATE Mode)
|
||||||
|
|
||||||
|
- AI is **supportive**, not competitive
|
||||||
|
- AI must respect sync timing and shared grid logic
|
||||||
|
- AI must not “cheat” or see hidden future pieces
|
||||||
|
- AI behavior must be deterministic per seed/difficulty
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Networking (COOPERATE Network Mode)
|
||||||
|
|
||||||
|
Follow `docs/ai/cooperate_network.md`.
|
||||||
|
If `network_cooperate_multiplayer.md` exists, keep it consistent with the canonical doc.
|
||||||
|
|
||||||
|
Mandatory model:
|
||||||
|
- **Input lockstep**
|
||||||
|
- Transmit inputs only (no board state replication)
|
||||||
|
|
||||||
|
Determinism requirements:
|
||||||
|
- Fixed tick (e.g. 60 Hz)
|
||||||
|
- Shared RNG seed
|
||||||
|
- Deterministic gravity, rotation, locking, scoring
|
||||||
|
|
||||||
|
Technology:
|
||||||
|
- Use **ENet**
|
||||||
|
- Do NOT use SDL_net or TCP-only networking
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- Networking must be isolated (e.g. `src/network/NetSession.*`)
|
||||||
|
- Game logic must not care if partner is local, AI, or network
|
||||||
|
|
||||||
|
Robustness:
|
||||||
|
- Input delay buffer (4–6 ticks)
|
||||||
|
- Periodic desync hashing
|
||||||
|
- Graceful disconnect handling
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
- Rollback
|
||||||
|
- Full state sync
|
||||||
|
- Server-authoritative sim
|
||||||
|
- Matchmaking SDKs
|
||||||
|
- Versus mechanics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Behavior Rules (IMPORTANT)
|
||||||
|
|
||||||
|
- Always read relevant markdown specs **before coding**
|
||||||
|
- Treat markdown specs as authoritative
|
||||||
|
- Do not invent APIs
|
||||||
|
- Do not assume external libraries exist
|
||||||
|
- Generate code **file by file**, not everything at once
|
||||||
|
- Ask before changing architecture or ownership boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Ask Before Proceeding
|
||||||
|
|
||||||
|
Ask the maintainer if unclear:
|
||||||
|
- UX or menu flow decisions
|
||||||
|
- Adding dependencies
|
||||||
|
- Refactors vs local patches
|
||||||
|
- Platform-specific behavior
|
||||||
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
@ -1,4 +1,4 @@
|
|||||||
# Copilot Instructions — Tetris (C++ SDL3)
|
# Copilot Instructions — Spacetris (C++ SDL3)
|
||||||
|
|
||||||
Purpose: Speed up development on this native SDL3 Tetris. Follow these conventions to keep builds reproducible and packages shippable.
|
Purpose: Speed up development on this native SDL3 Tetris. Follow these conventions to keep builds reproducible and packages shippable.
|
||||||
|
|
||||||
@ -9,11 +9,9 @@ Purpose: Speed up development on this native SDL3 Tetris. Follow these conventio
|
|||||||
- Assets: `assets/` (images/music/fonts), plus `FreeSans.ttf` at repo root.
|
- Assets: `assets/` (images/music/fonts), plus `FreeSans.ttf` at repo root.
|
||||||
|
|
||||||
## Build and run
|
## Build and run
|
||||||
- Configure and build Release:
|
|
||||||
- CMake picks up vcpkg toolchain if found (`VCPKG_ROOT`, local `vcpkg/`, or user path). Required packages: `sdl3`, `sdl3-ttf` (see `vcpkg.json`).
|
- CMake picks up vcpkg toolchain if found (`VCPKG_ROOT`, local `vcpkg/`, or user path). Required packages: `sdl3`, `sdl3-ttf` (see `vcpkg.json`).
|
||||||
- Typical sequence (PowerShell): `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release` then `cmake --build build-release --config Release`.
|
- Typical sequence (PowerShell): `cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release` then `cmake --build build-release --config Release`.
|
||||||
- Packaging: `.\build-production.ps1` creates `dist/TetrisGame/` with exe, DLLs, assets, README, and ZIP; it can clean via `-Clean` and package-only via `-PackageOnly`.
|
Packaging: `.\build-production.ps1` creates `dist/SpacetrisGame/` with exe, DLLs, assets, README, and ZIP; it can clean via `-Clean` and package-only via `-PackageOnly`.
|
||||||
- MSVC generator builds are under `build-msvc/` when using Visual Studio.
|
|
||||||
|
|
||||||
## Runtime dependencies
|
## Runtime dependencies
|
||||||
- Links: `SDL3::SDL3`, `SDL3_ttf::SDL3_ttf`; on Windows also `mfplat`, `mfreadwrite`, `mfuuid` for media.
|
- Links: `SDL3::SDL3`, `SDL3_ttf::SDL3_ttf`; on Windows also `mfplat`, `mfreadwrite`, `mfuuid` for media.
|
||||||
|
|||||||
36
.github/workflows/build.yml
vendored
36
.github/workflows/build.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Build and Package Tetris
|
name: Build and Package Spacetris
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -43,20 +43,20 @@ jobs:
|
|||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: tetris-windows-x64
|
name: spacetris-windows-x64
|
||||||
path: dist/TetrisGame/
|
path: dist/SpacetrisGame/
|
||||||
|
|
||||||
- name: Create Release ZIP
|
- name: Create Release ZIP
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
7z a ../TetrisGame-Windows-x64.zip TetrisGame/
|
7z a ../SpacetrisGame-Windows-x64.zip SpacetrisGame/
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: TetrisGame-Windows-x64.zip
|
files: SpacetrisGame-Windows-x64.zip
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@ -83,32 +83,32 @@ jobs:
|
|||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/TetrisGame-Linux
|
mkdir -p dist/SpacetrisGame-Linux
|
||||||
cp build/tetris dist/TetrisGame-Linux/
|
cp build/spacetris dist/SpacetrisGame-Linux/
|
||||||
cp -r assets dist/TetrisGame-Linux/
|
cp -r assets dist/SpacetrisGame-Linux/
|
||||||
cp FreeSans.ttf dist/TetrisGame-Linux/
|
cp FreeSans.ttf dist/SpacetrisGame-Linux/
|
||||||
echo '#!/bin/bash' > dist/TetrisGame-Linux/launch-tetris.sh
|
echo '#!/bin/bash' > dist/SpacetrisGame-Linux/launch-spacetris.sh
|
||||||
echo 'cd "$(dirname "$0")"' >> dist/TetrisGame-Linux/launch-tetris.sh
|
echo 'cd "$(dirname "$0")"' >> dist/SpacetrisGame-Linux/launch-spacetris.sh
|
||||||
echo './tetris' >> dist/TetrisGame-Linux/launch-tetris.sh
|
echo './spacetris' >> dist/SpacetrisGame-Linux/launch-spacetris.sh
|
||||||
chmod +x dist/TetrisGame-Linux/launch-tetris.sh
|
chmod +x dist/SpacetrisGame-Linux/launch-spacetris.sh
|
||||||
chmod +x dist/TetrisGame-Linux/tetris
|
chmod +x dist/SpacetrisGame-Linux/spacetris
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: tetris-linux-x64
|
name: spacetris-linux-x64
|
||||||
path: dist/TetrisGame-Linux/
|
path: dist/SpacetrisGame-Linux/
|
||||||
|
|
||||||
- name: Create Release TAR
|
- name: Create Release TAR
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
tar -czf ../TetrisGame-Linux-x64.tar.gz TetrisGame-Linux/
|
tar -czf ../SpacetrisGame-Linux-x64.tar.gz SpacetrisGame-Linux/
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: TetrisGame-Linux-x64.tar.gz
|
files: SpacetrisGame-Linux-x64.tar.gz
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
|||||||
# .gitignore for Tetris (native C++ project and web subproject)
|
# .gitignore for Spacetris (native C++ project and web subproject)
|
||||||
|
|
||||||
# Visual Studio / VS artifacts
|
# Visual Studio / VS artifacts
|
||||||
.vs/
|
.vs/
|
||||||
|
|||||||
@ -15,7 +15,7 @@ if(DEFINED CMAKE_TOOLCHAIN_FILE)
|
|||||||
set(CMAKE_TOOLCHAIN_FILE "${CMAKE_TOOLCHAIN_FILE}" CACHE STRING "" FORCE)
|
set(CMAKE_TOOLCHAIN_FILE "${CMAKE_TOOLCHAIN_FILE}" CACHE STRING "" FORCE)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(tetris_sdl3 LANGUAGES CXX)
|
project(spacetris_sdl3 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@ -28,11 +28,14 @@ find_package(SDL3_ttf CONFIG REQUIRED)
|
|||||||
find_package(SDL3_image CONFIG REQUIRED)
|
find_package(SDL3_image CONFIG REQUIRED)
|
||||||
find_package(cpr CONFIG REQUIRED)
|
find_package(cpr CONFIG REQUIRED)
|
||||||
find_package(nlohmann_json CONFIG REQUIRED)
|
find_package(nlohmann_json CONFIG REQUIRED)
|
||||||
|
find_package(unofficial-enet CONFIG REQUIRED)
|
||||||
|
|
||||||
set(TETRIS_SOURCES
|
set(TETRIS_SOURCES
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/app/TetrisApp.cpp
|
src/app/TetrisApp.cpp
|
||||||
src/gameplay/core/Game.cpp
|
src/gameplay/core/Game.cpp
|
||||||
|
src/gameplay/coop/CoopGame.cpp
|
||||||
|
src/gameplay/coop/CoopAIController.cpp
|
||||||
src/core/GravityManager.cpp
|
src/core/GravityManager.cpp
|
||||||
src/core/state/StateManager.cpp
|
src/core/state/StateManager.cpp
|
||||||
# New core architecture classes
|
# New core architecture classes
|
||||||
@ -43,16 +46,20 @@ set(TETRIS_SOURCES
|
|||||||
src/core/Settings.cpp
|
src/core/Settings.cpp
|
||||||
src/graphics/renderers/RenderManager.cpp
|
src/graphics/renderers/RenderManager.cpp
|
||||||
src/persistence/Scores.cpp
|
src/persistence/Scores.cpp
|
||||||
|
src/network/supabase_client.cpp
|
||||||
|
src/network/NetSession.cpp
|
||||||
src/graphics/effects/Starfield.cpp
|
src/graphics/effects/Starfield.cpp
|
||||||
src/graphics/effects/Starfield3D.cpp
|
src/graphics/effects/Starfield3D.cpp
|
||||||
src/graphics/effects/SpaceWarp.cpp
|
src/graphics/effects/SpaceWarp.cpp
|
||||||
src/graphics/ui/Font.cpp
|
src/graphics/ui/Font.cpp
|
||||||
src/graphics/ui/HelpOverlay.cpp
|
src/graphics/ui/HelpOverlay.cpp
|
||||||
src/graphics/renderers/GameRenderer.cpp
|
src/graphics/renderers/GameRenderer.cpp
|
||||||
|
src/graphics/renderers/SyncLineRenderer.cpp
|
||||||
src/graphics/renderers/UIRenderer.cpp
|
src/graphics/renderers/UIRenderer.cpp
|
||||||
src/audio/Audio.cpp
|
src/audio/Audio.cpp
|
||||||
src/gameplay/effects/LineEffect.cpp
|
src/gameplay/effects/LineEffect.cpp
|
||||||
src/audio/SoundEffect.cpp
|
src/audio/SoundEffect.cpp
|
||||||
|
src/video/VideoPlayer.cpp
|
||||||
src/ui/MenuLayout.cpp
|
src/ui/MenuLayout.cpp
|
||||||
src/ui/BottomMenu.cpp
|
src/ui/BottomMenu.cpp
|
||||||
src/app/BackgroundManager.cpp
|
src/app/BackgroundManager.cpp
|
||||||
@ -62,6 +69,7 @@ set(TETRIS_SOURCES
|
|||||||
src/states/LoadingManager.cpp
|
src/states/LoadingManager.cpp
|
||||||
# State implementations (new)
|
# State implementations (new)
|
||||||
src/states/LoadingState.cpp
|
src/states/LoadingState.cpp
|
||||||
|
src/states/VideoState.cpp
|
||||||
src/states/MenuState.cpp
|
src/states/MenuState.cpp
|
||||||
src/states/OptionsState.cpp
|
src/states/OptionsState.cpp
|
||||||
src/states/LevelSelectorState.cpp
|
src/states/LevelSelectorState.cpp
|
||||||
@ -72,27 +80,39 @@ set(TETRIS_SOURCES
|
|||||||
if(APPLE)
|
if(APPLE)
|
||||||
set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns")
|
set(APP_ICON "${CMAKE_SOURCE_DIR}/assets/favicon/AppIcon.icns")
|
||||||
if(EXISTS "${APP_ICON}")
|
if(EXISTS "${APP_ICON}")
|
||||||
add_executable(tetris MACOSX_BUNDLE ${TETRIS_SOURCES} "${APP_ICON}")
|
add_executable(spacetris MACOSX_BUNDLE ${TETRIS_SOURCES} "${APP_ICON}")
|
||||||
set_source_files_properties("${APP_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
|
set_source_files_properties("${APP_ICON}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
|
||||||
set_target_properties(tetris PROPERTIES
|
set_target_properties(spacetris PROPERTIES
|
||||||
MACOSX_BUNDLE_ICON_FILE "AppIcon"
|
MACOSX_BUNDLE_ICON_FILE "AppIcon"
|
||||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
|
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
|
||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
message(WARNING "App icon not found at ${APP_ICON}; bundle will use default icon")
|
message(WARNING "App icon not found at ${APP_ICON}; bundle will use default icon")
|
||||||
add_executable(tetris MACOSX_BUNDLE ${TETRIS_SOURCES})
|
add_executable(spacetris MACOSX_BUNDLE ${TETRIS_SOURCES})
|
||||||
set_target_properties(tetris PROPERTIES
|
set_target_properties(spacetris PROPERTIES
|
||||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
|
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/cmake/MacBundleInfo.plist.in"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
else()
|
else()
|
||||||
add_executable(tetris ${TETRIS_SOURCES})
|
add_executable(spacetris ${TETRIS_SOURCES})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Ensure the built executable is named `spacetris`.
|
||||||
|
set_target_properties(spacetris PROPERTIES OUTPUT_NAME "spacetris")
|
||||||
|
|
||||||
|
if (WIN32)
|
||||||
|
# No compatibility copy; built executable is `spacetris`.
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
# Embed the application icon into the executable
|
# Embed the application icon into the executable
|
||||||
set_source_files_properties(src/app_icon.rc PROPERTIES LANGUAGE RC)
|
set_source_files_properties(src/app_icon.rc PROPERTIES LANGUAGE RC)
|
||||||
target_sources(tetris PRIVATE src/app_icon.rc)
|
target_sources(spacetris PRIVATE src/app_icon.rc)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
# Prevent PDB write contention on MSVC by enabling /FS for this target
|
||||||
|
target_compile_options(spacetris PRIVATE /FS)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
@ -107,14 +127,14 @@ if (WIN32)
|
|||||||
COMMENT "Copy favicon.ico to build dir for resource compilation"
|
COMMENT "Copy favicon.ico to build dir for resource compilation"
|
||||||
)
|
)
|
||||||
add_custom_target(copy_favicon ALL DEPENDS ${FAVICON_DEST})
|
add_custom_target(copy_favicon ALL DEPENDS ${FAVICON_DEST})
|
||||||
add_dependencies(tetris copy_favicon)
|
add_dependencies(spacetris copy_favicon)
|
||||||
else()
|
else()
|
||||||
message(WARNING "Favicon not found at ${FAVICON_SRC}; app icon may not compile")
|
message(WARNING "Favicon not found at ${FAVICON_SRC}; app icon may not compile")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Also copy favicon into the runtime output folder (same dir as exe)
|
# Also copy favicon into the runtime output folder (same dir as exe)
|
||||||
add_custom_command(TARGET tetris POST_BUILD
|
add_custom_command(TARGET spacetris POST_BUILD
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FAVICON_SRC} $<TARGET_FILE_DIR:tetris>/favicon.ico
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FAVICON_SRC} $<TARGET_FILE_DIR:spacetris>/favicon.ico
|
||||||
COMMENT "Copy favicon.ico next to executable"
|
COMMENT "Copy favicon.ico next to executable"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
@ -123,35 +143,42 @@ if(APPLE)
|
|||||||
set(_mac_copy_commands)
|
set(_mac_copy_commands)
|
||||||
if(EXISTS "${CMAKE_SOURCE_DIR}/assets")
|
if(EXISTS "${CMAKE_SOURCE_DIR}/assets")
|
||||||
list(APPEND _mac_copy_commands
|
list(APPEND _mac_copy_commands
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "$<TARGET_FILE_DIR:tetris>/assets"
|
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "$<TARGET_FILE_DIR:spacetris>/assets"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
if(EXISTS "${CMAKE_SOURCE_DIR}/fonts")
|
if(EXISTS "${CMAKE_SOURCE_DIR}/fonts")
|
||||||
list(APPEND _mac_copy_commands
|
list(APPEND _mac_copy_commands
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/fonts" "$<TARGET_FILE_DIR:tetris>/fonts"
|
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/fonts" "$<TARGET_FILE_DIR:spacetris>/fonts"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
if(EXISTS "${CMAKE_SOURCE_DIR}/FreeSans.ttf")
|
if(EXISTS "${CMAKE_SOURCE_DIR}/FreeSans.ttf")
|
||||||
list(APPEND _mac_copy_commands
|
list(APPEND _mac_copy_commands
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "$<TARGET_FILE_DIR:tetris>/FreeSans.ttf"
|
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "$<TARGET_FILE_DIR:spacetris>/FreeSans.ttf"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
if(_mac_copy_commands)
|
if(_mac_copy_commands)
|
||||||
add_custom_command(TARGET tetris POST_BUILD
|
add_custom_command(TARGET spacetris POST_BUILD
|
||||||
${_mac_copy_commands}
|
${_mac_copy_commands}
|
||||||
COMMENT "Copying game assets into macOS bundle"
|
COMMENT "Copying game assets into macOS bundle"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
target_link_libraries(tetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json)
|
target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet)
|
||||||
|
|
||||||
|
find_package(FFMPEG REQUIRED)
|
||||||
|
if(FFMPEG_FOUND)
|
||||||
|
target_include_directories(spacetris PRIVATE ${FFMPEG_INCLUDE_DIRS})
|
||||||
|
target_link_directories(spacetris PRIVATE ${FFMPEG_LIBRARY_DIRS})
|
||||||
|
target_link_libraries(spacetris PRIVATE ${FFMPEG_LIBRARIES})
|
||||||
|
endif()
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
target_link_libraries(tetris PRIVATE mfplat mfreadwrite mfuuid)
|
target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm)
|
||||||
endif()
|
endif()
|
||||||
if(APPLE)
|
if(APPLE)
|
||||||
# Needed for MP3 decoding via AudioToolbox on macOS
|
# Needed for MP3 decoding via AudioToolbox on macOS
|
||||||
target_link_libraries(tetris PRIVATE "-framework AudioToolbox" "-framework CoreFoundation")
|
target_link_libraries(spacetris PRIVATE "-framework AudioToolbox" "-framework CoreFoundation")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Include production build configuration
|
# Include production build configuration
|
||||||
@ -162,22 +189,23 @@ enable_testing()
|
|||||||
|
|
||||||
# Unit tests (simple runner)
|
# Unit tests (simple runner)
|
||||||
find_package(Catch2 CONFIG REQUIRED)
|
find_package(Catch2 CONFIG REQUIRED)
|
||||||
add_executable(tetris_tests
|
add_executable(spacetris_tests
|
||||||
tests/GravityTests.cpp
|
tests/GravityTests.cpp
|
||||||
src/core/GravityManager.cpp
|
src/core/GravityManager.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(tetris_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
target_include_directories(spacetris_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||||
target_link_libraries(tetris_tests PRIVATE Catch2::Catch2WithMain)
|
target_link_libraries(spacetris_tests PRIVATE Catch2::Catch2WithMain)
|
||||||
add_test(NAME GravityTests COMMAND tetris_tests)
|
add_test(NAME GravityTests COMMAND spacetris_tests)
|
||||||
|
|
||||||
if(EXISTS "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
if(EXISTS "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
||||||
target_include_directories(tetris_tests PRIVATE "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
target_include_directories(spacetris_tests PRIVATE "${CMAKE_SOURCE_DIR}/vcpkg_installed/x64-windows/include")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Add new src subfolders to include path so old #includes continue to work
|
# Add new src subfolders to include path so old #includes continue to work
|
||||||
target_include_directories(tetris PRIVATE
|
target_include_directories(spacetris PRIVATE
|
||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
${CMAKE_SOURCE_DIR}/src/audio
|
${CMAKE_SOURCE_DIR}/src/audio
|
||||||
|
${CMAKE_SOURCE_DIR}/src/video
|
||||||
${CMAKE_SOURCE_DIR}/src/gameplay
|
${CMAKE_SOURCE_DIR}/src/gameplay
|
||||||
${CMAKE_SOURCE_DIR}/src/graphics
|
${CMAKE_SOURCE_DIR}/src/graphics
|
||||||
${CMAKE_SOURCE_DIR}/src/persistence
|
${CMAKE_SOURCE_DIR}/src/persistence
|
||||||
|
|||||||
760
CODE_ANALYSIS.md
760
CODE_ANALYSIS.md
@ -1,760 +0,0 @@
|
|||||||
# Tetris SDL3 - Code Analysis & Best Practices Review
|
|
||||||
|
|
||||||
**Generated:** 2025-12-03
|
|
||||||
**Project:** Tetris Game (SDL3)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Executive Summary
|
|
||||||
|
|
||||||
Your Tetris project is **well-structured and follows many modern C++ best practices**. The codebase demonstrates:
|
|
||||||
- ✅ Clean separation of concerns with a state-based architecture
|
|
||||||
- ✅ Modern C++20 features and RAII patterns
|
|
||||||
- ✅ Centralized configuration management
|
|
||||||
- ✅ Proper dependency management via vcpkg
|
|
||||||
- ✅ Good documentation and code organization
|
|
||||||
|
|
||||||
However, there are opportunities for improvement in areas like memory management, error handling, and code duplication.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Strengths
|
|
||||||
|
|
||||||
### 1. **Architecture & Design Patterns**
|
|
||||||
- **State Pattern Implementation**: Clean state management with `MenuState`, `PlayingState`, `OptionsState`, `LevelSelectorState`, and `LoadingState`
|
|
||||||
- **Separation of Concerns**: Game logic (`Game.cpp`), rendering (`GameRenderer`, `UIRenderer`), audio (`Audio`, `SoundEffect`), and persistence (`Scores`) are well-separated
|
|
||||||
- **Centralized Configuration**: `Config.h` provides a single source of truth for constants, eliminating magic numbers
|
|
||||||
- **Service Locator Pattern**: `StateContext` acts as a dependency injection container
|
|
||||||
|
|
||||||
### 2. **Modern C++ Practices**
|
|
||||||
- **C++20 Standard**: Using modern features like `std::filesystem`, `std::jthread`
|
|
||||||
- **RAII**: Proper resource management with smart pointers and automatic cleanup
|
|
||||||
- **Type Safety**: Strong typing with enums (`PieceType`, `AppState`, `LevelBackgroundPhase`)
|
|
||||||
- **Const Correctness**: Good use of `const` methods and references
|
|
||||||
|
|
||||||
### 3. **Code Organization**
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── audio/ # Audio system (music, sound effects)
|
|
||||||
├── core/ # Core systems (state management, settings, global state)
|
|
||||||
├── gameplay/ # Game logic (Tetris mechanics, effects)
|
|
||||||
├── graphics/ # Rendering (UI, game renderer, effects)
|
|
||||||
├── persistence/ # Score management
|
|
||||||
├── states/ # State implementations
|
|
||||||
└── utils/ # Utilities
|
|
||||||
```
|
|
||||||
This structure is logical and easy to navigate.
|
|
||||||
|
|
||||||
### 4. **Build System**
|
|
||||||
- **CMake**: Modern CMake with proper target configuration
|
|
||||||
- **vcpkg**: Excellent dependency management
|
|
||||||
- **Cross-platform**: Support for Windows and macOS
|
|
||||||
- **Testing**: Catch2 integration for unit tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Areas for Improvement
|
|
||||||
|
|
||||||
### 1. **Memory Management Issues**
|
|
||||||
|
|
||||||
#### **Problem: Raw Pointer Usage**
|
|
||||||
**Location:** `MenuState.h`, `main.cpp`
|
|
||||||
```cpp
|
|
||||||
// MenuState.h (lines 17-21)
|
|
||||||
SDL_Texture* playIcon = nullptr;
|
|
||||||
SDL_Texture* levelIcon = nullptr;
|
|
||||||
SDL_Texture* optionsIcon = nullptr;
|
|
||||||
SDL_Texture* exitIcon = nullptr;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issue:** Raw pointers to SDL resources without proper cleanup in all code paths.
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
```cpp
|
|
||||||
// Create a smart pointer wrapper for SDL_Texture
|
|
||||||
struct SDL_TextureDeleter {
|
|
||||||
void operator()(SDL_Texture* tex) const {
|
|
||||||
if (tex) SDL_DestroyTexture(tex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
using SDL_TexturePtr = std::unique_ptr<SDL_Texture, SDL_TextureDeleter>;
|
|
||||||
|
|
||||||
// Usage in MenuState.h
|
|
||||||
private:
|
|
||||||
SDL_TexturePtr playIcon;
|
|
||||||
SDL_TexturePtr levelIcon;
|
|
||||||
SDL_TexturePtr optionsIcon;
|
|
||||||
SDL_TexturePtr exitIcon;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
- Automatic cleanup
|
|
||||||
- Exception safety
|
|
||||||
- No manual memory management
|
|
||||||
- Clear ownership semantics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. **Error Handling**
|
|
||||||
|
|
||||||
#### **Problem: Inconsistent Error Handling**
|
|
||||||
**Location:** `main.cpp` (lines 86-114)
|
|
||||||
```cpp
|
|
||||||
static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::string& path, int* outW = nullptr, int* outH = nullptr) {
|
|
||||||
if (!renderer) {
|
|
||||||
return nullptr; // Silent failure
|
|
||||||
}
|
|
||||||
|
|
||||||
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
|
||||||
if (!surface) {
|
|
||||||
SDL_LogError(...); // Logs but returns nullptr
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues:**
|
|
||||||
- Silent failures make debugging difficult
|
|
||||||
- Callers must check for `nullptr` (easy to forget)
|
|
||||||
- No way to distinguish between different error types
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
```cpp
|
|
||||||
#include <expected> // C++23, or use tl::expected for C++20
|
|
||||||
|
|
||||||
struct TextureLoadError {
|
|
||||||
std::string message;
|
|
||||||
std::string path;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::expected<SDL_TexturePtr, TextureLoadError>
|
|
||||||
loadTextureFromImage(SDL_Renderer* renderer, const std::string& path,
|
|
||||||
int* outW = nullptr, int* outH = nullptr) {
|
|
||||||
if (!renderer) {
|
|
||||||
return std::unexpected(TextureLoadError{
|
|
||||||
"Renderer is null", path
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string resolvedPath = AssetPath::resolveImagePath(path);
|
|
||||||
SDL_Surface* surface = IMG_Load(resolvedPath.c_str());
|
|
||||||
if (!surface) {
|
|
||||||
return std::unexpected(TextureLoadError{
|
|
||||||
std::string("Failed to load: ") + SDL_GetError(),
|
|
||||||
resolvedPath
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... success case
|
|
||||||
return SDL_TexturePtr(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage:
|
|
||||||
auto result = loadTextureFromImage(renderer, "path.png");
|
|
||||||
if (result) {
|
|
||||||
// Use result.value()
|
|
||||||
} else {
|
|
||||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
|
||||||
"Failed to load %s: %s",
|
|
||||||
result.error().path.c_str(),
|
|
||||||
result.error().message.c_str());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. **Code Duplication**
|
|
||||||
|
|
||||||
#### **Problem: Repeated Patterns**
|
|
||||||
**Location:** `MenuState.cpp`, `PlayingState.cpp`, `OptionsState.cpp`
|
|
||||||
|
|
||||||
Similar lambda patterns for exit popup handling:
|
|
||||||
```cpp
|
|
||||||
auto setExitSelection = [&](int value) {
|
|
||||||
if (ctx.exitPopupSelectedButton) {
|
|
||||||
*ctx.exitPopupSelectedButton = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
auto getExitSelection = [&]() -> int {
|
|
||||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
Create a helper class in `StateContext`:
|
|
||||||
```cpp
|
|
||||||
// StateContext.h
|
|
||||||
class ExitPopupHelper {
|
|
||||||
public:
|
|
||||||
ExitPopupHelper(int* selectedButton, bool* showPopup)
|
|
||||||
: m_selectedButton(selectedButton), m_showPopup(showPopup) {}
|
|
||||||
|
|
||||||
void setSelection(int value) {
|
|
||||||
if (m_selectedButton) *m_selectedButton = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
int getSelection() const {
|
|
||||||
return m_selectedButton ? *m_selectedButton : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void show() {
|
|
||||||
if (m_showPopup) *m_showPopup = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void hide() {
|
|
||||||
if (m_showPopup) *m_showPopup = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isVisible() const {
|
|
||||||
return m_showPopup && *m_showPopup;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
int* m_selectedButton;
|
|
||||||
bool* m_showPopup;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage in states:
|
|
||||||
ExitPopupHelper exitPopup(ctx.exitPopupSelectedButton, ctx.showExitConfirmPopup);
|
|
||||||
exitPopup.setSelection(0);
|
|
||||||
if (exitPopup.isVisible()) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. **Magic Numbers**
|
|
||||||
|
|
||||||
#### **Problem: Some Magic Numbers Still Present**
|
|
||||||
**Location:** `MenuState.cpp` (lines 269-273)
|
|
||||||
```cpp
|
|
||||||
float btnW = 200.0f; // Fixed width to match background buttons
|
|
||||||
float btnH = 70.0f; // Fixed height to match background buttons
|
|
||||||
float btnX = LOGICAL_W * 0.5f + contentOffsetX;
|
|
||||||
float btnY = LOGICAL_H * 0.865f + contentOffsetY;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
Add to `Config.h`:
|
|
||||||
```cpp
|
|
||||||
namespace Config::UI {
|
|
||||||
constexpr float MENU_BUTTON_WIDTH = 200.0f;
|
|
||||||
constexpr float MENU_BUTTON_HEIGHT = 70.0f;
|
|
||||||
constexpr float MENU_BUTTON_Y_FRACTION = 0.865f;
|
|
||||||
constexpr float MENU_BUTTON_SPACING = 210.0f;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. **File I/O for Debugging**
|
|
||||||
|
|
||||||
#### **Problem: Debug Logging to Files**
|
|
||||||
**Location:** `MenuState.cpp` (lines 182-184, 195-203, etc.)
|
|
||||||
```cpp
|
|
||||||
FILE* f = fopen("tetris_trace.log", "a");
|
|
||||||
if (f) {
|
|
||||||
fprintf(f, "MenuState::render entry\n");
|
|
||||||
fclose(f);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues:**
|
|
||||||
- File handles not checked properly
|
|
||||||
- No error handling
|
|
||||||
- Performance overhead in production
|
|
||||||
- Should use proper logging framework
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
```cpp
|
|
||||||
// Create a simple logger utility
|
|
||||||
// src/utils/Logger.h
|
|
||||||
#pragma once
|
|
||||||
#include <string>
|
|
||||||
#include <fstream>
|
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
public:
|
|
||||||
enum class Level { TRACE, DEBUG, INFO, WARN, ERROR };
|
|
||||||
|
|
||||||
static Logger& instance();
|
|
||||||
|
|
||||||
void setLevel(Level level) { m_level = level; }
|
|
||||||
void setFile(const std::string& path);
|
|
||||||
|
|
||||||
template<typename... Args>
|
|
||||||
void trace(const char* fmt, Args... args) {
|
|
||||||
log(Level::TRACE, fmt, args...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename... Args>
|
|
||||||
void debug(const char* fmt, Args... args) {
|
|
||||||
log(Level::DEBUG, fmt, args...);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
Logger() = default;
|
|
||||||
|
|
||||||
template<typename... Args>
|
|
||||||
void log(Level level, const char* fmt, Args... args);
|
|
||||||
|
|
||||||
Level m_level = Level::INFO;
|
|
||||||
std::ofstream m_file;
|
|
||||||
std::mutex m_mutex;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage:
|
|
||||||
#ifdef DEBUG
|
|
||||||
Logger::instance().trace("MenuState::render entry");
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. **Const Correctness**
|
|
||||||
|
|
||||||
#### **Problem: Missing const in Some Places**
|
|
||||||
**Location:** `StateContext` and various state methods
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
```cpp
|
|
||||||
// State.h
|
|
||||||
class State {
|
|
||||||
public:
|
|
||||||
virtual void render(SDL_Renderer* renderer, float logicalScale,
|
|
||||||
SDL_Rect logicalVP) const = 0; // Add const
|
|
||||||
// Render shouldn't modify state
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. **Thread Safety**
|
|
||||||
|
|
||||||
#### **Problem: Potential Race Conditions**
|
|
||||||
**Location:** `Audio.cpp` - Background loading
|
|
||||||
|
|
||||||
**Current:**
|
|
||||||
```cpp
|
|
||||||
std::vector<AudioTrack> tracks;
|
|
||||||
std::mutex tracksMutex;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
- Document thread safety guarantees
|
|
||||||
- Use `std::shared_mutex` for read-heavy operations
|
|
||||||
- Consider using lock-free data structures for performance-critical paths
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
// Audio.h
|
|
||||||
class Audio {
|
|
||||||
private:
|
|
||||||
std::vector<AudioTrack> tracks;
|
|
||||||
mutable std::shared_mutex tracksMutex; // Allow concurrent reads
|
|
||||||
|
|
||||||
public:
|
|
||||||
// Read operation - shared lock
|
|
||||||
int getLoadedTrackCount() const {
|
|
||||||
std::shared_lock lock(tracksMutex);
|
|
||||||
return tracks.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write operation - exclusive lock
|
|
||||||
void addTrack(const std::string& path) {
|
|
||||||
std::unique_lock lock(tracksMutex);
|
|
||||||
tracks.push_back(loadTrack(path));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. **Testing Coverage**
|
|
||||||
|
|
||||||
#### **Current State:**
|
|
||||||
Only one test file: `tests/GravityTests.cpp`
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
Add comprehensive tests:
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── GravityTests.cpp ✅ Exists
|
|
||||||
├── GameLogicTests.cpp ❌ Missing
|
|
||||||
├── ScoreManagerTests.cpp ❌ Missing
|
|
||||||
├── StateTransitionTests.cpp ❌ Missing
|
|
||||||
└── AudioSystemTests.cpp ❌ Missing
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example Test Structure:**
|
|
||||||
```cpp
|
|
||||||
// tests/GameLogicTests.cpp
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
|
||||||
#include "gameplay/core/Game.h"
|
|
||||||
|
|
||||||
TEST_CASE("Game initialization", "[game]") {
|
|
||||||
Game game(0);
|
|
||||||
|
|
||||||
SECTION("Board starts empty") {
|
|
||||||
const auto& board = game.boardRef();
|
|
||||||
REQUIRE(std::all_of(board.begin(), board.end(),
|
|
||||||
[](int cell) { return cell == 0; }));
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("Score starts at zero") {
|
|
||||||
REQUIRE(game.score() == 0);
|
|
||||||
REQUIRE(game.lines() == 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Piece rotation", "[game]") {
|
|
||||||
Game game(0);
|
|
||||||
|
|
||||||
SECTION("Clockwise rotation") {
|
|
||||||
auto initialRot = game.current().rot;
|
|
||||||
game.rotate(1);
|
|
||||||
REQUIRE(game.current().rot == (initialRot + 1) % 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Line clearing", "[game]") {
|
|
||||||
Game game(0);
|
|
||||||
|
|
||||||
SECTION("Single line clear awards correct score") {
|
|
||||||
// Setup: Fill bottom row except one cell
|
|
||||||
// ... test implementation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. **Documentation**
|
|
||||||
|
|
||||||
#### **Current State:**
|
|
||||||
- Good inline comments
|
|
||||||
- Config.h has excellent documentation
|
|
||||||
- Missing: API documentation, architecture overview
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
Add Doxygen-style comments:
|
|
||||||
```cpp
|
|
||||||
/**
|
|
||||||
* @class Game
|
|
||||||
* @brief Core Tetris game logic engine
|
|
||||||
*
|
|
||||||
* Manages the game board, piece spawning, collision detection,
|
|
||||||
* line clearing, and scoring. This class is independent of
|
|
||||||
* rendering and input handling.
|
|
||||||
*
|
|
||||||
* @note Thread-safe for read operations, but write operations
|
|
||||||
* (move, rotate, etc.) should only be called from the
|
|
||||||
* main game thread.
|
|
||||||
*
|
|
||||||
* Example usage:
|
|
||||||
* @code
|
|
||||||
* Game game(5); // Start at level 5
|
|
||||||
* game.tickGravity(16.67); // Update for one frame
|
|
||||||
* if (game.isGameOver()) {
|
|
||||||
* // Handle game over
|
|
||||||
* }
|
|
||||||
* @endcode
|
|
||||||
*/
|
|
||||||
class Game {
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `docs/ARCHITECTURE.md`:
|
|
||||||
```markdown
|
|
||||||
# Architecture Overview
|
|
||||||
|
|
||||||
## State Machine
|
|
||||||
[Diagram of state transitions]
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
[Diagram showing how data flows through the system]
|
|
||||||
|
|
||||||
## Threading Model
|
|
||||||
- Main thread: Rendering, input, game logic
|
|
||||||
- Background thread: Audio loading
|
|
||||||
- Audio callback thread: Audio mixing
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. **Performance Considerations**
|
|
||||||
|
|
||||||
#### **Issue: Frequent String Allocations**
|
|
||||||
**Location:** Various places using `std::string` for paths
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
```cpp
|
|
||||||
// Use string_view for read-only string parameters
|
|
||||||
#include <string_view>
|
|
||||||
|
|
||||||
SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer,
|
|
||||||
std::string_view path, // Changed
|
|
||||||
int* outW = nullptr,
|
|
||||||
int* outH = nullptr);
|
|
||||||
|
|
||||||
// For compile-time strings, use constexpr
|
|
||||||
namespace AssetPaths {
|
|
||||||
constexpr std::string_view LOGO = "assets/images/logo.bmp";
|
|
||||||
constexpr std::string_view BACKGROUND = "assets/images/main_background.bmp";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Issue: Vector Reallocations**
|
|
||||||
**Location:** `fireworks` vector in `main.cpp`
|
|
||||||
|
|
||||||
**Recommendation:**
|
|
||||||
```cpp
|
|
||||||
// Reserve capacity upfront
|
|
||||||
fireworks.reserve(5); // Max 5 fireworks at once
|
|
||||||
|
|
||||||
// Or use a fixed-size container
|
|
||||||
std::array<std::optional<TetrisFirework>, 5> fireworks;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Specific Recommendations by Priority
|
|
||||||
|
|
||||||
### **High Priority** (Do These First)
|
|
||||||
|
|
||||||
1. **Replace raw SDL pointers with smart pointers**
|
|
||||||
- Impact: Prevents memory leaks
|
|
||||||
- Effort: Medium
|
|
||||||
- Files: `MenuState.h`, `main.cpp`, all state files
|
|
||||||
|
|
||||||
2. **Remove debug file I/O from production code**
|
|
||||||
- Impact: Performance, code cleanliness
|
|
||||||
- Effort: Low
|
|
||||||
- Files: `MenuState.cpp`, `main.cpp`
|
|
||||||
|
|
||||||
3. **Add error handling to asset loading**
|
|
||||||
- Impact: Better debugging, crash prevention
|
|
||||||
- Effort: Medium
|
|
||||||
- Files: `main.cpp`, `AssetManager.cpp`
|
|
||||||
|
|
||||||
### **Medium Priority**
|
|
||||||
|
|
||||||
4. **Extract common patterns into helper classes**
|
|
||||||
- Impact: Code maintainability
|
|
||||||
- Effort: Medium
|
|
||||||
- Files: All state files
|
|
||||||
|
|
||||||
5. **Move remaining magic numbers to Config.h**
|
|
||||||
- Impact: Maintainability
|
|
||||||
- Effort: Low
|
|
||||||
- Files: `MenuState.cpp`, `UIRenderer.cpp`
|
|
||||||
|
|
||||||
6. **Add comprehensive unit tests**
|
|
||||||
- Impact: Code quality, regression prevention
|
|
||||||
- Effort: High
|
|
||||||
- Files: New test files
|
|
||||||
|
|
||||||
### **Low Priority** (Nice to Have)
|
|
||||||
|
|
||||||
7. **Add Doxygen documentation**
|
|
||||||
- Impact: Developer onboarding
|
|
||||||
- Effort: Medium
|
|
||||||
|
|
||||||
8. **Performance profiling and optimization**
|
|
||||||
- Impact: Depends on current performance
|
|
||||||
- Effort: Medium
|
|
||||||
|
|
||||||
9. **Consider using `std::expected` for error handling**
|
|
||||||
- Impact: Better error handling
|
|
||||||
- Effort: High (requires C++23 or external library)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Code Style Observations
|
|
||||||
|
|
||||||
### **Good Practices You're Already Following:**
|
|
||||||
|
|
||||||
✅ **Consistent naming conventions:**
|
|
||||||
- Classes: `PascalCase` (e.g., `MenuState`, `GameRenderer`)
|
|
||||||
- Functions: `camelCase` (e.g., `tickGravity`, `loadTexture`)
|
|
||||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `LOGICAL_W`, `DAS_DELAY`)
|
|
||||||
- Member variables: `camelCase` with `m_` prefix in some places
|
|
||||||
|
|
||||||
✅ **Header guards:** Using `#pragma once`
|
|
||||||
|
|
||||||
✅ **Forward declarations:** Minimizing include dependencies
|
|
||||||
|
|
||||||
✅ **RAII:** Resources tied to object lifetime
|
|
||||||
|
|
||||||
### **Minor Style Inconsistencies:**
|
|
||||||
|
|
||||||
❌ **Inconsistent member variable naming:**
|
|
||||||
```cpp
|
|
||||||
// Some classes use m_ prefix
|
|
||||||
float m_masterVolume = 1.0f;
|
|
||||||
|
|
||||||
// Others don't
|
|
||||||
int selectedButton = 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation:** Pick one style and stick to it. I suggest:
|
|
||||||
```cpp
|
|
||||||
// Private members: m_ prefix
|
|
||||||
float m_masterVolume = 1.0f;
|
|
||||||
int m_selectedButton = 0;
|
|
||||||
|
|
||||||
// Public members: no prefix (rare in good design)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Architecture Suggestions
|
|
||||||
|
|
||||||
### **Consider Implementing:**
|
|
||||||
|
|
||||||
1. **Event System**
|
|
||||||
Instead of callbacks, use an event bus:
|
|
||||||
```cpp
|
|
||||||
// events/GameEvents.h
|
|
||||||
struct LineClearedEvent {
|
|
||||||
int linesCleared;
|
|
||||||
int newScore;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct LevelUpEvent {
|
|
||||||
int newLevel;
|
|
||||||
};
|
|
||||||
|
|
||||||
// EventBus.h
|
|
||||||
class EventBus {
|
|
||||||
public:
|
|
||||||
template<typename Event>
|
|
||||||
void subscribe(std::function<void(const Event&)> handler);
|
|
||||||
|
|
||||||
template<typename Event>
|
|
||||||
void publish(const Event& event);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage in Game.cpp
|
|
||||||
eventBus.publish(LineClearedEvent{linesCleared, _score});
|
|
||||||
|
|
||||||
// Usage in Audio system
|
|
||||||
eventBus.subscribe<LineClearedEvent>([](const auto& e) {
|
|
||||||
playLineClearSound(e.linesCleared);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Component-Based UI**
|
|
||||||
Extract UI components:
|
|
||||||
```cpp
|
|
||||||
class Button {
|
|
||||||
public:
|
|
||||||
void render(SDL_Renderer* renderer);
|
|
||||||
bool isHovered(int mouseX, int mouseY) const;
|
|
||||||
void onClick(std::function<void()> callback);
|
|
||||||
};
|
|
||||||
|
|
||||||
class Panel {
|
|
||||||
std::vector<std::unique_ptr<UIComponent>> children;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Asset Manager**
|
|
||||||
Centralize asset loading:
|
|
||||||
```cpp
|
|
||||||
class AssetManager {
|
|
||||||
public:
|
|
||||||
SDL_TexturePtr getTexture(std::string_view name);
|
|
||||||
FontAtlas* getFont(std::string_view name);
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::unordered_map<std::string, SDL_TexturePtr> textures;
|
|
||||||
std::unordered_map<std::string, std::unique_ptr<FontAtlas>> fonts;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Security Considerations
|
|
||||||
|
|
||||||
1. **File Path Validation**
|
|
||||||
```cpp
|
|
||||||
// AssetPath::resolveImagePath should validate paths
|
|
||||||
// to prevent directory traversal attacks
|
|
||||||
std::string resolveImagePath(std::string_view path) {
|
|
||||||
// Reject paths with ".."
|
|
||||||
if (path.find("..") != std::string_view::npos) {
|
|
||||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
|
||||||
"Invalid path: %s", path.data());
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
// ... rest of implementation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Score File Tampering**
|
|
||||||
Consider adding checksums to score files:
|
|
||||||
```cpp
|
|
||||||
// Scores.cpp
|
|
||||||
void ScoreManager::save() const {
|
|
||||||
nlohmann::json j;
|
|
||||||
j["scores"] = scores;
|
|
||||||
j["checksum"] = computeChecksum(scores);
|
|
||||||
// ... save to file
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Metrics
|
|
||||||
|
|
||||||
Based on the codebase analysis:
|
|
||||||
|
|
||||||
| Metric | Value | Rating |
|
|
||||||
|--------|-------|--------|
|
|
||||||
| **Code Organization** | Excellent | ⭐⭐⭐⭐⭐ |
|
|
||||||
| **Modern C++ Usage** | Very Good | ⭐⭐⭐⭐ |
|
|
||||||
| **Error Handling** | Fair | ⭐⭐⭐ |
|
|
||||||
| **Memory Safety** | Good | ⭐⭐⭐⭐ |
|
|
||||||
| **Test Coverage** | Poor | ⭐ |
|
|
||||||
| **Documentation** | Good | ⭐⭐⭐⭐ |
|
|
||||||
| **Performance** | Good | ⭐⭐⭐⭐ |
|
|
||||||
| **Maintainability** | Very Good | ⭐⭐⭐⭐ |
|
|
||||||
|
|
||||||
**Overall Score: 4/5 ⭐⭐⭐⭐**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Wins (Easy Improvements)
|
|
||||||
|
|
||||||
1. **Add `.clang-format` file** for consistent formatting
|
|
||||||
2. **Create `CONTRIBUTING.md`** with coding guidelines
|
|
||||||
3. **Add pre-commit hooks** for formatting and linting
|
|
||||||
4. **Set up GitHub Actions** for CI/CD
|
|
||||||
5. **Add `README.md`** with build instructions and screenshots
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Recommended Resources
|
|
||||||
|
|
||||||
- **Modern C++ Best Practices:** https://isocpp.github.io/CppCoreGuidelines/
|
|
||||||
- **SDL3 Migration Guide:** https://wiki.libsdl.org/SDL3/README/migration
|
|
||||||
- **Game Programming Patterns:** https://gameprogrammingpatterns.com/
|
|
||||||
- **C++ Testing with Catch2:** https://github.com/catchorg/Catch2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Conclusion
|
|
||||||
|
|
||||||
Your Tetris project demonstrates **strong software engineering practices** with a clean architecture, modern C++ usage, and good separation of concerns. The main areas for improvement are:
|
|
||||||
|
|
||||||
1. Enhanced error handling
|
|
||||||
2. Increased test coverage
|
|
||||||
3. Elimination of raw pointers
|
|
||||||
4. Removal of debug code from production
|
|
||||||
|
|
||||||
With these improvements, this codebase would be **production-ready** and serve as an excellent example of modern C++ game development.
|
|
||||||
|
|
||||||
**Keep up the excellent work!** 🎮
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Tetris SDL3 - Production Deployment Guide
|
# Spacetris SDL3 - Production Deployment Guide
|
||||||
|
|
||||||
## 🚀 Build Scripts Available
|
## 🚀 Build Scripts Available
|
||||||
|
|
||||||
@ -9,7 +9,7 @@
|
|||||||
- Uses existing Debug/Release build from build-msvc
|
- Uses existing Debug/Release build from build-msvc
|
||||||
- Creates distribution package with all dependencies
|
- Creates distribution package with all dependencies
|
||||||
- **Size: ~939 MB** (includes all assets and music)
|
- **Size: ~939 MB** (includes all assets and music)
|
||||||
- **Output:** `dist/TetrisGame/` + ZIP file
|
- **Output:** `dist/SpacetrisGame/` + ZIP file
|
||||||
|
|
||||||
### 2. Full Production Build
|
### 2. Full Production Build
|
||||||
```powershell
|
```powershell
|
||||||
@ -31,8 +31,8 @@ build-production.bat
|
|||||||
The distribution package includes:
|
The distribution package includes:
|
||||||
|
|
||||||
### Essential Files
|
### Essential Files
|
||||||
- ✅ `tetris.exe` - Main game executable
|
- ✅ `spacetris.exe` - Main game executable
|
||||||
- ✅ `Launch-Tetris.bat` - Safe launcher with error handling
|
- ✅ `Launch-Spacetris.bat` - Safe launcher with error handling
|
||||||
- ✅ `README.txt` - User instructions
|
- ✅ `README.txt` - User instructions
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
@ -49,9 +49,9 @@ The distribution package includes:
|
|||||||
## 🎯 Distribution Options
|
## 🎯 Distribution Options
|
||||||
|
|
||||||
### Option 1: ZIP Archive (Recommended)
|
### Option 1: ZIP Archive (Recommended)
|
||||||
- **File:** `TetrisGame-YYYY.MM.DD.zip`
|
- **File:** `SpacetrisGame-YYYY.MM.DD.zip`
|
||||||
- **Size:** ~939 MB
|
- **Size:** ~939 MB
|
||||||
- **Usage:** Users extract and run `Launch-Tetris.bat`
|
- **Usage:** Users extract and run `Launch-Spacetris.bat`
|
||||||
|
|
||||||
### Option 2: Installer (Future)
|
### Option 2: Installer (Future)
|
||||||
- Use CMake CPack to create NSIS installer
|
- Use CMake CPack to create NSIS installer
|
||||||
@ -60,11 +60,11 @@ The distribution package includes:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: Portable Folder
|
### Option 3: Portable Folder
|
||||||
- Direct distribution of `dist/TetrisGame/` folder
|
Direct distribution of `dist/SpacetrisGame/` folder
|
||||||
- Users copy folder and run executable
|
- Users copy folder and run executable
|
||||||
|
[ ] Launch-Spacetris.bat works
|
||||||
## 🔧 Build Requirements
|
## 🔧 Build Requirements
|
||||||
|
**Solution:** Use `Launch-Spacetris.bat` for better error reporting
|
||||||
### Development Environment
|
### Development Environment
|
||||||
- **CMake** 3.20+
|
- **CMake** 3.20+
|
||||||
- **Visual Studio 2026 (VS 18)** with Desktop development with C++ workload
|
- **Visual Studio 2026 (VS 18)** with Desktop development with C++ workload
|
||||||
@ -87,18 +87,21 @@ vcpkg install sdl3 sdl3-ttf --triplet=x64-windows
|
|||||||
- [ ] Font rendering works (both FreeSans and PressStart2P)
|
- [ ] Font rendering works (both FreeSans and PressStart2P)
|
||||||
- [ ] Game saves high scores
|
- [ ] Game saves high scores
|
||||||
|
|
||||||
|
### Package Validation
|
||||||
### Package Validation
|
### Package Validation
|
||||||
- [ ] All DLL files present
|
- [ ] All DLL files present
|
||||||
- [ ] Assets folder complete
|
- [ ] Assets folder complete
|
||||||
- [ ] Launch-Tetris.bat works
|
- [ ] Launch-Spacetris.bat works
|
||||||
- [ ] README.txt is informative
|
- [ ] README.txt is informative
|
||||||
- [ ] Package size reasonable (~939 MB)
|
- [ ] Package size reasonable (~939 MB)
|
||||||
|
|
||||||
### Distribution
|
### Distribution
|
||||||
- [ ] ZIP file created successfully
|
### "spacetris.exe is not recognized"
|
||||||
|
- **Solution:** Ensure all DLL files are in same folder as executable
|
||||||
- [ ] Test extraction on clean system
|
- [ ] Test extraction on clean system
|
||||||
- [ ] Verify game runs on target machines
|
- [ ] Verify game runs on target machines
|
||||||
- [ ] No missing dependencies
|
### Game won't start
|
||||||
|
- **Solution:** Use `Launch-Spacetris.bat` for better error reporting
|
||||||
|
|
||||||
## 📋 User System Requirements
|
## 📋 User System Requirements
|
||||||
|
|
||||||
@ -118,7 +121,7 @@ vcpkg install sdl3 sdl3-ttf --triplet=x64-windows
|
|||||||
|
|
||||||
## 🐛 Common Issues & Solutions
|
## 🐛 Common Issues & Solutions
|
||||||
|
|
||||||
### "tetris.exe is not recognized"
|
### "spacetris.exe is not recognized"
|
||||||
- **Solution:** Ensure all DLL files are in same folder as executable
|
- **Solution:** Ensure all DLL files are in same folder as executable
|
||||||
|
|
||||||
### "Failed to initialize SDL"
|
### "Failed to initialize SDL"
|
||||||
|
|||||||
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
Spacetris SDL3
|
||||||
|
=============
|
||||||
|
|
||||||
|
A native C++20 SDL3-based Tetris-style game.
|
||||||
|
|
||||||
|
Quick Start (Windows)
|
||||||
|
- Install vcpkg and packages: `vcpkg install sdl3 sdl3-ttf --triplet=x64-windows`
|
||||||
|
- Configure: `cmake -S . -B build-msvc -DCMAKE_BUILD_TYPE=Debug`
|
||||||
|
- Build: `cmake --build build-msvc --config Debug`
|
||||||
|
- Run (helper): `.
|
||||||
|
build-debug-and-run.ps1` or run `build-msvc\Debug\spacetris.exe`
|
||||||
|
|
||||||
|
Production Packaging
|
||||||
|
- Quick package (uses existing build): `.
|
||||||
|
package-quick.ps1`
|
||||||
|
- Full production (clean Release build + package): `.
|
||||||
|
build-production.ps1 -Clean`
|
||||||
|
|
||||||
|
Tests
|
||||||
|
- Unit tests target: `spacetris_tests` → exe `spacetris_tests.exe`
|
||||||
|
- Run tests: configure+build then `ctest -C Debug` or run the test exe directly
|
||||||
|
|
||||||
|
Where to look
|
||||||
|
- Main app sources: `src/` (entry `src/main.cpp`, app `src/app`)
|
||||||
|
- Build control: `CMakeLists.txt` and `cmake/ProductionBuild.cmake`
|
||||||
|
- Packaging helpers: `build-production.ps1`, `package-quick.ps1`
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- The canonical executable name is `spacetris` (`spacetris.exe` on Windows).
|
||||||
|
- Assets live in `assets/` and are copied into the distribution package.
|
||||||
|
|
||||||
|
If you want, I can:
|
||||||
|
- Run a Debug build and confirm the test executable name,
|
||||||
|
- Replace remaining legacy "tetris" tokens across generated files and docs.
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Tetris — Upgrade Roadmap
|
# Spacetris — Upgrade Roadmap
|
||||||
|
|
||||||
This document lists recommended code, architecture, tooling, and runtime upgrades for the native SDL3 Tetris project. Items are grouped, prioritized, and mapped to target files and effort estimates so you can plan incremental work.
|
This document lists recommended code, architecture, tooling, and runtime upgrades for the native SDL3 Tetris project. Items are grouped, prioritized, and mapped to target files and effort estimates so you can plan incremental work.
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ This document lists recommended code, architecture, tooling, and runtime upgrade
|
|||||||
- [x] Replace ad-hoc printf with SDL_Log or injected Logger service
|
- [x] Replace ad-hoc printf with SDL_Log or injected Logger service
|
||||||
- Note: majority of printf/fprintf debug prints were replaced with `SDL_Log*` calls; a quick grep audit is recommended to find any remaining ad-hoc prints.
|
- Note: majority of printf/fprintf debug prints were replaced with `SDL_Log*` calls; a quick grep audit is recommended to find any remaining ad-hoc prints.
|
||||||
- [x] Add unit tests (gravity conversion, level progression, line clear behavior)
|
- [x] Add unit tests (gravity conversion, level progression, line clear behavior)
|
||||||
- Note: a small test runner (`tests/GravityTests.cpp`) and the `tetris_tests` CMake target were added and run locally; gravity tests pass (see build-msvc test run). Converting to broader Catch2 suites is optional.
|
- Note: a small test runner (`tests/GravityTests.cpp`) and the `spacetris_tests` CMake target were added and run locally; gravity tests pass (see build-msvc test run). Converting to broader Catch2 suites is optional.
|
||||||
- [ ] Add CI (build + tests) and code style checks
|
- [ ] Add CI (build + tests) and code style checks
|
||||||
- [ ] Improve input hit-testing for level popup and scalable UI
|
- [ ] Improve input hit-testing for level popup and scalable UI
|
||||||
- [ ] Add defensive guards (clamps, null checks) and const-correctness
|
- [ ] Add defensive guards (clamps, null checks) and const-correctness
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/cooperate_info.png
Normal file
BIN
assets/images/cooperate_info.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
BIN
assets/videos/spacetris_intro.mp4
Normal file
BIN
assets/videos/spacetris_intro.mp4
Normal file
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Build and run debug executable for the Tetris project
|
REM Build and run debug executable for the Spacetris project
|
||||||
SETLOCAL
|
SETLOCAL
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
cmake --build build-msvc --config Debug
|
cmake --build build-msvc --config Debug
|
||||||
@ -7,5 +7,5 @@ if errorlevel 1 (
|
|||||||
echo Build failed.
|
echo Build failed.
|
||||||
exit /b %ERRORLEVEL%
|
exit /b %ERRORLEVEL%
|
||||||
)
|
)
|
||||||
"%~dp0build-msvc\Debug\tetris.exe"
|
"%~dp0build-msvc\Debug\spacetris.exe"
|
||||||
ENDLOCAL
|
ENDLOCAL
|
||||||
|
|||||||
@ -149,7 +149,7 @@ if ($NoRun) {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
$exePath = Join-Path $root "build-msvc\Debug\tetris.exe"
|
$exePath = Join-Path $root "build-msvc\Debug\spacetris.exe"
|
||||||
if (-not (Test-Path $exePath)) {
|
if (-not (Test-Path $exePath)) {
|
||||||
Write-Error "Executable not found: $exePath"
|
Write-Error "Executable not found: $exePath"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# macOS Production Build Script for the SDL3 Tetris project
|
# macOS Production Build Script for the SDL3 Spacetris project
|
||||||
# Mirrors the Windows PowerShell workflow but uses common POSIX tooling so it
|
# Mirrors the Windows PowerShell workflow but uses common POSIX tooling so it
|
||||||
# can be executed on macOS runners or local developer machines.
|
# can be executed on macOS runners or local developer machines.
|
||||||
|
|
||||||
PROJECT_NAME="tetris"
|
PROJECT_NAME="spacetris"
|
||||||
BUILD_DIR="build-release"
|
BUILD_DIR="build-release"
|
||||||
OUTPUT_DIR="dist"
|
OUTPUT_DIR="dist"
|
||||||
PACKAGE_DIR=""
|
PACKAGE_DIR=""
|
||||||
@ -67,7 +67,7 @@ parse_args() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configure_paths() {
|
configure_paths() {
|
||||||
PACKAGE_DIR="${OUTPUT_DIR}/TetrisGame-mac"
|
PACKAGE_DIR="${OUTPUT_DIR}/SpacetrisGame-mac"
|
||||||
}
|
}
|
||||||
|
|
||||||
generate_icns_if_needed() {
|
generate_icns_if_needed() {
|
||||||
@ -246,15 +246,15 @@ create_launchers() {
|
|||||||
launch_command="open \"./${app_dir}\""
|
launch_command="open \"./${app_dir}\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > "$PACKAGE_DIR/Launch-Tetris.command" <<EOF
|
cat > "$PACKAGE_DIR/Launch-Spacetris.command" <<EOF
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
cd "\$(dirname \"\$0\")"
|
cd "\$(dirname \"\$0\")"
|
||||||
${launch_command}
|
${launch_command}
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$PACKAGE_DIR/Launch-Tetris.command"
|
chmod +x "$PACKAGE_DIR/Launch-Spacetris.command"
|
||||||
|
|
||||||
cat > "$PACKAGE_DIR/README-mac.txt" <<EOF
|
cat > "$PACKAGE_DIR/README-mac.txt" <<EOF
|
||||||
Tetris SDL3 Game - macOS Release $VERSION
|
Spacetris SDL3 Game - macOS Release $VERSION
|
||||||
=========================================
|
=========================================
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
@ -262,12 +262,12 @@ Requirements:
|
|||||||
- GPU with Metal support
|
- GPU with Metal support
|
||||||
|
|
||||||
Installation:
|
Installation:
|
||||||
1. Unzip the archive anywhere (e.g., ~/Games/Tetris).
|
1. Unzip the archive anywhere (e.g., ~/Games/Spacetris).
|
||||||
2. Ensure the executable bit stays set (script already does this).
|
2. Ensure the executable bit stays set (script already does this).
|
||||||
3. Double-click Launch-Tetris.command or run the binary/app manually from Terminal or Finder.
|
3. Double-click Launch-Spacetris.command or run the binary/app manually from Terminal or Finder.
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
- tetris / Launch-Tetris.command (start the game)
|
- spacetris / Launch-Spacetris.command (start the game)
|
||||||
- assets/ (art, audio, fonts)
|
- assets/ (art, audio, fonts)
|
||||||
- *.dylib from SDL3 and related dependencies
|
- *.dylib from SDL3 and related dependencies
|
||||||
- FreeSans.ttf font
|
- FreeSans.ttf font
|
||||||
@ -298,7 +298,7 @@ validate_package() {
|
|||||||
|
|
||||||
create_zip() {
|
create_zip() {
|
||||||
mkdir -p "$OUTPUT_DIR"
|
mkdir -p "$OUTPUT_DIR"
|
||||||
local zip_name="TetrisGame-mac-${VERSION}.zip"
|
local zip_name="SpacetrisGame-mac-${VERSION}.zip"
|
||||||
local zip_path="$OUTPUT_DIR/$zip_name"
|
local zip_path="$OUTPUT_DIR/$zip_name"
|
||||||
log INFO "Creating zip archive $zip_path ..."
|
log INFO "Creating zip archive $zip_path ..."
|
||||||
if command -v ditto >/dev/null 2>&1; then
|
if command -v ditto >/dev/null 2>&1; then
|
||||||
@ -349,7 +349,7 @@ create_dmg() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local app_name="${APP_BUNDLE_PATH##*/}"
|
local app_name="${APP_BUNDLE_PATH##*/}"
|
||||||
local dmg_name="TetrisGame-mac-${VERSION}.dmg"
|
local dmg_name="SpacetrisGame-mac-${VERSION}.dmg"
|
||||||
local dmg_path="$OUTPUT_DIR/$dmg_name"
|
local dmg_path="$OUTPUT_DIR/$dmg_name"
|
||||||
|
|
||||||
if [[ ! -f "scripts/create-dmg.sh" ]]; then
|
if [[ ! -f "scripts/create-dmg.sh" ]]; then
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Simple Production Build Script for Tetris SDL3
|
REM Simple Production Build Script for Spacetris SDL3
|
||||||
REM This batch file builds and packages the game for distribution
|
REM This batch file builds and packages the game for distribution
|
||||||
|
|
||||||
setlocal EnableDelayedExpansion
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
echo ======================================
|
echo ======================================
|
||||||
echo Tetris SDL3 Production Builder
|
echo Spacetris SDL3 Production Builder
|
||||||
echo ======================================
|
echo ======================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
@ -45,13 +45,13 @@ cd ..
|
|||||||
|
|
||||||
REM Create distribution directory
|
REM Create distribution directory
|
||||||
echo Creating distribution package...
|
echo Creating distribution package...
|
||||||
mkdir "dist\TetrisGame"
|
mkdir "dist\SpacetrisGame"
|
||||||
|
|
||||||
REM Copy executable
|
REM Copy executable
|
||||||
if exist "build-release\Release\tetris.exe" (
|
if exist "build-release\Release\spacetris.exe" (
|
||||||
copy "build-release\Release\tetris.exe" "dist\TetrisGame\"
|
copy "build-release\Release\spacetris.exe" "dist\SpacetrisGame\"
|
||||||
) else if exist "build-release\tetris.exe" (
|
) else if exist "build-release\spacetris.exe" (
|
||||||
copy "build-release\tetris.exe" "dist\TetrisGame\"
|
copy "build-release\spacetris.exe" "dist\SpacetrisGame\"
|
||||||
) else (
|
) else (
|
||||||
echo Error: Executable not found!
|
echo Error: Executable not found!
|
||||||
pause
|
pause
|
||||||
@ -60,12 +60,12 @@ if exist "build-release\Release\tetris.exe" (
|
|||||||
|
|
||||||
REM Copy assets
|
REM Copy assets
|
||||||
echo Copying game assets...
|
echo Copying game assets...
|
||||||
if exist "assets" xcopy "assets" "dist\TetrisGame\assets\" /E /I /Y
|
if exist "assets" xcopy "assets" "dist\SpacetrisGame\assets\" /E /I /Y
|
||||||
if exist "FreeSans.ttf" copy "FreeSans.ttf" "dist\TetrisGame\"
|
if exist "FreeSans.ttf" copy "FreeSans.ttf" "dist\SpacetrisGame\"
|
||||||
|
|
||||||
REM Copy SDL DLLs (if available) - SDL_image no longer needed
|
REM Copy SDL DLLs (if available) - SDL_image no longer needed
|
||||||
echo Copying dependencies...
|
echo Copying dependencies...
|
||||||
set "PackageDir=dist\TetrisGame"
|
set "PackageDir=dist\SpacetrisGame"
|
||||||
set "copiedDependencies=0"
|
set "copiedDependencies=0"
|
||||||
|
|
||||||
call :CopyDependencyDir "build-release\vcpkg_installed\x64-windows\bin"
|
call :CopyDependencyDir "build-release\vcpkg_installed\x64-windows\bin"
|
||||||
@ -76,19 +76,19 @@ if "%copiedDependencies%"=="0" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
REM Create launcher batch file
|
REM Create launcher batch file
|
||||||
echo @echo off > "dist\TetrisGame\Launch-Tetris.bat"
|
echo @echo off > "dist\SpacetrisGame\Launch-Spacetris.bat"
|
||||||
echo cd /d "%%~dp0" >> "dist\TetrisGame\Launch-Tetris.bat"
|
echo cd /d "%%~dp0" >> "dist\SpacetrisGame\Launch-Spacetris.bat"
|
||||||
echo tetris.exe >> "dist\TetrisGame\Launch-Tetris.bat"
|
echo spacetris.exe >> "dist\SpacetrisGame\Launch-Spacetris.bat"
|
||||||
echo pause >> "dist\TetrisGame\Launch-Tetris.bat"
|
echo pause >> "dist\SpacetrisGame\Launch-Spacetris.bat"
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ======================================
|
echo ======================================
|
||||||
echo Build Completed Successfully!
|
echo Build Completed Successfully!
|
||||||
echo ======================================
|
echo ======================================
|
||||||
echo Package location: dist\TetrisGame
|
echo Package location: dist\SpacetrisGame
|
||||||
echo.
|
echo.
|
||||||
echo The game is ready for distribution!
|
echo The game is ready for distribution!
|
||||||
echo Users can run tetris.exe or Launch-Tetris.bat
|
echo Users can run spacetris.exe or Launch-Spacetris.bat
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
goto :eof
|
goto :eof
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env pwsh
|
#!/usr/bin/env pwsh
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Production Build Script for Tetris SDL3 Game
|
Production Build Script for Spacetris SDL3 Game
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
This script builds the Tetris game for production distribution, including:
|
This script builds the Spacetris game for production distribution, including:
|
||||||
- Clean Release build with optimizations
|
- Clean Release build with optimizations
|
||||||
- Dependency collection and packaging
|
- Dependency collection and packaging
|
||||||
- Asset organization and validation
|
- Asset organization and validation
|
||||||
@ -31,9 +31,9 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
$ProjectName = "tetris"
|
$ProjectName = "spacetris"
|
||||||
$BuildDir = "build-release"
|
$BuildDir = "build-release"
|
||||||
$PackageDir = Join-Path $OutputDir "TetrisGame"
|
$PackageDir = Join-Path $OutputDir "SpacetrisGame"
|
||||||
$Version = Get-Date -Format "yyyy.MM.dd"
|
$Version = Get-Date -Format "yyyy.MM.dd"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
@ -52,7 +52,7 @@ function Write-Warning { Write-ColorOutput Yellow $args }
|
|||||||
function Write-Error { Write-ColorOutput Red $args }
|
function Write-Error { Write-ColorOutput Red $args }
|
||||||
|
|
||||||
Write-Info "======================================"
|
Write-Info "======================================"
|
||||||
Write-Info " Tetris SDL3 Production Builder"
|
Write-Info " Spacetris SDL3 Production Builder"
|
||||||
Write-Info "======================================"
|
Write-Info "======================================"
|
||||||
Write-Info "Version: $Version"
|
Write-Info "Version: $Version"
|
||||||
Write-Info "Output: $PackageDir"
|
Write-Info "Output: $PackageDir"
|
||||||
@ -184,7 +184,7 @@ Write-Info "Creating distribution files..."
|
|||||||
|
|
||||||
# Create README
|
# Create README
|
||||||
$ReadmeContent = @"
|
$ReadmeContent = @"
|
||||||
Tetris SDL3 Game - Release $Version
|
Spacetris SDL3 Game - Release $Version
|
||||||
=====================================
|
=====================================
|
||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
@ -194,7 +194,7 @@ Tetris SDL3 Game - Release $Version
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
1. Extract all files to a folder
|
1. Extract all files to a folder
|
||||||
2. Run tetris.exe
|
2. Run spacetris.exe
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
- Arrow Keys: Move pieces
|
- Arrow Keys: Move pieces
|
||||||
@ -206,17 +206,17 @@ Tetris SDL3 Game - Release $Version
|
|||||||
- Esc: Return to menu
|
- Esc: Return to menu
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- If the game doesn't start, ensure all DLL files are in the same folder as tetris.exe
|
- If the game doesn't start, ensure all DLL files are in the same folder as spacetris.exe
|
||||||
- For audio issues, check that your audio drivers are up to date
|
- For audio issues, check that your audio drivers are up to date
|
||||||
- The game requires the assets folder to be in the same directory as the executable
|
- The game requires the assets folder to be in the same directory as the executable
|
||||||
|
|
||||||
## Files Included
|
## Files Included
|
||||||
- tetris.exe - Main game executable
|
- spacetris.exe - Main game executable
|
||||||
- SDL3.dll, SDL3_ttf.dll, SDL3_image.dll - Required libraries
|
- SDL3.dll, SDL3_ttf.dll, SDL3_image.dll - Required libraries
|
||||||
- assets/ - Game assets (images, music, fonts)
|
- assets/ - Game assets (images, music, fonts)
|
||||||
- FreeSans.ttf - Main font file
|
- FreeSans.ttf - Main font file
|
||||||
|
|
||||||
Enjoy playing Tetris!
|
Enjoy playing Spacetris!
|
||||||
"@
|
"@
|
||||||
|
|
||||||
$ReadmeContent | Out-File -FilePath (Join-Path $PackageDir "README.txt") -Encoding UTF8
|
$ReadmeContent | Out-File -FilePath (Join-Path $PackageDir "README.txt") -Encoding UTF8
|
||||||
@ -230,8 +230,8 @@ tetris.exe
|
|||||||
pause
|
pause
|
||||||
"@
|
"@
|
||||||
|
|
||||||
$BatchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Tetris.bat") -Encoding ASCII
|
$BatchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Spacetris.bat") -Encoding ASCII
|
||||||
Write-Success "Created Launch-Tetris.bat"
|
Write-Success "Created Launch-Spacetris.bat"
|
||||||
|
|
||||||
# Step 9: Validate package
|
# Step 9: Validate package
|
||||||
Write-Info "🔍 Validating package..."
|
Write-Info "🔍 Validating package..."
|
||||||
@ -259,7 +259,7 @@ $PackageSize = (Get-ChildItem $PackageDir -Recurse | Measure-Object -Property Le
|
|||||||
$PackageSizeMB = [math]::Round($PackageSize / 1MB, 2)
|
$PackageSizeMB = [math]::Round($PackageSize / 1MB, 2)
|
||||||
|
|
||||||
# Step 11: Create ZIP archive (optional)
|
# Step 11: Create ZIP archive (optional)
|
||||||
$ZipPath = Join-Path $OutputDir "TetrisGame-$Version.zip"
|
$ZipPath = Join-Path $OutputDir "SpacetrisGame-$Version.zip"
|
||||||
Write-Info "📁 Creating ZIP archive..."
|
Write-Info "📁 Creating ZIP archive..."
|
||||||
try {
|
try {
|
||||||
Compress-Archive -Path $PackageDir -DestinationPath $ZipPath -Force
|
Compress-Archive -Path $PackageDir -DestinationPath $ZipPath -Force
|
||||||
@ -280,5 +280,5 @@ if (Test-Path $ZipPath) {
|
|||||||
}
|
}
|
||||||
Write-Info ""
|
Write-Info ""
|
||||||
Write-Info "The game is ready for distribution!"
|
Write-Info "The game is ready for distribution!"
|
||||||
Write-Info "Users can run tetris.exe or Launch-Tetris.bat"
|
Write-Info "Users can run spacetris.exe or Launch-Spacetris.bat"
|
||||||
Write-Info ""
|
Write-Info ""
|
||||||
|
|||||||
@ -103,7 +103,7 @@ Optional additional scaling:
|
|||||||
- After level 100 completion: show completion screen + stats.
|
- After level 100 completion: show completion screen + stats.
|
||||||
|
|
||||||
### Game Over
|
### Game Over
|
||||||
- Standard Tetris game over: stack reaches spawn/top (existing behavior).
|
- Standard Spacetris game over: stack reaches spawn/top (existing behavior).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -7,11 +7,11 @@
|
|||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
|
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>com.example.tetris</string>
|
<string>com.example.spacetris</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Tetris</string>
|
<string>Spacetris</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@ -12,12 +12,19 @@ if(CMAKE_BUILD_TYPE STREQUAL "Release")
|
|||||||
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /OPT:REF /OPT:ICF")
|
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /OPT:REF /OPT:ICF")
|
||||||
|
|
||||||
# Enable whole program optimization
|
# Enable whole program optimization
|
||||||
set_target_properties(tetris PROPERTIES
|
# Detect game target (spacetris only)
|
||||||
|
if(TARGET spacetris)
|
||||||
|
set(GAME_TARGET spacetris)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "No game target found (expected 'spacetris')")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set_target_properties(${GAME_TARGET} PROPERTIES
|
||||||
INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE
|
INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set subsystem to Windows (no console) for release
|
# Set subsystem to Windows (no console) for release
|
||||||
set_target_properties(tetris PROPERTIES
|
set_target_properties(${GAME_TARGET} PROPERTIES
|
||||||
WIN32_EXECUTABLE TRUE
|
WIN32_EXECUTABLE TRUE
|
||||||
LINK_FLAGS_RELEASE "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup"
|
LINK_FLAGS_RELEASE "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup"
|
||||||
)
|
)
|
||||||
@ -30,61 +37,78 @@ if(CMAKE_BUILD_TYPE STREQUAL "Release")
|
|||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Ensure we have a GAME_TARGET set (spacetris only)
|
||||||
|
if(NOT DEFINED GAME_TARGET)
|
||||||
|
if(TARGET spacetris)
|
||||||
|
set(GAME_TARGET spacetris)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "No game target found (expected 'spacetris')")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
# Custom target for creating distribution package (renamed to avoid conflict with CPack)
|
# Custom target for creating distribution package (renamed to avoid conflict with CPack)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
# Windows-specific packaging
|
# Windows-specific packaging
|
||||||
add_custom_target(dist_package
|
if(GAME_TARGET)
|
||||||
COMMAND ${CMAKE_COMMAND} -E echo "Creating Windows distribution package..."
|
add_custom_target(dist_package
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/TetrisGame"
|
COMMAND ${CMAKE_COMMAND} -E echo "Creating Windows distribution package..."
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:tetris>" "${CMAKE_BINARY_DIR}/package/TetrisGame/"
|
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/SpacetrisGame"
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "${CMAKE_BINARY_DIR}/package/TetrisGame/assets"
|
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:${GAME_TARGET}>" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/"
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "${CMAKE_BINARY_DIR}/package/TetrisGame/"
|
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/assets" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/assets"
|
||||||
COMMENT "Packaging Tetris for distribution"
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/FreeSans.ttf" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/"
|
||||||
DEPENDS tetris
|
COMMENT "Packaging Spacetris for distribution"
|
||||||
)
|
DEPENDS ${GAME_TARGET}
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
message(WARNING "No game target detected; skipping dist_package target.")
|
||||||
|
endif()
|
||||||
|
|
||||||
# Try to copy SDL DLLs automatically (SDL_image no longer needed)
|
# Try to copy SDL DLLs automatically (SDL_image no longer needed)
|
||||||
find_file(SDL3_DLL SDL3.dll PATHS "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin" NO_DEFAULT_PATH)
|
find_file(SDL3_DLL SDL3.dll PATHS "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin" NO_DEFAULT_PATH)
|
||||||
find_file(SDL3_TTF_DLL SDL3_ttf.dll PATHS "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin" NO_DEFAULT_PATH)
|
find_file(SDL3_TTF_DLL SDL3_ttf.dll PATHS "${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin" NO_DEFAULT_PATH)
|
||||||
|
|
||||||
if(SDL3_DLL)
|
if(GAME_TARGET)
|
||||||
add_custom_command(TARGET dist_package POST_BUILD
|
if(SDL3_DLL)
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_DLL}" "${CMAKE_BINARY_DIR}/package/TetrisGame/"
|
add_custom_command(TARGET dist_package POST_BUILD
|
||||||
COMMENT "Copying SDL3.dll"
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_DLL}" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/"
|
||||||
)
|
COMMENT "Copying SDL3.dll"
|
||||||
endif()
|
)
|
||||||
|
endif()
|
||||||
if(SDL3_TTF_DLL)
|
|
||||||
add_custom_command(TARGET dist_package POST_BUILD
|
if(SDL3_TTF_DLL)
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_TTF_DLL}" "${CMAKE_BINARY_DIR}/package/TetrisGame/"
|
add_custom_command(TARGET dist_package POST_BUILD
|
||||||
COMMENT "Copying SDL3_ttf.dll"
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SDL3_TTF_DLL}" "${CMAKE_BINARY_DIR}/package/SpacetrisGame/"
|
||||||
)
|
COMMENT "Copying SDL3_ttf.dll"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Installation rules for system-wide installation
|
# Installation rules for system-wide installation
|
||||||
if(APPLE)
|
if(GAME_TARGET)
|
||||||
install(TARGETS tetris
|
if(APPLE)
|
||||||
BUNDLE DESTINATION .
|
install(TARGETS ${GAME_TARGET}
|
||||||
|
BUNDLE DESTINATION .
|
||||||
|
COMPONENT Runtime
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
install(TARGETS ${GAME_TARGET}
|
||||||
|
RUNTIME DESTINATION bin
|
||||||
|
COMPONENT Runtime
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(DIRECTORY assets/
|
||||||
|
DESTINATION share/${GAME_TARGET}/assets
|
||||||
COMPONENT Runtime
|
COMPONENT Runtime
|
||||||
)
|
)
|
||||||
else()
|
|
||||||
install(TARGETS tetris
|
install(FILES FreeSans.ttf
|
||||||
RUNTIME DESTINATION bin
|
DESTINATION share/${GAME_TARGET}
|
||||||
COMPONENT Runtime
|
COMPONENT Runtime
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
install(DIRECTORY assets/
|
|
||||||
DESTINATION share/tetris/assets
|
|
||||||
COMPONENT Runtime
|
|
||||||
)
|
|
||||||
|
|
||||||
install(FILES FreeSans.ttf
|
|
||||||
DESTINATION share/tetris
|
|
||||||
COMPONENT Runtime
|
|
||||||
)
|
|
||||||
|
|
||||||
# CPack configuration for creating installers (commented out - requires LICENSE file)
|
# CPack configuration for creating installers (commented out - requires LICENSE file)
|
||||||
# set(CPACK_PACKAGE_NAME "Tetris")
|
# set(CPACK_PACKAGE_NAME "Tetris")
|
||||||
# set(CPACK_PACKAGE_VENDOR "TetrisGame")
|
# set(CPACK_PACKAGE_VENDOR "TetrisGame")
|
||||||
|
|||||||
174
cooperate_mode_plan.md
Normal file
174
cooperate_mode_plan.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Spacetris — COOPERATE Mode (Two-Player Co-Op)
|
||||||
|
## VS Code Copilot AI Agent Prompt
|
||||||
|
|
||||||
|
> Implement a new **COOPERATE** play mode for Spacetris.
|
||||||
|
> This is a **two-player cooperative mode** with a shared board and synchronized line clears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mode Overview
|
||||||
|
|
||||||
|
### Mode Name
|
||||||
|
- **COOPERATE**
|
||||||
|
|
||||||
|
### Core Concept
|
||||||
|
- Two players play **together**, not versus.
|
||||||
|
- One shared game board with **double width**.
|
||||||
|
- Each player is responsible for **their own half** of the board.
|
||||||
|
- A line clears **only when BOTH halves of the same row are full**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Grid Layout
|
||||||
|
|
||||||
|
- Grid width: **20 columns**
|
||||||
|
- Grid height: **standard height** (same as Endless/Challenge)
|
||||||
|
- Column ownership:
|
||||||
|
- Player 1 → columns `0–9` (left half)
|
||||||
|
- Player 2 → columns `10–19` (right half)
|
||||||
|
|
||||||
|
### Visual Requirements
|
||||||
|
- Draw a **vertical divider line** between columns 9 and 10.
|
||||||
|
- Divider should be subtle but always visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Line Clear Rule (Critical)
|
||||||
|
|
||||||
|
A row clears **only if**:
|
||||||
|
- Player 1 half of the row is completely filled
|
||||||
|
- AND Player 2 half of the row is completely filled
|
||||||
|
|
||||||
|
If only one side is filled:
|
||||||
|
- The row **does NOT clear**
|
||||||
|
- Provide visual feedback:
|
||||||
|
- Glow or pulse on the completed half
|
||||||
|
- Optional hint on the incomplete half
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Player Mechanics
|
||||||
|
|
||||||
|
### Piece Streams
|
||||||
|
- Each player has their **own active piece**
|
||||||
|
- Each player has their **own NEXT queue**
|
||||||
|
- Both queues use the **same RNG seed** for fairness
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
- Player 1 controls only the left half
|
||||||
|
- Player 2 controls only the right half
|
||||||
|
- Players cannot move or rotate pieces into the other player’s half
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Gravity & Timing
|
||||||
|
|
||||||
|
- Gravity applies globally (same speed for both)
|
||||||
|
- Lock delay is handled **per player**
|
||||||
|
- One player locking does NOT block the other player
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Failure Conditions
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
- Game over occurs only when **both players top out**
|
||||||
|
|
||||||
|
Alternative (optional):
|
||||||
|
- If either player tops out → shared loss
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scoring System
|
||||||
|
|
||||||
|
- Score is **shared**
|
||||||
|
- Line clears grant:
|
||||||
|
- Base score
|
||||||
|
- Cooperative bonus if both halves complete simultaneously
|
||||||
|
- Combo bonus for consecutive cooperative clears
|
||||||
|
|
||||||
|
No competitive scoring between players.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI / HUD Requirements
|
||||||
|
|
||||||
|
- Two NEXT panels (one per player)
|
||||||
|
- Shared score display
|
||||||
|
- Shared level display
|
||||||
|
- Visual feedback for:
|
||||||
|
- Half-filled rows
|
||||||
|
- Successful cooperative clears
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- SYNC meter for advanced cooperative mechanics (future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data Model Suggestions
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class PlayerSide {
|
||||||
|
Left,
|
||||||
|
Right
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PlayerState {
|
||||||
|
PlayerSide side;
|
||||||
|
ActivePiece piece;
|
||||||
|
bool isAlive;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Cell {
|
||||||
|
bool occupied;
|
||||||
|
PlayerSide owner;
|
||||||
|
};
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Line Clear Algorithm (Guidance)
|
||||||
|
|
||||||
|
When checking for full rows:
|
||||||
|
|
||||||
|
1. For each row:
|
||||||
|
|
||||||
|
* Check columns `0–9` for full (Player 1)
|
||||||
|
* Check columns `10–19` for full (Player 2)
|
||||||
|
2. Only if **both are full**, mark row for clearing
|
||||||
|
3. Clear row normally and apply gravity to both halves
|
||||||
|
4. Update shared score and combos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Constraints
|
||||||
|
|
||||||
|
* COOPERATE is a separate play mode
|
||||||
|
* Do NOT reuse versus or garbage mechanics
|
||||||
|
* Focus on clarity, fairness, and readability
|
||||||
|
* Keep implementation modular (easy to expand later)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Acceptance Criteria
|
||||||
|
|
||||||
|
* Two players can play simultaneously on one board
|
||||||
|
* Each player fills only their half
|
||||||
|
* Lines clear only when both halves are filled
|
||||||
|
* Visual feedback clearly shows cooperative dependency
|
||||||
|
* Mode integrates cleanly into the main menu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Optional Future Hooks (Do Not Implement Now)
|
||||||
|
|
||||||
|
* Assist blocks
|
||||||
|
* Shared power-ups
|
||||||
|
* Cross-half interactions
|
||||||
|
* Online co-op
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Short Summary for the Agent
|
||||||
|
|
||||||
|
Implement a two-player COOPERATE mode with a 20-column board split into two halves. Each player fills their half independently. A line clears only when both halves of the same row are full. Score, level, and progress are shared. Add clear visual feedback and a divider between player halves.
|
||||||
271
docs/ai/cooperate_network.md
Normal file
271
docs/ai/cooperate_network.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# Spacetris — COOPERATE Mode
|
||||||
|
## Network Multiplayer (2 PLAYER – NETWORK)
|
||||||
|
### VS Code Copilot AI Agent Prompt
|
||||||
|
|
||||||
|
You are integrating **online cooperative multiplayer** into an existing **C++ / SDL3 game** called **Spacetris**.
|
||||||
|
|
||||||
|
This feature extends the existing **COOPERATE mode** to support:
|
||||||
|
- Local 2 players
|
||||||
|
- Human + AI
|
||||||
|
- **Human + Human over network (NEW)**
|
||||||
|
|
||||||
|
The networking solution must be **deterministic, lightweight, and stable**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. High-Level Goal
|
||||||
|
|
||||||
|
Add **COOPERATE 2 PLAYER (NETWORK)** mode where:
|
||||||
|
- Two players play together over the internet
|
||||||
|
- Each player controls one half of the shared grid
|
||||||
|
- A line clears only when both halves are filled
|
||||||
|
- Gameplay remains identical to local COOPERATE mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technology Constraints
|
||||||
|
|
||||||
|
- Language: **C++**
|
||||||
|
- Engine: **SDL3**
|
||||||
|
- Networking: **ENet (UDP with reliability)**
|
||||||
|
- No engine rewrite
|
||||||
|
- No authoritative server logic required (co-op only)
|
||||||
|
|
||||||
|
SDL3 is used ONLY for:
|
||||||
|
- Rendering
|
||||||
|
- Input
|
||||||
|
- Timing
|
||||||
|
|
||||||
|
Networking is a **separate layer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Network Model (MANDATORY)
|
||||||
|
|
||||||
|
### Use **Input Lockstep Networking**
|
||||||
|
|
||||||
|
#### Core idea:
|
||||||
|
- Both clients run the same deterministic simulation
|
||||||
|
- Only **player inputs** are sent over the network
|
||||||
|
- No board state is transmitted
|
||||||
|
- Both simulations must remain identical
|
||||||
|
|
||||||
|
This model is ideal for Tetris-like games.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Determinism Requirements (CRITICAL)
|
||||||
|
|
||||||
|
To ensure lockstep works:
|
||||||
|
|
||||||
|
- Fixed simulation tick (e.g. 60 Hz)
|
||||||
|
- Identical RNG seed for both clients
|
||||||
|
- Deterministic piece generation (bag system)
|
||||||
|
- No floating-point math in core gameplay
|
||||||
|
- Same gravity, rotation, lock-delay logic
|
||||||
|
- Identical line clear and scoring rules
|
||||||
|
|
||||||
|
Before networking:
|
||||||
|
- Input recording + replay must produce identical results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Network Topology
|
||||||
|
|
||||||
|
### Host / Client Model (Initial Implementation)
|
||||||
|
|
||||||
|
- One player hosts the game
|
||||||
|
- One player joins
|
||||||
|
- Host is authoritative for:
|
||||||
|
- RNG seed
|
||||||
|
- start tick
|
||||||
|
- game settings
|
||||||
|
|
||||||
|
This is sufficient and fair for cooperative gameplay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Network Library
|
||||||
|
|
||||||
|
Use **ENet** for:
|
||||||
|
- Reliable, ordered UDP packets
|
||||||
|
- Low latency
|
||||||
|
- Simple integration with C++
|
||||||
|
|
||||||
|
Do NOT use:
|
||||||
|
- SDL_net
|
||||||
|
- TCP-only networking
|
||||||
|
- High-level matchmaking SDKs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Network Packet Design
|
||||||
|
|
||||||
|
### Input Packet (Minimal)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct InputPacket {
|
||||||
|
uint32_t tick;
|
||||||
|
uint8_t buttons; // bitmask
|
||||||
|
};
|
||||||
|
````
|
||||||
|
|
||||||
|
Button bitmask example:
|
||||||
|
|
||||||
|
* bit 0 → move left
|
||||||
|
* bit 1 → move right
|
||||||
|
* bit 2 → rotate
|
||||||
|
* bit 3 → soft drop
|
||||||
|
* bit 4 → hard drop
|
||||||
|
* bit 5 → hold
|
||||||
|
|
||||||
|
Packets must be:
|
||||||
|
|
||||||
|
* Reliable
|
||||||
|
* Ordered
|
||||||
|
* Small
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tick & Latency Handling
|
||||||
|
|
||||||
|
### Input Delay Buffer (RECOMMENDED)
|
||||||
|
|
||||||
|
* Add fixed delay: **4–6 ticks**
|
||||||
|
* Simulate tick `T` using inputs for `T + delay`
|
||||||
|
* Prevents stalls due to latency spikes
|
||||||
|
|
||||||
|
Strict lockstep without buffering is NOT recommended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Desync Detection (IMPORTANT)
|
||||||
|
|
||||||
|
Every N ticks (e.g. once per second):
|
||||||
|
|
||||||
|
* Compute a hash of:
|
||||||
|
|
||||||
|
* Both grid halves
|
||||||
|
* Active pieces
|
||||||
|
* RNG index
|
||||||
|
* Score / lines / level
|
||||||
|
* Exchange hashes
|
||||||
|
* If mismatch:
|
||||||
|
|
||||||
|
* Log desync
|
||||||
|
* Stop game or mark session invalid
|
||||||
|
|
||||||
|
This is required for debugging and stability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Network Session Architecture
|
||||||
|
|
||||||
|
Create a dedicated networking module:
|
||||||
|
|
||||||
|
```
|
||||||
|
/network
|
||||||
|
NetSession.h
|
||||||
|
NetSession.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* ENet host/client setup
|
||||||
|
* Input packet send/receive
|
||||||
|
* Tick synchronization
|
||||||
|
* Latency buffering
|
||||||
|
* Disconnect handling
|
||||||
|
|
||||||
|
SDL main loop must NOT block on networking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Integration with Existing COOPERATE Logic
|
||||||
|
|
||||||
|
* COOPERATE grid logic stays unchanged
|
||||||
|
* SyncLineRenderer remains unchanged
|
||||||
|
* Scoring logic remains unchanged
|
||||||
|
* Network layer only injects **remote inputs**
|
||||||
|
|
||||||
|
Game logic should not know whether partner is:
|
||||||
|
|
||||||
|
* Local human
|
||||||
|
* AI
|
||||||
|
* Network player
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. UI Integration (Menu Changes)
|
||||||
|
|
||||||
|
In COOPERATE selection screen, add a new button:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ LOCAL CO-OP ] [ AI PARTNER ] [ 2 PLAYER (NETWORK) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### On selecting 2 PLAYER (NETWORK):
|
||||||
|
|
||||||
|
* Show:
|
||||||
|
|
||||||
|
* Host Game
|
||||||
|
* Join Game
|
||||||
|
* Display join code or IP
|
||||||
|
* Confirm connection before starting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Start Game Flow (Network)
|
||||||
|
|
||||||
|
1. Host creates session
|
||||||
|
2. Client connects
|
||||||
|
3. Host sends:
|
||||||
|
|
||||||
|
* RNG seed
|
||||||
|
* start tick
|
||||||
|
* game settings
|
||||||
|
4. Both wait until agreed start tick
|
||||||
|
5. Simulation begins simultaneously
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Disconnect & Error Handling
|
||||||
|
|
||||||
|
* If connection drops:
|
||||||
|
|
||||||
|
* Pause game
|
||||||
|
* Show “Reconnecting…”
|
||||||
|
* After timeout:
|
||||||
|
|
||||||
|
* End match or switch to AI (optional)
|
||||||
|
* Never crash
|
||||||
|
* Never corrupt game state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. What NOT to Implement
|
||||||
|
|
||||||
|
* ❌ Full state synchronization
|
||||||
|
* ❌ Prediction / rollback
|
||||||
|
* ❌ Server-authoritative gameplay
|
||||||
|
* ❌ Complex matchmaking
|
||||||
|
* ❌ Versus mechanics
|
||||||
|
|
||||||
|
This is cooperative, not competitive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Acceptance Criteria
|
||||||
|
|
||||||
|
* Two players can complete COOPERATE mode over network
|
||||||
|
* Gameplay matches local COOPERATE exactly
|
||||||
|
* No noticeable input lag under normal latency
|
||||||
|
* Desync detection works
|
||||||
|
* Offline / disconnect handled gracefully
|
||||||
|
* SDL3 render loop remains smooth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Summary for Copilot
|
||||||
|
|
||||||
|
Integrate networked cooperative multiplayer into Spacetris using SDL3 + C++ with ENet. Implement input lockstep networking with deterministic simulation, fixed tick rate, input buffering, and desync detection. Add a new COOPERATE menu option “2 PLAYER (NETWORK)” that allows host/join flow. Networking must be modular, non-blocking, and transparent to existing gameplay logic.
|
||||||
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env pwsh
|
#!/usr/bin/env pwsh
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Quick Production Package Creator for Tetris SDL3
|
Quick Production Package Creator for Spacetris SDL3
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
This script creates a production package using the existing build-msvc directory.
|
This script creates a production package using the existing build-msvc directory.
|
||||||
@ -12,8 +12,8 @@ param(
|
|||||||
[string]$OutputDir = "dist"
|
[string]$OutputDir = "dist"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ProjectName = "tetris"
|
$ProjectName = "spacetris"
|
||||||
$PackageDir = Join-Path $OutputDir "TetrisGame"
|
$PackageDir = Join-Path $OutputDir "SpacetrisGame"
|
||||||
$Version = Get-Date -Format "yyyy.MM.dd"
|
$Version = Get-Date -Format "yyyy.MM.dd"
|
||||||
|
|
||||||
function Write-ColorOutput($ForegroundColor) {
|
function Write-ColorOutput($ForegroundColor) {
|
||||||
@ -29,13 +29,13 @@ function Write-Warning { Write-ColorOutput Yellow $args }
|
|||||||
function Write-Error { Write-ColorOutput Red $args }
|
function Write-Error { Write-ColorOutput Red $args }
|
||||||
|
|
||||||
Write-Info "======================================"
|
Write-Info "======================================"
|
||||||
Write-Info " Tetris Quick Package Creator"
|
Write-Info " Spacetris Quick Package Creator"
|
||||||
Write-Info "======================================"
|
Write-Info "======================================"
|
||||||
|
|
||||||
# Check if build exists
|
# Check if build exists
|
||||||
$ExecutablePath = "build-msvc\Debug\tetris.exe"
|
$ExecutablePath = "build-msvc\Debug\spacetris.exe"
|
||||||
if (!(Test-Path $ExecutablePath)) {
|
if (!(Test-Path $ExecutablePath)) {
|
||||||
$ExecutablePath = "build-msvc\Release\tetris.exe"
|
$ExecutablePath = "build-msvc\Release\spacetris.exe"
|
||||||
if (!(Test-Path $ExecutablePath)) {
|
if (!(Test-Path $ExecutablePath)) {
|
||||||
Write-Error "No executable found in build-msvc directory. Please build the project first."
|
Write-Error "No executable found in build-msvc directory. Please build the project first."
|
||||||
exit 1
|
exit 1
|
||||||
@ -52,7 +52,7 @@ New-Item -ItemType Directory -Path $PackageDir -Force | Out-Null
|
|||||||
|
|
||||||
# Copy executable
|
# Copy executable
|
||||||
Copy-Item $ExecutablePath $PackageDir
|
Copy-Item $ExecutablePath $PackageDir
|
||||||
Write-Success "Copied tetris.exe"
|
Write-Success "Copied spacetris.exe"
|
||||||
|
|
||||||
# Copy assets
|
# Copy assets
|
||||||
if (Test-Path "assets") {
|
if (Test-Path "assets") {
|
||||||
@ -82,7 +82,7 @@ if (Test-Path $VcpkgBin) {
|
|||||||
$LaunchContent = @"
|
$LaunchContent = @"
|
||||||
@echo off
|
@echo off
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
tetris.exe
|
spacetris.exe
|
||||||
if %errorlevel% neq 0 (
|
if %errorlevel% neq 0 (
|
||||||
echo.
|
echo.
|
||||||
echo Game crashed or failed to start!
|
echo Game crashed or failed to start!
|
||||||
@ -91,22 +91,22 @@ if %errorlevel% neq 0 (
|
|||||||
pause
|
pause
|
||||||
)
|
)
|
||||||
"@
|
"@
|
||||||
$LaunchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Tetris.bat") -Encoding ASCII
|
$LaunchContent | Out-File -FilePath (Join-Path $PackageDir "Launch-Spacetris.bat") -Encoding ASCII
|
||||||
Write-Success "Created Launch-Tetris.bat"
|
Write-Success "Created Launch-Spacetris.bat"
|
||||||
|
|
||||||
# Create README
|
# Create README
|
||||||
$ReadmeContent = @"
|
$ReadmeContent = @"
|
||||||
Tetris SDL3 Game
|
Spacetris SDL3 Game
|
||||||
================
|
===================
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
1. Run Launch-Tetris.bat or tetris.exe
|
1. Run Launch-Spacetris.bat or spacetris.exe
|
||||||
2. Use arrow keys to move, Z/X to rotate, Space to drop
|
2. Use arrow keys to move, Z/X to rotate, Space to drop
|
||||||
3. Press F11 for fullscreen, Esc for menu
|
3. Press F11 for fullscreen, Esc for menu
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
- tetris.exe: Main game
|
- spacetris.exe: Main game
|
||||||
- Launch-Tetris.bat: Safe launcher with error handling
|
- Launch-Spacetris.bat: Safe launcher with error handling
|
||||||
- assets/: Game resources (music, images, fonts)
|
- assets/: Game resources (music, images, fonts)
|
||||||
- *.dll: Required libraries
|
- *.dll: Required libraries
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ $PackageSize = (Get-ChildItem $PackageDir -Recurse | Measure-Object -Property Le
|
|||||||
$PackageSizeMB = [math]::Round($PackageSize / 1MB, 2)
|
$PackageSizeMB = [math]::Round($PackageSize / 1MB, 2)
|
||||||
|
|
||||||
# Create ZIP
|
# Create ZIP
|
||||||
$ZipPath = Join-Path $OutputDir "TetrisGame-$Version.zip"
|
$ZipPath = Join-Path $OutputDir "SpacetrisGame-$Version.zip"
|
||||||
try {
|
try {
|
||||||
Compress-Archive -Path $PackageDir -DestinationPath $ZipPath -Force
|
Compress-Archive -Path $PackageDir -DestinationPath $ZipPath -Force
|
||||||
Write-Success "Created ZIP: $ZipPath"
|
Write-Success "Created ZIP: $ZipPath"
|
||||||
|
|||||||
0
scripts/check_braces.ps1
Normal file
0
scripts/check_braces.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
0
scripts/check_comments.ps1
Normal file
@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Create a distributable DMG for the macOS Tetris app
|
# Create a distributable DMG for the macOS Spacetris app
|
||||||
# Usage: ./scripts/create-dmg.sh <app-bundle-path> <output-dmg>
|
# Usage: ./scripts/create-dmg.sh <app-bundle-path> <output-dmg>
|
||||||
# Example: ./scripts/create-dmg.sh dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg
|
# Example: ./scripts/create-dmg.sh dist/SpacetrisGame-mac/spacetris.app dist/SpacetrisGame.dmg
|
||||||
|
|
||||||
if [[ $# -lt 2 ]]; then
|
if [[ $# -lt 2 ]]; then
|
||||||
echo "Usage: $0 <app-bundle-path> <output-dmg>"
|
echo "Usage: $0 <app-bundle-path> <output-dmg>"
|
||||||
echo "Example: $0 dist/TetrisGame-mac/tetris.app dist/TetrisGame.dmg"
|
echo "Example: $0 dist/SpacetrisGame-mac/spacetris.app dist/SpacetrisGame.dmg"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
0
scripts/find_unmatched.ps1
Normal file
0
scripts/find_unmatched.ps1
Normal file
@ -144,4 +144,7 @@ void draw(SDL_Renderer* renderer, SDL_Texture*) {
|
|||||||
|
|
||||||
double getLogoAnimCounter() { return logoAnimCounter; }
|
double getLogoAnimCounter() { return logoAnimCounter; }
|
||||||
int getHoveredButton() { return hoveredButton; }
|
int getHoveredButton() { return hoveredButton; }
|
||||||
|
void spawn(float x, float y) {
|
||||||
|
fireworks.emplace_back(x, y);
|
||||||
|
}
|
||||||
} // namespace AppFireworks
|
} // namespace AppFireworks
|
||||||
|
|||||||
@ -6,4 +6,5 @@ namespace AppFireworks {
|
|||||||
void update(double frameMs);
|
void update(double frameMs);
|
||||||
double getLogoAnimCounter();
|
double getLogoAnimCounter();
|
||||||
int getHoveredButton();
|
int getHoveredButton();
|
||||||
|
void spawn(float x, float y);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,8 @@
|
|||||||
#include "core/state/StateManager.h"
|
#include "core/state/StateManager.h"
|
||||||
|
|
||||||
#include "gameplay/core/Game.h"
|
#include "gameplay/core/Game.h"
|
||||||
|
#include "gameplay/coop/CoopGame.h"
|
||||||
|
#include "gameplay/coop/CoopAIController.h"
|
||||||
#include "gameplay/effects/LineEffect.h"
|
#include "gameplay/effects/LineEffect.h"
|
||||||
|
|
||||||
#include "graphics/effects/SpaceWarp.h"
|
#include "graphics/effects/SpaceWarp.h"
|
||||||
@ -47,6 +49,9 @@
|
|||||||
#include "graphics/ui/Font.h"
|
#include "graphics/ui/Font.h"
|
||||||
#include "graphics/ui/HelpOverlay.h"
|
#include "graphics/ui/HelpOverlay.h"
|
||||||
|
|
||||||
|
#include "network/CoopNetButtons.h"
|
||||||
|
#include "network/NetSession.h"
|
||||||
|
|
||||||
#include "persistence/Scores.h"
|
#include "persistence/Scores.h"
|
||||||
|
|
||||||
#include "states/LevelSelectorState.h"
|
#include "states/LevelSelectorState.h"
|
||||||
@ -55,6 +60,7 @@
|
|||||||
#include "states/MenuState.h"
|
#include "states/MenuState.h"
|
||||||
#include "states/OptionsState.h"
|
#include "states/OptionsState.h"
|
||||||
#include "states/PlayingState.h"
|
#include "states/PlayingState.h"
|
||||||
|
#include "states/VideoState.h"
|
||||||
#include "states/State.h"
|
#include "states/State.h"
|
||||||
|
|
||||||
#include "ui/BottomMenu.h"
|
#include "ui/BottomMenu.h"
|
||||||
@ -171,6 +177,8 @@ struct TetrisApp::Impl {
|
|||||||
int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
|
int hoveredButton = -1; // -1 = none, 0 = play, 1 = level, 2 = settings
|
||||||
bool isNewHighScore = false;
|
bool isNewHighScore = false;
|
||||||
std::string playerName;
|
std::string playerName;
|
||||||
|
std::string player2Name;
|
||||||
|
int highScoreEntryIndex = 0; // 0 = entering player1, 1 = entering player2
|
||||||
bool helpOverlayPausedGame = false;
|
bool helpOverlayPausedGame = false;
|
||||||
|
|
||||||
SDL_Window* window = nullptr;
|
SDL_Window* window = nullptr;
|
||||||
@ -228,6 +236,7 @@ struct TetrisApp::Impl {
|
|||||||
std::atomic<size_t> loadingStep{0};
|
std::atomic<size_t> loadingStep{0};
|
||||||
|
|
||||||
std::unique_ptr<Game> game;
|
std::unique_ptr<Game> game;
|
||||||
|
std::unique_ptr<CoopGame> coopGame;
|
||||||
std::vector<std::string> singleSounds;
|
std::vector<std::string> singleSounds;
|
||||||
std::vector<std::string> doubleSounds;
|
std::vector<std::string> doubleSounds;
|
||||||
std::vector<std::string> tripleSounds;
|
std::vector<std::string> tripleSounds;
|
||||||
@ -235,6 +244,11 @@ struct TetrisApp::Impl {
|
|||||||
bool suppressLineVoiceForLevelUp = false;
|
bool suppressLineVoiceForLevelUp = false;
|
||||||
bool skipNextLevelUpJingle = false;
|
bool skipNextLevelUpJingle = false;
|
||||||
|
|
||||||
|
// COOPERATE option: when true, right player is AI-controlled.
|
||||||
|
bool coopVsAI = false;
|
||||||
|
|
||||||
|
CoopAIController coopAI;
|
||||||
|
|
||||||
AppState state = AppState::Loading;
|
AppState state = AppState::Loading;
|
||||||
double loadingProgress = 0.0;
|
double loadingProgress = 0.0;
|
||||||
Uint64 loadStart = 0;
|
Uint64 loadStart = 0;
|
||||||
@ -242,7 +256,19 @@ struct TetrisApp::Impl {
|
|||||||
bool isFullscreen = false;
|
bool isFullscreen = false;
|
||||||
bool leftHeld = false;
|
bool leftHeld = false;
|
||||||
bool rightHeld = false;
|
bool rightHeld = false;
|
||||||
|
bool p1LeftHeld = false;
|
||||||
|
bool p1RightHeld = false;
|
||||||
|
bool p2LeftHeld = false;
|
||||||
|
bool p2RightHeld = false;
|
||||||
double moveTimerMs = 0.0;
|
double moveTimerMs = 0.0;
|
||||||
|
double p1MoveTimerMs = 0.0;
|
||||||
|
double p2MoveTimerMs = 0.0;
|
||||||
|
|
||||||
|
// Network coop fixed-tick state (used only when ctx.coopNetEnabled is true)
|
||||||
|
double coopNetAccMs = 0.0;
|
||||||
|
uint32_t coopNetCachedTick = 0xFFFFFFFFu;
|
||||||
|
uint8_t coopNetCachedButtons = 0;
|
||||||
|
uint32_t coopNetLastHashSentTick = 0xFFFFFFFFu;
|
||||||
double DAS = 170.0;
|
double DAS = 170.0;
|
||||||
double ARR = 40.0;
|
double ARR = 40.0;
|
||||||
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
SDL_Rect logicalVP{0, 0, LOGICAL_W, LOGICAL_H};
|
||||||
@ -285,11 +311,21 @@ struct TetrisApp::Impl {
|
|||||||
std::unique_ptr<StateManager> stateMgr;
|
std::unique_ptr<StateManager> stateMgr;
|
||||||
StateContext ctx{};
|
StateContext ctx{};
|
||||||
std::unique_ptr<LoadingState> loadingState;
|
std::unique_ptr<LoadingState> loadingState;
|
||||||
|
std::unique_ptr<VideoState> videoState;
|
||||||
std::unique_ptr<MenuState> menuState;
|
std::unique_ptr<MenuState> menuState;
|
||||||
std::unique_ptr<OptionsState> optionsState;
|
std::unique_ptr<OptionsState> optionsState;
|
||||||
std::unique_ptr<LevelSelectorState> levelSelectorState;
|
std::unique_ptr<LevelSelectorState> levelSelectorState;
|
||||||
std::unique_ptr<PlayingState> playingState;
|
std::unique_ptr<PlayingState> playingState;
|
||||||
|
|
||||||
|
// Startup fade-in overlay (used after intro video).
|
||||||
|
bool startupFadeActive = false;
|
||||||
|
float startupFadeAlpha = 0.0f; // 0..1 black overlay strength
|
||||||
|
double startupFadeClockMs = 0.0;
|
||||||
|
static constexpr double STARTUP_FADE_IN_MS = 650.0;
|
||||||
|
|
||||||
|
// Intro video path.
|
||||||
|
std::string introVideoPath = "assets/videos/spacetris_intro.mp4";
|
||||||
|
|
||||||
int init();
|
int init();
|
||||||
void runLoop();
|
void runLoop();
|
||||||
void shutdown();
|
void shutdown();
|
||||||
@ -421,6 +457,8 @@ int TetrisApp::Impl::init()
|
|||||||
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
game->setGravityGlobalMultiplier(Config::Gameplay::GRAVITY_SPEED_MULTIPLIER);
|
||||||
game->reset(startLevelSelection);
|
game->reset(startLevelSelection);
|
||||||
|
|
||||||
|
coopGame = std::make_unique<CoopGame>(startLevelSelection);
|
||||||
|
|
||||||
// Define voice line banks for gameplay callbacks
|
// Define voice line banks for gameplay callbacks
|
||||||
singleSounds = {"well_played", "smooth_clear", "great_move"};
|
singleSounds = {"well_played", "smooth_clear", "great_move"};
|
||||||
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
|
||||||
@ -458,6 +496,20 @@ int TetrisApp::Impl::init()
|
|||||||
suppressLineVoiceForLevelUp = false;
|
suppressLineVoiceForLevelUp = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep co-op line-clear SFX behavior identical to classic.
|
||||||
|
coopGame->setSoundCallback([this, playVoiceCue](int linesCleared) {
|
||||||
|
if (linesCleared <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||||
|
|
||||||
|
if (!suppressLineVoiceForLevelUp) {
|
||||||
|
playVoiceCue(linesCleared);
|
||||||
|
}
|
||||||
|
suppressLineVoiceForLevelUp = false;
|
||||||
|
});
|
||||||
|
|
||||||
game->setLevelUpCallback([this](int /*newLevel*/) {
|
game->setLevelUpCallback([this](int /*newLevel*/) {
|
||||||
if (skipNextLevelUpJingle) {
|
if (skipNextLevelUpJingle) {
|
||||||
skipNextLevelUpJingle = false;
|
skipNextLevelUpJingle = false;
|
||||||
@ -468,6 +520,17 @@ int TetrisApp::Impl::init()
|
|||||||
suppressLineVoiceForLevelUp = true;
|
suppressLineVoiceForLevelUp = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirror single-player level-up audio/visual behavior for Coop sessions
|
||||||
|
coopGame->setLevelUpCallback([this](int /*newLevel*/) {
|
||||||
|
if (skipNextLevelUpJingle) {
|
||||||
|
skipNextLevelUpJingle = false;
|
||||||
|
} else {
|
||||||
|
SoundEffectManager::instance().playSound("new_level", 1.0f);
|
||||||
|
SoundEffectManager::instance().playSound("lets_go", 1.0f);
|
||||||
|
}
|
||||||
|
suppressLineVoiceForLevelUp = true;
|
||||||
|
});
|
||||||
|
|
||||||
game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) {
|
game->setAsteroidDestroyedCallback([](AsteroidType /*type*/) {
|
||||||
SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f);
|
SoundEffectManager::instance().playSound("asteroid_destroy", 0.9f);
|
||||||
});
|
});
|
||||||
@ -479,7 +542,10 @@ int TetrisApp::Impl::init()
|
|||||||
isFullscreen = Settings::instance().isFullscreen();
|
isFullscreen = Settings::instance().isFullscreen();
|
||||||
leftHeld = false;
|
leftHeld = false;
|
||||||
rightHeld = false;
|
rightHeld = false;
|
||||||
|
p1LeftHeld = p1RightHeld = p2LeftHeld = p2RightHeld = false;
|
||||||
moveTimerMs = 0;
|
moveTimerMs = 0;
|
||||||
|
p1MoveTimerMs = 0.0;
|
||||||
|
p2MoveTimerMs = 0.0;
|
||||||
DAS = 170.0;
|
DAS = 170.0;
|
||||||
ARR = 40.0;
|
ARR = 40.0;
|
||||||
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
|
logicalVP = SDL_Rect{0, 0, LOGICAL_W, LOGICAL_H};
|
||||||
@ -506,6 +572,7 @@ int TetrisApp::Impl::init()
|
|||||||
ctx = StateContext{};
|
ctx = StateContext{};
|
||||||
ctx.stateManager = stateMgr.get();
|
ctx.stateManager = stateMgr.get();
|
||||||
ctx.game = game.get();
|
ctx.game = game.get();
|
||||||
|
ctx.coopGame = coopGame.get();
|
||||||
ctx.scores = nullptr;
|
ctx.scores = nullptr;
|
||||||
ctx.starfield = &starfield;
|
ctx.starfield = &starfield;
|
||||||
ctx.starfield3D = &starfield3D;
|
ctx.starfield3D = &starfield3D;
|
||||||
@ -526,6 +593,7 @@ int TetrisApp::Impl::init()
|
|||||||
ctx.mainScreenW = mainScreenW;
|
ctx.mainScreenW = mainScreenW;
|
||||||
ctx.mainScreenH = mainScreenH;
|
ctx.mainScreenH = mainScreenH;
|
||||||
ctx.musicEnabled = &musicEnabled;
|
ctx.musicEnabled = &musicEnabled;
|
||||||
|
ctx.coopVsAI = &coopVsAI;
|
||||||
ctx.startLevelSelection = &startLevelSelection;
|
ctx.startLevelSelection = &startLevelSelection;
|
||||||
ctx.hoveredButton = &hoveredButton;
|
ctx.hoveredButton = &hoveredButton;
|
||||||
ctx.showSettingsPopup = &showSettingsPopup;
|
ctx.showSettingsPopup = &showSettingsPopup;
|
||||||
@ -587,10 +655,17 @@ int TetrisApp::Impl::init()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state != AppState::Menu) {
|
if (state != AppState::Menu) {
|
||||||
|
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
|
||||||
|
coopAI.reset();
|
||||||
|
}
|
||||||
state = AppState::Playing;
|
state = AppState::Playing;
|
||||||
ctx.stateManager->setState(state);
|
ctx.stateManager->setState(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (game && game->getMode() == GameMode::Cooperate && coopGame && coopVsAI) {
|
||||||
|
coopAI.reset();
|
||||||
|
}
|
||||||
beginStateFade(AppState::Playing, true);
|
beginStateFade(AppState::Playing, true);
|
||||||
};
|
};
|
||||||
ctx.startPlayTransition = startMenuPlayTransition;
|
ctx.startPlayTransition = startMenuPlayTransition;
|
||||||
@ -607,7 +682,11 @@ int TetrisApp::Impl::init()
|
|||||||
};
|
};
|
||||||
ctx.requestFadeTransition = requestStateFade;
|
ctx.requestFadeTransition = requestStateFade;
|
||||||
|
|
||||||
|
ctx.startupFadeActive = &startupFadeActive;
|
||||||
|
ctx.startupFadeAlpha = &startupFadeAlpha;
|
||||||
|
|
||||||
loadingState = std::make_unique<LoadingState>(ctx);
|
loadingState = std::make_unique<LoadingState>(ctx);
|
||||||
|
videoState = std::make_unique<VideoState>(ctx);
|
||||||
menuState = std::make_unique<MenuState>(ctx);
|
menuState = std::make_unique<MenuState>(ctx);
|
||||||
optionsState = std::make_unique<OptionsState>(ctx);
|
optionsState = std::make_unique<OptionsState>(ctx);
|
||||||
levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
|
levelSelectorState = std::make_unique<LevelSelectorState>(ctx);
|
||||||
@ -617,6 +696,20 @@ int TetrisApp::Impl::init()
|
|||||||
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
|
stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); });
|
||||||
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
|
stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); });
|
||||||
|
|
||||||
|
stateMgr->registerHandler(AppState::Video, [this](const SDL_Event& e){ if (videoState) videoState->handleEvent(e); });
|
||||||
|
stateMgr->registerOnEnter(AppState::Video, [this]() {
|
||||||
|
if (!videoState) return;
|
||||||
|
const bool ok = videoState->begin(renderer, introVideoPath);
|
||||||
|
if (!ok) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Intro video unavailable; skipping to Menu");
|
||||||
|
state = AppState::Menu;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
videoState->onEnter();
|
||||||
|
});
|
||||||
|
stateMgr->registerOnExit(AppState::Video, [this](){ if (videoState) videoState->onExit(); });
|
||||||
|
|
||||||
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
|
stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); });
|
||||||
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
|
stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); });
|
||||||
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
|
stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); });
|
||||||
@ -761,13 +854,14 @@ void TetrisApp::Impl::runLoop()
|
|||||||
Settings::instance().setMusicEnabled(true);
|
Settings::instance().setMusicEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key.scancode == SDL_SCANCODE_S)
|
// K: Toggle sound effects (S is reserved for co-op movement)
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_K)
|
||||||
{
|
{
|
||||||
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
||||||
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled());
|
||||||
}
|
}
|
||||||
const bool helpToggleKey =
|
const bool helpToggleKey =
|
||||||
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu);
|
(e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu);
|
||||||
if (helpToggleKey)
|
if (helpToggleKey)
|
||||||
{
|
{
|
||||||
showHelpOverlay = !showHelpOverlay;
|
showHelpOverlay = !showHelpOverlay;
|
||||||
@ -837,27 +931,81 @@ void TetrisApp::Impl::runLoop()
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
|
if (!showHelpOverlay && state == AppState::GameOver && isNewHighScore && e.type == SDL_EVENT_TEXT_INPUT) {
|
||||||
if (playerName.length() < 12) {
|
// Support single-player and coop two-name entry
|
||||||
playerName += e.text.text;
|
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||||
|
if (highScoreEntryIndex == 0) {
|
||||||
|
if (playerName.length() < 12) playerName += e.text.text;
|
||||||
|
} else {
|
||||||
|
if (player2Name.length() < 12) player2Name += e.text.text;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (playerName.length() < 12) playerName += e.text.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
if (!showHelpOverlay && state == AppState::GameOver && e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
||||||
if (isNewHighScore) {
|
if (isNewHighScore) {
|
||||||
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
|
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||||
playerName.pop_back();
|
if (coopVsAI) {
|
||||||
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
// One-name entry flow (CPU is LEFT, human enters RIGHT name)
|
||||||
if (playerName.empty()) playerName = "PLAYER";
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
|
||||||
ensureScoresLoaded();
|
if (!player2Name.empty()) player2Name.pop_back();
|
||||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName);
|
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
||||||
Settings::instance().setPlayerName(playerName);
|
if (player2Name.empty()) player2Name = "P2";
|
||||||
isNewHighScore = false;
|
std::string combined = std::string("CPU") + " & " + player2Name;
|
||||||
SDL_StopTextInput(window);
|
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||||
|
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||||
|
int combinedScore = leftScore + rightScore;
|
||||||
|
ensureScoresLoaded();
|
||||||
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
|
||||||
|
Settings::instance().setPlayerName(player2Name);
|
||||||
|
isNewHighScore = false;
|
||||||
|
SDL_StopTextInput(window);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Two-name entry flow
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE) {
|
||||||
|
if (highScoreEntryIndex == 0 && !playerName.empty()) playerName.pop_back();
|
||||||
|
else if (highScoreEntryIndex == 1 && !player2Name.empty()) player2Name.pop_back();
|
||||||
|
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
||||||
|
if (highScoreEntryIndex == 0) {
|
||||||
|
if (playerName.empty()) playerName = "P1";
|
||||||
|
highScoreEntryIndex = 1; // move to second name
|
||||||
|
} else {
|
||||||
|
if (player2Name.empty()) player2Name = "P2";
|
||||||
|
// Submit combined name
|
||||||
|
std::string combined = playerName + " & " + player2Name;
|
||||||
|
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||||
|
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||||
|
int combinedScore = leftScore + rightScore;
|
||||||
|
ensureScoresLoaded();
|
||||||
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate");
|
||||||
|
Settings::instance().setPlayerName(playerName);
|
||||||
|
isNewHighScore = false;
|
||||||
|
SDL_StopTextInput(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_BACKSPACE && !playerName.empty()) {
|
||||||
|
playerName.pop_back();
|
||||||
|
} else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) {
|
||||||
|
if (playerName.empty()) playerName = "PLAYER";
|
||||||
|
ensureScoresLoaded();
|
||||||
|
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||||
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName, gt);
|
||||||
|
Settings::instance().setPlayerName(playerName);
|
||||||
|
isNewHighScore = false;
|
||||||
|
SDL_StopTextInput(window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||||
if (game->getMode() == GameMode::Challenge) {
|
if (game->getMode() == GameMode::Challenge) {
|
||||||
game->startChallengeRun(1);
|
game->startChallengeRun(1);
|
||||||
|
} else if (game->getMode() == GameMode::Cooperate) {
|
||||||
|
game->setMode(GameMode::Cooperate);
|
||||||
|
game->reset(startLevelSelection);
|
||||||
} else {
|
} else {
|
||||||
game->setMode(GameMode::Endless);
|
game->setMode(GameMode::Endless);
|
||||||
game->reset(startLevelSelection);
|
game->reset(startLevelSelection);
|
||||||
@ -893,6 +1041,11 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (game) game->setMode(GameMode::Endless);
|
if (game) game->setMode(GameMode::Endless);
|
||||||
startMenuPlayTransition();
|
startMenuPlayTransition();
|
||||||
break;
|
break;
|
||||||
|
case ui::BottomMenuItem::Cooperate:
|
||||||
|
if (menuState) {
|
||||||
|
menuState->showCoopSetupPanel(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case ui::BottomMenuItem::Challenge:
|
case ui::BottomMenuItem::Challenge:
|
||||||
if (game) {
|
if (game) {
|
||||||
game->setMode(GameMode::Challenge);
|
game->setMode(GameMode::Challenge);
|
||||||
@ -1034,12 +1187,31 @@ void TetrisApp::Impl::runLoop()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State transitions can be triggered from render/update (e.g. menu network handshake).
|
||||||
|
// Keep our cached `state` in sync every frame, not only when events occur.
|
||||||
|
state = stateMgr->getState();
|
||||||
|
|
||||||
Uint64 now = SDL_GetPerformanceCounter();
|
Uint64 now = SDL_GetPerformanceCounter();
|
||||||
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
|
double frameMs = double(now - lastMs) * 1000.0 / double(SDL_GetPerformanceFrequency());
|
||||||
lastMs = now;
|
lastMs = now;
|
||||||
if (frameMs > 100.0) frameMs = 100.0;
|
if (frameMs > 100.0) frameMs = 100.0;
|
||||||
gameplayBackgroundClockMs += frameMs;
|
gameplayBackgroundClockMs += frameMs;
|
||||||
|
|
||||||
|
if (startupFadeActive) {
|
||||||
|
if (startupFadeClockMs <= 0.0) {
|
||||||
|
startupFadeClockMs = STARTUP_FADE_IN_MS;
|
||||||
|
startupFadeAlpha = 1.0f;
|
||||||
|
}
|
||||||
|
startupFadeClockMs -= frameMs;
|
||||||
|
if (startupFadeClockMs <= 0.0) {
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeActive = false;
|
||||||
|
} else {
|
||||||
|
startupFadeAlpha = float(std::clamp(startupFadeClockMs / STARTUP_FADE_IN_MS, 0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto clearChallengeStory = [this]() {
|
auto clearChallengeStory = [this]() {
|
||||||
challengeStoryText.clear();
|
challengeStoryText.clear();
|
||||||
challengeStoryLevel = 0;
|
challengeStoryLevel = 0;
|
||||||
@ -1153,29 +1325,365 @@ void TetrisApp::Impl::runLoop()
|
|||||||
|
|
||||||
if (state == AppState::Playing)
|
if (state == AppState::Playing)
|
||||||
{
|
{
|
||||||
if (!game->isPaused()) {
|
const bool coopActive = game && game->getMode() == GameMode::Cooperate && coopGame;
|
||||||
game->tickGravity(frameMs);
|
|
||||||
game->updateElapsedTime();
|
|
||||||
|
|
||||||
if (lineEffect.isActive()) {
|
if (coopActive) {
|
||||||
if (lineEffect.update(frameMs / 1000.0f)) {
|
// Coop DAS/ARR handling (per-side)
|
||||||
game->clearCompletedLines();
|
const bool* ks = SDL_GetKeyboardState(nullptr);
|
||||||
|
|
||||||
|
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||||
|
bool leftHeldPrev,
|
||||||
|
bool rightHeldPrev,
|
||||||
|
double& timer,
|
||||||
|
SDL_Scancode leftKey,
|
||||||
|
SDL_Scancode rightKey,
|
||||||
|
SDL_Scancode downKey) {
|
||||||
|
bool left = ks[leftKey];
|
||||||
|
bool right = ks[rightKey];
|
||||||
|
bool down = ks[downKey];
|
||||||
|
|
||||||
|
coopGame->setSoftDropping(side, down);
|
||||||
|
|
||||||
|
int moveDir = 0;
|
||||||
|
if (left && !right) moveDir = -1;
|
||||||
|
else if (right && !left) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= frameMs;
|
||||||
|
if (timer <= 0) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (game->isPaused()) {
|
||||||
|
// While paused, suppress all continuous input changes so pieces don't drift.
|
||||||
|
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->poll(0);
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||||
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||||
|
p1MoveTimerMs = 0.0;
|
||||||
|
p2MoveTimerMs = 0.0;
|
||||||
|
p1LeftHeld = false;
|
||||||
|
p1RightHeld = false;
|
||||||
|
p2LeftHeld = false;
|
||||||
|
p2RightHeld = false;
|
||||||
|
} else {
|
||||||
|
const bool coopNetActive = ctx.coopNetEnabled && ctx.coopNetSession;
|
||||||
|
|
||||||
|
// If we just entered network co-op, reset per-session fixed-tick bookkeeping.
|
||||||
|
if (coopNetActive && coopNetCachedTick != 0xFFFFFFFFu && ctx.coopNetTick == 0u) {
|
||||||
|
coopNetAccMs = 0.0;
|
||||||
|
coopNetCachedTick = 0xFFFFFFFFu;
|
||||||
|
coopNetCachedButtons = 0;
|
||||||
|
coopNetLastHashSentTick = 0xFFFFFFFFu;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define canonical key mappings for left and right players
|
||||||
|
const SDL_Scancode leftLeftKey = SDL_SCANCODE_A;
|
||||||
|
const SDL_Scancode leftRightKey = SDL_SCANCODE_D;
|
||||||
|
const SDL_Scancode leftDownKey = SDL_SCANCODE_S;
|
||||||
|
|
||||||
|
const SDL_Scancode rightLeftKey = SDL_SCANCODE_LEFT;
|
||||||
|
const SDL_Scancode rightRightKey = SDL_SCANCODE_RIGHT;
|
||||||
|
const SDL_Scancode rightDownKey = SDL_SCANCODE_DOWN;
|
||||||
|
|
||||||
|
if (coopNetActive) {
|
||||||
|
// Network co-op: fixed tick lockstep.
|
||||||
|
// Use a fixed dt so both peers simulate identically.
|
||||||
|
static constexpr double FIXED_DT_MS = 1000.0 / 60.0;
|
||||||
|
static constexpr uint32_t HASH_INTERVAL_TICKS = 60; // ~1s
|
||||||
|
|
||||||
|
ctx.coopNetSession->poll(0);
|
||||||
|
|
||||||
|
// If the connection drops during gameplay, abort back to menu.
|
||||||
|
if (ctx.coopNetSession->state() == NetSession::ConnState::Disconnected ||
|
||||||
|
ctx.coopNetSession->state() == NetSession::ConnState::Error) {
|
||||||
|
const std::string reason = (ctx.coopNetSession->state() == NetSession::ConnState::Error && !ctx.coopNetSession->lastError().empty())
|
||||||
|
? (std::string("NET ERROR: ") + ctx.coopNetSession->lastError())
|
||||||
|
: std::string("NET DISCONNECTED");
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET COOP] %s", reason.c_str());
|
||||||
|
|
||||||
|
ctx.coopNetUiStatusText = reason;
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 6000.0;
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
ctx.coopNetDesyncDetected = false;
|
||||||
|
ctx.coopNetTick = 0;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't remain paused due to a previous net stall/desync.
|
||||||
|
if (game) {
|
||||||
|
game->setPaused(false);
|
||||||
|
}
|
||||||
|
state = AppState::Menu;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
coopNetAccMs = std::min(coopNetAccMs + frameMs, FIXED_DT_MS * 8.0);
|
||||||
|
|
||||||
|
auto buildLocalButtons = [&]() -> uint8_t {
|
||||||
|
uint8_t b = 0;
|
||||||
|
if (ctx.coopNetLocalIsLeft) {
|
||||||
|
if (ks[leftLeftKey]) b |= coopnet::MoveLeft;
|
||||||
|
if (ks[leftRightKey]) b |= coopnet::MoveRight;
|
||||||
|
if (ks[leftDownKey]) b |= coopnet::SoftDrop;
|
||||||
|
} else {
|
||||||
|
if (ks[rightLeftKey]) b |= coopnet::MoveLeft;
|
||||||
|
if (ks[rightRightKey]) b |= coopnet::MoveRight;
|
||||||
|
if (ks[rightDownKey]) b |= coopnet::SoftDrop;
|
||||||
|
}
|
||||||
|
b |= ctx.coopNetPendingButtons;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto applyButtonsForSide = [&](CoopGame::PlayerSide side,
|
||||||
|
uint8_t buttons,
|
||||||
|
bool& leftHeldPrev,
|
||||||
|
bool& rightHeldPrev,
|
||||||
|
double& timer) {
|
||||||
|
const bool leftHeldNow = coopnet::has(buttons, coopnet::MoveLeft);
|
||||||
|
const bool rightHeldNow = coopnet::has(buttons, coopnet::MoveRight);
|
||||||
|
const bool downHeldNow = coopnet::has(buttons, coopnet::SoftDrop);
|
||||||
|
|
||||||
|
coopGame->setSoftDropping(side, downHeldNow);
|
||||||
|
|
||||||
|
int moveDir = 0;
|
||||||
|
if (leftHeldNow && !rightHeldNow) moveDir = -1;
|
||||||
|
else if (rightHeldNow && !leftHeldNow) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeldPrev) || (moveDir == +1 && !rightHeldPrev)) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= FIXED_DT_MS;
|
||||||
|
if (timer <= 0.0) {
|
||||||
|
coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coopnet::has(buttons, coopnet::RotCW)) {
|
||||||
|
coopGame->rotate(side, +1);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::RotCCW)) {
|
||||||
|
coopGame->rotate(side, -1);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::HardDrop)) {
|
||||||
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
|
coopGame->hardDrop(side);
|
||||||
|
}
|
||||||
|
if (coopnet::has(buttons, coopnet::Hold)) {
|
||||||
|
coopGame->holdCurrent(side);
|
||||||
|
}
|
||||||
|
|
||||||
|
leftHeldPrev = leftHeldNow;
|
||||||
|
rightHeldPrev = rightHeldNow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* roleStr = ctx.coopNetIsHost ? "HOST" : "CLIENT";
|
||||||
|
|
||||||
|
int safetySteps = 0;
|
||||||
|
bool advancedTick = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
while (coopNetAccMs >= FIXED_DT_MS && safetySteps++ < 8) {
|
||||||
|
const uint32_t tick = ctx.coopNetTick;
|
||||||
|
|
||||||
|
if (coopNetCachedTick != tick) {
|
||||||
|
coopNetCachedTick = tick;
|
||||||
|
coopNetCachedButtons = buildLocalButtons();
|
||||||
|
if (!ctx.coopNetSession->sendLocalInput(tick, coopNetCachedButtons)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] sendLocalInput failed (tick=%u)",
|
||||||
|
roleStr,
|
||||||
|
tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto remoteButtonsOpt = ctx.coopNetSession->getRemoteButtons(tick);
|
||||||
|
if (!remoteButtonsOpt.has_value()) {
|
||||||
|
if (!ctx.coopNetStalled) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] STALL begin waitingForTick=%u",
|
||||||
|
roleStr,
|
||||||
|
tick);
|
||||||
|
}
|
||||||
|
ctx.coopNetStalled = true;
|
||||||
|
break; // lockstep stall
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t remoteButtons = remoteButtonsOpt.value();
|
||||||
|
const bool localIsLeft = ctx.coopNetLocalIsLeft;
|
||||||
|
|
||||||
|
if (localIsLeft) {
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Left, coopNetCachedButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Right, remoteButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
|
||||||
|
} else {
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Right, coopNetCachedButtons, p2LeftHeld, p2RightHeld, p2MoveTimerMs);
|
||||||
|
applyButtonsForSide(CoopGame::PlayerSide::Left, remoteButtons, p1LeftHeld, p1RightHeld, p1MoveTimerMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
coopGame->tickGravity(FIXED_DT_MS);
|
||||||
|
coopGame->updateVisualEffects(FIXED_DT_MS);
|
||||||
|
|
||||||
|
if ((tick % HASH_INTERVAL_TICKS) == 0 && coopNetLastHashSentTick != tick) {
|
||||||
|
coopNetLastHashSentTick = tick;
|
||||||
|
const uint64_t hash = coopGame->computeStateHash();
|
||||||
|
if (!ctx.coopNetSession->sendStateHash(tick, hash)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] sendStateHash failed (tick=%u hash=0x%016llX)",
|
||||||
|
roleStr,
|
||||||
|
tick,
|
||||||
|
(unsigned long long)hash);
|
||||||
|
}
|
||||||
|
auto rh = ctx.coopNetSession->takeRemoteHash(tick);
|
||||||
|
if (rh.has_value() && rh.value() != hash) {
|
||||||
|
ctx.coopNetDesyncDetected = true;
|
||||||
|
ctx.coopNetUiStatusText = "NET DESYNC";
|
||||||
|
ctx.coopNetUiStatusRemainingMs = 8000.0;
|
||||||
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] DESYNC detected at tick=%u local=0x%016llX remote=0x%016llX",
|
||||||
|
roleStr,
|
||||||
|
tick,
|
||||||
|
(unsigned long long)hash,
|
||||||
|
(unsigned long long)rh.value());
|
||||||
|
game->setPaused(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.coopNetTick++;
|
||||||
|
advancedTick = true;
|
||||||
|
coopNetAccMs -= FIXED_DT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advancedTick) {
|
||||||
|
if (ctx.coopNetStalled) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET COOP][%s] STALL end atTick=%u",
|
||||||
|
roleStr,
|
||||||
|
ctx.coopNetTick);
|
||||||
|
}
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
}
|
||||||
|
} else if (!coopVsAI) {
|
||||||
|
// Standard two-player: left uses WASD, right uses arrow keys
|
||||||
|
handleSide(CoopGame::PlayerSide::Left, p1LeftHeld, p1RightHeld, p1MoveTimerMs, leftLeftKey, leftRightKey, leftDownKey);
|
||||||
|
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
|
||||||
|
|
||||||
|
p1LeftHeld = ks[leftLeftKey];
|
||||||
|
p1RightHeld = ks[leftRightKey];
|
||||||
|
p2LeftHeld = ks[rightLeftKey];
|
||||||
|
p2RightHeld = ks[rightRightKey];
|
||||||
|
} else {
|
||||||
|
// Coop vs CPU: AI controls LEFT, human controls RIGHT (arrow keys).
|
||||||
|
// Handle continuous input for the human on the right side.
|
||||||
|
handleSide(CoopGame::PlayerSide::Right, p2LeftHeld, p2RightHeld, p2MoveTimerMs, rightLeftKey, rightRightKey, rightDownKey);
|
||||||
|
|
||||||
|
// Mirror the human soft-drop to the AI-controlled left board so both fall together.
|
||||||
|
const bool pRightSoftDrop = ks[rightDownKey];
|
||||||
|
coopGame->setSoftDropping(CoopGame::PlayerSide::Left, pRightSoftDrop);
|
||||||
|
|
||||||
|
// Reset left continuous timers/held flags (AI handles movement)
|
||||||
|
p1MoveTimerMs = 0.0;
|
||||||
|
p1LeftHeld = false;
|
||||||
|
p1RightHeld = false;
|
||||||
|
|
||||||
|
// Update AI for the left side
|
||||||
|
coopAI.update(*coopGame, CoopGame::PlayerSide::Left, frameMs);
|
||||||
|
// Update human-held flags for right-side controls so DAS/ARR state is tracked
|
||||||
|
p2LeftHeld = ks[rightLeftKey];
|
||||||
|
p2RightHeld = ks[rightRightKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coopNetActive) {
|
||||||
|
coopGame->tickGravity(frameMs);
|
||||||
|
coopGame->updateVisualEffects(frameMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (game->isGameOver())
|
if (coopGame->isGameOver()) {
|
||||||
{
|
// Compute combined coop stats for Game Over
|
||||||
if (game->score() > 0) {
|
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||||
isNewHighScore = true;
|
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||||
playerName.clear();
|
int combinedScore = leftScore + rightScore;
|
||||||
SDL_StartTextInput(window);
|
if (combinedScore > 0) {
|
||||||
} else {
|
isNewHighScore = true;
|
||||||
isNewHighScore = false;
|
if (coopVsAI) {
|
||||||
ensureScoresLoaded();
|
// AI is left, prompt human (right) for name
|
||||||
scores.submit(game->score(), game->lines(), game->level(), game->elapsed());
|
playerName = "CPU";
|
||||||
|
player2Name.clear();
|
||||||
|
highScoreEntryIndex = 1; // enter P2 (human)
|
||||||
|
} else {
|
||||||
|
playerName.clear();
|
||||||
|
player2Name.clear();
|
||||||
|
highScoreEntryIndex = 0;
|
||||||
|
}
|
||||||
|
SDL_StartTextInput(window);
|
||||||
|
} else {
|
||||||
|
isNewHighScore = false;
|
||||||
|
ensureScoresLoaded();
|
||||||
|
// When AI is present, label should indicate CPU left and human right
|
||||||
|
scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), coopVsAI ? "CPU & P2" : "P1 & P2", "cooperate");
|
||||||
|
}
|
||||||
|
state = AppState::GameOver;
|
||||||
|
stateMgr->setState(state);
|
||||||
|
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if (!game->isPaused()) {
|
||||||
|
game->tickGravity(frameMs);
|
||||||
|
game->updateElapsedTime();
|
||||||
|
|
||||||
|
if (lineEffect.isActive()) {
|
||||||
|
if (lineEffect.update(frameMs / 1000.0f)) {
|
||||||
|
game->clearCompletedLines();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (game->isGameOver())
|
||||||
|
{
|
||||||
|
if (game->score() > 0) {
|
||||||
|
isNewHighScore = true;
|
||||||
|
playerName.clear();
|
||||||
|
SDL_StartTextInput(window);
|
||||||
|
} else {
|
||||||
|
isNewHighScore = false;
|
||||||
|
ensureScoresLoaded();
|
||||||
|
{
|
||||||
|
std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||||
|
scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = AppState::GameOver;
|
||||||
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
state = AppState::GameOver;
|
|
||||||
stateMgr->setState(state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (state == AppState::Loading)
|
else if (state == AppState::Loading)
|
||||||
@ -1346,7 +1854,15 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (totalTasks > 0) {
|
if (totalTasks > 0) {
|
||||||
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
|
loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks));
|
||||||
if (loadingProgress >= 1.0 && musicLoaded) {
|
if (loadingProgress >= 1.0 && musicLoaded) {
|
||||||
state = AppState::Menu;
|
startupFadeActive = false;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
|
||||||
|
if (std::filesystem::exists(introVideoPath)) {
|
||||||
|
state = AppState::Video;
|
||||||
|
} else {
|
||||||
|
state = AppState::Menu;
|
||||||
|
}
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1374,7 +1890,15 @@ void TetrisApp::Impl::runLoop()
|
|||||||
if (loadingProgress > 0.99) loadingProgress = 1.0;
|
if (loadingProgress > 0.99) loadingProgress = 1.0;
|
||||||
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
|
if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0;
|
||||||
if (loadingProgress >= 1.0 && musicLoaded) {
|
if (loadingProgress >= 1.0 && musicLoaded) {
|
||||||
state = AppState::Menu;
|
startupFadeActive = false;
|
||||||
|
startupFadeAlpha = 0.0f;
|
||||||
|
startupFadeClockMs = 0.0;
|
||||||
|
|
||||||
|
if (std::filesystem::exists(introVideoPath)) {
|
||||||
|
state = AppState::Video;
|
||||||
|
} else {
|
||||||
|
state = AppState::Menu;
|
||||||
|
}
|
||||||
stateMgr->setState(state);
|
stateMgr->setState(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1441,6 +1965,9 @@ void TetrisApp::Impl::runLoop()
|
|||||||
case AppState::Loading:
|
case AppState::Loading:
|
||||||
loadingState->update(frameMs);
|
loadingState->update(frameMs);
|
||||||
break;
|
break;
|
||||||
|
case AppState::Video:
|
||||||
|
if (videoState) videoState->update(frameMs);
|
||||||
|
break;
|
||||||
case AppState::Menu:
|
case AppState::Menu:
|
||||||
menuState->update(frameMs);
|
menuState->update(frameMs);
|
||||||
break;
|
break;
|
||||||
@ -1743,6 +2270,11 @@ void TetrisApp::Impl::runLoop()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case AppState::Video:
|
||||||
|
if (videoState) {
|
||||||
|
videoState->render(renderer, logicalScale, logicalVP);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case AppState::Menu:
|
case AppState::Menu:
|
||||||
if (!mainScreenTex) {
|
if (!mainScreenTex) {
|
||||||
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
|
mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN);
|
||||||
@ -1866,32 +2398,44 @@ void TetrisApp::Impl::runLoop()
|
|||||||
SDL_RenderFillRect(renderer, &boxRect);
|
SDL_RenderFillRect(renderer, &boxRect);
|
||||||
|
|
||||||
ensureScoresLoaded();
|
ensureScoresLoaded();
|
||||||
bool realHighScore = scores.isHighScore(game->score());
|
// Choose display values based on mode (single-player vs coop)
|
||||||
|
int displayScore = 0;
|
||||||
|
int displayLines = 0;
|
||||||
|
int displayLevel = 0;
|
||||||
|
if (game && game->getMode() == GameMode::Cooperate && coopGame) {
|
||||||
|
int leftScore = coopGame->score(CoopGame::PlayerSide::Left);
|
||||||
|
int rightScore = coopGame->score(CoopGame::PlayerSide::Right);
|
||||||
|
displayScore = leftScore + rightScore;
|
||||||
|
displayLines = coopGame->lines();
|
||||||
|
displayLevel = coopGame->level();
|
||||||
|
} else if (game) {
|
||||||
|
displayScore = game->score();
|
||||||
|
displayLines = game->lines();
|
||||||
|
displayLevel = game->level();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool realHighScore = scores.isHighScore(displayScore);
|
||||||
const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER";
|
const char* title = realHighScore ? "NEW HIGH SCORE!" : "GAME OVER";
|
||||||
int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH);
|
int tW=0, tH=0; pixelFont.measure(title, 2.0f, tW, tH);
|
||||||
pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255});
|
pixelFont.draw(renderer, boxX + (boxW - tW) * 0.5f + contentOffsetX, boxY + 40 + contentOffsetY, title, 2.0f, realHighScore ? SDL_Color{255, 220, 0, 255} : SDL_Color{255, 60, 60, 255});
|
||||||
|
|
||||||
char scoreStr[64];
|
char scoreStr[64];
|
||||||
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", game->score());
|
snprintf(scoreStr, sizeof(scoreStr), "SCORE: %d", displayScore);
|
||||||
int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH);
|
int sW=0, sH=0; pixelFont.measure(scoreStr, 1.2f, sW, sH);
|
||||||
pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255});
|
pixelFont.draw(renderer, boxX + (boxW - sW) * 0.5f + contentOffsetX, boxY + 100 + contentOffsetY, scoreStr, 1.2f, {255, 255, 255, 255});
|
||||||
|
|
||||||
if (isNewHighScore) {
|
if (isNewHighScore) {
|
||||||
const char* enterName = "ENTER NAME:";
|
const bool isCoopEntry = (game && game->getMode() == GameMode::Cooperate && coopGame);
|
||||||
|
const char* enterName = isCoopEntry ? "ENTER NAMES:" : "ENTER NAME:";
|
||||||
int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH);
|
int enW=0, enH=0; pixelFont.measure(enterName, 1.0f, enW, enH);
|
||||||
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
|
if (!isCoopEntry) {
|
||||||
|
pixelFont.draw(renderer, boxX + (boxW - enW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, enterName, 1.0f, {200, 200, 220, 255});
|
||||||
|
}
|
||||||
|
|
||||||
float inputW = 300.0f;
|
const float inputW = isCoopEntry ? 260.0f : 300.0f;
|
||||||
float inputH = 40.0f;
|
const float inputH = 40.0f;
|
||||||
float inputX = boxX + (boxW - inputW) * 0.5f;
|
const float inputX = boxX + (boxW - inputW) * 0.5f;
|
||||||
float inputY = boxY + 200.0f;
|
const float inputY = boxY + 200.0f;
|
||||||
|
|
||||||
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
|
||||||
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
|
|
||||||
SDL_RenderFillRect(renderer, &inputRect);
|
|
||||||
|
|
||||||
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
|
|
||||||
SDL_RenderRect(renderer, &inputRect);
|
|
||||||
|
|
||||||
const float nameScale = 1.2f;
|
const float nameScale = 1.2f;
|
||||||
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
|
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
|
||||||
@ -1900,34 +2444,67 @@ void TetrisApp::Impl::runLoop()
|
|||||||
pixelFont.measure("A", nameScale, metricsW, metricsH);
|
pixelFont.measure("A", nameScale, metricsW, metricsH);
|
||||||
if (metricsH == 0) metricsH = 24;
|
if (metricsH == 0) metricsH = 24;
|
||||||
|
|
||||||
int nameW = 0, nameH = 0;
|
// Single name entry (non-coop) --- keep original behavior
|
||||||
if (!playerName.empty()) {
|
if (!isCoopEntry) {
|
||||||
pixelFont.measure(playerName, nameScale, nameW, nameH);
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
||||||
|
SDL_FRect inputRect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
|
||||||
|
SDL_RenderFillRect(renderer, &inputRect);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
|
||||||
|
SDL_RenderRect(renderer, &inputRect);
|
||||||
|
|
||||||
|
int nameW = 0, nameH = 0;
|
||||||
|
if (!playerName.empty()) pixelFont.measure(playerName, nameScale, nameW, nameH);
|
||||||
|
else nameH = metricsH;
|
||||||
|
|
||||||
|
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
||||||
|
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
||||||
|
|
||||||
|
if (!playerName.empty()) pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255,255,255,255});
|
||||||
|
|
||||||
|
if (showCursor) {
|
||||||
|
int cursorW = 0, cursorH = 0; pixelFont.measure("_", nameScale, cursorW, cursorH);
|
||||||
|
float cursorX = playerName.empty() ? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX : textX + static_cast<float>(nameW);
|
||||||
|
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
||||||
|
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255});
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* hint = "PRESS ENTER TO SUBMIT";
|
||||||
|
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
|
||||||
|
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
|
||||||
} else {
|
} else {
|
||||||
nameH = metricsH;
|
// Coop: prompt sequentially. First ask Player 1, then ask Player 2 after Enter.
|
||||||
|
const bool askingP1 = (highScoreEntryIndex == 0);
|
||||||
|
const char* label = askingP1 ? "PLAYER 1:" : "PLAYER 2:";
|
||||||
|
int labW=0, labH=0; pixelFont.measure(label, 1.0f, labW, labH);
|
||||||
|
pixelFont.draw(renderer, boxX + (boxW - labW) * 0.5f + contentOffsetX, boxY + 160 + contentOffsetY, label, 1.0f, {200,200,220,255});
|
||||||
|
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
||||||
|
SDL_FRect rect{inputX + contentOffsetX, inputY + contentOffsetY, inputW, inputH};
|
||||||
|
SDL_RenderFillRect(renderer, &rect);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
|
||||||
|
SDL_RenderRect(renderer, &rect);
|
||||||
|
|
||||||
|
const std::string &activeName = askingP1 ? playerName : player2Name;
|
||||||
|
int nameW = 0, nameH = 0;
|
||||||
|
if (!activeName.empty()) pixelFont.measure(activeName, nameScale, nameW, nameH);
|
||||||
|
else nameH = metricsH;
|
||||||
|
|
||||||
|
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
||||||
|
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
||||||
|
if (!activeName.empty()) pixelFont.draw(renderer, textX, textY, activeName, nameScale, {255,255,255,255});
|
||||||
|
|
||||||
|
if (showCursor) {
|
||||||
|
int cursorW=0, cursorH=0; pixelFont.measure("_", nameScale, cursorW, cursorH);
|
||||||
|
float cursorX = activeName.empty() ? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX : textX + static_cast<float>(nameW);
|
||||||
|
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
||||||
|
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255,255,255,255});
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* hint = askingP1 ? "PRESS ENTER FOR NEXT NAME" : "PRESS ENTER TO SUBMIT";
|
||||||
|
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
|
||||||
|
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 300 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
|
||||||
}
|
}
|
||||||
|
|
||||||
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
|
|
||||||
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
|
|
||||||
|
|
||||||
if (!playerName.empty()) {
|
|
||||||
pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCursor) {
|
|
||||||
int cursorW = 0, cursorH = 0;
|
|
||||||
pixelFont.measure("_", nameScale, cursorW, cursorH);
|
|
||||||
float cursorX = playerName.empty()
|
|
||||||
? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX
|
|
||||||
: textX + static_cast<float>(nameW);
|
|
||||||
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
|
|
||||||
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255});
|
|
||||||
}
|
|
||||||
|
|
||||||
const char* hint = "PRESS ENTER TO SUBMIT";
|
|
||||||
int hW=0, hH=0; pixelFont.measure(hint, 0.8f, hW, hH);
|
|
||||||
pixelFont.draw(renderer, boxX + (boxW - hW) * 0.5f + contentOffsetX, boxY + 280 + contentOffsetY, hint, 0.8f, {150, 150, 150, 255});
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
char linesStr[64];
|
char linesStr[64];
|
||||||
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines());
|
snprintf(linesStr, sizeof(linesStr), "LINES: %d", game->lines());
|
||||||
@ -2091,6 +2668,17 @@ void TetrisApp::Impl::runLoop()
|
|||||||
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
|
HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startupFadeActive && startupFadeAlpha > 0.0f) {
|
||||||
|
SDL_SetRenderViewport(renderer, nullptr);
|
||||||
|
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
const Uint8 a = (Uint8)std::clamp((int)std::lround(startupFadeAlpha * 255.0f), 0, 255);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
|
||||||
|
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
|
||||||
|
SDL_RenderFillRect(renderer, &full);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
}
|
||||||
|
|
||||||
SDL_RenderPresent(renderer);
|
SDL_RenderPresent(renderer);
|
||||||
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
SDL_SetRenderScale(renderer, 1.f, 1.f);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,11 @@ std::string Settings::getSettingsPath() {
|
|||||||
bool Settings::load() {
|
bool Settings::load() {
|
||||||
std::ifstream file(getSettingsPath());
|
std::ifstream file(getSettingsPath());
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults");
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Settings file not found, using defaults. Creating settings file with defaults.");
|
||||||
|
// Persist defaults so next run has an explicit settings.ini
|
||||||
|
try {
|
||||||
|
save();
|
||||||
|
} catch (...) {}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,8 @@ private:
|
|||||||
Settings& operator=(const Settings&) = delete;
|
Settings& operator=(const Settings&) = delete;
|
||||||
|
|
||||||
// Settings values
|
// Settings values
|
||||||
bool m_fullscreen = false;
|
// Default to fullscreen on first run when no settings.ini exists
|
||||||
|
bool m_fullscreen = true;
|
||||||
bool m_musicEnabled = true;
|
bool m_musicEnabled = true;
|
||||||
bool m_soundEnabled = true;
|
bool m_soundEnabled = true;
|
||||||
bool m_debugEnabled = false;
|
bool m_debugEnabled = false;
|
||||||
|
|||||||
@ -25,20 +25,31 @@
|
|||||||
#include "../../graphics/effects/Starfield.h"
|
#include "../../graphics/effects/Starfield.h"
|
||||||
#include "../../graphics/renderers/GameRenderer.h"
|
#include "../../graphics/renderers/GameRenderer.h"
|
||||||
#include "../../gameplay/core/Game.h"
|
#include "../../gameplay/core/Game.h"
|
||||||
|
#include "../../gameplay/coop/CoopGame.h"
|
||||||
#include "../../gameplay/effects/LineEffect.h"
|
#include "../../gameplay/effects/LineEffect.h"
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
#include <SDL3_image/SDL_image.h>
|
#include <SDL3_image/SDL_image.h>
|
||||||
#include <SDL3_ttf/SDL_ttf.h>
|
#include <SDL3_ttf/SDL_ttf.h>
|
||||||
#include "../../utils/ImagePathResolver.h"
|
#include "../../utils/ImagePathResolver.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include "../../video/VideoPlayer.h"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#ifdef _WIN32
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#ifndef NOMINMAX
|
||||||
|
#define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#endif
|
||||||
|
// (Intro video playback is now handled in-process via VideoPlayer)
|
||||||
|
|
||||||
ApplicationManager::ApplicationManager() = default;
|
ApplicationManager::ApplicationManager() = default;
|
||||||
|
|
||||||
static void traceFile(const char* msg) {
|
static void traceFile(const char* msg) {
|
||||||
std::ofstream f("tetris_trace.log", std::ios::app);
|
std::ofstream f("spacetris_trace.log", std::ios::app);
|
||||||
if (f) f << msg << "\n";
|
if (f) f << msg << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +65,15 @@ void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& r
|
|||||||
if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
|
if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual);
|
||||||
app->m_starfield3D->draw(renderer.getSDLRenderer());
|
app->m_starfield3D->draw(renderer.getSDLRenderer());
|
||||||
}
|
}
|
||||||
|
// If intro video is playing, render it instead of the loading UI
|
||||||
|
if (app->m_introStarted && app->m_videoPlayer) {
|
||||||
|
SDL_Renderer* sdlR = renderer.getSDLRenderer();
|
||||||
|
int winW=0, winH=0; renderer.getWindowSize(winW, winH);
|
||||||
|
app->m_videoPlayer->render(sdlR, winW, winH);
|
||||||
|
SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr);
|
||||||
|
SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
SDL_Rect logicalVP = {0,0,0,0};
|
SDL_Rect logicalVP = {0,0,0,0};
|
||||||
float logicalScale = 1.0f;
|
float logicalScale = 1.0f;
|
||||||
if (app->m_renderManager) {
|
if (app->m_renderManager) {
|
||||||
@ -561,6 +580,7 @@ bool ApplicationManager::initializeGame() {
|
|||||||
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
m_lineEffect->init(m_renderManager->getSDLRenderer());
|
||||||
}
|
}
|
||||||
m_game = std::make_unique<Game>(m_startLevelSelection);
|
m_game = std::make_unique<Game>(m_startLevelSelection);
|
||||||
|
m_coopGame = std::make_unique<CoopGame>(m_startLevelSelection);
|
||||||
// Wire up sound callbacks as main.cpp did
|
// Wire up sound callbacks as main.cpp did
|
||||||
if (m_game) {
|
if (m_game) {
|
||||||
// Apply global gravity speed multiplier from config
|
// Apply global gravity speed multiplier from config
|
||||||
@ -580,6 +600,18 @@ bool ApplicationManager::initializeGame() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m_coopGame) {
|
||||||
|
// TODO: tune gravity with Config and shared level scaling once coop rules are finalized
|
||||||
|
m_coopGame->reset(m_startLevelSelection);
|
||||||
|
// Wire coop sound callback to reuse same clear-line VO/SFX behavior
|
||||||
|
m_coopGame->setSoundCallback([&](int linesCleared){
|
||||||
|
SoundEffectManager::instance().playSound("clear_line", 1.0f);
|
||||||
|
if (linesCleared == 2) SoundEffectManager::instance().playRandomSound({"nice_combo"}, 1.0f);
|
||||||
|
else if (linesCleared == 3) SoundEffectManager::instance().playRandomSound({"great_move"}, 1.0f);
|
||||||
|
else if (linesCleared == 4) SoundEffectManager::instance().playRandomSound({"amazing"}, 1.0f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare a StateContext-like struct by setting up handlers that capture
|
// Prepare a StateContext-like struct by setting up handlers that capture
|
||||||
// pointers and flags. State objects in this refactor expect these to be
|
// pointers and flags. State objects in this refactor expect these to be
|
||||||
// available via StateManager event/update/render hooks, so we'll store them
|
// available via StateManager event/update/render hooks, so we'll store them
|
||||||
@ -621,6 +653,7 @@ bool ApplicationManager::initializeGame() {
|
|||||||
{
|
{
|
||||||
m_stateContext.stateManager = m_stateManager.get();
|
m_stateContext.stateManager = m_stateManager.get();
|
||||||
m_stateContext.game = m_game.get();
|
m_stateContext.game = m_game.get();
|
||||||
|
m_stateContext.coopGame = m_coopGame.get();
|
||||||
m_stateContext.scores = m_scoreManager.get();
|
m_stateContext.scores = m_scoreManager.get();
|
||||||
m_stateContext.starfield = m_starfield.get();
|
m_stateContext.starfield = m_starfield.get();
|
||||||
m_stateContext.starfield3D = m_starfield3D.get();
|
m_stateContext.starfield3D = m_starfield3D.get();
|
||||||
@ -765,17 +798,44 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_starfield3D->update(deltaTime / 1000.0f);
|
m_starfield3D->update(deltaTime / 1000.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if loading is complete and transition to menu
|
// Check if loading is complete and transition to next stage
|
||||||
if (m_assetManager->isLoadingComplete()) {
|
if (m_assetManager->isLoadingComplete()) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu");
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow");
|
||||||
|
|
||||||
// Update texture pointers now that assets are loaded
|
// Update texture pointers now that assets are loaded
|
||||||
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
m_stateContext.backgroundTex = m_assetManager->getTexture("background");
|
||||||
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
m_stateContext.blocksTex = m_assetManager->getTexture("blocks");
|
||||||
|
|
||||||
bool ok = m_stateManager->setState(AppState::Menu);
|
// If an intro video exists and hasn't been started, attempt to play it in-process
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0);
|
std::filesystem::path introPath = m_introPath;
|
||||||
traceFile("- to Menu returned");
|
if (!m_introStarted && std::filesystem::exists(introPath)) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str());
|
||||||
|
try {
|
||||||
|
if (!m_videoPlayer) m_videoPlayer = std::make_unique<VideoPlayer>();
|
||||||
|
SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr;
|
||||||
|
if (m_videoPlayer->open(introPath.string(), sdlRend)) {
|
||||||
|
m_introStarted = true;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process");
|
||||||
|
} else {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping");
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what());
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
} else if (m_introStarted) {
|
||||||
|
// Let VideoPlayer decode frames; once finished, transition to playing
|
||||||
|
if (m_videoPlayer) m_videoPlayer->update();
|
||||||
|
if (!m_videoPlayer || m_videoPlayer->isFinished()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing");
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No intro to play; transition directly to Playing
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing");
|
||||||
|
m_stateManager->setState(AppState::Playing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -917,8 +977,8 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_showExitConfirmPopup = true;
|
m_showExitConfirmPopup = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// S: toggle SFX enable state (music handled globally)
|
// K: toggle SFX enable state (music handled globally)
|
||||||
if (event.key.scancode == SDL_SCANCODE_S) {
|
if (event.key.scancode == SDL_SCANCODE_K) {
|
||||||
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
SoundEffectManager::instance().setEnabled(!SoundEffectManager::instance().isEnabled());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1217,13 +1277,25 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
// "GAME OVER" title
|
// "GAME OVER" title
|
||||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, {255, 80, 60, 255});
|
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 140, "GAME OVER", 3.0f, {255, 80, 60, 255});
|
||||||
|
|
||||||
// Game stats
|
// Game stats (single-player or coop combined)
|
||||||
char buf[128];
|
char buf[128];
|
||||||
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
if (m_stateContext.game && m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame) {
|
||||||
m_stateContext.game->score(),
|
int leftScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Left);
|
||||||
m_stateContext.game->lines(),
|
int rightScore = m_stateContext.coopGame->score(::CoopGame::PlayerSide::Right);
|
||||||
m_stateContext.game->level());
|
int total = leftScore + rightScore;
|
||||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 180, 220, buf, 1.2f, {220, 220, 230, 255});
|
std::snprintf(buf, sizeof(buf), "SCORE %d + %d = %d LINES %d LEVEL %d",
|
||||||
|
leftScore,
|
||||||
|
rightScore,
|
||||||
|
total,
|
||||||
|
m_stateContext.coopGame->lines(),
|
||||||
|
m_stateContext.coopGame->level());
|
||||||
|
} else {
|
||||||
|
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d",
|
||||||
|
m_stateContext.game ? m_stateContext.game->score() : 0,
|
||||||
|
m_stateContext.game ? m_stateContext.game->lines() : 0,
|
||||||
|
m_stateContext.game ? m_stateContext.game->level() : 0);
|
||||||
|
}
|
||||||
|
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 220, 220, buf, 1.2f, {220, 220, 230, 255});
|
||||||
|
|
||||||
// Instructions
|
// Instructions
|
||||||
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255});
|
font.draw(renderer.getSDLRenderer(), LOGICAL_W * 0.5f - 120, 270, "PRESS ENTER / SPACE", 1.2f, {200, 200, 220, 255});
|
||||||
@ -1237,74 +1309,160 @@ void ApplicationManager::setupStateHandlers() {
|
|||||||
m_stateManager->registerUpdateHandler(AppState::Playing,
|
m_stateManager->registerUpdateHandler(AppState::Playing,
|
||||||
[this](double frameMs) {
|
[this](double frameMs) {
|
||||||
if (!m_stateContext.game) return;
|
if (!m_stateContext.game) return;
|
||||||
|
|
||||||
|
const bool coopActive = m_stateContext.game->getMode() == GameMode::Cooperate && m_stateContext.coopGame;
|
||||||
|
|
||||||
// Get current keyboard state
|
// Get current keyboard state
|
||||||
const bool *ks = SDL_GetKeyboardState(nullptr);
|
const bool *ks = SDL_GetKeyboardState(nullptr);
|
||||||
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
|
||||||
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
|
||||||
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
|
||||||
|
|
||||||
// Handle soft drop
|
|
||||||
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
|
||||||
|
|
||||||
// Handle DAS/ARR movement timing (from original main.cpp)
|
|
||||||
int moveDir = 0;
|
|
||||||
if (left && !right)
|
|
||||||
moveDir = -1;
|
|
||||||
else if (right && !left)
|
|
||||||
moveDir = +1;
|
|
||||||
|
|
||||||
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
|
||||||
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
|
||||||
// First press - immediate movement
|
|
||||||
m_stateContext.game->move(moveDir);
|
|
||||||
m_moveTimerMs = DAS; // Set initial delay
|
|
||||||
} else {
|
|
||||||
// Key held - handle repeat timing
|
|
||||||
m_moveTimerMs -= frameMs;
|
|
||||||
if (m_moveTimerMs <= 0) {
|
|
||||||
m_stateContext.game->move(moveDir);
|
|
||||||
m_moveTimerMs += ARR; // Set repeat rate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m_moveTimerMs = 0; // Reset timer when no movement
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update held state for next frame
|
|
||||||
m_leftHeld = left;
|
|
||||||
m_rightHeld = right;
|
|
||||||
|
|
||||||
// Handle soft drop boost
|
|
||||||
if (down && !m_stateContext.game->isPaused()) {
|
|
||||||
m_stateContext.game->softDropBoost(frameMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to PlayingState for other updates (gravity, line effects)
|
|
||||||
if (m_playingState) {
|
|
||||||
m_playingState->update(frameMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update background fade progression (match main.cpp semantics approx)
|
if (coopActive) {
|
||||||
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
// Paused: suppress all continuous input so pieces don't drift while paused.
|
||||||
const float LEVEL_FADE_DURATION = 1200.0f;
|
if (m_stateContext.game->isPaused()) {
|
||||||
if (m_nextLevelBackgroundTex) {
|
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Left, false);
|
||||||
m_levelFadeElapsed += (float)frameMs;
|
m_stateContext.coopGame->setSoftDropping(CoopGame::PlayerSide::Right, false);
|
||||||
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
m_p1MoveTimerMs = 0.0;
|
||||||
}
|
m_p2MoveTimerMs = 0.0;
|
||||||
|
m_p1LeftHeld = false;
|
||||||
// Check for game over and transition to GameOver state
|
m_p1RightHeld = false;
|
||||||
if (m_stateContext.game->isGameOver()) {
|
m_p2LeftHeld = false;
|
||||||
// Submit score before transitioning
|
m_p2RightHeld = false;
|
||||||
if (m_stateContext.scores) {
|
return;
|
||||||
m_stateContext.scores->submit(
|
}
|
||||||
m_stateContext.game->score(),
|
|
||||||
m_stateContext.game->lines(),
|
auto handleSide = [&](CoopGame::PlayerSide side,
|
||||||
m_stateContext.game->level(),
|
bool leftHeld,
|
||||||
m_stateContext.game->elapsed()
|
bool rightHeld,
|
||||||
);
|
double& timer,
|
||||||
|
SDL_Scancode leftKey,
|
||||||
|
SDL_Scancode rightKey,
|
||||||
|
SDL_Scancode downKey) {
|
||||||
|
bool left = ks[leftKey];
|
||||||
|
bool right = ks[rightKey];
|
||||||
|
bool down = ks[downKey];
|
||||||
|
|
||||||
|
// Soft drop flag
|
||||||
|
m_stateContext.coopGame->setSoftDropping(side, down);
|
||||||
|
|
||||||
|
int moveDir = 0;
|
||||||
|
if (left && !right) moveDir = -1;
|
||||||
|
else if (right && !left) moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0) {
|
||||||
|
if ((moveDir == -1 && !leftHeld) || (moveDir == +1 && !rightHeld)) {
|
||||||
|
// First press - immediate movement
|
||||||
|
m_stateContext.coopGame->move(side, moveDir);
|
||||||
|
timer = DAS;
|
||||||
|
} else {
|
||||||
|
timer -= frameMs;
|
||||||
|
if (timer <= 0) {
|
||||||
|
m_stateContext.coopGame->move(side, moveDir);
|
||||||
|
timer += ARR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft drop boost: coop uses same gravity path; fall acceleration handled inside tickGravity
|
||||||
|
};
|
||||||
|
|
||||||
|
// Left player (WASD): A/D horizontal, S soft drop
|
||||||
|
handleSide(CoopGame::PlayerSide::Left, m_p1LeftHeld, m_p1RightHeld, m_p1MoveTimerMs,
|
||||||
|
SDL_SCANCODE_A, SDL_SCANCODE_D, SDL_SCANCODE_S);
|
||||||
|
// Right player (arrows): Left/Right horizontal, Down soft drop
|
||||||
|
handleSide(CoopGame::PlayerSide::Right, m_p2LeftHeld, m_p2RightHeld, m_p2MoveTimerMs,
|
||||||
|
SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_DOWN);
|
||||||
|
|
||||||
|
// Update held flags for next frame
|
||||||
|
m_p1LeftHeld = ks[SDL_SCANCODE_A];
|
||||||
|
m_p1RightHeld = ks[SDL_SCANCODE_D];
|
||||||
|
m_p2LeftHeld = ks[SDL_SCANCODE_LEFT];
|
||||||
|
m_p2RightHeld = ks[SDL_SCANCODE_RIGHT];
|
||||||
|
|
||||||
|
// Gravity / effects
|
||||||
|
m_stateContext.coopGame->tickGravity(frameMs);
|
||||||
|
m_stateContext.coopGame->updateVisualEffects(frameMs);
|
||||||
|
|
||||||
|
// Delegate to PlayingState for any ancillary updates (renderer transport bookkeeping)
|
||||||
|
if (m_playingState) {
|
||||||
|
m_playingState->update(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game over transition for coop
|
||||||
|
if (m_stateContext.coopGame->isGameOver()) {
|
||||||
|
m_stateManager->setState(AppState::GameOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
bool left = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_A];
|
||||||
|
bool right = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_D];
|
||||||
|
bool down = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_S];
|
||||||
|
|
||||||
|
// Handle soft drop
|
||||||
|
m_stateContext.game->setSoftDropping(down && !m_stateContext.game->isPaused());
|
||||||
|
|
||||||
|
// Handle DAS/ARR movement timing (from original main.cpp)
|
||||||
|
int moveDir = 0;
|
||||||
|
if (left && !right)
|
||||||
|
moveDir = -1;
|
||||||
|
else if (right && !left)
|
||||||
|
moveDir = +1;
|
||||||
|
|
||||||
|
if (moveDir != 0 && !m_stateContext.game->isPaused()) {
|
||||||
|
if ((moveDir == -1 && !m_leftHeld) || (moveDir == +1 && !m_rightHeld)) {
|
||||||
|
// First press - immediate movement
|
||||||
|
m_stateContext.game->move(moveDir);
|
||||||
|
m_moveTimerMs = DAS; // Set initial delay
|
||||||
|
} else {
|
||||||
|
// Key held - handle repeat timing
|
||||||
|
m_moveTimerMs -= frameMs;
|
||||||
|
if (m_moveTimerMs <= 0) {
|
||||||
|
m_stateContext.game->move(moveDir);
|
||||||
|
m_moveTimerMs += ARR; // Set repeat rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m_moveTimerMs = 0; // Reset timer when no movement
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update held state for next frame
|
||||||
|
m_leftHeld = left;
|
||||||
|
m_rightHeld = right;
|
||||||
|
|
||||||
|
// Handle soft drop boost
|
||||||
|
if (down && !m_stateContext.game->isPaused()) {
|
||||||
|
m_stateContext.game->softDropBoost(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to PlayingState for other updates (gravity, line effects)
|
||||||
|
if (m_playingState) {
|
||||||
|
m_playingState->update(frameMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update background fade progression (match main.cpp semantics approx)
|
||||||
|
// Duration 1200ms fade (same as LEVEL_FADE_DURATION used in main.cpp snippets)
|
||||||
|
const float LEVEL_FADE_DURATION = 1200.0f;
|
||||||
|
if (m_nextLevelBackgroundTex) {
|
||||||
|
m_levelFadeElapsed += (float)frameMs;
|
||||||
|
m_levelFadeAlpha = std::min(1.0f, m_levelFadeElapsed / LEVEL_FADE_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for game over and transition to GameOver state
|
||||||
|
if (m_stateContext.game->isGameOver()) {
|
||||||
|
// Submit score before transitioning
|
||||||
|
if (m_stateContext.scores) {
|
||||||
|
std::string gt = (m_stateContext.game->getMode() == GameMode::Challenge) ? "challenge" : "classic";
|
||||||
|
m_stateContext.scores->submit(
|
||||||
|
m_stateContext.game->score(),
|
||||||
|
m_stateContext.game->lines(),
|
||||||
|
m_stateContext.game->level(),
|
||||||
|
m_stateContext.game->elapsed(),
|
||||||
|
std::string("PLAYER"),
|
||||||
|
gt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
m_stateManager->setState(AppState::GameOver);
|
||||||
}
|
}
|
||||||
m_stateManager->setState(AppState::GameOver);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Debug overlay: show current window and logical sizes on the right side of the screen
|
// Debug overlay: show current window and logical sizes on the right side of the screen
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class Starfield;
|
|||||||
class Starfield3D;
|
class Starfield3D;
|
||||||
class FontAtlas;
|
class FontAtlas;
|
||||||
class LineEffect;
|
class LineEffect;
|
||||||
|
class CoopGame;
|
||||||
|
|
||||||
// Forward declare state classes (top-level, defined under src/states)
|
// Forward declare state classes (top-level, defined under src/states)
|
||||||
class LoadingState;
|
class LoadingState;
|
||||||
@ -109,6 +110,7 @@ private:
|
|||||||
std::unique_ptr<ScoreManager> m_scoreManager;
|
std::unique_ptr<ScoreManager> m_scoreManager;
|
||||||
// Gameplay pieces
|
// Gameplay pieces
|
||||||
std::unique_ptr<Game> m_game;
|
std::unique_ptr<Game> m_game;
|
||||||
|
std::unique_ptr<CoopGame> m_coopGame;
|
||||||
std::unique_ptr<LineEffect> m_lineEffect;
|
std::unique_ptr<LineEffect> m_lineEffect;
|
||||||
|
|
||||||
// DAS/ARR movement timing (from original main.cpp)
|
// DAS/ARR movement timing (from original main.cpp)
|
||||||
@ -118,6 +120,14 @@ private:
|
|||||||
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
static constexpr double DAS = 170.0; // Delayed Auto Shift
|
||||||
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
static constexpr double ARR = 40.0; // Auto Repeat Rate
|
||||||
|
|
||||||
|
// Coop DAS/ARR per player
|
||||||
|
bool m_p1LeftHeld = false;
|
||||||
|
bool m_p1RightHeld = false;
|
||||||
|
bool m_p2LeftHeld = false;
|
||||||
|
bool m_p2RightHeld = false;
|
||||||
|
double m_p1MoveTimerMs = 0.0;
|
||||||
|
double m_p2MoveTimerMs = 0.0;
|
||||||
|
|
||||||
// State context (must be a member to ensure lifetime)
|
// State context (must be a member to ensure lifetime)
|
||||||
StateContext m_stateContext;
|
StateContext m_stateContext;
|
||||||
|
|
||||||
@ -143,6 +153,11 @@ private:
|
|||||||
float m_logoAnimCounter = 0.0f;
|
float m_logoAnimCounter = 0.0f;
|
||||||
bool m_helpOverlayPausedGame = false;
|
bool m_helpOverlayPausedGame = false;
|
||||||
|
|
||||||
|
// Intro video playback (in-process via FFmpeg)
|
||||||
|
bool m_introStarted = false;
|
||||||
|
std::string m_introPath = "assets/videos/spacetris_intro.mp4";
|
||||||
|
std::unique_ptr<class VideoPlayer> m_videoPlayer;
|
||||||
|
|
||||||
// Gameplay background (per-level) with fade, mirroring main.cpp behavior
|
// Gameplay background (per-level) with fade, mirroring main.cpp behavior
|
||||||
SDL_Texture* m_levelBackgroundTex = nullptr;
|
SDL_Texture* m_levelBackgroundTex = nullptr;
|
||||||
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions
|
SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions
|
||||||
|
|||||||
@ -18,7 +18,7 @@ void InputManager::processEvents() {
|
|||||||
while (SDL_PollEvent(&event)) {
|
while (SDL_PollEvent(&event)) {
|
||||||
// Trace every polled event type for debugging abrupt termination
|
// Trace every polled event type for debugging abrupt termination
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "InputManager: polled event type=%d\n", (int)event.type); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "InputManager: polled event type=%d\n", (int)event.type); fclose(f); }
|
||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case SDL_EVENT_QUIT:
|
case SDL_EVENT_QUIT:
|
||||||
@ -349,7 +349,7 @@ void InputManager::reset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void InputManager::handleQuitEvent() {
|
void InputManager::handleQuitEvent() {
|
||||||
FILE* f = fopen("tetris_trace.log", "a");
|
FILE* f = fopen("spacetris_trace.log", "a");
|
||||||
if (f) {
|
if (f) {
|
||||||
fprintf(f, "InputManager::handleQuitEvent invoked\n");
|
fprintf(f, "InputManager::handleQuitEvent invoked\n");
|
||||||
fclose(f);
|
fclose(f);
|
||||||
|
|||||||
@ -86,7 +86,7 @@ bool StateManager::setState(AppState newState) {
|
|||||||
getStateName(m_currentState), getStateName(newState));
|
getStateName(m_currentState), getStateName(newState));
|
||||||
// Persistent trace for debugging abrupt exits
|
// Persistent trace for debugging abrupt exits
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "setState start %s -> %s\n", getStateName(m_currentState), getStateName(newState)); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "setState start %s -> %s\n", getStateName(m_currentState), getStateName(newState)); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute exit hooks for current state
|
// Execute exit hooks for current state
|
||||||
@ -101,7 +101,7 @@ bool StateManager::setState(AppState newState) {
|
|||||||
|
|
||||||
// Trace completion
|
// Trace completion
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "setState end %s\n", getStateName(m_currentState)); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "setState end %s\n", getStateName(m_currentState)); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool StateManager::isValidState(AppState state) const {
|
bool StateManager::isValidState(AppState state) const {
|
||||||
// All enum values are currently valid
|
switch (state) {
|
||||||
return static_cast<int>(state) >= static_cast<int>(AppState::Loading) &&
|
case AppState::Loading:
|
||||||
static_cast<int>(state) <= static_cast<int>(AppState::GameOver);
|
case AppState::Video:
|
||||||
|
case AppState::Menu:
|
||||||
|
case AppState::Options:
|
||||||
|
case AppState::LevelSelector:
|
||||||
|
case AppState::Playing:
|
||||||
|
case AppState::LevelSelect:
|
||||||
|
case AppState::GameOver:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateManager::canTransitionTo(AppState newState) const {
|
bool StateManager::canTransitionTo(AppState newState) const {
|
||||||
@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const {
|
|||||||
const char* StateManager::getStateName(AppState state) const {
|
const char* StateManager::getStateName(AppState state) const {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case AppState::Loading: return "Loading";
|
case AppState::Loading: return "Loading";
|
||||||
|
case AppState::Video: return "Video";
|
||||||
case AppState::Menu: return "Menu";
|
case AppState::Menu: return "Menu";
|
||||||
case AppState::Options: return "Options";
|
case AppState::Options: return "Options";
|
||||||
case AppState::LevelSelector: return "LevelSelector";
|
case AppState::LevelSelector: return "LevelSelector";
|
||||||
@ -190,7 +201,7 @@ void StateManager::executeEnterHooks(AppState state) {
|
|||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Executing enter hook %d for state %s", idx, getStateName(state));
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Executing enter hook %d for state %s", idx, getStateName(state));
|
||||||
// Also write to trace file for persistent record
|
// Also write to trace file for persistent record
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "executeEnterHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "executeEnterHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
hook();
|
hook();
|
||||||
@ -212,7 +223,7 @@ void StateManager::executeExitHooks(AppState state) {
|
|||||||
for (auto& hook : it->second) {
|
for (auto& hook : it->second) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Executing exit hook %d for state %s", idx, getStateName(state));
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Executing exit hook %d for state %s", idx, getStateName(state));
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "executeExitHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "executeExitHook %d %s\n", idx, getStateName(state)); fclose(f); }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
hook();
|
hook();
|
||||||
|
|||||||
@ -12,6 +12,7 @@ class RenderManager;
|
|||||||
// Application states used across the app
|
// Application states used across the app
|
||||||
enum class AppState {
|
enum class AppState {
|
||||||
Loading,
|
Loading,
|
||||||
|
Video,
|
||||||
Menu,
|
Menu,
|
||||||
Options,
|
Options,
|
||||||
LevelSelector,
|
LevelSelector,
|
||||||
|
|||||||
317
src/gameplay/coop/CoopAIController.cpp
Normal file
317
src/gameplay/coop/CoopAIController.cpp
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
#include "CoopAIController.h"
|
||||||
|
|
||||||
|
#include "CoopGame.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
static bool canPlacePieceForSide(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
|
||||||
|
const CoopGame::Piece& p,
|
||||||
|
CoopGame::PlayerSide side) {
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(p, cx, cy)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int bx = p.x + cx;
|
||||||
|
const int by = p.y + cy;
|
||||||
|
|
||||||
|
// Keep the AI strictly in the correct half.
|
||||||
|
if (side == CoopGame::PlayerSide::Right) {
|
||||||
|
if (bx < 10 || bx >= CoopGame::COLS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (bx < 0 || bx >= 10) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above the visible board is allowed.
|
||||||
|
if (by < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (by >= CoopGame::ROWS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (board[by * CoopGame::COLS + bx].occupied) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int dropYFor(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
|
||||||
|
CoopGame::Piece p,
|
||||||
|
CoopGame::PlayerSide side) {
|
||||||
|
// Assumes p is currently placeable.
|
||||||
|
while (true) {
|
||||||
|
CoopGame::Piece next = p;
|
||||||
|
next.y += 1;
|
||||||
|
if (!canPlacePieceForSide(board, next, side)) {
|
||||||
|
return p.y;
|
||||||
|
}
|
||||||
|
p = next;
|
||||||
|
if (p.y > CoopGame::ROWS) {
|
||||||
|
return p.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void applyPiece(std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS>& occ,
|
||||||
|
const CoopGame::Piece& p) {
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(p, cx, cy)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int bx = p.x + cx;
|
||||||
|
const int by = p.y + cy;
|
||||||
|
if (by < 0 || by >= CoopGame::ROWS || bx < 0 || bx >= CoopGame::COLS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
occ[by * CoopGame::COLS + bx] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Eval {
|
||||||
|
double score = -std::numeric_limits<double>::infinity();
|
||||||
|
int rot = 0;
|
||||||
|
int x = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) {
|
||||||
|
const auto& board = game.boardRef();
|
||||||
|
|
||||||
|
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> occ{};
|
||||||
|
for (int i = 0; i < CoopGame::COLS * CoopGame::ROWS; ++i) {
|
||||||
|
occ[i] = board[i].occupied ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoopGame::Piece cur = game.current(side);
|
||||||
|
|
||||||
|
Eval best{};
|
||||||
|
|
||||||
|
// Iterate rotations and x positions. IMPORTANT: allow x to go slightly out of bounds
|
||||||
|
// because our pieces are represented in a 4x4 mask and many rotations have leading
|
||||||
|
// empty columns. For example, placing a vertical I/J/L into column 0 often requires
|
||||||
|
// p.x == -1 or p.x == -2 so the filled cells land at bx==0.
|
||||||
|
// canPlacePieceForSide() enforces the actual half-board bounds.
|
||||||
|
for (int rot = 0; rot < 4; ++rot) {
|
||||||
|
int xmin = (side == CoopGame::PlayerSide::Right) ? 6 : -3;
|
||||||
|
int xmax = (side == CoopGame::PlayerSide::Right) ? 22 : 13;
|
||||||
|
for (int x = xmin; x <= xmax; ++x) {
|
||||||
|
CoopGame::Piece p = cur;
|
||||||
|
p.rot = rot;
|
||||||
|
p.x = x;
|
||||||
|
|
||||||
|
// If this rotation/x is illegal at the current y, try near the top spawn band.
|
||||||
|
if (!canPlacePieceForSide(board, p, side)) {
|
||||||
|
p.y = -2;
|
||||||
|
if (!canPlacePieceForSide(board, p, side)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.y = dropYFor(board, p, side);
|
||||||
|
|
||||||
|
auto occ2 = occ;
|
||||||
|
applyPiece(occ2, p);
|
||||||
|
|
||||||
|
// Count completed full rows (all 20 cols) after placement.
|
||||||
|
int fullRows = 0;
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
bool full = true;
|
||||||
|
for (int cx = 0; cx < CoopGame::COLS; ++cx) {
|
||||||
|
if (!occ2[y * CoopGame::COLS + cx]) {
|
||||||
|
full = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
++fullRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-half column heights + holes + bumpiness.
|
||||||
|
std::array<int, 10> heights{};
|
||||||
|
int aggregateHeight = 0;
|
||||||
|
int holes = 0;
|
||||||
|
|
||||||
|
for (int c = 0; c < 10; ++c) {
|
||||||
|
const int bx = (side == CoopGame::PlayerSide::Right) ? (10 + c) : c;
|
||||||
|
int h = 0;
|
||||||
|
bool found = false;
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
if (occ2[y * CoopGame::COLS + bx]) {
|
||||||
|
h = CoopGame::ROWS - y;
|
||||||
|
found = true;
|
||||||
|
// Count holes below the first filled cell.
|
||||||
|
for (int yy = y + 1; yy < CoopGame::ROWS; ++yy) {
|
||||||
|
if (!occ2[yy * CoopGame::COLS + bx]) {
|
||||||
|
++holes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heights[c] = found ? h : 0;
|
||||||
|
aggregateHeight += heights[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
int bump = 0;
|
||||||
|
for (int i = 0; i < 9; ++i) {
|
||||||
|
bump += std::abs(heights[i] - heights[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward sync potential: rows where the right half is full (10..19).
|
||||||
|
int sideHalfFullRows = 0;
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
bool full = true;
|
||||||
|
int start = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
|
||||||
|
int end = (side == CoopGame::PlayerSide::Right) ? 20 : 10;
|
||||||
|
for (int bx = start; bx < end; ++bx) {
|
||||||
|
if (!occ2[y * CoopGame::COLS + bx]) {
|
||||||
|
full = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
++sideHalfFullRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple heuristic:
|
||||||
|
// - Strongly prefer completed full rows
|
||||||
|
// - Prefer making the right half complete (helps cooperative clears)
|
||||||
|
// - Penalize holes and excessive height/bumpiness
|
||||||
|
double s = 0.0;
|
||||||
|
// Strongly prefer full-line clears across the whole board (rare but best).
|
||||||
|
s += static_cast<double>(fullRows) * 12000.0;
|
||||||
|
// Heavily prefer completing the player's half — make this a primary objective.
|
||||||
|
s += static_cast<double>(sideHalfFullRows) * 6000.0;
|
||||||
|
// Penalize holes and height less aggressively so completing half-rows is prioritized.
|
||||||
|
s -= static_cast<double>(holes) * 180.0;
|
||||||
|
s -= static_cast<double>(aggregateHeight) * 4.0;
|
||||||
|
s -= static_cast<double>(bump) * 10.0;
|
||||||
|
|
||||||
|
// Reduce center bias so edge placements to complete rows are not punished.
|
||||||
|
double centerTarget = (side == CoopGame::PlayerSide::Right) ? 15.0 : 4.5;
|
||||||
|
const double centerBias = -std::abs((x + 1.5) - centerTarget) * 1.0;
|
||||||
|
s += centerBias;
|
||||||
|
|
||||||
|
if (s > best.score) {
|
||||||
|
best.score = s;
|
||||||
|
best.rot = rot;
|
||||||
|
best.x = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void CoopAIController::reset() {
|
||||||
|
m_lastPieceSeq = 0;
|
||||||
|
m_hasPlan = false;
|
||||||
|
m_targetRot = 0;
|
||||||
|
m_targetX = 10;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopAIController::computePlan(const CoopGame& game, CoopGame::PlayerSide side) {
|
||||||
|
const Eval best = evaluateBestPlacementForSide(game, side);
|
||||||
|
m_targetRot = best.rot;
|
||||||
|
m_targetX = best.x;
|
||||||
|
m_hasPlan = true;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopAIController::update(CoopGame& game, CoopGame::PlayerSide side, double frameMs) {
|
||||||
|
const uint64_t seq = game.currentPieceSequence(side);
|
||||||
|
if (seq != m_lastPieceSeq) {
|
||||||
|
m_lastPieceSeq = seq;
|
||||||
|
m_hasPlan = false;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_hasPlan) {
|
||||||
|
computePlan(game, side);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoopGame::Piece cur = game.current(side);
|
||||||
|
|
||||||
|
// Clamp negative deltas (defensive; callers should pass >= 0).
|
||||||
|
const double dt = std::max(0.0, frameMs);
|
||||||
|
|
||||||
|
// Update timers.
|
||||||
|
if (m_moveTimerMs > 0.0) {
|
||||||
|
m_moveTimerMs -= dt;
|
||||||
|
if (m_moveTimerMs < 0.0) m_moveTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
if (m_rotateTimerMs > 0.0) {
|
||||||
|
m_rotateTimerMs -= dt;
|
||||||
|
if (m_rotateTimerMs < 0.0) m_rotateTimerMs = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate toward target first.
|
||||||
|
const int curRot = ((cur.rot % 4) + 4) % 4;
|
||||||
|
const int tgtRot = ((m_targetRot % 4) + 4) % 4;
|
||||||
|
int diff = (tgtRot - curRot + 4) % 4;
|
||||||
|
if (diff != 0) {
|
||||||
|
// Human-ish rotation rate limiting.
|
||||||
|
if (m_rotateTimerMs <= 0.0) {
|
||||||
|
const int dir = (diff == 3) ? -1 : 1;
|
||||||
|
game.rotate(side, dir);
|
||||||
|
m_rotateTimerMs = m_rotateIntervalMs;
|
||||||
|
}
|
||||||
|
// While rotating, do not also slide horizontally in the same frame.
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move horizontally toward target.
|
||||||
|
int desiredDir = 0;
|
||||||
|
if (cur.x < m_targetX) desiredDir = +1;
|
||||||
|
else if (cur.x > m_targetX) desiredDir = -1;
|
||||||
|
|
||||||
|
if (desiredDir == 0) {
|
||||||
|
// Aligned: do nothing. Gravity controls fall speed (no AI hard drops).
|
||||||
|
m_moveDir = 0;
|
||||||
|
m_moveTimerMs = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DAS/ARR-style horizontal movement pacing.
|
||||||
|
if (m_moveDir != desiredDir) {
|
||||||
|
// New direction / initial press: move immediately, then wait DAS.
|
||||||
|
game.move(side, desiredDir);
|
||||||
|
m_moveDir = desiredDir;
|
||||||
|
m_moveTimerMs = m_dasMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holding direction: repeat every ARR once DAS has elapsed.
|
||||||
|
if (m_moveTimerMs <= 0.0) {
|
||||||
|
game.move(side, desiredDir);
|
||||||
|
m_moveTimerMs = m_arrMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/gameplay/coop/CoopAIController.h
Normal file
36
src/gameplay/coop/CoopAIController.h
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include "CoopGame.h"
|
||||||
|
|
||||||
|
// Minimal, lightweight AI driver for a CoopGame player side (left or right).
|
||||||
|
// It chooses a target rotation/x placement using a simple board heuristic,
|
||||||
|
// then steers the active piece toward that target at a human-like input rate.
|
||||||
|
class CoopAIController {
|
||||||
|
public:
|
||||||
|
CoopAIController() = default;
|
||||||
|
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
// frameMs is the frame delta in milliseconds (same unit used across the gameplay loop).
|
||||||
|
void update(CoopGame& game, CoopGame::PlayerSide side, double frameMs);
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint64_t m_lastPieceSeq = 0;
|
||||||
|
bool m_hasPlan = false;
|
||||||
|
|
||||||
|
int m_targetRot = 0;
|
||||||
|
int m_targetX = 10;
|
||||||
|
|
||||||
|
// Input pacing (ms). These intentionally mirror the defaults used for human input.
|
||||||
|
double m_dasMs = 170.0;
|
||||||
|
double m_arrMs = 40.0;
|
||||||
|
double m_rotateIntervalMs = 110.0;
|
||||||
|
|
||||||
|
// Internal timers/state for rate limiting.
|
||||||
|
double m_moveTimerMs = 0.0;
|
||||||
|
int m_moveDir = 0; // -1, 0, +1
|
||||||
|
double m_rotateTimerMs = 0.0;
|
||||||
|
|
||||||
|
void computePlan(const CoopGame& game, CoopGame::PlayerSide side);
|
||||||
|
};
|
||||||
600
src/gameplay/coop/CoopGame.cpp
Normal file
600
src/gameplay/coop/CoopGame.cpp
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
#include "CoopGame.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// NES (NTSC) gravity table reused from single-player for level progression (ms per cell)
|
||||||
|
constexpr double NES_FPS = 60.0988;
|
||||||
|
constexpr double FRAME_MS = 1000.0 / NES_FPS;
|
||||||
|
|
||||||
|
struct LevelGravity { int framesPerCell; double levelMultiplier; };
|
||||||
|
|
||||||
|
LevelGravity LEVEL_TABLE[30] = {
|
||||||
|
{48,1.0}, {43,1.0}, {38,1.0}, {33,1.0}, {28,1.0}, {23,1.0}, {18,1.0}, {13,1.0}, {8,1.0}, {6,1.0},
|
||||||
|
{5,1.0}, {5,1.0}, {5,1.0}, {4,1.0}, {4,1.0}, {4,1.0}, {3,1.0}, {3,1.0}, {3,1.0}, {2,1.0},
|
||||||
|
{2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {1,1.0}
|
||||||
|
};
|
||||||
|
|
||||||
|
inline double gravityMsForLevelInternal(int level, double globalMultiplier) {
|
||||||
|
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
|
||||||
|
const LevelGravity& lg = LEVEL_TABLE[idx];
|
||||||
|
double frames = lg.framesPerCell * lg.levelMultiplier;
|
||||||
|
return frames * FRAME_MS * globalMultiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0).
|
||||||
|
static const std::array<Shape, PIECE_COUNT> SHAPES = {{
|
||||||
|
Shape{ 0x0F00, 0x2222, 0x00F0, 0x4444 }, // I
|
||||||
|
Shape{ 0x0660, 0x0660, 0x0660, 0x0660 }, // O
|
||||||
|
Shape{ 0x0E40, 0x4C40, 0x4E00, 0x4640 }, // T
|
||||||
|
Shape{ 0x06C0, 0x4620, 0x06C0, 0x4620 }, // S
|
||||||
|
Shape{ 0x0C60, 0x2640, 0x0C60, 0x2640 }, // Z
|
||||||
|
Shape{ 0x08E0, 0x6440, 0x0E20, 0x44C0 }, // J
|
||||||
|
Shape{ 0x02E0, 0x4460, 0x0E80, 0xC440 }, // L
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
CoopGame::CoopGame(int startLevel_) {
|
||||||
|
reset(startLevel_);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
uint64_t fnv1a64(uint64_t h, const void* data, size_t size) {
|
||||||
|
const uint8_t* p = static_cast<const uint8_t*>(data);
|
||||||
|
for (size_t i = 0; i < size; ++i) {
|
||||||
|
h ^= static_cast<uint64_t>(p[i]);
|
||||||
|
h *= 1099511628211ull;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
uint64_t hashPod(uint64_t h, const T& v) {
|
||||||
|
return fnv1a64(h, &v, sizeof(T));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt) {
|
||||||
|
std::fill(board.begin(), board.end(), Cell{});
|
||||||
|
rowStates.fill(RowHalfState{});
|
||||||
|
completedLines.clear();
|
||||||
|
hardDropCells.clear();
|
||||||
|
hardDropFxId = 0;
|
||||||
|
hardDropShakeTimerMs = 0.0;
|
||||||
|
_score = 0;
|
||||||
|
_lines = 0;
|
||||||
|
_level = startLevel_;
|
||||||
|
startLevel = startLevel_;
|
||||||
|
gravityMs = gravityMsForLevel(_level);
|
||||||
|
gameOver = false;
|
||||||
|
pieceSequence = 0;
|
||||||
|
elapsedMs = 0.0;
|
||||||
|
|
||||||
|
left = PlayerState{};
|
||||||
|
right = PlayerState{ PlayerSide::Right };
|
||||||
|
|
||||||
|
auto initPlayer = [&](PlayerState& ps, uint32_t seed) {
|
||||||
|
ps.canHold = true;
|
||||||
|
ps.hold.type = PIECE_COUNT;
|
||||||
|
ps.softDropping = false;
|
||||||
|
ps.toppedOut = false;
|
||||||
|
ps.fallAcc = 0.0;
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
ps.pieceSeq = 0;
|
||||||
|
ps.score = 0;
|
||||||
|
ps.lines = 0;
|
||||||
|
ps.level = startLevel_;
|
||||||
|
ps.tetrisesMade = 0;
|
||||||
|
ps.currentCombo = 0;
|
||||||
|
ps.maxCombo = 0;
|
||||||
|
ps.comboCount = 0;
|
||||||
|
ps.bag.clear();
|
||||||
|
ps.next.type = PIECE_COUNT;
|
||||||
|
ps.rng.seed(seed);
|
||||||
|
refillBag(ps);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (seedOpt.has_value()) {
|
||||||
|
const uint32_t seed = seedOpt.value();
|
||||||
|
initPlayer(left, seed);
|
||||||
|
initPlayer(right, seed ^ 0x9E3779B9u);
|
||||||
|
} else {
|
||||||
|
// Preserve existing behavior: random seed when not in deterministic mode.
|
||||||
|
std::random_device rd;
|
||||||
|
initPlayer(left, static_cast<uint32_t>(rd()));
|
||||||
|
initPlayer(right, static_cast<uint32_t>(rd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn(left);
|
||||||
|
spawn(right);
|
||||||
|
updateRowStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::reset(int startLevel_) {
|
||||||
|
resetInternal(startLevel_, std::nullopt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::resetDeterministic(int startLevel_, uint32_t seed) {
|
||||||
|
resetInternal(startLevel_, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::setSoftDropping(PlayerSide side, bool on) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
auto stepFor = [&](bool soft)->double { return soft ? std::max(5.0, gravityMs / 5.0) : gravityMs; };
|
||||||
|
double oldStep = stepFor(ps.softDropping);
|
||||||
|
double newStep = stepFor(on);
|
||||||
|
if (oldStep <= 0.0 || newStep <= 0.0) {
|
||||||
|
ps.softDropping = on;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double progress = ps.fallAcc / oldStep;
|
||||||
|
progress = std::clamp(progress, 0.0, 1.0);
|
||||||
|
ps.fallAcc = progress * newStep;
|
||||||
|
ps.softDropping = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t CoopGame::computeStateHash() const {
|
||||||
|
uint64_t h = 1469598103934665603ull;
|
||||||
|
|
||||||
|
// Board
|
||||||
|
for (const auto& c : board) {
|
||||||
|
const uint8_t occ = c.occupied ? 1u : 0u;
|
||||||
|
const uint8_t owner = (c.owner == PlayerSide::Left) ? 0u : 1u;
|
||||||
|
const uint8_t val = static_cast<uint8_t>(std::clamp(c.value, 0, 255));
|
||||||
|
h = hashPod(h, occ);
|
||||||
|
h = hashPod(h, owner);
|
||||||
|
h = hashPod(h, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto hashPiece = [&](const Piece& p) {
|
||||||
|
const uint8_t type = static_cast<uint8_t>(p.type);
|
||||||
|
const int32_t rot = p.rot;
|
||||||
|
const int32_t x = p.x;
|
||||||
|
const int32_t y = p.y;
|
||||||
|
h = hashPod(h, type);
|
||||||
|
h = hashPod(h, rot);
|
||||||
|
h = hashPod(h, x);
|
||||||
|
h = hashPod(h, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
auto hashPlayer = [&](const PlayerState& ps) {
|
||||||
|
const uint8_t side = (ps.side == PlayerSide::Left) ? 0u : 1u;
|
||||||
|
h = hashPod(h, side);
|
||||||
|
hashPiece(ps.cur);
|
||||||
|
hashPiece(ps.next);
|
||||||
|
hashPiece(ps.hold);
|
||||||
|
const uint8_t canHoldB = ps.canHold ? 1u : 0u;
|
||||||
|
const uint8_t toppedOutB = ps.toppedOut ? 1u : 0u;
|
||||||
|
h = hashPod(h, canHoldB);
|
||||||
|
h = hashPod(h, toppedOutB);
|
||||||
|
h = hashPod(h, ps.score);
|
||||||
|
h = hashPod(h, ps.lines);
|
||||||
|
h = hashPod(h, ps.level);
|
||||||
|
h = hashPod(h, ps.tetrisesMade);
|
||||||
|
h = hashPod(h, ps.currentCombo);
|
||||||
|
h = hashPod(h, ps.maxCombo);
|
||||||
|
h = hashPod(h, ps.comboCount);
|
||||||
|
h = hashPod(h, ps.pieceSeq);
|
||||||
|
|
||||||
|
const uint32_t bagSize = static_cast<uint32_t>(ps.bag.size());
|
||||||
|
h = hashPod(h, bagSize);
|
||||||
|
for (auto t : ps.bag) {
|
||||||
|
const uint8_t tt = static_cast<uint8_t>(t);
|
||||||
|
h = hashPod(h, tt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hashPlayer(left);
|
||||||
|
hashPlayer(right);
|
||||||
|
|
||||||
|
// Session-wide counters/stats
|
||||||
|
h = hashPod(h, _score);
|
||||||
|
h = hashPod(h, _lines);
|
||||||
|
h = hashPod(h, _level);
|
||||||
|
h = hashPod(h, _tetrisesMade);
|
||||||
|
h = hashPod(h, _currentCombo);
|
||||||
|
h = hashPod(h, _maxCombo);
|
||||||
|
h = hashPod(h, _comboCount);
|
||||||
|
h = hashPod(h, startLevel);
|
||||||
|
h = hashPod(h, pieceSequence);
|
||||||
|
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::move(PlayerSide side, int dx) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
tryMove(ps, dx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::rotate(PlayerSide side, int dir) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
|
||||||
|
auto minOccupiedY = [&](const Piece& p) -> int {
|
||||||
|
int minY = 999;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(p, cx, cy)) continue;
|
||||||
|
minY = std::min(minY, p.y + cy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (minY == 999) ? p.y : minY;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto tryApplyWithTopKick = [&](const Piece& candidate) -> bool {
|
||||||
|
// If rotation would place any occupied cell above the visible grid,
|
||||||
|
// kick it down just enough to keep all blocks visible.
|
||||||
|
int minY = minOccupiedY(candidate);
|
||||||
|
int baseDy = (minY < 0) ? -minY : 0;
|
||||||
|
|
||||||
|
// Try minimal adjustment first; allow a couple extra pixels/rows for safety.
|
||||||
|
for (int dy = baseDy; dy <= baseDy + 2; ++dy) {
|
||||||
|
Piece test = candidate;
|
||||||
|
test.y += dy;
|
||||||
|
if (!collides(ps, test)) {
|
||||||
|
ps.cur = test;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Piece rotated = ps.cur;
|
||||||
|
rotated.rot = (rotated.rot + dir + 4) % 4;
|
||||||
|
|
||||||
|
// Simple wall kick: try in place, then left, then right.
|
||||||
|
if (tryApplyWithTopKick(rotated)) return;
|
||||||
|
rotated.x -= 1;
|
||||||
|
if (tryApplyWithTopKick(rotated)) return;
|
||||||
|
rotated.x += 2;
|
||||||
|
if (tryApplyWithTopKick(rotated)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::hardDrop(PlayerSide side) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
|
||||||
|
hardDropCells.clear();
|
||||||
|
bool moved = false;
|
||||||
|
int dropped = 0;
|
||||||
|
while (tryMove(ps, 0, 1)) {
|
||||||
|
moved = true;
|
||||||
|
dropped++;
|
||||||
|
// Record path for potential effects
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||||
|
int px = ps.cur.x + cx;
|
||||||
|
int py = ps.cur.y + cy;
|
||||||
|
if (py >= 0) {
|
||||||
|
hardDropCells.push_back(SDL_Point{ px, py });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moved) {
|
||||||
|
_score += dropped; // 1 point per cell, matches single-player hard drop
|
||||||
|
ps.score += dropped;
|
||||||
|
hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS;
|
||||||
|
hardDropFxId++;
|
||||||
|
}
|
||||||
|
lock(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::holdCurrent(PlayerSide side) {
|
||||||
|
PlayerState& ps = player(side);
|
||||||
|
if (gameOver || ps.toppedOut) return;
|
||||||
|
if (!ps.canHold) return;
|
||||||
|
if (ps.hold.type == PIECE_COUNT) {
|
||||||
|
ps.hold = ps.cur;
|
||||||
|
spawn(ps);
|
||||||
|
} else {
|
||||||
|
std::swap(ps.cur, ps.hold);
|
||||||
|
ps.cur.rot = 0;
|
||||||
|
ps.cur.x = columnMin(ps.side) + 3;
|
||||||
|
// Match single-player spawn height (I starts higher)
|
||||||
|
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||||
|
ps.pieceSeq++;
|
||||||
|
pieceSequence++;
|
||||||
|
}
|
||||||
|
ps.canHold = false;
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::tickGravity(double frameMs) {
|
||||||
|
if (gameOver) return;
|
||||||
|
|
||||||
|
elapsedMs += frameMs;
|
||||||
|
|
||||||
|
auto stepPlayer = [&](PlayerState& ps) {
|
||||||
|
if (ps.toppedOut) return;
|
||||||
|
double step = ps.softDropping ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||||
|
ps.fallAcc += frameMs;
|
||||||
|
while (ps.fallAcc >= step) {
|
||||||
|
ps.fallAcc -= step;
|
||||||
|
if (!tryMove(ps, 0, 1)) {
|
||||||
|
ps.lockAcc += step;
|
||||||
|
if (ps.lockAcc >= LOCK_DELAY_MS) {
|
||||||
|
lock(ps);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Award soft drop points when actively holding down
|
||||||
|
if (ps.softDropping) {
|
||||||
|
_score += 1;
|
||||||
|
ps.score += 1;
|
||||||
|
}
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stepPlayer(left);
|
||||||
|
stepPlayer(right);
|
||||||
|
|
||||||
|
updateRowStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::updateVisualEffects(double frameMs) {
|
||||||
|
if (hardDropShakeTimerMs > 0.0) {
|
||||||
|
hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double CoopGame::hardDropShakeStrength() const {
|
||||||
|
if (hardDropShakeTimerMs <= 0.0) return 0.0;
|
||||||
|
return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double CoopGame::gravityMsForLevel(int level) const {
|
||||||
|
return gravityMsForLevelInternal(level, gravityGlobalMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoopGame::cellFilled(const Piece& p, int cx, int cy) {
|
||||||
|
if (p.type >= PIECE_COUNT) return false;
|
||||||
|
const Shape& shape = SHAPES[p.type];
|
||||||
|
uint16_t mask = shape[p.rot % 4];
|
||||||
|
int bitIndex = cy * 4 + cx;
|
||||||
|
// Masks are defined row-major 4x4 with bit 0 = (0,0) (same convention as classic).
|
||||||
|
return (mask >> bitIndex) & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::clearCompletedLines() {
|
||||||
|
if (completedLines.empty()) return;
|
||||||
|
clearLinesInternal();
|
||||||
|
completedLines.clear();
|
||||||
|
updateRowStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::refillBag(PlayerState& ps) {
|
||||||
|
ps.bag.clear();
|
||||||
|
ps.bag.reserve(PIECE_COUNT);
|
||||||
|
for (int i = 0; i < PIECE_COUNT; ++i) {
|
||||||
|
ps.bag.push_back(static_cast<PieceType>(i));
|
||||||
|
}
|
||||||
|
std::shuffle(ps.bag.begin(), ps.bag.end(), ps.rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
CoopGame::Piece CoopGame::drawFromBag(PlayerState& ps) {
|
||||||
|
if (ps.bag.empty()) {
|
||||||
|
refillBag(ps);
|
||||||
|
}
|
||||||
|
PieceType t = ps.bag.back();
|
||||||
|
ps.bag.pop_back();
|
||||||
|
Piece p{};
|
||||||
|
p.type = t;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::spawn(PlayerState& ps) {
|
||||||
|
if (ps.next.type == PIECE_COUNT) {
|
||||||
|
ps.next = drawFromBag(ps);
|
||||||
|
}
|
||||||
|
ps.cur = ps.next;
|
||||||
|
ps.cur.rot = 0;
|
||||||
|
ps.cur.x = columnMin(ps.side) + 3; // center within side
|
||||||
|
// Match single-player spawn height (I starts higher)
|
||||||
|
ps.cur.y = (ps.cur.type == PieceType::I) ? -2 : -1;
|
||||||
|
ps.next = drawFromBag(ps);
|
||||||
|
ps.canHold = true;
|
||||||
|
ps.softDropping = false;
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
ps.fallAcc = 0.0;
|
||||||
|
ps.pieceSeq++;
|
||||||
|
pieceSequence++;
|
||||||
|
if (collides(ps, ps.cur)) {
|
||||||
|
ps.toppedOut = true;
|
||||||
|
// Cooperative mode: game ends when any player tops out.
|
||||||
|
gameOver = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoopGame::collides(const PlayerState& ps, const Piece& p) const {
|
||||||
|
int minX = columnMin(ps.side);
|
||||||
|
int maxX = columnMax(ps.side);
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(p, cx, cy)) continue;
|
||||||
|
int px = p.x + cx;
|
||||||
|
int py = p.y + cy;
|
||||||
|
if (px < minX || px > maxX) return true;
|
||||||
|
if (py >= ROWS) return true;
|
||||||
|
if (py < 0) continue; // allow spawn above board
|
||||||
|
int idx = py * COLS + px;
|
||||||
|
if (board[idx].occupied) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoopGame::tryMove(PlayerState& ps, int dx, int dy) {
|
||||||
|
Piece test = ps.cur;
|
||||||
|
test.x += dx;
|
||||||
|
test.y += dy;
|
||||||
|
if (collides(ps, test)) return false;
|
||||||
|
ps.cur = test;
|
||||||
|
if (dy > 0) {
|
||||||
|
ps.lockAcc = 0.0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::lock(PlayerState& ps) {
|
||||||
|
// Write piece into the board
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!cellFilled(ps.cur, cx, cy)) continue;
|
||||||
|
int px = ps.cur.x + cx;
|
||||||
|
int py = ps.cur.y + cy;
|
||||||
|
if (py < 0 || py >= ROWS) continue;
|
||||||
|
int idx = py * COLS + px;
|
||||||
|
board[idx].occupied = true;
|
||||||
|
board[idx].owner = ps.side;
|
||||||
|
board[idx].value = static_cast<int>(ps.cur.type) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Detect completed lines and apply rewards but DO NOT clear them here.
|
||||||
|
// Clearing is deferred to the visual `LineEffect` system (as in single-player)
|
||||||
|
findCompletedLines();
|
||||||
|
if (!completedLines.empty()) {
|
||||||
|
int cleared = static_cast<int>(completedLines.size());
|
||||||
|
applyLineClearRewards(ps, cleared);
|
||||||
|
// Notify audio layer if present (matches single-player behavior)
|
||||||
|
if (soundCallback) soundCallback(cleared);
|
||||||
|
// Leave `completedLines` populated; `clearCompletedLines()` will be
|
||||||
|
// invoked by the state when the LineEffect finishes.
|
||||||
|
} else {
|
||||||
|
_currentCombo = 0;
|
||||||
|
ps.currentCombo = 0;
|
||||||
|
}
|
||||||
|
spawn(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::findCompletedLines() {
|
||||||
|
completedLines.clear();
|
||||||
|
for (int r = 0; r < ROWS; ++r) {
|
||||||
|
bool leftFull = true;
|
||||||
|
bool rightFull = true;
|
||||||
|
for (int c = 0; c < COLS; ++c) {
|
||||||
|
const Cell& cell = board[r * COLS + c];
|
||||||
|
if (!cell.occupied) {
|
||||||
|
if (c < 10) leftFull = false; else rightFull = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowStates[r].leftFull = leftFull;
|
||||||
|
rowStates[r].rightFull = rightFull;
|
||||||
|
if (leftFull && rightFull) {
|
||||||
|
completedLines.push_back(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::applyLineClearRewards(PlayerState& creditPlayer, int cleared) {
|
||||||
|
if (cleared <= 0) return;
|
||||||
|
|
||||||
|
// Base NES scoring scaled by shared level (level 0 => 1x multiplier)
|
||||||
|
int base = 0;
|
||||||
|
switch (cleared) {
|
||||||
|
case 1: base = 40; break;
|
||||||
|
case 2: base = 100; break;
|
||||||
|
case 3: base = 300; break;
|
||||||
|
case 4: base = 1200; break;
|
||||||
|
default: base = 0; break;
|
||||||
|
}
|
||||||
|
_score += base * (_level + 1);
|
||||||
|
creditPlayer.score += base * (creditPlayer.level + 1);
|
||||||
|
|
||||||
|
// Also award a trivial per-line bonus to both players so clears benefit
|
||||||
|
// both participants equally (as requested).
|
||||||
|
if (cleared > 0) {
|
||||||
|
left.score += cleared;
|
||||||
|
right.score += cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lines += cleared;
|
||||||
|
// Credit both players with the cleared lines so cooperative play counts for both
|
||||||
|
left.lines += cleared;
|
||||||
|
right.lines += cleared;
|
||||||
|
|
||||||
|
_currentCombo += 1;
|
||||||
|
if (_currentCombo > _maxCombo) _maxCombo = _currentCombo;
|
||||||
|
if (cleared > 1) {
|
||||||
|
_comboCount += 1;
|
||||||
|
}
|
||||||
|
if (cleared == 4) {
|
||||||
|
_tetrisesMade += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
creditPlayer.currentCombo += 1;
|
||||||
|
if (creditPlayer.currentCombo > creditPlayer.maxCombo) creditPlayer.maxCombo = creditPlayer.currentCombo;
|
||||||
|
if (cleared > 1) {
|
||||||
|
creditPlayer.comboCount += 1;
|
||||||
|
}
|
||||||
|
if (cleared == 4) {
|
||||||
|
creditPlayer.tetrisesMade += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level progression mirrors single-player: threshold after (startLevel+1)*10 then every 10 lines
|
||||||
|
int targetLevel = startLevel;
|
||||||
|
int firstThreshold = (startLevel + 1) * 10;
|
||||||
|
if (_lines >= firstThreshold) {
|
||||||
|
targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10;
|
||||||
|
}
|
||||||
|
if (targetLevel > _level) {
|
||||||
|
_level = targetLevel;
|
||||||
|
gravityMs = gravityMsForLevel(_level);
|
||||||
|
if (levelUpCallback) levelUpCallback(_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-player level progression mirrors the shared rules but is driven by
|
||||||
|
// that player's credited line clears.
|
||||||
|
{
|
||||||
|
int pTargetLevel = startLevel;
|
||||||
|
int pFirstThreshold = (startLevel + 1) * 10;
|
||||||
|
if (creditPlayer.lines >= pFirstThreshold) {
|
||||||
|
pTargetLevel = startLevel + 1 + (creditPlayer.lines - pFirstThreshold) / 10;
|
||||||
|
}
|
||||||
|
creditPlayer.level = std::max(creditPlayer.level, pTargetLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CoopGame::clearLinesInternal() {
|
||||||
|
if (completedLines.empty()) return;
|
||||||
|
std::sort(completedLines.begin(), completedLines.end());
|
||||||
|
for (int idx = static_cast<int>(completedLines.size()) - 1; idx >= 0; --idx) {
|
||||||
|
int row = completedLines[idx];
|
||||||
|
for (int y = row; y > 0; --y) {
|
||||||
|
for (int x = 0; x < COLS; ++x) {
|
||||||
|
board[y * COLS + x] = board[(y - 1) * COLS + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int x = 0; x < COLS; ++x) {
|
||||||
|
board[x] = Cell{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound callback (optional) - invoked when lines are detected so audio can play
|
||||||
|
// (set via setSoundCallback)
|
||||||
|
// NOTE: defined inline in header as a std::function member; forward usage above
|
||||||
|
|
||||||
|
void CoopGame::updateRowStates() {
|
||||||
|
for (int r = 0; r < ROWS; ++r) {
|
||||||
|
bool leftFull = true;
|
||||||
|
bool rightFull = true;
|
||||||
|
for (int c = 0; c < COLS; ++c) {
|
||||||
|
const Cell& cell = board[r * COLS + c];
|
||||||
|
if (!cell.occupied) {
|
||||||
|
if (c < 10) leftFull = false; else rightFull = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowStates[r].leftFull = leftFull;
|
||||||
|
rowStates[r].rightFull = rightFull;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/gameplay/coop/CoopGame.h
Normal file
167
src/gameplay/coop/CoopGame.h
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <optional>
|
||||||
|
#include <random>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include "../core/Game.h" // For PieceType enums and gravity table helpers
|
||||||
|
|
||||||
|
// Cooperative two-player session with a shared 20-column board split into halves.
|
||||||
|
// This is an early scaffold: rules and rendering hooks will be iterated in follow-up passes.
|
||||||
|
class CoopGame {
|
||||||
|
public:
|
||||||
|
enum class PlayerSide { Left, Right };
|
||||||
|
|
||||||
|
static constexpr int COLS = 20;
|
||||||
|
static constexpr int ROWS = Game::ROWS;
|
||||||
|
static constexpr int TILE = Game::TILE;
|
||||||
|
|
||||||
|
struct Piece { PieceType type{PIECE_COUNT}; int rot{0}; int x{0}; int y{-2}; };
|
||||||
|
|
||||||
|
struct Cell {
|
||||||
|
int value{0}; // 0 empty else color index (1..7)
|
||||||
|
PlayerSide owner{PlayerSide::Left};
|
||||||
|
bool occupied{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RowHalfState {
|
||||||
|
bool leftFull{false};
|
||||||
|
bool rightFull{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PlayerState {
|
||||||
|
PlayerSide side{PlayerSide::Left};
|
||||||
|
Piece cur{};
|
||||||
|
Piece hold{};
|
||||||
|
Piece next{};
|
||||||
|
uint64_t pieceSeq{0};
|
||||||
|
bool canHold{true};
|
||||||
|
bool softDropping{false};
|
||||||
|
bool toppedOut{false};
|
||||||
|
double fallAcc{0.0};
|
||||||
|
double lockAcc{0.0};
|
||||||
|
int score{0};
|
||||||
|
int lines{0};
|
||||||
|
int level{0};
|
||||||
|
int tetrisesMade{0};
|
||||||
|
int currentCombo{0};
|
||||||
|
int maxCombo{0};
|
||||||
|
int comboCount{0};
|
||||||
|
std::vector<PieceType> bag{}; // 7-bag queue
|
||||||
|
std::mt19937 rng{ std::random_device{}() };
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit CoopGame(int startLevel = 0);
|
||||||
|
using SoundCallback = std::function<void(int)>;
|
||||||
|
using LevelUpCallback = std::function<void(int)>;
|
||||||
|
void setSoundCallback(SoundCallback cb) { soundCallback = cb; }
|
||||||
|
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback = cb; }
|
||||||
|
|
||||||
|
void reset(int startLevel = 0);
|
||||||
|
void resetDeterministic(int startLevel, uint32_t seed);
|
||||||
|
void tickGravity(double frameMs);
|
||||||
|
void updateVisualEffects(double frameMs);
|
||||||
|
|
||||||
|
// Determinism / desync detection
|
||||||
|
uint64_t computeStateHash() const;
|
||||||
|
|
||||||
|
// Per-player inputs -----------------------------------------------------
|
||||||
|
void setSoftDropping(PlayerSide side, bool on);
|
||||||
|
void move(PlayerSide side, int dx);
|
||||||
|
void rotate(PlayerSide side, int dir); // +1 cw, -1 ccw
|
||||||
|
void hardDrop(PlayerSide side);
|
||||||
|
void holdCurrent(PlayerSide side);
|
||||||
|
|
||||||
|
// Accessors -------------------------------------------------------------
|
||||||
|
const std::array<Cell, COLS * ROWS>& boardRef() const { return board; }
|
||||||
|
const Piece& current(PlayerSide s) const { return player(s).cur; }
|
||||||
|
const Piece& next(PlayerSide s) const { return player(s).next; }
|
||||||
|
const Piece& held(PlayerSide s) const { return player(s).hold; }
|
||||||
|
bool canHold(PlayerSide s) const { return player(s).canHold; }
|
||||||
|
bool isGameOver() const { return gameOver; }
|
||||||
|
int score() const { return _score; }
|
||||||
|
int score(PlayerSide s) const { return player(s).score; }
|
||||||
|
int lines() const { return _lines; }
|
||||||
|
int lines(PlayerSide s) const { return player(s).lines; }
|
||||||
|
int level() const { return _level; }
|
||||||
|
int level(PlayerSide s) const { return player(s).level; }
|
||||||
|
int comboCount() const { return _comboCount; }
|
||||||
|
int maxCombo() const { return _maxCombo; }
|
||||||
|
int tetrisesMade() const { return _tetrisesMade; }
|
||||||
|
int elapsed() const { return static_cast<int>(elapsedMs / 1000.0); }
|
||||||
|
int elapsed(PlayerSide) const { return elapsed(); }
|
||||||
|
int startLevelBase() const { return startLevel; }
|
||||||
|
double getGravityMs() const { return gravityMs; }
|
||||||
|
double getFallAccumulator(PlayerSide s) const { return player(s).fallAcc; }
|
||||||
|
bool isSoftDropping(PlayerSide s) const { return player(s).softDropping; }
|
||||||
|
uint64_t currentPieceSequence(PlayerSide s) const { return player(s).pieceSeq; }
|
||||||
|
const std::vector<int>& getCompletedLines() const { return completedLines; }
|
||||||
|
bool hasCompletedLines() const { return !completedLines.empty(); }
|
||||||
|
void clearCompletedLines();
|
||||||
|
const std::array<RowHalfState, ROWS>& rowHalfStates() const { return rowStates; }
|
||||||
|
|
||||||
|
// Simple visual-effect compatibility (stubbed for now)
|
||||||
|
bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; }
|
||||||
|
double hardDropShakeStrength() const;
|
||||||
|
const std::vector<SDL_Point>& getHardDropCells() const { return hardDropCells; }
|
||||||
|
uint32_t getHardDropFxId() const { return hardDropFxId; }
|
||||||
|
|
||||||
|
static bool cellFilled(const Piece& p, int cx, int cy);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr double LOCK_DELAY_MS = 500.0;
|
||||||
|
|
||||||
|
void resetInternal(int startLevel_, const std::optional<uint32_t>& seedOpt);
|
||||||
|
|
||||||
|
std::array<Cell, COLS * ROWS> board{};
|
||||||
|
std::array<RowHalfState, ROWS> rowStates{};
|
||||||
|
PlayerState left{};
|
||||||
|
PlayerState right{ PlayerSide::Right };
|
||||||
|
|
||||||
|
int _score{0};
|
||||||
|
int _lines{0};
|
||||||
|
int _level{1};
|
||||||
|
int _tetrisesMade{0};
|
||||||
|
int _currentCombo{0};
|
||||||
|
int _maxCombo{0};
|
||||||
|
int _comboCount{0};
|
||||||
|
int startLevel{0};
|
||||||
|
double gravityMs{800.0};
|
||||||
|
double gravityGlobalMultiplier{1.0};
|
||||||
|
bool gameOver{false};
|
||||||
|
|
||||||
|
double elapsedMs{0.0};
|
||||||
|
|
||||||
|
std::vector<int> completedLines;
|
||||||
|
|
||||||
|
// Impact FX
|
||||||
|
double hardDropShakeTimerMs{0.0};
|
||||||
|
static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0;
|
||||||
|
std::vector<SDL_Point> hardDropCells;
|
||||||
|
uint32_t hardDropFxId{0};
|
||||||
|
uint64_t pieceSequence{0};
|
||||||
|
SoundCallback soundCallback;
|
||||||
|
LevelUpCallback levelUpCallback;
|
||||||
|
|
||||||
|
// Helpers ---------------------------------------------------------------
|
||||||
|
PlayerState& player(PlayerSide s) { return s == PlayerSide::Left ? left : right; }
|
||||||
|
const PlayerState& player(PlayerSide s) const { return s == PlayerSide::Left ? left : right; }
|
||||||
|
|
||||||
|
void refillBag(PlayerState& ps);
|
||||||
|
Piece drawFromBag(PlayerState& ps);
|
||||||
|
void spawn(PlayerState& ps);
|
||||||
|
bool collides(const PlayerState& ps, const Piece& p) const;
|
||||||
|
bool tryMove(PlayerState& ps, int dx, int dy);
|
||||||
|
void lock(PlayerState& ps);
|
||||||
|
void findCompletedLines();
|
||||||
|
void clearLinesInternal();
|
||||||
|
void updateRowStates();
|
||||||
|
void applyLineClearRewards(PlayerState& creditPlayer, int cleared);
|
||||||
|
double gravityMsForLevel(int level) const;
|
||||||
|
int columnMin(PlayerSide s) const { return s == PlayerSide::Left ? 0 : 10; }
|
||||||
|
int columnMax(PlayerSide s) const { return s == PlayerSide::Left ? 9 : 19; }
|
||||||
|
};
|
||||||
@ -15,7 +15,7 @@ enum PieceType { I, O, T, S, Z, J, L, PIECE_COUNT };
|
|||||||
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
using Shape = std::array<uint16_t, 4>; // four rotation bitmasks
|
||||||
|
|
||||||
// Game runtime mode
|
// Game runtime mode
|
||||||
enum class GameMode { Endless, Challenge };
|
enum class GameMode { Endless, Cooperate, Challenge };
|
||||||
|
|
||||||
// Special obstacle blocks used by Challenge mode
|
// Special obstacle blocks used by Challenge mode
|
||||||
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
enum class AsteroidType : uint8_t { Normal = 0, Armored = 1, Falling = 2, Core = 3 };
|
||||||
|
|||||||
@ -188,10 +188,13 @@ void LineEffect::initAudio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize) {
|
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols, int gapPx, int gapAfterCol) {
|
||||||
if (rows.empty()) return;
|
if (rows.empty()) return;
|
||||||
|
|
||||||
clearingRows = rows;
|
clearingRows = rows;
|
||||||
|
effectGridCols = std::max(1, gridCols);
|
||||||
|
effectGapPx = std::max(0, gapPx);
|
||||||
|
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||||
state = AnimationState::FLASH_WHITE;
|
state = AnimationState::FLASH_WHITE;
|
||||||
timer = 0.0f;
|
timer = 0.0f;
|
||||||
dropProgress = 0.0f;
|
dropProgress = 0.0f;
|
||||||
@ -228,8 +231,11 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
|
|||||||
|
|
||||||
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
|
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
|
||||||
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
|
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
|
||||||
for (int col = 0; col < Game::COLS; ++col) {
|
for (int col = 0; col < effectGridCols; ++col) {
|
||||||
float centerX = gridX + col * blockSize + blockSize * 0.5f;
|
float centerX = gridX + col * blockSize + blockSize * 0.5f;
|
||||||
|
if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) {
|
||||||
|
centerX += static_cast<float>(effectGapPx);
|
||||||
|
}
|
||||||
SDL_Color tint = pickFireColor();
|
SDL_Color tint = pickFireColor();
|
||||||
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
|
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
|
||||||
spawnShardBurst(centerX, centerY, tint);
|
spawnShardBurst(centerX, centerY, tint);
|
||||||
@ -337,8 +343,12 @@ void LineEffect::updateGlowPulses(float dt) {
|
|||||||
glowPulses.end());
|
glowPulses.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) {
|
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx, int gapAfterCol) {
|
||||||
if (state == AnimationState::IDLE) return;
|
if (state == AnimationState::IDLE) return;
|
||||||
|
|
||||||
|
// Allow caller to override gap mapping (useful for Coop renderer that inserts a mid-gap).
|
||||||
|
effectGapPx = std::max(0, gapPx);
|
||||||
|
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case AnimationState::FLASH_WHITE:
|
case AnimationState::FLASH_WHITE:
|
||||||
@ -383,10 +393,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
|
|||||||
|
|
||||||
for (int row : clearingRows) {
|
for (int row : clearingRows) {
|
||||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
|
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
|
||||||
|
const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0;
|
||||||
SDL_FRect flashRect = {
|
SDL_FRect flashRect = {
|
||||||
static_cast<float>(gridX - 4),
|
static_cast<float>(gridX - 4),
|
||||||
static_cast<float>(gridY + row * blockSize - 4),
|
static_cast<float>(gridY + row * blockSize - 4),
|
||||||
static_cast<float>(10 * blockSize + 8),
|
static_cast<float>(effectGridCols * blockSize + gapW + 8),
|
||||||
static_cast<float>(blockSize + 8)
|
static_cast<float>(blockSize + 8)
|
||||||
};
|
};
|
||||||
SDL_RenderFillRect(renderer, &flashRect);
|
SDL_RenderFillRect(renderer, &flashRect);
|
||||||
|
|||||||
@ -69,11 +69,11 @@ public:
|
|||||||
void shutdown();
|
void shutdown();
|
||||||
|
|
||||||
// Start line clear effect for the specified rows
|
// Start line clear effect for the specified rows
|
||||||
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize);
|
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS, int gapPx = 0, int gapAfterCol = 0);
|
||||||
|
|
||||||
// Update and render the effect
|
// Update and render the effect
|
||||||
bool update(float deltaTime); // Returns true if effect is complete
|
bool update(float deltaTime); // Returns true if effect is complete
|
||||||
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize);
|
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx = 0, int gapAfterCol = 0);
|
||||||
float getRowDropOffset(int row) const;
|
float getRowDropOffset(int row) const;
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
@ -120,4 +120,7 @@ private:
|
|||||||
std::array<float, Game::ROWS> rowDropTargets{};
|
std::array<float, Game::ROWS> rowDropTargets{};
|
||||||
float dropProgress = 0.0f;
|
float dropProgress = 0.0f;
|
||||||
int dropBlockSize = 0;
|
int dropBlockSize = 0;
|
||||||
|
int effectGridCols = Game::COLS;
|
||||||
|
int effectGapPx = 0;
|
||||||
|
int effectGapAfterCol = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -84,7 +84,7 @@ void RenderManager::beginFrame() {
|
|||||||
|
|
||||||
// Trace beginFrame entry
|
// Trace beginFrame entry
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the screen (wrapped with trace)
|
// Clear the screen (wrapped with trace)
|
||||||
@ -92,7 +92,7 @@ void RenderManager::beginFrame() {
|
|||||||
|
|
||||||
// Trace after clear
|
// Trace after clear
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,14 +102,14 @@ void RenderManager::endFrame() {
|
|||||||
}
|
}
|
||||||
// Trace before present
|
// Trace before present
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_RenderPresent(m_renderer);
|
SDL_RenderPresent(m_renderer);
|
||||||
|
|
||||||
// Trace after present
|
// Trace after present
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,11 +170,11 @@ void RenderManager::renderTexture(SDL_Texture* texture, const SDL_FRect* src, co
|
|||||||
|
|
||||||
// Trace renderTexture usage
|
// Trace renderTexture usage
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
||||||
}
|
}
|
||||||
SDL_RenderTexture(m_renderer, texture, src, dst);
|
SDL_RenderTexture(m_renderer, texture, src, dst);
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
#include "GameRenderer.h"
|
#include "GameRenderer.h"
|
||||||
|
|
||||||
|
#include "SyncLineRenderer.h"
|
||||||
#include "../../gameplay/core/Game.h"
|
#include "../../gameplay/core/Game.h"
|
||||||
|
#include "../../gameplay/coop/CoopGame.h"
|
||||||
|
#include "../../app/Fireworks.h"
|
||||||
#include "../ui/Font.h"
|
#include "../ui/Font.h"
|
||||||
#include "../../gameplay/effects/LineEffect.h"
|
#include "../../gameplay/effects/LineEffect.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@ -693,6 +697,11 @@ void GameRenderer::renderPlayingState(
|
|||||||
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||||
auto completedLines = game->getCompletedLines();
|
auto completedLines = game->getCompletedLines();
|
||||||
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
|
||||||
|
// Trigger fireworks visually for a 4-line clear (TETRIS)
|
||||||
|
if (completedLines.size() == 4) {
|
||||||
|
// spawn near center of grid
|
||||||
|
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw game grid border
|
// Draw game grid border
|
||||||
@ -1356,6 +1365,26 @@ void GameRenderer::renderPlayingState(
|
|||||||
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
|
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug: log single-player smoothing/fall values when enabled
|
||||||
|
if (Settings::instance().isDebugEnabled()) {
|
||||||
|
float sp_targetX = static_cast<float>(game->current().x);
|
||||||
|
double sp_gravityMs = game->getGravityMs();
|
||||||
|
double sp_fallAcc = game->getFallAccumulator();
|
||||||
|
int sp_soft = game->isSoftDropping() ? 1 : 0;
|
||||||
|
/*
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
|
||||||
|
(unsigned long long)s_activePieceSmooth.sequence,
|
||||||
|
s_activePieceSmooth.visualX,
|
||||||
|
sp_targetX,
|
||||||
|
activePiecePixelOffsetX,
|
||||||
|
activePiecePixelOffsetY,
|
||||||
|
sp_gravityMs,
|
||||||
|
sp_fallAcc,
|
||||||
|
sp_soft
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
// Draw ghost piece (where current piece will land)
|
// Draw ghost piece (where current piece will land)
|
||||||
if (allowActivePieceRender) {
|
if (allowActivePieceRender) {
|
||||||
Game::Piece ghostPiece = game->current();
|
Game::Piece ghostPiece = game->current();
|
||||||
@ -1806,6 +1835,929 @@ void GameRenderer::renderPlayingState(
|
|||||||
// Exit popup logic moved to renderExitPopup
|
// Exit popup logic moved to renderExitPopup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameRenderer::renderCoopPlayingState(
|
||||||
|
SDL_Renderer* renderer,
|
||||||
|
CoopGame* game,
|
||||||
|
FontAtlas* pixelFont,
|
||||||
|
LineEffect* lineEffect,
|
||||||
|
SDL_Texture* blocksTex,
|
||||||
|
SDL_Texture* statisticsPanelTex,
|
||||||
|
SDL_Texture* scorePanelTex,
|
||||||
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
|
bool paused,
|
||||||
|
float logicalW,
|
||||||
|
float logicalH,
|
||||||
|
float logicalScale,
|
||||||
|
float winW,
|
||||||
|
float winH
|
||||||
|
) {
|
||||||
|
if (!renderer || !game || !pixelFont) return;
|
||||||
|
|
||||||
|
static SyncLineRenderer s_syncLine;
|
||||||
|
static bool s_lastHadCompletedLines = false;
|
||||||
|
|
||||||
|
static Uint32 s_lastCoopTick = SDL_GetTicks();
|
||||||
|
Uint32 nowTicks = SDL_GetTicks();
|
||||||
|
float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
|
||||||
|
s_lastCoopTick = nowTicks;
|
||||||
|
if (deltaMs < 0.0f || deltaMs > 100.0f) {
|
||||||
|
deltaMs = 16.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float deltaSeconds = std::clamp(deltaMs / 1000.0f, 0.0f, 0.033f);
|
||||||
|
s_syncLine.Update(deltaSeconds);
|
||||||
|
|
||||||
|
const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
|
||||||
|
struct SmoothState { bool initialized{false}; uint64_t seq{0}; float visualX{0.0f}; float visualY{0.0f}; };
|
||||||
|
static SmoothState s_leftSmooth{};
|
||||||
|
static SmoothState s_rightSmooth{};
|
||||||
|
struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; int spawnY{0}; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; };
|
||||||
|
static SpawnFadeState s_leftSpawnFade{};
|
||||||
|
static SpawnFadeState s_rightSpawnFade{};
|
||||||
|
|
||||||
|
// Layout constants (reuse single-player feel but sized for 20 cols)
|
||||||
|
const float MIN_MARGIN = 40.0f;
|
||||||
|
const float TOP_MARGIN = 60.0f;
|
||||||
|
const float PANEL_WIDTH = 180.0f;
|
||||||
|
const float PANEL_SPACING = 30.0f;
|
||||||
|
const float NEXT_PANEL_HEIGHT = 120.0f;
|
||||||
|
const float BOTTOM_MARGIN = 60.0f;
|
||||||
|
|
||||||
|
// Content offset (centered logical viewport inside window)
|
||||||
|
float contentScale = logicalScale;
|
||||||
|
float contentW = logicalW * contentScale;
|
||||||
|
float contentH = logicalH * contentScale;
|
||||||
|
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
|
||||||
|
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
|
||||||
|
|
||||||
|
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
|
||||||
|
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
|
||||||
|
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr float COOP_GAP_PX = 20.0f;
|
||||||
|
|
||||||
|
const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
|
||||||
|
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PANEL_HEIGHT;
|
||||||
|
|
||||||
|
const float usableGridWidth = std::max(0.0f, availableWidth - COOP_GAP_PX);
|
||||||
|
const float maxBlockSizeW = usableGridWidth / CoopGame::COLS;
|
||||||
|
const float maxBlockSizeH = availableHeight / CoopGame::ROWS;
|
||||||
|
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
|
||||||
|
const float finalBlockSize = std::max(16.0f, std::min(BLOCK_SIZE, 36.0f));
|
||||||
|
|
||||||
|
const float HALF_W = 10.0f * finalBlockSize;
|
||||||
|
const float GRID_W = CoopGame::COLS * finalBlockSize + COOP_GAP_PX;
|
||||||
|
const float GRID_H = CoopGame::ROWS * finalBlockSize;
|
||||||
|
|
||||||
|
const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
|
||||||
|
const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
|
||||||
|
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
|
||||||
|
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
|
||||||
|
|
||||||
|
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
|
||||||
|
const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
|
||||||
|
|
||||||
|
const float statsX = layoutStartX + contentOffsetX;
|
||||||
|
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
|
||||||
|
const float gridY = contentStartY + NEXT_PANEL_HEIGHT + contentOffsetY;
|
||||||
|
|
||||||
|
const float rightPanelX = gridX + GRID_W + PANEL_SPACING;
|
||||||
|
|
||||||
|
const float statsY = gridY;
|
||||||
|
const float statsW = PANEL_WIDTH;
|
||||||
|
const float statsH = GRID_H;
|
||||||
|
|
||||||
|
// (Score panels are drawn per-player below using scorePanelTex and classic sizing.)
|
||||||
|
|
||||||
|
// Handle line clearing effects (defer to LineEffect like single-player)
|
||||||
|
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
|
||||||
|
auto completedLines = game->getCompletedLines();
|
||||||
|
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), CoopGame::COLS, static_cast<int>(COOP_GAP_PX), 10);
|
||||||
|
if (completedLines.size() == 4) {
|
||||||
|
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute row drop offsets (line collapse effect)
|
||||||
|
std::array<float, CoopGame::ROWS> rowDropOffsets{};
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid backdrop and border (one border around both halves)
|
||||||
|
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
|
||||||
|
// Background for left+right halves
|
||||||
|
drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
|
||||||
|
// Gap background (slightly darker so the 10px separation reads clearly)
|
||||||
|
drawRectWithOffset(gridX + HALF_W - contentOffsetX, gridY - contentOffsetY, COOP_GAP_PX, GRID_H, {12, 14, 18, 255});
|
||||||
|
|
||||||
|
// Sync divider line centered in the gap between halves.
|
||||||
|
const float dividerCenterX = gridX + HALF_W + (COOP_GAP_PX * 0.5f);
|
||||||
|
s_syncLine.SetRect(SDL_FRect{ dividerCenterX - 2.0f, gridY, 4.0f, GRID_H });
|
||||||
|
|
||||||
|
auto cellX = [&](int col) -> float {
|
||||||
|
float x = gridX + col * finalBlockSize;
|
||||||
|
if (col >= 10) {
|
||||||
|
x += COOP_GAP_PX;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grid lines (draw per-half so the gap is clean)
|
||||||
|
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
|
||||||
|
for (int x = 1; x < 10; ++x) {
|
||||||
|
float lineX = gridX + x * finalBlockSize;
|
||||||
|
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
|
||||||
|
}
|
||||||
|
for (int x = 1; x < 10; ++x) {
|
||||||
|
float lineX = gridX + HALF_W + COOP_GAP_PX + x * finalBlockSize;
|
||||||
|
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
|
||||||
|
}
|
||||||
|
for (int y = 1; y < CoopGame::ROWS; ++y) {
|
||||||
|
float lineY = gridY + y * finalBlockSize;
|
||||||
|
SDL_RenderLine(renderer, gridX, lineY, gridX + HALF_W, lineY);
|
||||||
|
SDL_RenderLine(renderer, gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-grid 3D starfield + ambient sparkles (match classic feel, per-half)
|
||||||
|
{
|
||||||
|
static Uint32 s_lastCoopSparkTick = SDL_GetTicks();
|
||||||
|
static std::mt19937 s_coopSparkRng{ std::random_device{}() };
|
||||||
|
static std::vector<Sparkle> s_leftSparkles;
|
||||||
|
static std::vector<Sparkle> s_rightSparkles;
|
||||||
|
static std::vector<ImpactSpark> s_leftImpactSparks;
|
||||||
|
static std::vector<ImpactSpark> s_rightImpactSparks;
|
||||||
|
static float s_leftSparkleSpawnAcc = 0.0f;
|
||||||
|
static float s_rightSparkleSpawnAcc = 0.0f;
|
||||||
|
|
||||||
|
float halfW = HALF_W;
|
||||||
|
const float leftGridX = gridX;
|
||||||
|
const float rightGridX = gridX + HALF_W + COOP_GAP_PX;
|
||||||
|
|
||||||
|
Uint32 sparkNow = nowTicks;
|
||||||
|
float sparkDeltaMs = static_cast<float>(sparkNow - s_lastCoopSparkTick);
|
||||||
|
s_lastCoopSparkTick = sparkNow;
|
||||||
|
if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) {
|
||||||
|
sparkDeltaMs = 16.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!s_starfieldInitialized) {
|
||||||
|
s_inGridStarfield.init(static_cast<int>(halfW), static_cast<int>(GRID_H), 180);
|
||||||
|
s_starfieldInitialized = true;
|
||||||
|
} else {
|
||||||
|
s_inGridStarfield.resize(static_cast<int>(halfW), static_cast<int>(GRID_H));
|
||||||
|
}
|
||||||
|
|
||||||
|
const float deltaSeconds = std::clamp(sparkDeltaMs / 1000.0f, 0.0f, 0.033f);
|
||||||
|
s_inGridStarfield.update(deltaSeconds);
|
||||||
|
|
||||||
|
struct MagnetInfo { bool active{false}; float x{0.0f}; float y{0.0f}; };
|
||||||
|
auto computeMagnet = [&](CoopGame::PlayerSide side) -> MagnetInfo {
|
||||||
|
MagnetInfo info{};
|
||||||
|
const CoopGame::Piece& activePiece = game->current(side);
|
||||||
|
const int pieceType = static_cast<int>(activePiece.type);
|
||||||
|
if (pieceType < 0 || pieceType >= PIECE_COUNT) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
float sumLocalX = 0.0f;
|
||||||
|
float sumLocalY = 0.0f;
|
||||||
|
int filledCells = 0;
|
||||||
|
const int localXOffsetCols = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(activePiece, cx, cy)) continue;
|
||||||
|
sumLocalX += ((activePiece.x - localXOffsetCols) + cx + 0.5f) * finalBlockSize;
|
||||||
|
sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize;
|
||||||
|
++filledCells;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filledCells <= 0) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.active = true;
|
||||||
|
info.x = std::clamp(sumLocalX / static_cast<float>(filledCells), 0.0f, halfW);
|
||||||
|
info.y = std::clamp(sumLocalY / static_cast<float>(filledCells), 0.0f, GRID_H);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MagnetInfo leftMagnet = computeMagnet(CoopGame::PlayerSide::Left);
|
||||||
|
const MagnetInfo rightMagnet = computeMagnet(CoopGame::PlayerSide::Right);
|
||||||
|
|
||||||
|
SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE;
|
||||||
|
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
|
||||||
|
auto drawStarfieldHalf = [&](float originX, const MagnetInfo& magnet) {
|
||||||
|
if (magnet.active) {
|
||||||
|
const float magnetStrength = finalBlockSize * 2.2f;
|
||||||
|
s_inGridStarfield.setMagnetTarget(magnet.x, magnet.y, magnetStrength);
|
||||||
|
} else {
|
||||||
|
s_inGridStarfield.clearMagnetTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
const float jitterAmp = 1.6f;
|
||||||
|
const float tms = static_cast<float>(sparkNow) * 0.001f;
|
||||||
|
const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f;
|
||||||
|
const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f);
|
||||||
|
s_inGridStarfield.draw(renderer, originX + jitterX, gridY + jitterY, 0.22f, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
drawStarfieldHalf(leftGridX, leftMagnet);
|
||||||
|
drawStarfieldHalf(rightGridX, rightMagnet);
|
||||||
|
|
||||||
|
auto updateAndDrawSparkleLayer = [&](std::vector<Sparkle>& sparkles,
|
||||||
|
std::vector<ImpactSpark>& impactSparks,
|
||||||
|
float& spawnAcc,
|
||||||
|
const MagnetInfo& magnet,
|
||||||
|
float originX) {
|
||||||
|
if (!paused) {
|
||||||
|
const float spawnInterval = 0.08f;
|
||||||
|
spawnAcc += deltaSeconds;
|
||||||
|
while (spawnAcc >= spawnInterval) {
|
||||||
|
spawnAcc -= spawnInterval;
|
||||||
|
|
||||||
|
Sparkle s;
|
||||||
|
bool spawnNearPiece = magnet.active && (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) > 0.35f);
|
||||||
|
|
||||||
|
float sx = 0.0f;
|
||||||
|
float sy = 0.0f;
|
||||||
|
if (spawnNearPiece) {
|
||||||
|
float jitterX = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
|
||||||
|
float jitterY = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
|
||||||
|
sx = std::clamp(magnet.x + jitterX, -finalBlockSize * 2.0f, halfW + finalBlockSize * 2.0f);
|
||||||
|
sy = std::clamp(magnet.y + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f);
|
||||||
|
} else {
|
||||||
|
float side = std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng);
|
||||||
|
const float borderBand = std::max(12.0f, finalBlockSize * 1.0f);
|
||||||
|
if (side < 0.2f) {
|
||||||
|
sx = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
|
||||||
|
sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
|
||||||
|
} else if (side < 0.4f) {
|
||||||
|
sx = std::uniform_real_distribution<float>(halfW, halfW + borderBand)(s_coopSparkRng);
|
||||||
|
sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
|
||||||
|
} else if (side < 0.6f) {
|
||||||
|
sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
|
||||||
|
sy = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
|
||||||
|
} else if (side < 0.9f) {
|
||||||
|
sx = std::uniform_real_distribution<float>(0.0f, halfW)(s_coopSparkRng);
|
||||||
|
sy = std::uniform_real_distribution<float>(0.0f, finalBlockSize * 2.0f)(s_coopSparkRng);
|
||||||
|
} else {
|
||||||
|
sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
|
||||||
|
sy = std::uniform_real_distribution<float>(GRID_H, GRID_H + borderBand)(s_coopSparkRng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.x = sx;
|
||||||
|
s.y = sy;
|
||||||
|
float speed = std::uniform_real_distribution<float>(10.0f, 60.0f)(s_coopSparkRng);
|
||||||
|
float ang = std::uniform_real_distribution<float>(-3.14159f, 3.14159f)(s_coopSparkRng);
|
||||||
|
s.vx = std::cos(ang) * speed;
|
||||||
|
s.vy = std::sin(ang) * speed * 0.25f;
|
||||||
|
s.maxLifeMs = std::uniform_real_distribution<float>(350.0f, 900.0f)(s_coopSparkRng);
|
||||||
|
s.lifeMs = s.maxLifeMs;
|
||||||
|
s.size = std::uniform_real_distribution<float>(1.5f, 5.0f)(s_coopSparkRng);
|
||||||
|
if (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) < 0.5f) {
|
||||||
|
s.color = SDL_Color{255, 230, 180, 255};
|
||||||
|
} else {
|
||||||
|
s.color = SDL_Color{180, 220, 255, 255};
|
||||||
|
}
|
||||||
|
s.pulse = std::uniform_real_distribution<float>(0.0f, 6.28f)(s_coopSparkRng);
|
||||||
|
sparkles.push_back(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sparkles.empty()) {
|
||||||
|
auto it = sparkles.begin();
|
||||||
|
while (it != sparkles.end()) {
|
||||||
|
Sparkle& sp = *it;
|
||||||
|
sp.lifeMs -= sparkDeltaMs;
|
||||||
|
if (sp.lifeMs <= 0.0f) {
|
||||||
|
const int burstCount = std::uniform_int_distribution<int>(4, 8)(s_coopSparkRng);
|
||||||
|
for (int bi = 0; bi < burstCount; ++bi) {
|
||||||
|
ImpactSpark ps;
|
||||||
|
ps.x = originX + sp.x + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
|
||||||
|
ps.y = gridY + sp.y + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
|
||||||
|
float ang = std::uniform_real_distribution<float>(0.0f, 6.2831853f)(s_coopSparkRng);
|
||||||
|
float speed = std::uniform_real_distribution<float>(10.0f, 120.0f)(s_coopSparkRng);
|
||||||
|
ps.vx = std::cos(ang) * speed;
|
||||||
|
ps.vy = std::sin(ang) * speed * 0.8f;
|
||||||
|
ps.maxLifeMs = std::uniform_real_distribution<float>(220.0f, 500.0f)(s_coopSparkRng);
|
||||||
|
ps.lifeMs = ps.maxLifeMs;
|
||||||
|
ps.size = std::max(1.0f, sp.size * 0.5f);
|
||||||
|
ps.color = sp.color;
|
||||||
|
impactSparks.push_back(ps);
|
||||||
|
}
|
||||||
|
it = sparkles.erase(it);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float lifeRatio = sp.lifeMs / sp.maxLifeMs;
|
||||||
|
sp.x += sp.vx * deltaSeconds;
|
||||||
|
sp.y += sp.vy * deltaSeconds;
|
||||||
|
sp.vy *= 0.995f;
|
||||||
|
sp.pulse += deltaSeconds * 8.0f;
|
||||||
|
|
||||||
|
float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
|
||||||
|
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
|
||||||
|
SDL_SetRenderDrawColor(renderer, sp.color.r, sp.color.g, sp.color.b, alpha);
|
||||||
|
float half = sp.size * 0.5f;
|
||||||
|
SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size };
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!impactSparks.empty()) {
|
||||||
|
auto it = impactSparks.begin();
|
||||||
|
while (it != impactSparks.end()) {
|
||||||
|
ImpactSpark& spark = *it;
|
||||||
|
spark.vy += 0.00045f * sparkDeltaMs;
|
||||||
|
spark.x += spark.vx * sparkDeltaMs;
|
||||||
|
spark.y += spark.vy * sparkDeltaMs;
|
||||||
|
spark.lifeMs -= sparkDeltaMs;
|
||||||
|
if (spark.lifeMs <= 0.0f) {
|
||||||
|
it = impactSparks.erase(it);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
float lifeRatio = spark.lifeMs / spark.maxLifeMs;
|
||||||
|
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
|
||||||
|
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha);
|
||||||
|
SDL_FRect sparkRect{
|
||||||
|
spark.x - spark.size * 0.5f,
|
||||||
|
spark.y - spark.size * 0.5f,
|
||||||
|
spark.size,
|
||||||
|
spark.size * 1.4f
|
||||||
|
};
|
||||||
|
SDL_RenderFillRect(renderer, &sparkRect);
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateAndDrawSparkleLayer(s_leftSparkles, s_leftImpactSparks, s_leftSparkleSpawnAcc, leftMagnet, leftGridX);
|
||||||
|
updateAndDrawSparkleLayer(s_rightSparkles, s_rightImpactSparks, s_rightSparkleSpawnAcc, rightMagnet, rightGridX);
|
||||||
|
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half-row feedback: lightly tint rows where one side is filled, brighter where both are pending clear
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
const auto& rowStates = game->rowHalfStates();
|
||||||
|
|
||||||
|
bool leftReady = false;
|
||||||
|
bool rightReady = false;
|
||||||
|
bool synced = false;
|
||||||
|
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
const auto& rs = rowStates[y];
|
||||||
|
float rowY = gridY + y * finalBlockSize;
|
||||||
|
|
||||||
|
if (rs.leftFull && rs.rightFull) {
|
||||||
|
synced = true;
|
||||||
|
} else {
|
||||||
|
leftReady = leftReady || (rs.leftFull && !rs.rightFull);
|
||||||
|
rightReady = rightReady || (rs.rightFull && !rs.leftFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rs.leftFull && rs.rightFull) {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45);
|
||||||
|
SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize};
|
||||||
|
SDL_RenderFillRect(renderer, &frL);
|
||||||
|
SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize};
|
||||||
|
SDL_RenderFillRect(renderer, &frR);
|
||||||
|
} else if (rs.leftFull ^ rs.rightFull) {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35);
|
||||||
|
float w = HALF_W;
|
||||||
|
float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX);
|
||||||
|
SDL_FRect fr{x, rowY, w, finalBlockSize};
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
|
||||||
|
// Trigger a brief flash exactly when cooperative lines are actually cleared:
|
||||||
|
// `completedLines` remains populated during the LineEffect, then becomes empty
|
||||||
|
// immediately after `CoopGame::clearCompletedLines()` is invoked.
|
||||||
|
const bool hasCompletedLines = game->hasCompletedLines();
|
||||||
|
if (s_lastHadCompletedLines && !hasCompletedLines) {
|
||||||
|
s_syncLine.TriggerClearFlash();
|
||||||
|
}
|
||||||
|
s_lastHadCompletedLines = hasCompletedLines;
|
||||||
|
|
||||||
|
if (synced) {
|
||||||
|
s_syncLine.SetState(SyncState::Synced);
|
||||||
|
} else if (leftReady) {
|
||||||
|
s_syncLine.SetState(SyncState::LeftReady);
|
||||||
|
} else if (rightReady) {
|
||||||
|
s_syncLine.SetState(SyncState::RightReady);
|
||||||
|
} else {
|
||||||
|
s_syncLine.SetState(SyncState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-drop impact shake (match classic feel)
|
||||||
|
float impactStrength = 0.0f;
|
||||||
|
float impactEased = 0.0f;
|
||||||
|
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> impactMask{};
|
||||||
|
std::array<float, CoopGame::COLS * CoopGame::ROWS> impactWeight{};
|
||||||
|
if (game->hasHardDropShake()) {
|
||||||
|
impactStrength = static_cast<float>(game->hardDropShakeStrength());
|
||||||
|
impactStrength = std::clamp(impactStrength, 0.0f, 1.0f);
|
||||||
|
impactEased = impactStrength * impactStrength;
|
||||||
|
const auto& impactCells = game->getHardDropCells();
|
||||||
|
const auto& boardRef = game->boardRef();
|
||||||
|
for (const auto& cell : impactCells) {
|
||||||
|
if (cell.x < 0 || cell.x >= CoopGame::COLS || cell.y < 0 || cell.y >= CoopGame::ROWS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int idx = cell.y * CoopGame::COLS + cell.x;
|
||||||
|
impactMask[idx] = 1;
|
||||||
|
impactWeight[idx] = 1.0f;
|
||||||
|
|
||||||
|
int depth = 0;
|
||||||
|
for (int ny = cell.y + 1; ny < CoopGame::ROWS && depth < 4; ++ny) {
|
||||||
|
if (!boardRef[ny * CoopGame::COLS + cell.x].occupied) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++depth;
|
||||||
|
int nidx = ny * CoopGame::COLS + cell.x;
|
||||||
|
impactMask[nidx] = 1;
|
||||||
|
float weight = std::max(0.15f, 1.0f - depth * 0.35f);
|
||||||
|
impactWeight[nidx] = std::max(impactWeight[nidx], weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw settled blocks
|
||||||
|
const auto& board = game->boardRef();
|
||||||
|
for (int y = 0; y < CoopGame::ROWS; ++y) {
|
||||||
|
float dropOffset = rowDropOffsets[y];
|
||||||
|
for (int x = 0; x < CoopGame::COLS; ++x) {
|
||||||
|
const auto& cell = board[y * CoopGame::COLS + x];
|
||||||
|
if (!cell.occupied || cell.value <= 0) continue;
|
||||||
|
float px = cellX(x);
|
||||||
|
float py = gridY + y * finalBlockSize + dropOffset;
|
||||||
|
|
||||||
|
const int cellIdx = y * CoopGame::COLS + x;
|
||||||
|
float weight = impactWeight[cellIdx];
|
||||||
|
if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) {
|
||||||
|
float cellSeed = static_cast<float>((x * 37 + y * 61) % 113);
|
||||||
|
float t = static_cast<float>(nowTicks % 10000) * 0.018f + cellSeed;
|
||||||
|
float amplitude = 6.0f * impactEased * weight;
|
||||||
|
float freq = 2.0f + weight * 1.3f;
|
||||||
|
px += amplitude * std::sin(t * freq);
|
||||||
|
py += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active pieces (per-side smoothing)
|
||||||
|
auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) {
|
||||||
|
float offsetX = 0.0f;
|
||||||
|
float offsetY = 0.0f;
|
||||||
|
|
||||||
|
if (smoothScrollEnabled) {
|
||||||
|
const uint64_t seq = game->currentPieceSequence(side);
|
||||||
|
const float targetX = static_cast<float>(game->current(side).x);
|
||||||
|
if (!ss.initialized || ss.seq != seq) {
|
||||||
|
ss.initialized = true;
|
||||||
|
ss.seq = seq;
|
||||||
|
ss.visualX = targetX;
|
||||||
|
// Trigger a short spawn fade so the newly spawned piece visually
|
||||||
|
// fades into the first visible row (like classic mode).
|
||||||
|
SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade;
|
||||||
|
sf.active = true;
|
||||||
|
sf.startTick = nowTicks;
|
||||||
|
sf.durationMs = 200.0f;
|
||||||
|
sf.seq = seq;
|
||||||
|
sf.piece = game->current(side);
|
||||||
|
sf.spawnY = sf.piece.y;
|
||||||
|
sf.tileSize = finalBlockSize;
|
||||||
|
// Note: during the spawn fade we draw the live piece each frame.
|
||||||
|
// If the piece is still above the visible grid, we temporarily pin
|
||||||
|
// it so the topmost filled cell appears at row 0 (no spawn delay),
|
||||||
|
// while still applying smoothing offsets so it starts moving
|
||||||
|
// immediately.
|
||||||
|
sf.targetX = 0.0f;
|
||||||
|
sf.targetY = 0.0f;
|
||||||
|
} else {
|
||||||
|
// Reuse exact horizontal smoothing from single-player
|
||||||
|
constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
|
||||||
|
const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
|
||||||
|
ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor);
|
||||||
|
}
|
||||||
|
offsetX = (ss.visualX - targetX) * finalBlockSize;
|
||||||
|
|
||||||
|
// Reuse exact single-player fall offset computation (per-side getters)
|
||||||
|
double gravityMs = game->getGravityMs();
|
||||||
|
if (gravityMs > 0.0) {
|
||||||
|
double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs;
|
||||||
|
double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs);
|
||||||
|
float progress = static_cast<float>(accumulator / effectiveMs);
|
||||||
|
progress = std::clamp(progress, 0.0f, 1.0f);
|
||||||
|
offsetY = progress * finalBlockSize;
|
||||||
|
|
||||||
|
// Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player)
|
||||||
|
const auto& boardRef = game->boardRef();
|
||||||
|
const CoopGame::Piece& piece = game->current(side);
|
||||||
|
float maxAllowed = finalBlockSize;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||||
|
int gx = piece.x + cx;
|
||||||
|
int gy = piece.y + cy;
|
||||||
|
if (gx < 0 || gx >= CoopGame::COLS) continue;
|
||||||
|
int testY = gy + 1;
|
||||||
|
int emptyRows = 0;
|
||||||
|
if (testY < 0) {
|
||||||
|
emptyRows -= testY;
|
||||||
|
testY = 0;
|
||||||
|
}
|
||||||
|
while (testY >= 0 && testY < CoopGame::ROWS) {
|
||||||
|
if (boardRef[testY * CoopGame::COLS + gx].occupied) break;
|
||||||
|
++emptyRows;
|
||||||
|
++testY;
|
||||||
|
}
|
||||||
|
float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
|
||||||
|
maxAllowed = std::min(maxAllowed, cellLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offsetY = std::min(offsetY, maxAllowed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ss.initialized = true;
|
||||||
|
ss.seq = game->currentPieceSequence(side);
|
||||||
|
ss.visualX = static_cast<float>(game->current(side).x);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings::instance().isDebugEnabled()) {
|
||||||
|
float dbg_targetX = static_cast<float>(game->current(side).x);
|
||||||
|
double gMsDbg = game->getGravityMs();
|
||||||
|
double accDbg = game->getFallAccumulator(side);
|
||||||
|
int softDbg = game->isSoftDropping(side) ? 1 : 0;
|
||||||
|
/*
|
||||||
|
SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
|
||||||
|
(side == CoopGame::PlayerSide::Left) ? "L" : "R",
|
||||||
|
(unsigned long long)ss.seq,
|
||||||
|
ss.visualX,
|
||||||
|
dbg_targetX,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
gMsDbg,
|
||||||
|
accDbg,
|
||||||
|
softDbg
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return std::pair<float, float>{ offsetX, offsetY };
|
||||||
|
};
|
||||||
|
|
||||||
|
auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair<float, float>& offsets) {
|
||||||
|
if (!sf.active) return;
|
||||||
|
|
||||||
|
// If the piece has already changed, stop the fade.
|
||||||
|
const uint64_t currentSeq = game->currentPieceSequence(side);
|
||||||
|
if (sf.seq != currentSeq) {
|
||||||
|
sf.active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoopGame::Piece& livePiece = game->current(side);
|
||||||
|
float elapsed = static_cast<float>(nowTicks - sf.startTick);
|
||||||
|
float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
|
||||||
|
Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
|
||||||
|
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha);
|
||||||
|
|
||||||
|
int minCy = 4;
|
||||||
|
int maxCy = -1;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
|
||||||
|
minCy = std::min(minCy, cy);
|
||||||
|
maxCy = std::max(maxCy, cy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minCy == 4) {
|
||||||
|
minCy = 0;
|
||||||
|
}
|
||||||
|
if (maxCy < 0) {
|
||||||
|
maxCy = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin only when *no* filled cell is visible yet. Using maxCy avoids pinning
|
||||||
|
// cases like vertical I where some blocks are already visible at spawn.
|
||||||
|
const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0;
|
||||||
|
|
||||||
|
const float baseX = cellX(livePiece.x) + offsets.first;
|
||||||
|
float baseY = 0.0f;
|
||||||
|
if (pinToFirstVisibleRow) {
|
||||||
|
// Keep the piece visible (topmost filled cell at row 0), but also
|
||||||
|
// incorporate real y-step progression so the fall accumulator wrapping
|
||||||
|
// doesn't produce a one-row snap.
|
||||||
|
const int dySteps = livePiece.y - sf.spawnY;
|
||||||
|
baseY = (gridY - static_cast<float>(minCy) * sf.tileSize)
|
||||||
|
+ static_cast<float>(dySteps) * sf.tileSize
|
||||||
|
+ offsets.second;
|
||||||
|
} else {
|
||||||
|
baseY = gridY + static_cast<float>(livePiece.y) * sf.tileSize + offsets.second;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the live piece (either pinned-to-row0 or at its real position).
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
|
||||||
|
int pyIdx = livePiece.y + cy;
|
||||||
|
if (!pinToFirstVisibleRow && pyIdx < 0) continue;
|
||||||
|
float px = baseX + static_cast<float>(cx) * sf.tileSize;
|
||||||
|
float py = baseY + static_cast<float>(cy) * sf.tileSize;
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
|
||||||
|
|
||||||
|
// End fade after duration, but never stop while we are pinning (otherwise
|
||||||
|
// I can briefly disappear until it becomes visible in the real grid).
|
||||||
|
if (t >= 1.0f && !pinToFirstVisibleRow) {
|
||||||
|
sf.active = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto drawPiece = [&](const CoopGame::Piece& p, const std::pair<float, float>& offsets, bool isGhost) {
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(p, cx, cy)) continue;
|
||||||
|
int pxIdx = p.x + cx;
|
||||||
|
int pyIdx = p.y + cy;
|
||||||
|
if (pyIdx < 0) continue; // don't draw parts above the visible grid
|
||||||
|
float px = cellX(pxIdx) + offsets.first;
|
||||||
|
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
|
||||||
|
if (isGhost) {
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 20);
|
||||||
|
SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
|
||||||
|
SDL_RenderFillRect(renderer, &rect);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 180, 180, 180, 30);
|
||||||
|
SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
|
||||||
|
SDL_RenderRect(renderer, &border);
|
||||||
|
} else {
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth);
|
||||||
|
const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth);
|
||||||
|
// Draw transient spawn fades (if active)
|
||||||
|
drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left, leftOffsets);
|
||||||
|
drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right, rightOffsets);
|
||||||
|
|
||||||
|
// Draw classic-style ghost pieces (landing position), grid-aligned.
|
||||||
|
// This intentionally does NOT use smoothing offsets.
|
||||||
|
auto computeGhostPiece = [&](CoopGame::PlayerSide side) {
|
||||||
|
CoopGame::Piece ghostPiece = game->current(side);
|
||||||
|
const auto& boardRef = game->boardRef();
|
||||||
|
while (true) {
|
||||||
|
CoopGame::Piece testPiece = ghostPiece;
|
||||||
|
testPiece.y++;
|
||||||
|
bool collision = false;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(testPiece, cx, cy)) continue;
|
||||||
|
int gx = testPiece.x + cx;
|
||||||
|
int gy = testPiece.y + cy;
|
||||||
|
if (gy >= CoopGame::ROWS || gx < 0 || gx >= CoopGame::COLS ||
|
||||||
|
(gy >= 0 && boardRef[gy * CoopGame::COLS + gx].occupied)) {
|
||||||
|
collision = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (collision) break;
|
||||||
|
}
|
||||||
|
if (collision) break;
|
||||||
|
ghostPiece = testPiece;
|
||||||
|
}
|
||||||
|
return ghostPiece;
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::pair<float, float> ghostOffsets{0.0f, 0.0f};
|
||||||
|
drawPiece(computeGhostPiece(CoopGame::PlayerSide::Left), ghostOffsets, true);
|
||||||
|
drawPiece(computeGhostPiece(CoopGame::PlayerSide::Right), ghostOffsets, true);
|
||||||
|
|
||||||
|
// If a spawn fade is active for a side and matches the current piece
|
||||||
|
// sequence, only draw the fade visual and skip the regular piece draw
|
||||||
|
// to avoid a double-draw that appears as a jump when falling starts.
|
||||||
|
if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) {
|
||||||
|
drawPiece(game->current(CoopGame::PlayerSide::Left), leftOffsets, false);
|
||||||
|
}
|
||||||
|
if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) {
|
||||||
|
drawPiece(game->current(CoopGame::PlayerSide::Right), rightOffsets, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line clearing effects above pieces (matches single-player)
|
||||||
|
if (lineEffect && lineEffect->isActive()) {
|
||||||
|
lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), static_cast<int>(COOP_GAP_PX), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the SYNC divider last so it stays visible above effects/blocks.
|
||||||
|
s_syncLine.Render(renderer);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
||||||
|
|
||||||
|
// Next panels (two)
|
||||||
|
const float nextPanelPad = 12.0f;
|
||||||
|
const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f;
|
||||||
|
const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
|
||||||
|
float nextLeftX = gridX + finalBlockSize;
|
||||||
|
float nextRightX = gridX + HALF_W + COOP_GAP_PX + (HALF_W - finalBlockSize - nextPanelW);
|
||||||
|
float nextY = contentStartY + contentOffsetY;
|
||||||
|
|
||||||
|
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
|
||||||
|
SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
|
||||||
|
if (nextPanelTex) {
|
||||||
|
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel);
|
||||||
|
} else {
|
||||||
|
drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
|
||||||
|
}
|
||||||
|
// Center piece inside panel
|
||||||
|
int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||||
|
minCx = std::min(minCx, cx);
|
||||||
|
minCy = std::min(minCy, cy);
|
||||||
|
maxCx = std::max(maxCx, cx);
|
||||||
|
maxCy = std::max(maxCy, cy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxCx >= minCx && maxCy >= minCy) {
|
||||||
|
float tile = finalBlockSize * 0.8f;
|
||||||
|
float pieceW = (maxCx - minCx + 1) * tile;
|
||||||
|
float pieceH = (maxCy - minCy + 1) * tile;
|
||||||
|
float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile;
|
||||||
|
float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile;
|
||||||
|
for (int cy = 0; cy < 4; ++cy) {
|
||||||
|
for (int cx = 0; cx < 4; ++cx) {
|
||||||
|
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
|
||||||
|
float px = startX + cx * tile;
|
||||||
|
float py = startY + cy * tile;
|
||||||
|
drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left));
|
||||||
|
drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right));
|
||||||
|
|
||||||
|
// Per-player scoreboards (left and right)
|
||||||
|
auto drawPlayerScoreboard = [&](CoopGame::PlayerSide side, float columnLeftX, float columnRightX, const char* title) {
|
||||||
|
const SDL_Color labelColor{255, 220, 0, 255};
|
||||||
|
const SDL_Color valueColor{255, 255, 255, 255};
|
||||||
|
const SDL_Color nextColor{80, 255, 120, 255};
|
||||||
|
|
||||||
|
// Match classic vertical placement feel
|
||||||
|
const float contentTopOffset = 0.0f;
|
||||||
|
const float contentBottomOffset = 290.0f;
|
||||||
|
const float contentPad = 36.0f;
|
||||||
|
float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
|
||||||
|
float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;
|
||||||
|
|
||||||
|
const float statsPanelPadLeft = 40.0f;
|
||||||
|
const float statsPanelPadRight = 34.0f;
|
||||||
|
const float statsPanelPadY = 28.0f;
|
||||||
|
|
||||||
|
const float textX = columnLeftX + statsPanelPadLeft;
|
||||||
|
|
||||||
|
char scoreStr[32];
|
||||||
|
std::snprintf(scoreStr, sizeof(scoreStr), "%d", game->score(side));
|
||||||
|
|
||||||
|
char linesStr[16];
|
||||||
|
std::snprintf(linesStr, sizeof(linesStr), "%03d", game->lines(side));
|
||||||
|
|
||||||
|
char levelStr[16];
|
||||||
|
std::snprintf(levelStr, sizeof(levelStr), "%02d", game->level(side));
|
||||||
|
|
||||||
|
// Next level progression (per-player lines)
|
||||||
|
int startLv = game->startLevelBase();
|
||||||
|
int linesDone = game->lines(side);
|
||||||
|
int firstThreshold = (startLv + 1) * 10;
|
||||||
|
int nextThreshold = 0;
|
||||||
|
if (linesDone < firstThreshold) {
|
||||||
|
nextThreshold = firstThreshold;
|
||||||
|
} else {
|
||||||
|
int blocksPast = linesDone - firstThreshold;
|
||||||
|
nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
|
||||||
|
}
|
||||||
|
int linesForNext = std::max(0, nextThreshold - linesDone);
|
||||||
|
char nextStr[32];
|
||||||
|
std::snprintf(nextStr, sizeof(nextStr), "%d LINES", linesForNext);
|
||||||
|
|
||||||
|
// Time display (shared session time)
|
||||||
|
int totalSecs = game->elapsed(side);
|
||||||
|
int mins = totalSecs / 60;
|
||||||
|
int secs = totalSecs % 60;
|
||||||
|
char timeStr[16];
|
||||||
|
std::snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);
|
||||||
|
|
||||||
|
struct StatLine {
|
||||||
|
const char* text;
|
||||||
|
float offsetY;
|
||||||
|
float scale;
|
||||||
|
SDL_Color color;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep offsets aligned with classic spacing
|
||||||
|
std::vector<StatLine> statLines;
|
||||||
|
statLines.reserve(12);
|
||||||
|
statLines.push_back({title, 0.0f, 0.95f, SDL_Color{200, 220, 235, 220}});
|
||||||
|
statLines.push_back({"SCORE", 30.0f, 1.0f, labelColor});
|
||||||
|
statLines.push_back({scoreStr, 55.0f, 0.9f, valueColor});
|
||||||
|
statLines.push_back({"LINES", 100.0f, 1.0f, labelColor});
|
||||||
|
statLines.push_back({linesStr, 125.0f, 0.9f, valueColor});
|
||||||
|
statLines.push_back({"LEVEL", 170.0f, 1.0f, labelColor});
|
||||||
|
statLines.push_back({levelStr, 195.0f, 0.9f, valueColor});
|
||||||
|
statLines.push_back({"NEXT LVL", 230.0f, 1.0f, labelColor});
|
||||||
|
statLines.push_back({nextStr, 255.0f, 0.9f, nextColor});
|
||||||
|
statLines.push_back({"TIME", 295.0f, 1.0f, labelColor});
|
||||||
|
statLines.push_back({timeStr, 320.0f, 0.9f, valueColor});
|
||||||
|
|
||||||
|
// Size the panel like classic: measure the text block and fit the background.
|
||||||
|
float statsContentTop = std::numeric_limits<float>::max();
|
||||||
|
float statsContentBottom = std::numeric_limits<float>::lowest();
|
||||||
|
float statsContentMaxWidth = 0.0f;
|
||||||
|
for (const auto& line : statLines) {
|
||||||
|
int textW = 0;
|
||||||
|
int textH = 0;
|
||||||
|
pixelFont->measure(line.text, line.scale, textW, textH);
|
||||||
|
float y = baseY + line.offsetY;
|
||||||
|
statsContentTop = std::min(statsContentTop, y);
|
||||||
|
statsContentBottom = std::max(statsContentBottom, y + static_cast<float>(textH));
|
||||||
|
statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast<float>(textW));
|
||||||
|
}
|
||||||
|
|
||||||
|
float panelW = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight;
|
||||||
|
float panelH = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f;
|
||||||
|
float panelY = statsContentTop - statsPanelPadY;
|
||||||
|
// Left player is left-aligned in its column; right player is right-aligned.
|
||||||
|
float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX;
|
||||||
|
SDL_FRect panelBg{ panelX, panelY, panelW, panelH };
|
||||||
|
if (scorePanelTex) {
|
||||||
|
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &panelBg);
|
||||||
|
} else {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 12, 18, 32, 205);
|
||||||
|
SDL_RenderFillRect(renderer, &panelBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
float textDrawX = panelX + statsPanelPadLeft;
|
||||||
|
for (const auto& line : statLines) {
|
||||||
|
pixelFont->draw(renderer, textDrawX, baseY + line.offsetY, line.text, line.scale, line.color);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nudge panels toward the window edges for tighter symmetry.
|
||||||
|
const float scorePanelEdgeNudge = 20.0f;
|
||||||
|
const float leftColumnLeftX = statsX - scorePanelEdgeNudge;
|
||||||
|
const float leftColumnRightX = leftColumnLeftX + statsW;
|
||||||
|
const float rightColumnLeftX = rightPanelX;
|
||||||
|
const float rightColumnRightX = rightColumnLeftX + statsW + scorePanelEdgeNudge;
|
||||||
|
|
||||||
|
drawPlayerScoreboard(CoopGame::PlayerSide::Left, leftColumnLeftX, leftColumnRightX, "PLAYER 1");
|
||||||
|
drawPlayerScoreboard(CoopGame::PlayerSide::Right, rightColumnLeftX, rightColumnRightX, "PLAYER 2");
|
||||||
|
|
||||||
|
// Combined score summary centered under the grid
|
||||||
|
{
|
||||||
|
int leftScore = game->score(CoopGame::PlayerSide::Left);
|
||||||
|
int rightScore = game->score(CoopGame::PlayerSide::Right);
|
||||||
|
int sumScore = leftScore + rightScore;
|
||||||
|
char sumLabel[64];
|
||||||
|
char sumValue[64];
|
||||||
|
std::snprintf(sumLabel, sizeof(sumLabel), "SCORE %d + SCORE %d =", leftScore, rightScore);
|
||||||
|
std::snprintf(sumValue, sizeof(sumValue), "%d", sumScore);
|
||||||
|
|
||||||
|
// Draw label smaller and value larger
|
||||||
|
float labelScale = 0.9f;
|
||||||
|
float valueScale = 1.6f;
|
||||||
|
SDL_Color labelColor = {200, 220, 235, 220};
|
||||||
|
SDL_Color valueColor = {255, 230, 130, 255};
|
||||||
|
|
||||||
|
// Position: centered beneath the grid
|
||||||
|
float centerX = gridX + GRID_W * 0.5f;
|
||||||
|
int lw=0, lh=0; pixelFont->measure(sumLabel, labelScale, lw, lh);
|
||||||
|
int vw=0, vh=0; pixelFont->measure(sumValue, valueScale, vw, vh);
|
||||||
|
float labelX = centerX - static_cast<float>(lw) * 0.5f;
|
||||||
|
float valueX = centerX - static_cast<float>(vw) * 0.5f;
|
||||||
|
float belowY = gridY + GRID_H + 14.0f; // small gap below grid
|
||||||
|
|
||||||
|
pixelFont->draw(renderer, labelX, belowY, sumLabel, labelScale, labelColor);
|
||||||
|
pixelFont->draw(renderer, valueX, belowY + 22.0f, sumValue, valueScale, valueColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void GameRenderer::renderExitPopup(
|
void GameRenderer::renderExitPopup(
|
||||||
SDL_Renderer* renderer,
|
SDL_Renderer* renderer,
|
||||||
FontAtlas* pixelFont,
|
FontAtlas* pixelFont,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include "../../gameplay/core/Game.h"
|
#include "../../gameplay/core/Game.h"
|
||||||
|
#include "../../gameplay/coop/CoopGame.h"
|
||||||
|
|
||||||
// Forward declarations
|
// Forward declarations
|
||||||
class FontAtlas;
|
class FontAtlas;
|
||||||
@ -61,6 +62,24 @@ public:
|
|||||||
int selectedButton
|
int selectedButton
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static void renderCoopPlayingState(
|
||||||
|
SDL_Renderer* renderer,
|
||||||
|
CoopGame* game,
|
||||||
|
FontAtlas* pixelFont,
|
||||||
|
LineEffect* lineEffect,
|
||||||
|
SDL_Texture* blocksTex,
|
||||||
|
SDL_Texture* statisticsPanelTex,
|
||||||
|
SDL_Texture* scorePanelTex,
|
||||||
|
SDL_Texture* nextPanelTex,
|
||||||
|
SDL_Texture* holdPanelTex,
|
||||||
|
bool paused,
|
||||||
|
float logicalW,
|
||||||
|
float logicalH,
|
||||||
|
float logicalScale,
|
||||||
|
float winW,
|
||||||
|
float winH
|
||||||
|
);
|
||||||
|
|
||||||
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
// Public wrapper that forwards to the private tile-drawing helper. Use this if
|
||||||
// calling from non-member helper functions (e.g. visual effects) that cannot
|
// calling from non-member helper functions (e.g. visual effects) that cannot
|
||||||
// access private class members.
|
// access private class members.
|
||||||
|
|||||||
@ -84,7 +84,7 @@ void RenderManager::beginFrame() {
|
|||||||
|
|
||||||
// Trace beginFrame entry
|
// Trace beginFrame entry
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame entry\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the screen (wrapped with trace)
|
// Clear the screen (wrapped with trace)
|
||||||
@ -92,7 +92,7 @@ void RenderManager::beginFrame() {
|
|||||||
|
|
||||||
// Trace after clear
|
// Trace after clear
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::beginFrame after clear\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,14 +102,14 @@ void RenderManager::endFrame() {
|
|||||||
}
|
}
|
||||||
// Trace before present
|
// Trace before present
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame before present\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_RenderPresent(m_renderer);
|
SDL_RenderPresent(m_renderer);
|
||||||
|
|
||||||
// Trace after present
|
// Trace after present
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::endFrame after present\n"); fclose(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,11 +200,11 @@ void RenderManager::renderTexture(SDL_Texture* texture, const SDL_FRect* src, co
|
|||||||
|
|
||||||
// Trace renderTexture usage
|
// Trace renderTexture usage
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture entry tex=%llu src=%p dst=%p\n", (unsigned long long)(uintptr_t)texture, (void*)src, (void*)dst); fclose(f); }
|
||||||
}
|
}
|
||||||
SDL_RenderTexture(m_renderer, texture, src, dst);
|
SDL_RenderTexture(m_renderer, texture, src, dst);
|
||||||
{
|
{
|
||||||
FILE* f = fopen("tetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
FILE* f = fopen("spacetris_trace.log", "a"); if (f) { fprintf(f, "RenderManager::renderTexture after SDL_RenderTexture tex=%llu\n", (unsigned long long)(uintptr_t)texture); fclose(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
358
src/graphics/renderers/SyncLineRenderer.cpp
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
#include "SyncLineRenderer.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
SyncLineRenderer::SyncLineRenderer()
|
||||||
|
: m_state(SyncState::Idle),
|
||||||
|
m_flashTimer(0.0f),
|
||||||
|
m_time(0.0f) {
|
||||||
|
m_particles.reserve(MAX_PARTICLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
static float syncWobbleX(float t) {
|
||||||
|
// Small, smooth horizontal motion to make the conduit feel fluid.
|
||||||
|
// Kept subtle so it doesn't distract from gameplay.
|
||||||
|
return std::sinf(t * 2.1f) * 1.25f + std::sinf(t * 5.2f + 1.3f) * 0.55f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::SpawnParticle() {
|
||||||
|
if (m_particles.size() >= MAX_PARTICLES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncParticle p;
|
||||||
|
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||||
|
// Spawn around the beam center so it reads like a conduit.
|
||||||
|
const float jitter = -8.0f + static_cast<float>(std::rand() % 17);
|
||||||
|
|
||||||
|
p.x = centerX + jitter;
|
||||||
|
p.y = m_rect.y + m_rect.h + static_cast<float>(std::rand() % 10);
|
||||||
|
|
||||||
|
// Two styles: tiny sparkle dots + short streaks.
|
||||||
|
const bool dot = (std::rand() % 100) < 35;
|
||||||
|
if (dot) {
|
||||||
|
p.vx = (-18.0f + static_cast<float>(std::rand() % 37));
|
||||||
|
p.vy = 180.0f + static_cast<float>(std::rand() % 180);
|
||||||
|
p.w = 1.0f + static_cast<float>(std::rand() % 2);
|
||||||
|
p.h = 1.0f + static_cast<float>(std::rand() % 2);
|
||||||
|
p.alpha = 240.0f;
|
||||||
|
} else {
|
||||||
|
p.vx = (-14.0f + static_cast<float>(std::rand() % 29));
|
||||||
|
p.vy = 160.0f + static_cast<float>(std::rand() % 200);
|
||||||
|
p.w = 1.0f + static_cast<float>(std::rand() % 3);
|
||||||
|
p.h = 3.0f + static_cast<float>(std::rand() % 10);
|
||||||
|
p.alpha = 220.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slight color variance (cyan/green/white) to keep it energetic.
|
||||||
|
const int roll = std::rand() % 100;
|
||||||
|
if (roll < 55) {
|
||||||
|
p.color = SDL_Color{110, 255, 210, 255};
|
||||||
|
} else if (roll < 90) {
|
||||||
|
p.color = SDL_Color{120, 210, 255, 255};
|
||||||
|
} else {
|
||||||
|
p.color = SDL_Color{255, 255, 255, 255};
|
||||||
|
}
|
||||||
|
|
||||||
|
m_particles.push_back(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::SpawnBurst(int count) {
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
SpawnParticle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::SetRect(const SDL_FRect& rect) {
|
||||||
|
m_rect = rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::SetState(SyncState state) {
|
||||||
|
if (state != SyncState::ClearFlash) {
|
||||||
|
m_state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::TriggerClearFlash() {
|
||||||
|
m_state = SyncState::ClearFlash;
|
||||||
|
m_flashTimer = FLASH_DURATION;
|
||||||
|
|
||||||
|
// Reward burst: strong visual feedback on cooperative clear.
|
||||||
|
SpawnBurst(56);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::Update(float deltaTime) {
|
||||||
|
m_time += deltaTime;
|
||||||
|
m_pulseTime += deltaTime;
|
||||||
|
|
||||||
|
// State-driven particle spawning
|
||||||
|
float spawnRatePerSec = 0.0f;
|
||||||
|
int particlesPerSpawn = 1;
|
||||||
|
switch (m_state) {
|
||||||
|
case SyncState::LeftReady:
|
||||||
|
case SyncState::RightReady:
|
||||||
|
spawnRatePerSec = 24.0f; // steady
|
||||||
|
break;
|
||||||
|
case SyncState::Synced:
|
||||||
|
spawnRatePerSec = 78.0f; // very heavy stream
|
||||||
|
particlesPerSpawn = 2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
spawnRatePerSec = 18.0f; // always-on sparkle stream
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnRatePerSec <= 0.0f) {
|
||||||
|
m_spawnAcc = 0.0f;
|
||||||
|
} else {
|
||||||
|
m_spawnAcc += deltaTime * spawnRatePerSec;
|
||||||
|
while (m_spawnAcc >= 1.0f) {
|
||||||
|
m_spawnAcc -= 1.0f;
|
||||||
|
for (int i = 0; i < particlesPerSpawn; ++i) {
|
||||||
|
SpawnParticle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update particles
|
||||||
|
for (auto& p : m_particles) {
|
||||||
|
p.x += p.vx * deltaTime;
|
||||||
|
p.y -= p.vy * deltaTime;
|
||||||
|
// Slow drift & fade.
|
||||||
|
p.vx *= (1.0f - 0.35f * deltaTime);
|
||||||
|
p.alpha -= 115.0f * deltaTime;
|
||||||
|
}
|
||||||
|
std::erase_if(m_particles, [&](const SyncParticle& p) {
|
||||||
|
// Cull when out of view or too far from the beam.
|
||||||
|
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + syncWobbleX(m_time);
|
||||||
|
const float maxDx = 18.0f;
|
||||||
|
return (p.y < (m_rect.y - 16.0f)) || p.alpha <= 0.0f || std::fabs(p.x - centerX) > maxDx;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (m_state == SyncState::ClearFlash) {
|
||||||
|
m_flashTimer -= deltaTime;
|
||||||
|
if (m_flashTimer <= 0.0f) {
|
||||||
|
m_state = SyncState::Idle;
|
||||||
|
m_flashTimer = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Color SyncLineRenderer::GetBaseColor() const {
|
||||||
|
switch (m_state) {
|
||||||
|
case SyncState::LeftReady:
|
||||||
|
case SyncState::RightReady:
|
||||||
|
return SDL_Color{255, 220, 100, 235};
|
||||||
|
|
||||||
|
case SyncState::Synced:
|
||||||
|
return SDL_Color{100, 255, 120, 240};
|
||||||
|
|
||||||
|
case SyncState::ClearFlash:
|
||||||
|
return SDL_Color{255, 255, 255, 255};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return SDL_Color{80, 180, 255, 235};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncLineRenderer::Render(SDL_Renderer* renderer) {
|
||||||
|
if (!renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We render the conduit with lots of translucent layers. Using additive blending
|
||||||
|
// for glow/pulse makes it read like a blurred beam without shaders.
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
|
||||||
|
const float wobbleX = syncWobbleX(m_time);
|
||||||
|
const float centerX = (m_rect.x + (m_rect.w * 0.5f)) + wobbleX;
|
||||||
|
const float h = m_rect.h;
|
||||||
|
const float hotspotH = std::clamp(h * 0.12f, 18.0f, 44.0f);
|
||||||
|
|
||||||
|
// Flash factor (0..1)
|
||||||
|
const float flashT = (m_state == SyncState::ClearFlash && FLASH_DURATION > 0.0f)
|
||||||
|
? std::clamp(m_flashTimer / FLASH_DURATION, 0.0f, 1.0f)
|
||||||
|
: 0.0f;
|
||||||
|
|
||||||
|
SDL_Color color = GetBaseColor();
|
||||||
|
|
||||||
|
// Synced pulse drives aura + core intensity.
|
||||||
|
float pulse01 = 0.0f;
|
||||||
|
if (m_state == SyncState::Synced) {
|
||||||
|
pulse01 = 0.5f + 0.5f * std::sinf(m_time * 6.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Outer aura layers (bloom-like using rectangles)
|
||||||
|
auto drawGlow = [&](float extraW, Uint8 a, SDL_Color c) {
|
||||||
|
SDL_FRect fr{
|
||||||
|
centerX - (m_rect.w + extraW) * 0.5f,
|
||||||
|
m_rect.y,
|
||||||
|
m_rect.w + extraW,
|
||||||
|
m_rect.h
|
||||||
|
};
|
||||||
|
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, a);
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
};
|
||||||
|
|
||||||
|
SDL_Color aura = color;
|
||||||
|
// Slightly bias aura towards cyan so it reads “energy conduit”.
|
||||||
|
aura.r = static_cast<Uint8>(std::min(255, static_cast<int>(aura.r) + 10));
|
||||||
|
aura.g = static_cast<Uint8>(std::min(255, static_cast<int>(aura.g) + 10));
|
||||||
|
aura.b = static_cast<Uint8>(std::min(255, static_cast<int>(aura.b) + 35));
|
||||||
|
|
||||||
|
const float auraBoost = (m_state == SyncState::Synced) ? (0.70f + 0.80f * pulse01) : 0.70f;
|
||||||
|
const float flashBoost = 1.0f + flashT * 1.45f;
|
||||||
|
|
||||||
|
SDL_BlendMode oldBlend = SDL_BLENDMODE_BLEND;
|
||||||
|
SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||||
|
|
||||||
|
SDL_Color auraOuter = aura;
|
||||||
|
auraOuter.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.r) + 10));
|
||||||
|
auraOuter.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.g) + 5));
|
||||||
|
auraOuter.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraOuter.b) + 55));
|
||||||
|
|
||||||
|
SDL_Color auraInner = aura;
|
||||||
|
auraInner.r = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.r) + 40));
|
||||||
|
auraInner.g = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.g) + 40));
|
||||||
|
auraInner.b = static_cast<Uint8>(std::min(255, static_cast<int>(auraInner.b) + 70));
|
||||||
|
|
||||||
|
// Wider + softer outer halo, then tighter inner glow.
|
||||||
|
drawGlow(62.0f, static_cast<Uint8>(std::clamp(12.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||||
|
drawGlow(44.0f, static_cast<Uint8>(std::clamp(20.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||||
|
drawGlow(30.0f, static_cast<Uint8>(std::clamp(34.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraOuter);
|
||||||
|
drawGlow(18.0f, static_cast<Uint8>(std::clamp(54.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||||
|
drawGlow(10.0f, static_cast<Uint8>(std::clamp(78.0f * auraBoost * flashBoost, 0.0f, 255.0f)), auraInner);
|
||||||
|
|
||||||
|
// 2) Hotspots near top/bottom (adds that “powered endpoints” vibe)
|
||||||
|
SDL_Color hot = auraInner;
|
||||||
|
hot.r = static_cast<Uint8>(std::min(255, static_cast<int>(hot.r) + 35));
|
||||||
|
hot.g = static_cast<Uint8>(std::min(255, static_cast<int>(hot.g) + 35));
|
||||||
|
hot.b = static_cast<Uint8>(std::min(255, static_cast<int>(hot.b) + 35));
|
||||||
|
{
|
||||||
|
const float hotW1 = 34.0f;
|
||||||
|
const float hotW2 = 18.0f;
|
||||||
|
SDL_FRect topHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y, m_rect.w + hotW1, hotspotH };
|
||||||
|
SDL_FRect botHot1{ centerX - (m_rect.w + hotW1) * 0.5f, m_rect.y + m_rect.h - hotspotH, m_rect.w + hotW1, hotspotH };
|
||||||
|
SDL_FRect topHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + hotspotH * 0.12f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||||
|
SDL_FRect botHot2{ centerX - (m_rect.w + hotW2) * 0.5f, m_rect.y + m_rect.h - hotspotH * 0.90f, m_rect.w + hotW2, hotspotH * 0.78f };
|
||||||
|
|
||||||
|
Uint8 ha1 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 85.0f : 55.0f) * flashBoost, 0.0f, 255.0f));
|
||||||
|
Uint8 ha2 = static_cast<Uint8>(std::clamp((m_state == SyncState::Synced ? 130.0f : 90.0f) * flashBoost, 0.0f, 255.0f));
|
||||||
|
SDL_SetRenderDrawColor(renderer, hot.r, hot.g, hot.b, ha1);
|
||||||
|
SDL_RenderFillRect(renderer, &topHot1);
|
||||||
|
SDL_RenderFillRect(renderer, &botHot1);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ha2);
|
||||||
|
SDL_RenderFillRect(renderer, &topHot2);
|
||||||
|
SDL_RenderFillRect(renderer, &botHot2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Synced pulse wave (a travelling “breath” around the beam)
|
||||||
|
if (m_state == SyncState::Synced) {
|
||||||
|
float wave = std::fmod(m_pulseTime * 2.4f, 1.0f);
|
||||||
|
float width = 10.0f + wave * 26.0f;
|
||||||
|
Uint8 alpha = static_cast<Uint8>(std::clamp(150.0f * (1.0f - wave) * flashBoost, 0.0f, 255.0f));
|
||||||
|
|
||||||
|
SDL_FRect waveRect{
|
||||||
|
centerX - (m_rect.w + width) * 0.5f,
|
||||||
|
m_rect.y,
|
||||||
|
m_rect.w + width,
|
||||||
|
m_rect.h
|
||||||
|
};
|
||||||
|
|
||||||
|
SDL_SetRenderDrawColor(renderer, 140, 255, 220, alpha);
|
||||||
|
SDL_RenderFillRect(renderer, &waveRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Shimmer bands (stylish motion inside the conduit)
|
||||||
|
{
|
||||||
|
const int bands = 7;
|
||||||
|
const float speed = (m_state == SyncState::Synced) ? 160.0f : 95.0f;
|
||||||
|
const float bandW = m_rect.w + 12.0f;
|
||||||
|
for (int i = 0; i < bands; ++i) {
|
||||||
|
const float phase = (static_cast<float>(i) / static_cast<float>(bands));
|
||||||
|
const float y = m_rect.y + std::fmod(m_time * speed + phase * h, h);
|
||||||
|
const float fade = 0.35f + 0.65f * std::sinf((m_time * 2.1f) + phase * 6.28318f);
|
||||||
|
const float bandH = 2.0f + (phase * 2.0f);
|
||||||
|
Uint8 a = static_cast<Uint8>(std::clamp((26.0f + 36.0f * pulse01) * std::fabs(fade) * flashBoost, 0.0f, 255.0f));
|
||||||
|
SDL_FRect fr{ centerX - bandW * 0.5f, y, bandW, bandH };
|
||||||
|
SDL_SetRenderDrawColor(renderer, 200, 255, 255, a);
|
||||||
|
SDL_RenderFillRect(renderer, &fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Core beam (thin bright core + thicker body with horizontal gradient)
|
||||||
|
Uint8 bodyA = color.a;
|
||||||
|
if (m_state == SyncState::Synced) {
|
||||||
|
bodyA = static_cast<Uint8>(std::clamp(175.0f + pulse01 * 75.0f, 0.0f, 255.0f));
|
||||||
|
}
|
||||||
|
// Keep the center more translucent; let glow carry intensity.
|
||||||
|
bodyA = static_cast<Uint8>(std::clamp(bodyA * (0.72f + flashT * 0.35f), 0.0f, 255.0f));
|
||||||
|
|
||||||
|
// Render a smooth-looking body by stacking a few vertical strips.
|
||||||
|
// This approximates a gradient (bright center -> soft edges) without shaders.
|
||||||
|
{
|
||||||
|
// Allow thinner beam while keeping gradient readable.
|
||||||
|
const float bodyW = std::max(4.0f, m_rect.w);
|
||||||
|
const float x0 = centerX - bodyW * 0.5f;
|
||||||
|
|
||||||
|
SDL_FRect left{ x0, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||||
|
SDL_FRect mid{ x0 + bodyW * 0.34f, m_rect.y, bodyW * 0.32f, m_rect.h };
|
||||||
|
SDL_FRect right{ x0 + bodyW * 0.66f, m_rect.y, bodyW * 0.34f, m_rect.h };
|
||||||
|
|
||||||
|
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, static_cast<Uint8>(std::clamp(bodyA * 0.60f, 0.0f, 255.0f)));
|
||||||
|
SDL_RenderFillRect(renderer, &left);
|
||||||
|
SDL_RenderFillRect(renderer, &right);
|
||||||
|
|
||||||
|
SDL_SetRenderDrawColor(renderer,
|
||||||
|
static_cast<Uint8>(std::min(255, static_cast<int>(color.r) + 35)),
|
||||||
|
static_cast<Uint8>(std::min(255, static_cast<int>(color.g) + 35)),
|
||||||
|
static_cast<Uint8>(std::min(255, static_cast<int>(color.b) + 55)),
|
||||||
|
static_cast<Uint8>(std::clamp(bodyA * 0.88f, 0.0f, 255.0f)));
|
||||||
|
SDL_RenderFillRect(renderer, &mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_FRect coreRect{ centerX - 1.1f, m_rect.y, 2.2f, m_rect.h };
|
||||||
|
Uint8 coreA = static_cast<Uint8>(std::clamp(210.0f + pulse01 * 70.0f + flashT * 95.0f, 0.0f, 255.0f));
|
||||||
|
SDL_SetRenderDrawColor(renderer, 255, 255, 255, coreA);
|
||||||
|
SDL_RenderFillRect(renderer, &coreRect);
|
||||||
|
|
||||||
|
// Switch back to normal alpha blend for particles so they stay readable.
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
|
||||||
|
// 6) Energy particles (sparks/streaks traveling upward)
|
||||||
|
for (const auto& p : m_particles) {
|
||||||
|
Uint8 a = static_cast<Uint8>(std::clamp(p.alpha, 0.0f, 255.0f));
|
||||||
|
|
||||||
|
// Add a tiny sinusoidal sway so the stream feels alive.
|
||||||
|
const float sway = std::sinf((p.y * 0.045f) + (m_time * 6.2f)) * 0.9f;
|
||||||
|
SDL_FRect spark{ (p.x + sway) - (p.w * 0.5f), p.y, p.w, p.h };
|
||||||
|
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, a);
|
||||||
|
SDL_RenderFillRect(renderer, &spark);
|
||||||
|
|
||||||
|
// A little aura around each spark helps it read at speed.
|
||||||
|
if (a > 40) {
|
||||||
|
SDL_FRect sparkGlow{ spark.x - 1.0f, spark.y - 1.0f, spark.w + 2.0f, spark.h + 2.0f };
|
||||||
|
SDL_SetRenderDrawColor(renderer, p.color.r, p.color.g, p.color.b, static_cast<Uint8>(a * 0.35f));
|
||||||
|
SDL_RenderFillRect(renderer, &sparkGlow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Flash/glow overlay (adds “clear burst” punch)
|
||||||
|
if (m_state == SyncState::ClearFlash) {
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
|
||||||
|
|
||||||
|
const float extra = 74.0f;
|
||||||
|
SDL_FRect glow{ centerX - (m_rect.w + extra) * 0.5f, m_rect.y, m_rect.w + extra, m_rect.h };
|
||||||
|
Uint8 ga = static_cast<Uint8>(std::clamp(90.0f + 140.0f * flashT, 0.0f, 255.0f));
|
||||||
|
SDL_SetRenderDrawColor(renderer, 255, 255, 255, ga);
|
||||||
|
SDL_RenderFillRect(renderer, &glow);
|
||||||
|
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore whatever blend mode the caller had.
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, oldBlend);
|
||||||
|
}
|
||||||
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
54
src/graphics/renderers/SyncLineRenderer.h
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
enum class SyncState {
|
||||||
|
Idle,
|
||||||
|
LeftReady,
|
||||||
|
RightReady,
|
||||||
|
Synced,
|
||||||
|
ClearFlash
|
||||||
|
};
|
||||||
|
|
||||||
|
class SyncLineRenderer {
|
||||||
|
public:
|
||||||
|
SyncLineRenderer();
|
||||||
|
|
||||||
|
void SetRect(const SDL_FRect& rect);
|
||||||
|
void SetState(SyncState state);
|
||||||
|
void TriggerClearFlash();
|
||||||
|
|
||||||
|
void Update(float deltaTime);
|
||||||
|
void Render(SDL_Renderer* renderer);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct SyncParticle {
|
||||||
|
float x;
|
||||||
|
float y;
|
||||||
|
float vx;
|
||||||
|
float vy;
|
||||||
|
float w;
|
||||||
|
float h;
|
||||||
|
float alpha;
|
||||||
|
SDL_Color color;
|
||||||
|
};
|
||||||
|
|
||||||
|
SDL_FRect m_rect{};
|
||||||
|
SyncState m_state;
|
||||||
|
|
||||||
|
float m_flashTimer;
|
||||||
|
float m_time;
|
||||||
|
|
||||||
|
float m_pulseTime{0.0f};
|
||||||
|
float m_spawnAcc{0.0f};
|
||||||
|
std::vector<SyncParticle> m_particles;
|
||||||
|
|
||||||
|
static constexpr float FLASH_DURATION = 0.15f;
|
||||||
|
static constexpr size_t MAX_PARTICLES = 240;
|
||||||
|
|
||||||
|
void SpawnParticle();
|
||||||
|
void SpawnBurst(int count);
|
||||||
|
|
||||||
|
SDL_Color GetBaseColor() const;
|
||||||
|
};
|
||||||
@ -232,6 +232,6 @@ void UIRenderer::drawSettingsPopup(SDL_Renderer* renderer, FontAtlas* font, floa
|
|||||||
|
|
||||||
// Instructions
|
// Instructions
|
||||||
font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
|
font->draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, {200, 200, 220, 255});
|
||||||
font->draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
font->draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, {200, 200, 220, 255});
|
||||||
font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
|
font->draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, {200, 200, 220, 255});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ void Render(SDL_Renderer* renderer, FontAtlas& font, float logicalWidth, float l
|
|||||||
{"ESC", "Back / cancel current popup"},
|
{"ESC", "Back / cancel current popup"},
|
||||||
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
{"F11 or ALT+ENTER", "Toggle fullscreen"},
|
||||||
{"M", "Mute or unmute music"},
|
{"M", "Mute or unmute music"},
|
||||||
{"S", "Toggle sound effects"}
|
{"K", "Toggle sound effects"}
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const std::array<ShortcutEntry, 2> menuShortcuts{{
|
const std::array<ShortcutEntry, 2> menuShortcuts{{
|
||||||
|
|||||||
21
src/network/CoopNetButtons.h
Normal file
21
src/network/CoopNetButtons.h
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace coopnet {
|
||||||
|
// 8-bit input mask carried in NetSession::InputFrame.
|
||||||
|
// Keep in sync across capture/apply on both peers.
|
||||||
|
enum Buttons : uint8_t {
|
||||||
|
MoveLeft = 1u << 0,
|
||||||
|
MoveRight = 1u << 1,
|
||||||
|
SoftDrop = 1u << 2,
|
||||||
|
RotCW = 1u << 3,
|
||||||
|
RotCCW = 1u << 4,
|
||||||
|
HardDrop = 1u << 5,
|
||||||
|
Hold = 1u << 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool has(uint8_t mask, Buttons b) {
|
||||||
|
return (mask & static_cast<uint8_t>(b)) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/network/NetSession.cpp
Normal file
324
src/network/NetSession.cpp
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
#include "NetSession.h"
|
||||||
|
|
||||||
|
#include <enet/enet.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint8_t kChannelReliable = 0;
|
||||||
|
|
||||||
|
static bool netLogVerboseEnabled() {
|
||||||
|
// Set environment variable / hint: SPACETRIS_NET_LOG=1
|
||||||
|
const char* v = SDL_GetHint("SPACETRIS_NET_LOG");
|
||||||
|
return v && v[0] == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
static void append(std::vector<uint8_t>& out, const T& value) {
|
||||||
|
const uint8_t* p = reinterpret_cast<const uint8_t*>(&value);
|
||||||
|
out.insert(out.end(), p, p + sizeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
static bool read(const uint8_t* data, size_t size, size_t& off, T& out) {
|
||||||
|
if (off + sizeof(T) > size) return false;
|
||||||
|
std::memcpy(&out, data + off, sizeof(T));
|
||||||
|
off += sizeof(T);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NetSession::NetSession() = default;
|
||||||
|
|
||||||
|
NetSession::~NetSession() {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::ensureEnetInitialized() {
|
||||||
|
static bool s_inited = false;
|
||||||
|
if (s_inited) return true;
|
||||||
|
if (enet_initialize() != 0) {
|
||||||
|
setError("enet_initialize failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s_inited = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::setError(const std::string& msg) {
|
||||||
|
m_lastError = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::host(const std::string& bindHost, uint16_t port) {
|
||||||
|
shutdown();
|
||||||
|
if (!ensureEnetInitialized()) return false;
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] host(bind='%s', port=%u)", bindHost.c_str(), (unsigned)port);
|
||||||
|
|
||||||
|
ENetAddress address{};
|
||||||
|
address.host = ENET_HOST_ANY;
|
||||||
|
address.port = port;
|
||||||
|
|
||||||
|
if (!bindHost.empty() && bindHost != "0.0.0.0") {
|
||||||
|
if (enet_address_set_host(&address, bindHost.c_str()) != 0) {
|
||||||
|
setError("enet_address_set_host (bind) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 peer, 2 channels (reserve extra)
|
||||||
|
m_host = enet_host_create(&address, 1, 2, 0, 0);
|
||||||
|
if (!m_host) {
|
||||||
|
setError("enet_host_create (host) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::Host;
|
||||||
|
m_state = ConnState::Connecting;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::join(const std::string& hostNameOrIp, uint16_t port) {
|
||||||
|
shutdown();
|
||||||
|
if (!ensureEnetInitialized()) return false;
|
||||||
|
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] join(remote='%s', port=%u)", hostNameOrIp.c_str(), (unsigned)port);
|
||||||
|
|
||||||
|
m_host = enet_host_create(nullptr, 1, 2, 0, 0);
|
||||||
|
if (!m_host) {
|
||||||
|
setError("enet_host_create (client) failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ENetAddress address{};
|
||||||
|
if (enet_address_set_host(&address, hostNameOrIp.c_str()) != 0) {
|
||||||
|
setError("enet_address_set_host failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
address.port = port;
|
||||||
|
|
||||||
|
m_peer = enet_host_connect(m_host, &address, 2, 0);
|
||||||
|
if (!m_peer) {
|
||||||
|
setError("enet_host_connect failed");
|
||||||
|
m_state = ConnState::Error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::Client;
|
||||||
|
m_state = ConnState::Connecting;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::shutdown() {
|
||||||
|
if (m_host || m_peer) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] shutdown(mode=%d state=%d)", (int)m_mode, (int)m_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remoteInputs.clear();
|
||||||
|
m_remoteHashes.clear();
|
||||||
|
m_receivedHandshake.reset();
|
||||||
|
|
||||||
|
m_inputsSent = 0;
|
||||||
|
m_inputsReceived = 0;
|
||||||
|
m_hashesSent = 0;
|
||||||
|
m_hashesReceived = 0;
|
||||||
|
m_handshakesSent = 0;
|
||||||
|
m_handshakesReceived = 0;
|
||||||
|
m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||||
|
m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||||
|
m_lastStatsLogMs = 0;
|
||||||
|
|
||||||
|
if (m_peer) {
|
||||||
|
enet_peer_disconnect(m_peer, 0);
|
||||||
|
m_peer = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_host) {
|
||||||
|
enet_host_destroy(m_host);
|
||||||
|
m_host = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = Mode::None;
|
||||||
|
m_state = ConnState::Disconnected;
|
||||||
|
m_lastError.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::poll(uint32_t timeoutMs) {
|
||||||
|
if (!m_host) return;
|
||||||
|
|
||||||
|
ENetEvent event{};
|
||||||
|
while (enet_host_service(m_host, &event, static_cast<enet_uint32>(timeoutMs)) > 0) {
|
||||||
|
switch (event.type) {
|
||||||
|
case ENET_EVENT_TYPE_CONNECT:
|
||||||
|
m_peer = event.peer;
|
||||||
|
m_state = ConnState::Connected;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] connected (mode=%d)", (int)m_mode);
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_RECEIVE:
|
||||||
|
if (event.packet) {
|
||||||
|
handlePacket(event.packet->data, event.packet->dataLength);
|
||||||
|
enet_packet_destroy(event.packet);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_DISCONNECT:
|
||||||
|
m_peer = nullptr;
|
||||||
|
m_state = ConnState::Disconnected;
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[NET] disconnected");
|
||||||
|
break;
|
||||||
|
case ENET_EVENT_TYPE_NONE:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After first event, do non-blocking passes.
|
||||||
|
timeoutMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limited stats log (opt-in)
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
const uint32_t nowMs = SDL_GetTicks();
|
||||||
|
if (m_lastStatsLogMs == 0) m_lastStatsLogMs = nowMs;
|
||||||
|
if (nowMs - m_lastStatsLogMs >= 1000u) {
|
||||||
|
m_lastStatsLogMs = nowMs;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||||
|
"[NET] stats: sent(in=%u hash=%u hs=%u) recv(in=%u hash=%u hs=%u) lastRecv(inTick=%u hashTick=%u) state=%d",
|
||||||
|
m_inputsSent,
|
||||||
|
m_hashesSent,
|
||||||
|
m_handshakesSent,
|
||||||
|
m_inputsReceived,
|
||||||
|
m_hashesReceived,
|
||||||
|
m_handshakesReceived,
|
||||||
|
m_lastRecvInputTick,
|
||||||
|
m_lastRecvHashTick,
|
||||||
|
(int)m_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendBytesReliable(const void* data, size_t size) {
|
||||||
|
if (!m_peer) return false;
|
||||||
|
ENetPacket* packet = enet_packet_create(data, size, ENET_PACKET_FLAG_RELIABLE);
|
||||||
|
if (!packet) return false;
|
||||||
|
if (enet_peer_send(m_peer, kChannelReliable, packet) != 0) {
|
||||||
|
enet_packet_destroy(packet);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Let the caller decide flush cadence; but for tiny control packets, flushing is cheap.
|
||||||
|
enet_host_flush(m_host);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendHandshake(const Handshake& hs) {
|
||||||
|
if (m_mode != Mode::Host) return false;
|
||||||
|
m_handshakesSent++;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) * 2 + sizeof(uint8_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Handshake));
|
||||||
|
append(buf, hs.rngSeed);
|
||||||
|
append(buf, hs.startTick);
|
||||||
|
append(buf, hs.startLevel);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<NetSession::Handshake> NetSession::takeReceivedHandshake() {
|
||||||
|
auto out = m_receivedHandshake;
|
||||||
|
m_receivedHandshake.reset();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendLocalInput(uint32_t tick, uint8_t buttons) {
|
||||||
|
m_inputsSent++;
|
||||||
|
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||||
|
}
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint8_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Input));
|
||||||
|
append(buf, tick);
|
||||||
|
append(buf, buttons);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint8_t> NetSession::getRemoteButtons(uint32_t tick) const {
|
||||||
|
auto it = m_remoteInputs.find(tick);
|
||||||
|
if (it == m_remoteInputs.end()) return std::nullopt;
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetSession::sendStateHash(uint32_t tick, uint64_t hash) {
|
||||||
|
m_hashesSent++;
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] sendHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||||
|
}
|
||||||
|
std::vector<uint8_t> buf;
|
||||||
|
buf.reserve(1 + sizeof(uint32_t) + sizeof(uint64_t));
|
||||||
|
buf.push_back(static_cast<uint8_t>(MsgType::Hash));
|
||||||
|
append(buf, tick);
|
||||||
|
append(buf, hash);
|
||||||
|
return sendBytesReliable(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint64_t> NetSession::takeRemoteHash(uint32_t tick) {
|
||||||
|
auto it = m_remoteHashes.find(tick);
|
||||||
|
if (it == m_remoteHashes.end()) return std::nullopt;
|
||||||
|
uint64_t v = it->second;
|
||||||
|
m_remoteHashes.erase(it);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetSession::handlePacket(const uint8_t* data, size_t size) {
|
||||||
|
if (!data || size < 1) return;
|
||||||
|
size_t off = 0;
|
||||||
|
uint8_t typeByte = 0;
|
||||||
|
if (!read(data, size, off, typeByte)) return;
|
||||||
|
|
||||||
|
MsgType t = static_cast<MsgType>(typeByte);
|
||||||
|
switch (t) {
|
||||||
|
case MsgType::Handshake: {
|
||||||
|
Handshake hs{};
|
||||||
|
if (!read(data, size, off, hs.rngSeed)) return;
|
||||||
|
if (!read(data, size, off, hs.startTick)) return;
|
||||||
|
if (!read(data, size, off, hs.startLevel)) return;
|
||||||
|
m_receivedHandshake = hs;
|
||||||
|
m_handshakesReceived++;
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHandshake(seed=%u startTick=%u startLevel=%u)", hs.rngSeed, hs.startTick, (unsigned)hs.startLevel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MsgType::Input: {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint8_t buttons = 0;
|
||||||
|
if (!read(data, size, off, tick)) return;
|
||||||
|
if (!read(data, size, off, buttons)) return;
|
||||||
|
m_remoteInputs[tick] = buttons;
|
||||||
|
m_inputsReceived++;
|
||||||
|
m_lastRecvInputTick = tick;
|
||||||
|
if (netLogVerboseEnabled() && (tick % 60u) == 0u) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvInput(tick=%u buttons=0x%02X)", tick, (unsigned)buttons);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MsgType::Hash: {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint64_t hash = 0;
|
||||||
|
if (!read(data, size, off, tick)) return;
|
||||||
|
if (!read(data, size, off, hash)) return;
|
||||||
|
m_remoteHashes[tick] = hash;
|
||||||
|
m_hashesReceived++;
|
||||||
|
m_lastRecvHashTick = tick;
|
||||||
|
if (netLogVerboseEnabled()) {
|
||||||
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[NET] recvHash(tick=%u hash=%llu)", tick, (unsigned long long)hash);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/network/NetSession.h
Normal file
118
src/network/NetSession.h
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct _ENetHost;
|
||||||
|
struct _ENetPeer;
|
||||||
|
|
||||||
|
// Lockstep networking session for COOPERATE (network) mode.
|
||||||
|
//
|
||||||
|
// Design goals:
|
||||||
|
// - Non-blocking polling (caller drives poll from the main loop)
|
||||||
|
// - Reliable, ordered delivery for inputs and control messages
|
||||||
|
// - Host provides seed + start tick (handshake)
|
||||||
|
// - Only inputs/state hashes are exchanged (no board sync)
|
||||||
|
class NetSession {
|
||||||
|
public:
|
||||||
|
enum class Mode {
|
||||||
|
None,
|
||||||
|
Host,
|
||||||
|
Client,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ConnState {
|
||||||
|
Disconnected,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Handshake {
|
||||||
|
uint32_t rngSeed = 0;
|
||||||
|
uint32_t startTick = 0;
|
||||||
|
uint8_t startLevel = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct InputFrame {
|
||||||
|
uint32_t tick = 0;
|
||||||
|
uint8_t buttons = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
NetSession();
|
||||||
|
~NetSession();
|
||||||
|
|
||||||
|
NetSession(const NetSession&) = delete;
|
||||||
|
NetSession& operator=(const NetSession&) = delete;
|
||||||
|
|
||||||
|
// If bindHost is empty or "0.0.0.0", binds to ENET_HOST_ANY.
|
||||||
|
bool host(const std::string& bindHost, uint16_t port);
|
||||||
|
bool join(const std::string& hostNameOrIp, uint16_t port);
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
void poll(uint32_t timeoutMs = 0);
|
||||||
|
|
||||||
|
Mode mode() const { return m_mode; }
|
||||||
|
ConnState state() const { return m_state; }
|
||||||
|
bool isConnected() const { return m_state == ConnState::Connected; }
|
||||||
|
|
||||||
|
// Host-only: send handshake once the peer connects.
|
||||||
|
bool sendHandshake(const Handshake& hs);
|
||||||
|
|
||||||
|
// Client-only: becomes available once received from host.
|
||||||
|
std::optional<Handshake> takeReceivedHandshake();
|
||||||
|
|
||||||
|
// Input exchange --------------------------------------------------------
|
||||||
|
// Send local input for a given simulation tick.
|
||||||
|
bool sendLocalInput(uint32_t tick, uint8_t buttons);
|
||||||
|
|
||||||
|
// Returns the last received remote input for a tick (if any).
|
||||||
|
std::optional<uint8_t> getRemoteButtons(uint32_t tick) const;
|
||||||
|
|
||||||
|
// Hash exchange (for desync detection) ---------------------------------
|
||||||
|
bool sendStateHash(uint32_t tick, uint64_t hash);
|
||||||
|
std::optional<uint64_t> takeRemoteHash(uint32_t tick);
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
std::string lastError() const { return m_lastError; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class MsgType : uint8_t {
|
||||||
|
Handshake = 1,
|
||||||
|
Input = 2,
|
||||||
|
Hash = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ensureEnetInitialized();
|
||||||
|
void setError(const std::string& msg);
|
||||||
|
|
||||||
|
bool sendBytesReliable(const void* data, size_t size);
|
||||||
|
void handlePacket(const uint8_t* data, size_t size);
|
||||||
|
|
||||||
|
Mode m_mode = Mode::None;
|
||||||
|
ConnState m_state = ConnState::Disconnected;
|
||||||
|
|
||||||
|
_ENetHost* m_host = nullptr;
|
||||||
|
_ENetPeer* m_peer = nullptr;
|
||||||
|
|
||||||
|
std::string m_lastError;
|
||||||
|
|
||||||
|
std::optional<Handshake> m_receivedHandshake;
|
||||||
|
|
||||||
|
std::unordered_map<uint32_t, uint8_t> m_remoteInputs;
|
||||||
|
std::unordered_map<uint32_t, uint64_t> m_remoteHashes;
|
||||||
|
|
||||||
|
// Debug logging (rate-limited)
|
||||||
|
uint32_t m_inputsSent = 0;
|
||||||
|
uint32_t m_inputsReceived = 0;
|
||||||
|
uint32_t m_hashesSent = 0;
|
||||||
|
uint32_t m_hashesReceived = 0;
|
||||||
|
uint32_t m_handshakesSent = 0;
|
||||||
|
uint32_t m_handshakesReceived = 0;
|
||||||
|
uint32_t m_lastRecvInputTick = 0xFFFFFFFFu;
|
||||||
|
uint32_t m_lastRecvHashTick = 0xFFFFFFFFu;
|
||||||
|
uint32_t m_lastStatsLogMs = 0;
|
||||||
|
};
|
||||||
182
src/network/supabase_client.cpp
Normal file
182
src/network/supabase_client.cpp
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
#include "supabase_client.h"
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <thread>
|
||||||
|
#include <iostream>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Supabase constants (publishable anon key)
|
||||||
|
const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co";
|
||||||
|
const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA";
|
||||||
|
|
||||||
|
std::string buildUrl(const std::string &path) {
|
||||||
|
std::string url = SUPABASE_URL;
|
||||||
|
if (!url.empty() && url.back() == '/') url.pop_back();
|
||||||
|
url += "/rest/v1/" + path;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||||
|
size_t realSize = size * nmemb;
|
||||||
|
std::string *s = reinterpret_cast<std::string*>(userp);
|
||||||
|
s->append(reinterpret_cast<char*>(contents), realSize);
|
||||||
|
return realSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CurlInit {
|
||||||
|
CurlInit() { curl_global_init(CURL_GLOBAL_DEFAULT); }
|
||||||
|
~CurlInit() { curl_global_cleanup(); }
|
||||||
|
};
|
||||||
|
static CurlInit g_curl_init;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace supabase {
|
||||||
|
|
||||||
|
static bool g_verbose = false;
|
||||||
|
|
||||||
|
void SetVerbose(bool enabled) {
|
||||||
|
g_verbose = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SubmitHighscoreAsync(const ScoreEntry &entry) {
|
||||||
|
std::thread([entry]() {
|
||||||
|
try {
|
||||||
|
CURL* curl = curl_easy_init();
|
||||||
|
if (!curl) return;
|
||||||
|
|
||||||
|
std::string url = buildUrl("highscores");
|
||||||
|
|
||||||
|
json j;
|
||||||
|
j["score"] = entry.score;
|
||||||
|
j["lines"] = entry.lines;
|
||||||
|
j["level"] = entry.level;
|
||||||
|
j["time_sec"] = static_cast<int>(std::lround(entry.timeSec));
|
||||||
|
j["name"] = entry.name;
|
||||||
|
j["game_type"] = entry.gameType;
|
||||||
|
j["timestamp"] = static_cast<int>(std::time(nullptr));
|
||||||
|
|
||||||
|
std::string body = j.dump();
|
||||||
|
struct curl_slist *headers = nullptr;
|
||||||
|
std::string h1 = std::string("apikey: ") + SUPABASE_ANON_KEY;
|
||||||
|
std::string h2 = std::string("Authorization: Bearer ") + SUPABASE_ANON_KEY;
|
||||||
|
headers = curl_slist_append(headers, h1.c_str());
|
||||||
|
headers = curl_slist_append(headers, h2.c_str());
|
||||||
|
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||||
|
|
||||||
|
std::string resp;
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
|
||||||
|
|
||||||
|
if (g_verbose) {
|
||||||
|
std::cerr << "[Supabase] POST " << url << "\n";
|
||||||
|
std::cerr << "[Supabase] Body: " << body << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
CURLcode res = curl_easy_perform(curl);
|
||||||
|
if (res != CURLE_OK) {
|
||||||
|
if (g_verbose) std::cerr << "[Supabase] POST error: " << curl_easy_strerror(res) << "\n";
|
||||||
|
} else {
|
||||||
|
long http_code = 0;
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
if (g_verbose) {
|
||||||
|
std::cerr << "[Supabase] POST response code: " << http_code << " body_len=" << resp.size() << "\n";
|
||||||
|
if (!resp.empty()) std::cerr << "[Supabase] POST response: " << resp << "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
} catch (...) {
|
||||||
|
// swallow errors
|
||||||
|
}
|
||||||
|
}).detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ScoreEntry> FetchHighscores(const std::string &gameType, int limit) {
|
||||||
|
std::vector<ScoreEntry> out;
|
||||||
|
try {
|
||||||
|
CURL* curl = curl_easy_init();
|
||||||
|
if (!curl) return out;
|
||||||
|
|
||||||
|
std::string path = "highscores";
|
||||||
|
// Clamp limit to max 10 to keep payloads small
|
||||||
|
int l = std::clamp(limit, 1, 10);
|
||||||
|
std::string query;
|
||||||
|
if (!gameType.empty()) {
|
||||||
|
if (gameType == "challenge") {
|
||||||
|
query = "?game_type=eq." + gameType + "&order=level.desc,time_sec.asc&limit=" + std::to_string(l);
|
||||||
|
} else {
|
||||||
|
query = "?game_type=eq." + gameType + "&order=score.desc&limit=" + std::to_string(l);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query = "?order=score.desc&limit=" + std::to_string(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string url = buildUrl(path) + query;
|
||||||
|
|
||||||
|
struct curl_slist *headers = nullptr;
|
||||||
|
headers = curl_slist_append(headers, ("apikey: " + SUPABASE_ANON_KEY).c_str());
|
||||||
|
headers = curl_slist_append(headers, ("Authorization: Bearer " + SUPABASE_ANON_KEY).c_str());
|
||||||
|
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||||
|
|
||||||
|
|
||||||
|
std::string resp;
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
|
||||||
|
|
||||||
|
if (g_verbose) std::cerr << "[Supabase] GET " << url << "\n";
|
||||||
|
|
||||||
|
CURLcode res = curl_easy_perform(curl);
|
||||||
|
if (res == CURLE_OK) {
|
||||||
|
long http_code = 0;
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
if (g_verbose) {
|
||||||
|
std::cerr << "[Supabase] GET response code: " << http_code << " body_len=" << resp.size() << "\n";
|
||||||
|
if (!resp.empty()) std::cerr << "[Supabase] GET response: " << resp << "\n";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
auto j = json::parse(resp);
|
||||||
|
if (j.is_array()) {
|
||||||
|
for (auto &v : j) {
|
||||||
|
ScoreEntry e{};
|
||||||
|
if (v.contains("score")) e.score = v["score"].get<int>();
|
||||||
|
if (v.contains("lines")) e.lines = v["lines"].get<int>();
|
||||||
|
if (v.contains("level")) e.level = v["level"].get<int>();
|
||||||
|
if (v.contains("time_sec")) {
|
||||||
|
try { e.timeSec = v["time_sec"].get<double>(); } catch(...) { e.timeSec = v["time_sec"].get<int>(); }
|
||||||
|
} else if (v.contains("timestamp")) {
|
||||||
|
e.timeSec = v["timestamp"].get<int>();
|
||||||
|
}
|
||||||
|
if (v.contains("name")) e.name = v["name"].get<std::string>();
|
||||||
|
if (v.contains("game_type")) e.gameType = v["game_type"].get<std::string>();
|
||||||
|
out.push_back(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
if (g_verbose) std::cerr << "[Supabase] GET parse error" << std::endl;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (g_verbose) std::cerr << "[Supabase] GET error: " << curl_easy_strerror(res) << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
} catch (...) {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace supabase
|
||||||
17
src/network/supabase_client.h
Normal file
17
src/network/supabase_client.h
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include "../persistence/Scores.h"
|
||||||
|
|
||||||
|
namespace supabase {
|
||||||
|
|
||||||
|
// Submit a highscore asynchronously (detached thread)
|
||||||
|
void SubmitHighscoreAsync(const ScoreEntry &entry);
|
||||||
|
|
||||||
|
// Fetch highscores for a game type. If gameType is empty, fetch all (limited).
|
||||||
|
std::vector<ScoreEntry> FetchHighscores(const std::string &gameType, int limit);
|
||||||
|
|
||||||
|
// Enable or disable verbose logging to stderr. Disabled by default.
|
||||||
|
void SetVerbose(bool enabled);
|
||||||
|
|
||||||
|
} // namespace supabase
|
||||||
@ -1,25 +1,23 @@
|
|||||||
// Scores.cpp - Implementation of ScoreManager with Firebase Sync
|
// Scores.cpp - Implementation of ScoreManager
|
||||||
#include "Scores.h"
|
#include "Scores.h"
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cpr/cpr.h>
|
#include "../network/supabase_client.h"
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
using json = nlohmann::json;
|
using json = nlohmann::json;
|
||||||
|
|
||||||
// Firebase Realtime Database URL
|
|
||||||
const std::string FIREBASE_URL = "https://tetris-90139.firebaseio.com/scores.json";
|
|
||||||
|
|
||||||
ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {}
|
ScoreManager::ScoreManager(size_t maxScores) : maxEntries(maxScores) {}
|
||||||
|
|
||||||
std::string ScoreManager::filePath() const {
|
std::string ScoreManager::filePath() const {
|
||||||
static std::string path; if (!path.empty()) return path;
|
static std::string path; if (!path.empty()) return path;
|
||||||
char* base = SDL_GetPrefPath("example","tetris_sdl3");
|
char* base = SDL_GetPrefPath("example","spacetris_sdl3");
|
||||||
if (base) { path = std::string(base)+"highscores.txt"; SDL_free(base);} else path="highscores.txt";
|
if (base) { path = std::string(base)+"highscores.txt"; SDL_free(base);} else path="highscores.txt";
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@ -27,48 +25,19 @@ std::string ScoreManager::filePath() const {
|
|||||||
void ScoreManager::load() {
|
void ScoreManager::load() {
|
||||||
scores.clear();
|
scores.clear();
|
||||||
|
|
||||||
// Try to load from Firebase first
|
// Try to load from Supabase first
|
||||||
try {
|
try {
|
||||||
cpr::Response r = cpr::Get(cpr::Url{FIREBASE_URL}, cpr::Timeout{2000}); // 2s timeout
|
// Request only 10 records from Supabase to keep payload small
|
||||||
if (r.status_code == 200 && !r.text.empty() && r.text != "null") {
|
auto fetched = supabase::FetchHighscores("", 10);
|
||||||
auto j = json::parse(r.text);
|
if (!fetched.empty()) {
|
||||||
|
scores = fetched;
|
||||||
// Firebase returns a map of auto-generated IDs to objects
|
|
||||||
if (j.is_object()) {
|
|
||||||
for (auto& [key, value] : j.items()) {
|
|
||||||
ScoreEntry e;
|
|
||||||
if (value.contains("score")) e.score = value["score"];
|
|
||||||
if (value.contains("lines")) e.lines = value["lines"];
|
|
||||||
if (value.contains("level")) e.level = value["level"];
|
|
||||||
if (value.contains("timeSec")) e.timeSec = value["timeSec"];
|
|
||||||
if (value.contains("name")) e.name = value["name"];
|
|
||||||
scores.push_back(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Or it might be an array if keys are integers (unlikely for Firebase push)
|
|
||||||
else if (j.is_array()) {
|
|
||||||
for (auto& value : j) {
|
|
||||||
ScoreEntry e;
|
|
||||||
if (value.contains("score")) e.score = value["score"];
|
|
||||||
if (value.contains("lines")) e.lines = value["lines"];
|
|
||||||
if (value.contains("level")) e.level = value["level"];
|
|
||||||
if (value.contains("timeSec")) e.timeSec = value["timeSec"];
|
|
||||||
if (value.contains("name")) e.name = value["name"];
|
|
||||||
scores.push_back(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort and keep top scores
|
|
||||||
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||||
if (scores.size() > maxEntries) scores.resize(maxEntries);
|
if (scores.size() > maxEntries) scores.resize(maxEntries);
|
||||||
|
|
||||||
// Save to local cache
|
|
||||||
save();
|
save();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
// Ignore network errors and fall back to local file
|
std::cerr << "Failed to load from Supabase, falling back to local file." << std::endl;
|
||||||
std::cerr << "Failed to load from Firebase, falling back to local file." << std::endl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to local file
|
// Fallback to local file
|
||||||
@ -86,11 +55,32 @@ void ScoreManager::load() {
|
|||||||
ScoreEntry e;
|
ScoreEntry e;
|
||||||
iss >> e.score >> e.lines >> e.level >> e.timeSec;
|
iss >> e.score >> e.lines >> e.level >> e.timeSec;
|
||||||
if (iss) {
|
if (iss) {
|
||||||
// Try to read name (rest of line after timeSec)
|
// Try to read name (rest of line after timeSec). We may also have a trailing gameType token.
|
||||||
std::string remaining;
|
std::string remaining;
|
||||||
std::getline(iss, remaining);
|
std::getline(iss, remaining);
|
||||||
if (!remaining.empty() && remaining[0] == ' ') {
|
if (!remaining.empty() && remaining[0] == ' ') remaining = remaining.substr(1);
|
||||||
e.name = remaining.substr(1); // Remove leading space
|
if (!remaining.empty()) {
|
||||||
|
static const std::vector<std::string> known = {"classic","cooperate","challenge","versus"};
|
||||||
|
while (!remaining.empty() && (remaining.back() == '\n' || remaining.back() == '\r' || remaining.back() == ' ')) remaining.pop_back();
|
||||||
|
size_t lastSpace = remaining.find_last_of(' ');
|
||||||
|
std::string lastToken = (lastSpace == std::string::npos) ? remaining : remaining.substr(lastSpace + 1);
|
||||||
|
bool matched = false;
|
||||||
|
for (const auto &k : known) {
|
||||||
|
if (lastToken == k) {
|
||||||
|
matched = true;
|
||||||
|
e.gameType = k;
|
||||||
|
if (lastSpace == std::string::npos) e.name = "PLAYER";
|
||||||
|
else e.name = remaining.substr(0, lastSpace);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
e.name = remaining;
|
||||||
|
e.gameType = "classic";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.name = "PLAYER";
|
||||||
|
e.gameType = "classic";
|
||||||
}
|
}
|
||||||
scores.push_back(e);
|
scores.push_back(e);
|
||||||
}
|
}
|
||||||
@ -108,42 +98,28 @@ void ScoreManager::load() {
|
|||||||
void ScoreManager::save() const {
|
void ScoreManager::save() const {
|
||||||
std::ofstream f(filePath(), std::ios::trunc);
|
std::ofstream f(filePath(), std::ios::trunc);
|
||||||
for (auto &e : scores) {
|
for (auto &e : scores) {
|
||||||
f << e.score << ' ' << e.lines << ' ' << e.level << ' ' << e.timeSec << ' ' << e.name << '\n';
|
// Save gameType as trailing token so future loads can preserve it
|
||||||
|
f << e.score << ' ' << e.lines << ' ' << e.level << ' ' << e.timeSec << ' ' << e.name << ' ' << e.gameType << '\n';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name) {
|
void ScoreManager::submit(int score, int lines, int level, double timeSec, const std::string& name, const std::string& gameType) {
|
||||||
// Add to local list
|
// Add to local list
|
||||||
scores.push_back(ScoreEntry{score,lines,level,timeSec, name});
|
ScoreEntry newEntry{};
|
||||||
|
newEntry.score = score;
|
||||||
|
newEntry.lines = lines;
|
||||||
|
newEntry.level = level;
|
||||||
|
newEntry.timeSec = timeSec;
|
||||||
|
newEntry.name = name;
|
||||||
|
// preserve the game type locally so menu filtering works immediately
|
||||||
|
newEntry.gameType = gameType;
|
||||||
|
scores.push_back(newEntry);
|
||||||
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||||
if (scores.size()>maxEntries) scores.resize(maxEntries);
|
if (scores.size()>maxEntries) scores.resize(maxEntries);
|
||||||
save();
|
save();
|
||||||
|
// Submit to Supabase asynchronously
|
||||||
// Submit to Firebase
|
ScoreEntry se{score, lines, level, timeSec, name, gameType};
|
||||||
// Run in a detached thread to avoid blocking the UI?
|
supabase::SubmitHighscoreAsync(se);
|
||||||
// For simplicity, we'll do it blocking for now, or rely on short timeout.
|
|
||||||
// Ideally this should be async.
|
|
||||||
|
|
||||||
json j;
|
|
||||||
j["score"] = score;
|
|
||||||
j["lines"] = lines;
|
|
||||||
j["level"] = level;
|
|
||||||
j["timeSec"] = timeSec;
|
|
||||||
j["name"] = name;
|
|
||||||
j["timestamp"] = std::time(nullptr); // Add timestamp
|
|
||||||
|
|
||||||
// Fire and forget (async) would be better, but for now let's just try to send
|
|
||||||
// We can use std::thread to make it async
|
|
||||||
std::thread([j]() {
|
|
||||||
try {
|
|
||||||
cpr::Post(cpr::Url{FIREBASE_URL},
|
|
||||||
cpr::Body{j.dump()},
|
|
||||||
cpr::Header{{"Content-Type", "application/json"}},
|
|
||||||
cpr::Timeout{5000});
|
|
||||||
} catch (...) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}).detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ScoreManager::isHighScore(int score) const {
|
bool ScoreManager::isHighScore(int score) const {
|
||||||
@ -151,19 +127,28 @@ bool ScoreManager::isHighScore(int score) const {
|
|||||||
return score > scores.back().score;
|
return score > scores.back().score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScoreManager::replaceAll(const std::vector<ScoreEntry>& newScores) {
|
||||||
|
scores = newScores;
|
||||||
|
// Ensure ordering and trimming to our configured maxEntries
|
||||||
|
std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;});
|
||||||
|
if (scores.size() > maxEntries) scores.resize(maxEntries);
|
||||||
|
// Persist new set to local file for next launch
|
||||||
|
try { save(); } catch (...) { /* swallow */ }
|
||||||
|
}
|
||||||
|
|
||||||
void ScoreManager::createSampleScores() {
|
void ScoreManager::createSampleScores() {
|
||||||
scores = {
|
scores = {
|
||||||
{159840, 189, 14, 972, "GREGOR"},
|
{159840, 189, 14, 972.0, "GREGOR"},
|
||||||
{156340, 132, 12, 714, "GREGOR"},
|
{156340, 132, 12, 714.0, "GREGOR"},
|
||||||
{155219, 125, 12, 696, "GREGOR"},
|
{155219, 125, 12, 696.0, "GREGOR"},
|
||||||
{141823, 123, 10, 710, "GREGOR"},
|
{141823, 123, 10, 710.0, "GREGOR"},
|
||||||
{140079, 71, 11, 410, "GREGOR"},
|
{140079, 71, 11, 410.0, "GREGOR"},
|
||||||
{116012, 121, 10, 619, "GREGOR"},
|
{116012, 121, 10, 619.0, "GREGOR"},
|
||||||
{112643, 137, 13, 689, "GREGOR"},
|
{112643, 137, 13, 689.0, "GREGOR"},
|
||||||
{99190, 61, 10, 378, "GREGOR"},
|
{99190, 61, 10, 378.0, "GREGOR"},
|
||||||
{93648, 107, 10, 629, "GREGOR"},
|
{93648, 107, 10, 629.0, "GREGOR"},
|
||||||
{89041, 115, 10, 618, "GREGOR"},
|
{89041, 115, 10, 618.0, "GREGOR"},
|
||||||
{88600, 55, 9, 354, "GREGOR"},
|
{88600, 55, 9, 354.0, "GREGOR"},
|
||||||
{86346, 141, 13, 723, "GREGOR"}
|
{86346, 141, 13, 723.0, "GREGOR"}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,18 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
struct ScoreEntry { int score{}; int lines{}; int level{}; double timeSec{}; std::string name{"PLAYER"}; };
|
struct ScoreEntry { int score{}; int lines{}; int level{}; double timeSec{}; std::string name{"PLAYER"}; std::string gameType{"classic"}; };
|
||||||
|
|
||||||
class ScoreManager {
|
class ScoreManager {
|
||||||
public:
|
public:
|
||||||
explicit ScoreManager(size_t maxScores = 12);
|
explicit ScoreManager(size_t maxScores = 12);
|
||||||
void load();
|
void load();
|
||||||
void save() const;
|
void save() const;
|
||||||
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER");
|
// Replace the in-memory scores (thread-safe caller should ensure non-blocking)
|
||||||
|
void replaceAll(const std::vector<ScoreEntry>& newScores);
|
||||||
|
// New optional `gameType` parameter will be sent as `game_type`.
|
||||||
|
// Allowed values: "classic", "versus", "cooperate", "challenge".
|
||||||
|
void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER", const std::string& gameType = "classic");
|
||||||
bool isHighScore(int score) const;
|
bool isHighScore(int score) const;
|
||||||
const std::vector<ScoreEntry>& all() const { return scores; }
|
const std::vector<ScoreEntry>& all() const { return scores; }
|
||||||
private:
|
private:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "State.h"
|
#include "State.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class NetSession;
|
||||||
|
|
||||||
class MenuState : public State {
|
class MenuState : public State {
|
||||||
public:
|
public:
|
||||||
MenuState(StateContext& ctx);
|
MenuState(StateContext& ctx);
|
||||||
@ -19,9 +25,13 @@ public:
|
|||||||
void showHelpPanel(bool show);
|
void showHelpPanel(bool show);
|
||||||
// Show or hide the inline ABOUT panel (menu-style)
|
// Show or hide the inline ABOUT panel (menu-style)
|
||||||
void showAboutPanel(bool show);
|
void showAboutPanel(bool show);
|
||||||
|
|
||||||
|
// Show or hide the inline COOPERATE setup panel (2P vs AI).
|
||||||
|
// If `resumeMusic` is false when hiding, the menu music will not be restarted.
|
||||||
|
void showCoopSetupPanel(bool show, bool resumeMusic = true);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int selectedButton = 0; // 0 = PLAY, 1 = LEVEL, 2 = OPTIONS, 3 = HELP, 4 = ABOUT, 5 = EXIT
|
int selectedButton = 0; // 0=PLAY,1=COOPERATE,2=CHALLENGE,3=LEVEL,4=OPTIONS,5=HELP,6=ABOUT,7=EXIT
|
||||||
|
|
||||||
// Button icons (optional - will use text if nullptr)
|
// Button icons (optional - will use text if nullptr)
|
||||||
SDL_Texture* playIcon = nullptr;
|
SDL_Texture* playIcon = nullptr;
|
||||||
@ -94,4 +104,37 @@ private:
|
|||||||
double aboutTransition = 0.0; // 0..1
|
double aboutTransition = 0.0; // 0..1
|
||||||
double aboutTransitionDurationMs = 360.0;
|
double aboutTransitionDurationMs = 360.0;
|
||||||
int aboutDirection = 1; // 1 show, -1 hide
|
int aboutDirection = 1; // 1 show, -1 hide
|
||||||
|
|
||||||
|
// Coop setup panel (inline HUD like Exit/Help)
|
||||||
|
bool coopSetupVisible = false;
|
||||||
|
bool coopSetupAnimating = false;
|
||||||
|
double coopSetupTransition = 0.0; // 0..1
|
||||||
|
double coopSetupTransitionDurationMs = 320.0;
|
||||||
|
int coopSetupDirection = 1; // 1 show, -1 hide
|
||||||
|
// 0 = Local co-op (2 players), 1 = AI partner, 2 = 2 player (network)
|
||||||
|
int coopSetupSelected = 0;
|
||||||
|
|
||||||
|
enum class CoopSetupStep {
|
||||||
|
ChoosePartner,
|
||||||
|
NetworkChooseRole,
|
||||||
|
NetworkEnterAddress,
|
||||||
|
NetworkWaiting,
|
||||||
|
};
|
||||||
|
CoopSetupStep coopSetupStep = CoopSetupStep::ChoosePartner;
|
||||||
|
|
||||||
|
// Network sub-flow state (only used when coopSetupSelected == 2)
|
||||||
|
int coopNetworkRoleSelected = 0; // 0 = host, 1 = join
|
||||||
|
std::string coopNetworkBindAddress = "0.0.0.0";
|
||||||
|
std::string coopNetworkJoinAddress = "127.0.0.1";
|
||||||
|
uint16_t coopNetworkPort = 7777;
|
||||||
|
bool coopNetworkHandshakeSent = false;
|
||||||
|
std::string coopNetworkStatusText;
|
||||||
|
std::unique_ptr<NetSession> coopNetworkSession;
|
||||||
|
|
||||||
|
SDL_FRect coopSetupBtnRects[3]{};
|
||||||
|
bool coopSetupRectsValid = false;
|
||||||
|
// Optional cooperative info image shown when coop setup panel is active
|
||||||
|
SDL_Texture* coopInfoTexture = nullptr;
|
||||||
|
int coopInfoTexW = 0;
|
||||||
|
int coopInfoTexH = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
#include "PlayingState.h"
|
#include "PlayingState.h"
|
||||||
#include "../core/state/StateManager.h"
|
#include "../core/state/StateManager.h"
|
||||||
#include "../gameplay/core/Game.h"
|
#include "../gameplay/core/Game.h"
|
||||||
|
#include "../gameplay/coop/CoopGame.h"
|
||||||
#include "../gameplay/effects/LineEffect.h"
|
#include "../gameplay/effects/LineEffect.h"
|
||||||
#include "../persistence/Scores.h"
|
#include "../persistence/Scores.h"
|
||||||
#include "../audio/Audio.h"
|
#include "../audio/Audio.h"
|
||||||
#include "../audio/SoundEffect.h"
|
#include "../audio/SoundEffect.h"
|
||||||
|
#include "../graphics/Font.h"
|
||||||
#include "../graphics/renderers/GameRenderer.h"
|
#include "../graphics/renderers/GameRenderer.h"
|
||||||
#include "../core/Settings.h"
|
#include "../core/Settings.h"
|
||||||
#include "../core/Config.h"
|
#include "../core/Config.h"
|
||||||
|
#include "../network/CoopNetButtons.h"
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
// File-scope transport/spawn detection state
|
// File-scope transport/spawn detection state
|
||||||
@ -18,12 +21,23 @@ PlayingState::PlayingState(StateContext& ctx) : State(ctx) {}
|
|||||||
|
|
||||||
void PlayingState::onEnter() {
|
void PlayingState::onEnter() {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Entering Playing state");
|
||||||
// Initialize the game based on mode: endless uses chosen start level, challenge keeps its run state
|
// Initialize the game based on mode: endless/cooperate use chosen start level, challenge keeps its run state
|
||||||
if (ctx.game) {
|
if (ctx.game) {
|
||||||
if (ctx.game->getMode() == GameMode::Endless) {
|
if (ctx.game->getMode() == GameMode::Endless || ctx.game->getMode() == GameMode::Cooperate) {
|
||||||
if (ctx.startLevelSelection) {
|
if (ctx.startLevelSelection) {
|
||||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[PLAYING] Resetting game with level %d", *ctx.startLevelSelection);
|
||||||
ctx.game->reset(*ctx.startLevelSelection);
|
const bool coopNetActive = (ctx.game->getMode() == GameMode::Cooperate) && ctx.coopNetEnabled && ctx.coopNetSession;
|
||||||
|
|
||||||
|
// For network co-op, MenuState already performed a deterministic reset using the negotiated seed.
|
||||||
|
// Re-resetting here would overwrite it (and will desync).
|
||||||
|
if (!coopNetActive) {
|
||||||
|
ctx.game->reset(*ctx.startLevelSelection);
|
||||||
|
if (ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame) {
|
||||||
|
ctx.coopGame->reset(*ctx.startLevelSelection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.game->setPaused(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
// Challenge run is prepared before entering; ensure gameplay is unpaused
|
||||||
@ -42,127 +56,248 @@ void PlayingState::onExit() {
|
|||||||
SDL_DestroyTexture(m_renderTarget);
|
SDL_DestroyTexture(m_renderTarget);
|
||||||
m_renderTarget = nullptr;
|
m_renderTarget = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we are leaving gameplay during network co-op, tear down the session so
|
||||||
|
// hosting/joining again works without restarting the app.
|
||||||
|
if (ctx.coopNetSession) {
|
||||||
|
ctx.coopNetSession->shutdown();
|
||||||
|
ctx.coopNetSession.reset();
|
||||||
|
}
|
||||||
|
ctx.coopNetEnabled = false;
|
||||||
|
ctx.coopNetStalled = false;
|
||||||
|
ctx.coopNetDesyncDetected = false;
|
||||||
|
ctx.coopNetTick = 0;
|
||||||
|
ctx.coopNetPendingButtons = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayingState::handleEvent(const SDL_Event& e) {
|
void PlayingState::handleEvent(const SDL_Event& e) {
|
||||||
|
if (!ctx.game) return;
|
||||||
|
|
||||||
// If a transport animation is active, ignore gameplay input entirely.
|
// If a transport animation is active, ignore gameplay input entirely.
|
||||||
if (GameRenderer::isTransportActive()) {
|
if (GameRenderer::isTransportActive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// We keep short-circuited input here; main still owns mouse UI
|
|
||||||
if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat) {
|
|
||||||
if (!ctx.game) return;
|
|
||||||
|
|
||||||
auto setExitSelection = [&](int value) {
|
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||||
if (ctx.exitPopupSelectedButton) {
|
|
||||||
*ctx.exitPopupSelectedButton = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
auto getExitSelection = [&]() -> int {
|
auto setExitSelection = [&](int idx) {
|
||||||
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
if (ctx.exitPopupSelectedButton) {
|
||||||
};
|
*ctx.exitPopupSelectedButton = idx;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
auto getExitSelection = [&]() -> int {
|
||||||
|
return ctx.exitPopupSelectedButton ? *ctx.exitPopupSelectedButton : 1;
|
||||||
|
};
|
||||||
|
|
||||||
// Pause toggle (P)
|
if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) {
|
||||||
if (e.key.scancode == SDL_SCANCODE_P) {
|
return;
|
||||||
bool paused = ctx.game->isPaused();
|
}
|
||||||
ctx.game->setPaused(!paused);
|
|
||||||
|
// If exit-confirm popup is visible, handle shortcuts here
|
||||||
|
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
||||||
|
// Navigate between YES (0) and NO (1) buttons
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
|
setExitSelection(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
||||||
|
setExitSelection(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If exit-confirm popup is visible, handle shortcuts here
|
// Activate selected button with Enter or Space
|
||||||
if (ctx.showExitConfirmPopup && *ctx.showExitConfirmPopup) {
|
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||||
// Navigate between YES (0) and NO (1) buttons
|
const bool confirmExit = (getExitSelection() == 0);
|
||||||
if (e.key.scancode == SDL_SCANCODE_LEFT || e.key.scancode == SDL_SCANCODE_UP) {
|
*ctx.showExitConfirmPopup = false;
|
||||||
setExitSelection(0);
|
if (confirmExit) {
|
||||||
return;
|
// YES - Reset game and return to menu
|
||||||
}
|
if (ctx.startLevelSelection) {
|
||||||
if (e.key.scancode == SDL_SCANCODE_RIGHT || e.key.scancode == SDL_SCANCODE_DOWN) {
|
ctx.game->reset(*ctx.startLevelSelection);
|
||||||
setExitSelection(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate selected button with Enter or Space
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER || e.key.scancode == SDL_SCANCODE_SPACE) {
|
|
||||||
const bool confirmExit = (getExitSelection() == 0);
|
|
||||||
*ctx.showExitConfirmPopup = false;
|
|
||||||
if (confirmExit) {
|
|
||||||
// YES - Reset game and return to menu
|
|
||||||
if (ctx.startLevelSelection) {
|
|
||||||
ctx.game->reset(*ctx.startLevelSelection);
|
|
||||||
} else {
|
|
||||||
ctx.game->reset(0);
|
|
||||||
}
|
|
||||||
ctx.game->setPaused(false);
|
|
||||||
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
|
||||||
} else {
|
} else {
|
||||||
// NO - Just close popup and resume
|
ctx.game->reset(0);
|
||||||
ctx.game->setPaused(false);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Cancel with Esc (same as NO)
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
|
||||||
*ctx.showExitConfirmPopup = false;
|
|
||||||
ctx.game->setPaused(false);
|
ctx.game->setPaused(false);
|
||||||
setExitSelection(1);
|
if (ctx.stateManager) ctx.stateManager->setState(AppState::Menu);
|
||||||
return;
|
} else {
|
||||||
|
// NO - Just close popup and resume
|
||||||
|
ctx.game->setPaused(false);
|
||||||
}
|
}
|
||||||
// While modal is open, suppress other gameplay keys
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Cancel with Esc (same as NO)
|
||||||
// ESC key - open confirmation popup
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||||
if (ctx.showExitConfirmPopup) {
|
*ctx.showExitConfirmPopup = false;
|
||||||
if (ctx.game) ctx.game->setPaused(true);
|
|
||||||
*ctx.showExitConfirmPopup = true;
|
|
||||||
setExitSelection(1); // Default to NO for safety
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: skip to next challenge level (B)
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_B && ctx.game && ctx.game->getMode() == GameMode::Challenge) {
|
|
||||||
ctx.game->beginNextChallengeLevel();
|
|
||||||
// Cancel any countdown so play resumes immediately on the new level
|
|
||||||
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
|
||||||
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
|
||||||
ctx.game->setPaused(false);
|
ctx.game->setPaused(false);
|
||||||
|
setExitSelection(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// While modal is open, suppress other gameplay keys
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC key - open confirmation popup
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_ESCAPE) {
|
||||||
|
if (ctx.showExitConfirmPopup) {
|
||||||
|
ctx.game->setPaused(true);
|
||||||
|
*ctx.showExitConfirmPopup = true;
|
||||||
|
setExitSelection(1); // Default to NO for safety
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: skip to next challenge level (B)
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_B && ctx.game->getMode() == GameMode::Challenge) {
|
||||||
|
ctx.game->beginNextChallengeLevel();
|
||||||
|
// Cancel any countdown so play resumes immediately on the new level
|
||||||
|
if (ctx.gameplayCountdownActive) *ctx.gameplayCountdownActive = false;
|
||||||
|
if (ctx.menuPlayCountdownArmed) *ctx.menuPlayCountdownArmed = false;
|
||||||
|
ctx.game->setPaused(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause toggle (P) - matches classic behavior; disabled during countdown
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_P) {
|
||||||
|
// Network co-op uses lockstep; local pause would desync/stall the peer.
|
||||||
|
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bool countdown = (ctx.gameplayCountdownActive && *ctx.gameplayCountdownActive) ||
|
||||||
|
(ctx.menuPlayCountdownArmed && *ctx.menuPlayCountdownArmed);
|
||||||
|
if (!countdown) {
|
||||||
|
ctx.game->setPaused(!ctx.game->isPaused());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tetris controls (only when not paused)
|
||||||
|
if (ctx.game->isPaused()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coopActive && ctx.coopGame) {
|
||||||
|
// Network co-op: route one-shot actions into a pending bitmask for lockstep.
|
||||||
|
if (ctx.coopNetEnabled && ctx.coopNetSession) {
|
||||||
|
const bool localIsLeft = ctx.coopNetLocalIsLeft;
|
||||||
|
const SDL_Scancode sc = e.key.scancode;
|
||||||
|
if (localIsLeft) {
|
||||||
|
if (sc == SDL_SCANCODE_W) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::RotCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_Q) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::RotCCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_LSHIFT || sc == SDL_SCANCODE_E) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::HardDrop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_LCTRL) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::Hold;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sc == SDL_SCANCODE_UP) {
|
||||||
|
const bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
|
ctx.coopNetPendingButtons |= upIsCW ? coopnet::RotCW : coopnet::RotCCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_RALT) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::RotCCW;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_SPACE || sc == SDL_SCANCODE_RSHIFT) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::HardDrop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sc == SDL_SCANCODE_RCTRL) {
|
||||||
|
ctx.coopNetPendingButtons |= coopnet::Hold;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If coopNet is active, suppress local co-op direct action keys.
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool coopAIEnabled = (ctx.coopVsAI && *ctx.coopVsAI);
|
||||||
|
|
||||||
|
// Player 1 (left): when AI is enabled it controls the left side so
|
||||||
|
// ignore direct player input for the left board.
|
||||||
|
if (coopAIEnabled) {
|
||||||
|
// Left side controlled by AI; skip left-side input handling here.
|
||||||
|
} else {
|
||||||
|
// Player 1 manual controls (left side)
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_W) {
|
||||||
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_Q) {
|
||||||
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Left, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hard drop (left): keep LSHIFT, also allow E for convenience.
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_LSHIFT || e.key.scancode == SDL_SCANCODE_E) {
|
||||||
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
|
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_LCTRL) {
|
||||||
|
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Left);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
|
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, upIsCW ? 1 : -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_RALT) {
|
||||||
|
ctx.coopGame->rotate(CoopGame::PlayerSide::Right, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hard drop (right): SPACE is the primary key for arrow controls; keep RSHIFT as an alternate.
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_SPACE || e.key.scancode == SDL_SCANCODE_RSHIFT) {
|
||||||
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
|
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Right);
|
||||||
|
if (coopAIEnabled) {
|
||||||
|
// Mirror human-initiated hard-drop to AI on left
|
||||||
|
ctx.coopGame->hardDrop(CoopGame::PlayerSide::Left);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_RCTRL) {
|
||||||
|
ctx.coopGame->holdCurrent(CoopGame::PlayerSide::Right);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single-player classic controls
|
||||||
|
// Hold / swap current piece (H)
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_H) {
|
||||||
|
ctx.game->holdCurrent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tetris controls (only when not paused)
|
// Rotation (still event-based for precise timing)
|
||||||
if (!ctx.game->isPaused()) {
|
if (e.key.scancode == SDL_SCANCODE_UP) {
|
||||||
// Hold / swap current piece (H)
|
// Use user setting to determine whether UP rotates clockwise
|
||||||
if (e.key.scancode == SDL_SCANCODE_H) {
|
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
||||||
ctx.game->holdCurrent();
|
ctx.game->rotate(upIsCW ? 1 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.key.scancode == SDL_SCANCODE_X) {
|
||||||
|
// Toggle the mapping so UP will rotate in the opposite direction
|
||||||
|
bool current = Settings::instance().isUpRotateClockwise();
|
||||||
|
Settings::instance().setUpRotateClockwise(!current);
|
||||||
|
Settings::instance().save();
|
||||||
|
// Play a subtle feedback sound if available
|
||||||
|
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Rotation (still event-based for precise timing)
|
// Hard drop (space)
|
||||||
if (e.key.scancode == SDL_SCANCODE_UP) {
|
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
||||||
// Use user setting to determine whether UP rotates clockwise
|
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
||||||
bool upIsCW = Settings::instance().isUpRotateClockwise();
|
ctx.game->hardDrop();
|
||||||
ctx.game->rotate(upIsCW ? 1 : -1);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_X) {
|
|
||||||
// Toggle the mapping so UP will rotate in the opposite direction
|
|
||||||
bool current = Settings::instance().isUpRotateClockwise();
|
|
||||||
Settings::instance().setUpRotateClockwise(!current);
|
|
||||||
Settings::instance().save();
|
|
||||||
// Play a subtle feedback sound if available
|
|
||||||
SoundEffectManager::instance().playSound("menu_toggle", 0.6f);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hard drop (space)
|
|
||||||
if (e.key.scancode == SDL_SCANCODE_SPACE) {
|
|
||||||
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
|
|
||||||
ctx.game->hardDrop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,7 +307,21 @@ void PlayingState::handleEvent(const SDL_Event& e) {
|
|||||||
|
|
||||||
void PlayingState::update(double frameMs) {
|
void PlayingState::update(double frameMs) {
|
||||||
if (!ctx.game) return;
|
if (!ctx.game) return;
|
||||||
|
|
||||||
|
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||||
|
|
||||||
|
if (coopActive) {
|
||||||
|
// Visual effects only; gravity and movement handled from ApplicationManager for coop
|
||||||
|
ctx.coopGame->updateVisualEffects(frameMs);
|
||||||
|
// Update line clear effect for coop mode as well (renderer starts the effect)
|
||||||
|
if (ctx.lineEffect && ctx.lineEffect->isActive()) {
|
||||||
|
if (ctx.lineEffect->update(frameMs / 1000.0f)) {
|
||||||
|
ctx.coopGame->clearCompletedLines();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.game->updateVisualEffects(frameMs);
|
ctx.game->updateVisualEffects(frameMs);
|
||||||
// If a transport animation is active, pause gameplay updates and ignore inputs
|
// If a transport animation is active, pause gameplay updates and ignore inputs
|
||||||
if (GameRenderer::isTransportActive()) {
|
if (GameRenderer::isTransportActive()) {
|
||||||
@ -204,6 +353,8 @@ void PlayingState::update(double frameMs) {
|
|||||||
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||||
if (!ctx.game) return;
|
if (!ctx.game) return;
|
||||||
|
|
||||||
|
const bool coopActive = ctx.game->getMode() == GameMode::Cooperate && ctx.coopGame;
|
||||||
|
|
||||||
// Get current window size
|
// Get current window size
|
||||||
int winW = 0, winH = 0;
|
int winW = 0, winH = 0;
|
||||||
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
||||||
@ -231,6 +382,31 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
// But countdown should definitely NOT show the "PAUSED" overlay.
|
// But countdown should definitely NOT show the "PAUSED" overlay.
|
||||||
bool shouldBlur = paused && !countdown && !challengeClearFx;
|
bool shouldBlur = paused && !countdown && !challengeClearFx;
|
||||||
|
|
||||||
|
auto renderNetOverlay = [&]() {
|
||||||
|
if (!coopActive || !ctx.coopNetEnabled || !ctx.pixelFont) return;
|
||||||
|
if (!ctx.coopNetDesyncDetected && !ctx.coopNetStalled) return;
|
||||||
|
|
||||||
|
const char* text = ctx.coopNetDesyncDetected ? "NET: DESYNC" : "NET: STALLED";
|
||||||
|
SDL_Color textColor = ctx.coopNetDesyncDetected ? SDL_Color{255, 230, 180, 255} : SDL_Color{255, 224, 130, 255};
|
||||||
|
float scale = 0.75f;
|
||||||
|
int tw = 0, th = 0;
|
||||||
|
ctx.pixelFont->measure(text, scale, tw, th);
|
||||||
|
|
||||||
|
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||||
|
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
|
||||||
|
const float pad = 8.0f;
|
||||||
|
const float x = 18.0f;
|
||||||
|
const float y = 14.0f;
|
||||||
|
SDL_FRect bg{ x - pad, y - pad, (float)tw + pad * 2.0f, (float)th + pad * 2.0f };
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
|
||||||
|
SDL_RenderFillRect(renderer, &bg);
|
||||||
|
ctx.pixelFont->draw(renderer, x, y, text, scale, textColor);
|
||||||
|
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, prevBlend);
|
||||||
|
};
|
||||||
|
|
||||||
if (shouldBlur && m_renderTarget) {
|
if (shouldBlur && m_renderTarget) {
|
||||||
// Render game to texture
|
// Render game to texture
|
||||||
SDL_SetRenderTarget(renderer, m_renderTarget);
|
SDL_SetRenderTarget(renderer, m_renderTarget);
|
||||||
@ -244,26 +420,45 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
// Render game content (no overlays)
|
// Render game content (no overlays)
|
||||||
// If a transport effect was requested due to a recent spawn, start it here so
|
// If a transport effect was requested due to a recent spawn, start it here so
|
||||||
// the renderer has the correct layout and renderer context to compute coords.
|
// the renderer has the correct layout and renderer context to compute coords.
|
||||||
if (s_pendingTransport) {
|
if (!coopActive && s_pendingTransport) {
|
||||||
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||||
s_pendingTransport = false;
|
s_pendingTransport = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
GameRenderer::renderPlayingState(
|
if (coopActive && ctx.coopGame) {
|
||||||
renderer,
|
GameRenderer::renderCoopPlayingState(
|
||||||
ctx.game,
|
renderer,
|
||||||
ctx.pixelFont,
|
ctx.coopGame,
|
||||||
ctx.lineEffect,
|
ctx.pixelFont,
|
||||||
ctx.blocksTex,
|
ctx.lineEffect,
|
||||||
ctx.asteroidsTex,
|
ctx.blocksTex,
|
||||||
ctx.statisticsPanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.scorePanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.nextPanelTex,
|
||||||
ctx.holdPanelTex,
|
ctx.holdPanelTex,
|
||||||
countdown,
|
paused,
|
||||||
1200.0f, // LOGICAL_W
|
1200.0f,
|
||||||
1000.0f, // LOGICAL_H
|
1000.0f,
|
||||||
logicalScale,
|
logicalScale,
|
||||||
|
(float)winW,
|
||||||
|
(float)winH
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
GameRenderer::renderPlayingState(
|
||||||
|
renderer,
|
||||||
|
ctx.game,
|
||||||
|
ctx.pixelFont,
|
||||||
|
ctx.lineEffect,
|
||||||
|
ctx.blocksTex,
|
||||||
|
ctx.asteroidsTex,
|
||||||
|
ctx.statisticsPanelTex,
|
||||||
|
ctx.scorePanelTex,
|
||||||
|
ctx.nextPanelTex,
|
||||||
|
ctx.holdPanelTex,
|
||||||
|
countdown,
|
||||||
|
1200.0f, // LOGICAL_W
|
||||||
|
1000.0f, // LOGICAL_H
|
||||||
|
logicalScale,
|
||||||
(float)winW,
|
(float)winW,
|
||||||
(float)winH,
|
(float)winH,
|
||||||
challengeClearFx,
|
challengeClearFx,
|
||||||
@ -272,7 +467,8 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
challengeClearDuration,
|
challengeClearDuration,
|
||||||
countdown ? nullptr : ctx.challengeStoryText,
|
countdown ? nullptr : ctx.challengeStoryText,
|
||||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset to screen
|
// Reset to screen
|
||||||
SDL_SetRenderTarget(renderer, nullptr);
|
SDL_SetRenderTarget(renderer, nullptr);
|
||||||
@ -319,6 +515,9 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
SDL_SetRenderViewport(renderer, &oldVP);
|
SDL_SetRenderViewport(renderer, &oldVP);
|
||||||
SDL_SetRenderScale(renderer, oldSX, oldSY);
|
SDL_SetRenderScale(renderer, oldSX, oldSY);
|
||||||
|
|
||||||
|
// Net overlay (on top of blurred game, under pause/exit overlays)
|
||||||
|
renderNetOverlay();
|
||||||
|
|
||||||
// Draw overlays
|
// Draw overlays
|
||||||
if (exitPopup) {
|
if (exitPopup) {
|
||||||
GameRenderer::renderExitPopup(
|
GameRenderer::renderExitPopup(
|
||||||
@ -341,33 +540,57 @@ void PlayingState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Render normally directly to screen
|
// Render normally directly to screen
|
||||||
if (s_pendingTransport) {
|
if (!coopActive && s_pendingTransport) {
|
||||||
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
GameRenderer::startTransportEffectForGame(ctx.game, ctx.blocksTex, 1200.0f, 1000.0f, logicalScale, (float)winW, (float)winH, 0.4f);
|
||||||
s_pendingTransport = false;
|
s_pendingTransport = false;
|
||||||
}
|
}
|
||||||
GameRenderer::renderPlayingState(
|
|
||||||
renderer,
|
if (coopActive && ctx.coopGame) {
|
||||||
ctx.game,
|
GameRenderer::renderCoopPlayingState(
|
||||||
ctx.pixelFont,
|
renderer,
|
||||||
ctx.lineEffect,
|
ctx.coopGame,
|
||||||
ctx.blocksTex,
|
ctx.pixelFont,
|
||||||
ctx.asteroidsTex,
|
ctx.lineEffect,
|
||||||
ctx.statisticsPanelTex,
|
ctx.blocksTex,
|
||||||
ctx.scorePanelTex,
|
ctx.statisticsPanelTex,
|
||||||
ctx.nextPanelTex,
|
ctx.scorePanelTex,
|
||||||
ctx.holdPanelTex,
|
ctx.nextPanelTex,
|
||||||
countdown,
|
ctx.holdPanelTex,
|
||||||
1200.0f,
|
paused,
|
||||||
1000.0f,
|
1200.0f,
|
||||||
logicalScale,
|
1000.0f,
|
||||||
(float)winW,
|
logicalScale,
|
||||||
(float)winH,
|
(float)winW,
|
||||||
challengeClearFx,
|
(float)winH
|
||||||
challengeClearOrder,
|
);
|
||||||
challengeClearElapsed,
|
|
||||||
challengeClearDuration,
|
// Net overlay (on top of coop HUD)
|
||||||
countdown ? nullptr : ctx.challengeStoryText,
|
renderNetOverlay();
|
||||||
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
} else {
|
||||||
);
|
GameRenderer::renderPlayingState(
|
||||||
|
renderer,
|
||||||
|
ctx.game,
|
||||||
|
ctx.pixelFont,
|
||||||
|
ctx.lineEffect,
|
||||||
|
ctx.blocksTex,
|
||||||
|
ctx.asteroidsTex,
|
||||||
|
ctx.statisticsPanelTex,
|
||||||
|
ctx.scorePanelTex,
|
||||||
|
ctx.nextPanelTex,
|
||||||
|
ctx.holdPanelTex,
|
||||||
|
countdown,
|
||||||
|
1200.0f,
|
||||||
|
1000.0f,
|
||||||
|
logicalScale,
|
||||||
|
(float)winW,
|
||||||
|
(float)winH,
|
||||||
|
challengeClearFx,
|
||||||
|
challengeClearOrder,
|
||||||
|
challengeClearElapsed,
|
||||||
|
challengeClearDuration,
|
||||||
|
countdown ? nullptr : ctx.challengeStoryText,
|
||||||
|
countdown ? 0.0f : (ctx.challengeStoryAlpha ? *ctx.challengeStoryAlpha : 0.0f)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,13 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "../network/NetSession.h"
|
||||||
|
|
||||||
// Forward declarations for frequently used types
|
// Forward declarations for frequently used types
|
||||||
class Game;
|
class Game;
|
||||||
|
class CoopGame;
|
||||||
class ScoreManager;
|
class ScoreManager;
|
||||||
class Starfield;
|
class Starfield;
|
||||||
class Starfield3D;
|
class Starfield3D;
|
||||||
@ -24,6 +28,7 @@ class StateManager;
|
|||||||
struct StateContext {
|
struct StateContext {
|
||||||
// Core subsystems (may be null if not available)
|
// Core subsystems (may be null if not available)
|
||||||
Game* game = nullptr;
|
Game* game = nullptr;
|
||||||
|
CoopGame* coopGame = nullptr;
|
||||||
ScoreManager* scores = nullptr;
|
ScoreManager* scores = nullptr;
|
||||||
Starfield* starfield = nullptr;
|
Starfield* starfield = nullptr;
|
||||||
Starfield3D* starfield3D = nullptr;
|
Starfield3D* starfield3D = nullptr;
|
||||||
@ -77,12 +82,33 @@ struct StateContext {
|
|||||||
int* challengeStoryLevel = nullptr; // Cached level for the current story line
|
int* challengeStoryLevel = nullptr; // Cached level for the current story line
|
||||||
float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade
|
float* challengeStoryAlpha = nullptr; // Current render alpha for story text fade
|
||||||
std::string* playerName = nullptr; // Shared player name buffer for highscores/options
|
std::string* playerName = nullptr; // Shared player name buffer for highscores/options
|
||||||
|
// Coop setting: when true, COOPERATE runs with a computer-controlled right player.
|
||||||
|
bool* coopVsAI = nullptr;
|
||||||
|
|
||||||
|
// COOPERATE (network) --------------------------------------------------
|
||||||
|
// These fields are only meaningful when `coopNetEnabled` is true.
|
||||||
|
bool coopNetEnabled = false;
|
||||||
|
bool coopNetIsHost = false;
|
||||||
|
bool coopNetLocalIsLeft = true; // host = left (WASD), client = right (arrows)
|
||||||
|
uint32_t coopNetRngSeed = 0;
|
||||||
|
uint32_t coopNetTick = 0;
|
||||||
|
uint8_t coopNetPendingButtons = 0; // one-shot actions captured from keydown (rotate/hold/harddrop)
|
||||||
|
bool coopNetStalled = false; // true when waiting for remote input for current tick
|
||||||
|
bool coopNetDesyncDetected = false;
|
||||||
|
std::string coopNetUiStatusText; // transient status shown in menu after net abort
|
||||||
|
double coopNetUiStatusRemainingMs = 0.0;
|
||||||
|
std::unique_ptr<NetSession> coopNetSession;
|
||||||
bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available
|
bool* fullscreenFlag = nullptr; // Tracks current fullscreen state when available
|
||||||
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
|
std::function<void(bool)> applyFullscreen; // Allows states to request fullscreen changes
|
||||||
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable
|
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable
|
||||||
std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully
|
std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully
|
||||||
std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
|
std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
|
||||||
std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level)
|
std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level)
|
||||||
|
|
||||||
|
// Startup transition fade (used for intro video -> main).
|
||||||
|
// When active, the app should render a black overlay with alpha = startupFadeAlpha*255.
|
||||||
|
bool* startupFadeActive = nullptr;
|
||||||
|
float* startupFadeAlpha = nullptr;
|
||||||
// Pointer to the application's StateManager so states can request transitions
|
// Pointer to the application's StateManager so states can request transitions
|
||||||
StateManager* stateManager = nullptr;
|
StateManager* stateManager = nullptr;
|
||||||
// Optional explicit per-button coordinates (logical coordinates). When
|
// Optional explicit per-button coordinates (logical coordinates). When
|
||||||
|
|||||||
389
src/states/VideoState.cpp
Normal file
389
src/states/VideoState.cpp
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
// VideoState.cpp
|
||||||
|
#include "VideoState.h"
|
||||||
|
|
||||||
|
#include "../video/VideoPlayer.h"
|
||||||
|
#include "../audio/Audio.h"
|
||||||
|
#include "../core/state/StateManager.h"
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavutil/avutil.h>
|
||||||
|
#include <libavutil/channel_layout.h>
|
||||||
|
#include <libswresample/swresample.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoState::VideoState(StateContext& ctx)
|
||||||
|
: State(ctx)
|
||||||
|
, m_player(std::make_unique<VideoPlayer>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoState::~VideoState() {
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoState::begin(SDL_Renderer* renderer, const std::string& path) {
|
||||||
|
m_path = path;
|
||||||
|
|
||||||
|
if (!m_player) {
|
||||||
|
m_player = std::make_unique<VideoPlayer>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_player->open(m_path, renderer)) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to open intro video: %s", m_path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_player->decodeFirstFrame()) {
|
||||||
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to decode first frame: %s", m_path.c_str());
|
||||||
|
// Still allow entering; we will likely render black.
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::onEnter() {
|
||||||
|
m_phase = Phase::FadeInFirstFrame;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
m_blackOverlayAlpha = 1.0f;
|
||||||
|
|
||||||
|
m_audioDecoded.store(false);
|
||||||
|
m_audioDecodeFailed.store(false);
|
||||||
|
m_audioStarted = false;
|
||||||
|
m_audioPcm.clear();
|
||||||
|
m_audioRate = 44100;
|
||||||
|
m_audioChannels = 2;
|
||||||
|
|
||||||
|
// Decode audio in the background during fade-in.
|
||||||
|
m_audioThread = std::make_unique<std::jthread>([this](std::stop_token st) {
|
||||||
|
(void)st;
|
||||||
|
std::vector<int16_t> pcm;
|
||||||
|
int rate = 44100;
|
||||||
|
int channels = 2;
|
||||||
|
|
||||||
|
const bool ok = decodeAudioPcm16Stereo44100(m_path, pcm, rate, channels);
|
||||||
|
if (!ok) {
|
||||||
|
m_audioDecodeFailed.store(true);
|
||||||
|
m_audioDecoded.store(true, std::memory_order_release);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer results.
|
||||||
|
m_audioRate = rate;
|
||||||
|
m_audioChannels = channels;
|
||||||
|
m_audioPcm = std::move(pcm);
|
||||||
|
m_audioDecoded.store(true, std::memory_order_release);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::onExit() {
|
||||||
|
stopAudio();
|
||||||
|
|
||||||
|
if (m_audioThread) {
|
||||||
|
// Request stop and join.
|
||||||
|
m_audioThread.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::handleEvent(const SDL_Event& e) {
|
||||||
|
(void)e;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::startAudioIfReady() {
|
||||||
|
if (m_audioStarted) return;
|
||||||
|
if (!m_audioDecoded.load(std::memory_order_acquire)) return;
|
||||||
|
if (m_audioDecodeFailed.load()) return;
|
||||||
|
if (m_audioPcm.empty()) return;
|
||||||
|
|
||||||
|
// Use the existing audio output path (same device as music/SFX).
|
||||||
|
Audio::instance().playSfx(m_audioPcm, m_audioChannels, m_audioRate, 1.0f);
|
||||||
|
m_audioStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::stopAudio() {
|
||||||
|
// We currently feed intro audio as an SFX buffer into the mixer.
|
||||||
|
// It will naturally end; no explicit stop is required.
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::update(double frameMs) {
|
||||||
|
switch (m_phase) {
|
||||||
|
case Phase::FadeInFirstFrame: {
|
||||||
|
m_phaseClockMs += frameMs;
|
||||||
|
const float t = (FADE_IN_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_IN_MS, 0.0, 1.0)) : 1.0f;
|
||||||
|
m_blackOverlayAlpha = 1.0f - t;
|
||||||
|
|
||||||
|
if (t >= 1.0f) {
|
||||||
|
m_phase = Phase::Playing;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
if (m_player) {
|
||||||
|
m_player->start();
|
||||||
|
}
|
||||||
|
startAudioIfReady();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Phase::Playing: {
|
||||||
|
startAudioIfReady();
|
||||||
|
if (m_player) {
|
||||||
|
m_player->update(frameMs);
|
||||||
|
if (m_player->isFinished()) {
|
||||||
|
m_phase = Phase::FadeOutToBlack;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
m_blackOverlayAlpha = 0.0f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m_phase = Phase::FadeOutToBlack;
|
||||||
|
m_phaseClockMs = 0.0;
|
||||||
|
m_blackOverlayAlpha = 0.0f;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Phase::FadeOutToBlack: {
|
||||||
|
m_phaseClockMs += frameMs;
|
||||||
|
const float t = (FADE_OUT_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_OUT_MS, 0.0, 1.0)) : 1.0f;
|
||||||
|
m_blackOverlayAlpha = t;
|
||||||
|
if (t >= 1.0f) {
|
||||||
|
// Switch to MAIN (Menu) with a fade-in from black.
|
||||||
|
if (ctx.startupFadeAlpha) {
|
||||||
|
*ctx.startupFadeAlpha = 1.0f;
|
||||||
|
}
|
||||||
|
if (ctx.startupFadeActive) {
|
||||||
|
*ctx.startupFadeActive = true;
|
||||||
|
}
|
||||||
|
if (ctx.stateManager) {
|
||||||
|
ctx.stateManager->setState(AppState::Menu);
|
||||||
|
}
|
||||||
|
m_phase = Phase::Done;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Phase::Done:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) {
|
||||||
|
(void)logicalScale;
|
||||||
|
(void)logicalVP;
|
||||||
|
|
||||||
|
if (!renderer) return;
|
||||||
|
|
||||||
|
int winW = 0, winH = 0;
|
||||||
|
SDL_GetRenderOutputSize(renderer, &winW, &winH);
|
||||||
|
|
||||||
|
// Draw video fullscreen if available.
|
||||||
|
if (m_player && m_player->isTextureReady()) {
|
||||||
|
SDL_SetRenderViewport(renderer, nullptr);
|
||||||
|
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
|
||||||
|
m_player->render(renderer, winW, winH);
|
||||||
|
} else {
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
|
||||||
|
SDL_FRect r{0.f, 0.f, (float)winW, (float)winH};
|
||||||
|
SDL_RenderFillRect(renderer, &r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply fade overlay (black).
|
||||||
|
if (m_blackOverlayAlpha > 0.0f) {
|
||||||
|
const Uint8 a = (Uint8)std::clamp((int)std::lround(m_blackOverlayAlpha * 255.0f), 0, 255);
|
||||||
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, a);
|
||||||
|
SDL_FRect full{0.f, 0.f, (float)winW, (float)winH};
|
||||||
|
SDL_RenderFillRect(renderer, &full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoState::decodeAudioPcm16Stereo44100(
|
||||||
|
const std::string& path,
|
||||||
|
std::vector<int16_t>& outPcm,
|
||||||
|
int& outRate,
|
||||||
|
int& outChannels
|
||||||
|
) {
|
||||||
|
outPcm.clear();
|
||||||
|
outRate = 44100;
|
||||||
|
outChannels = 2;
|
||||||
|
|
||||||
|
AVFormatContext* fmt = nullptr;
|
||||||
|
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avformat_find_stream_info(fmt, nullptr) < 0) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int audioStream = -1;
|
||||||
|
for (unsigned i = 0; i < fmt->nb_streams; ++i) {
|
||||||
|
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
|
||||||
|
audioStream = (int)i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audioStream < 0) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVCodecParameters* codecpar = fmt->streams[audioStream]->codecpar;
|
||||||
|
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
|
||||||
|
if (!codec) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVCodecContext* dec = avcodec_alloc_context3(codec);
|
||||||
|
if (!dec) {
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_parameters_to_context(dec, codecpar) < 0) {
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_open2(dec, codec, nullptr) < 0) {
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVChannelLayout outLayout{};
|
||||||
|
av_channel_layout_default(&outLayout, 2);
|
||||||
|
|
||||||
|
AVChannelLayout inLayout{};
|
||||||
|
if (av_channel_layout_copy(&inLayout, &dec->ch_layout) < 0 || inLayout.nb_channels <= 0) {
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_default(&inLayout, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
SwrContext* swr = nullptr;
|
||||||
|
if (swr_alloc_set_opts2(
|
||||||
|
&swr,
|
||||||
|
&outLayout,
|
||||||
|
AV_SAMPLE_FMT_S16,
|
||||||
|
44100,
|
||||||
|
&inLayout,
|
||||||
|
dec->sample_fmt,
|
||||||
|
dec->sample_rate,
|
||||||
|
0,
|
||||||
|
nullptr
|
||||||
|
) < 0) {
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swr_init(swr) < 0) {
|
||||||
|
swr_free(&swr);
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVPacket* pkt = av_packet_alloc();
|
||||||
|
AVFrame* frame = av_frame_alloc();
|
||||||
|
if (!pkt || !frame) {
|
||||||
|
if (pkt) av_packet_free(&pkt);
|
||||||
|
if (frame) av_frame_free(&frame);
|
||||||
|
swr_free(&swr);
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int outRateConst = 44100;
|
||||||
|
const int outCh = 2;
|
||||||
|
|
||||||
|
while (av_read_frame(fmt, pkt) >= 0) {
|
||||||
|
if (pkt->stream_index != audioStream) {
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_send_packet(dec, pkt) < 0) {
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const int rr = avcodec_receive_frame(dec, frame);
|
||||||
|
if (rr == AVERROR(EAGAIN) || rr == AVERROR_EOF) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (rr < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
|
||||||
|
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
|
||||||
|
|
||||||
|
std::vector<uint8_t> outBytes;
|
||||||
|
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
|
||||||
|
|
||||||
|
uint8_t* outData[1] = { outBytes.data() };
|
||||||
|
const uint8_t** inData = (const uint8_t**)frame->data;
|
||||||
|
|
||||||
|
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
|
||||||
|
if (converted > 0) {
|
||||||
|
const size_t samplesOut = (size_t)converted * (size_t)outCh;
|
||||||
|
const int16_t* asS16 = (const int16_t*)outBytes.data();
|
||||||
|
const size_t oldSize = outPcm.size();
|
||||||
|
outPcm.resize(oldSize + samplesOut);
|
||||||
|
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
av_frame_unref(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush decoder
|
||||||
|
avcodec_send_packet(dec, nullptr);
|
||||||
|
while (avcodec_receive_frame(dec, frame) >= 0) {
|
||||||
|
const int64_t delay = swr_get_delay(swr, dec->sample_rate);
|
||||||
|
const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP);
|
||||||
|
std::vector<uint8_t> outBytes;
|
||||||
|
outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t));
|
||||||
|
uint8_t* outData[1] = { outBytes.data() };
|
||||||
|
const uint8_t** inData = (const uint8_t**)frame->data;
|
||||||
|
const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples);
|
||||||
|
if (converted > 0) {
|
||||||
|
const size_t samplesOut = (size_t)converted * (size_t)outCh;
|
||||||
|
const int16_t* asS16 = (const int16_t*)outBytes.data();
|
||||||
|
const size_t oldSize = outPcm.size();
|
||||||
|
outPcm.resize(oldSize + samplesOut);
|
||||||
|
std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
av_frame_unref(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
av_frame_free(&frame);
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
swr_free(&swr);
|
||||||
|
av_channel_layout_uninit(&inLayout);
|
||||||
|
av_channel_layout_uninit(&outLayout);
|
||||||
|
avcodec_free_context(&dec);
|
||||||
|
avformat_close_input(&fmt);
|
||||||
|
|
||||||
|
outRate = outRateConst;
|
||||||
|
outChannels = outCh;
|
||||||
|
|
||||||
|
return !outPcm.empty();
|
||||||
|
}
|
||||||
67
src/states/VideoState.h
Normal file
67
src/states/VideoState.h
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// VideoState.h
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "State.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class VideoPlayer;
|
||||||
|
|
||||||
|
class VideoState : public State {
|
||||||
|
public:
|
||||||
|
explicit VideoState(StateContext& ctx);
|
||||||
|
~VideoState() override;
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void handleEvent(const SDL_Event& e) override;
|
||||||
|
void update(double frameMs) override;
|
||||||
|
void render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) override;
|
||||||
|
|
||||||
|
// Called from the App's on-enter hook so we can create textures.
|
||||||
|
bool begin(SDL_Renderer* renderer, const std::string& path);
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Phase {
|
||||||
|
FadeInFirstFrame,
|
||||||
|
Playing,
|
||||||
|
FadeOutToBlack,
|
||||||
|
Done
|
||||||
|
};
|
||||||
|
|
||||||
|
void startAudioIfReady();
|
||||||
|
void stopAudio();
|
||||||
|
|
||||||
|
static bool decodeAudioPcm16Stereo44100(
|
||||||
|
const std::string& path,
|
||||||
|
std::vector<int16_t>& outPcm,
|
||||||
|
int& outRate,
|
||||||
|
int& outChannels
|
||||||
|
);
|
||||||
|
|
||||||
|
std::unique_ptr<VideoPlayer> m_player;
|
||||||
|
std::string m_path;
|
||||||
|
|
||||||
|
Phase m_phase = Phase::FadeInFirstFrame;
|
||||||
|
double m_phaseClockMs = 0.0;
|
||||||
|
|
||||||
|
static constexpr double FADE_IN_MS = 900.0;
|
||||||
|
static constexpr double FADE_OUT_MS = 450.0;
|
||||||
|
|
||||||
|
// Audio decoding runs in the background while we fade in.
|
||||||
|
std::atomic<bool> m_audioDecoded{false};
|
||||||
|
std::atomic<bool> m_audioDecodeFailed{false};
|
||||||
|
std::vector<int16_t> m_audioPcm;
|
||||||
|
int m_audioRate = 44100;
|
||||||
|
int m_audioChannels = 2;
|
||||||
|
bool m_audioStarted = false;
|
||||||
|
|
||||||
|
std::unique_ptr<std::jthread> m_audioThread;
|
||||||
|
|
||||||
|
// Render-time overlay alpha (0..1) for fade stages.
|
||||||
|
float m_blackOverlayAlpha = 1.0f;
|
||||||
|
};
|
||||||
@ -13,7 +13,7 @@ static bool pointInRect(const SDL_FRect& r, float x, float y) {
|
|||||||
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
|
return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI) {
|
||||||
BottomMenu menu{};
|
BottomMenu menu{};
|
||||||
|
|
||||||
auto rects = computeMenuButtonRects(params);
|
auto rects = computeMenuButtonRects(params);
|
||||||
@ -22,12 +22,14 @@ BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel) {
|
|||||||
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
std::snprintf(levelBtnText, sizeof(levelBtnText), "LEVEL %d", startLevel);
|
||||||
|
|
||||||
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
menu.buttons[0] = Button{ BottomMenuItem::Play, rects[0], "PLAY", false };
|
||||||
menu.buttons[1] = Button{ BottomMenuItem::Challenge, rects[1], "CHALLENGE", false };
|
// Always show a neutral "COOPERATE" label (remove per-mode suffixes)
|
||||||
menu.buttons[2] = Button{ BottomMenuItem::Level, rects[2], levelBtnText, true };
|
menu.buttons[1] = Button{ BottomMenuItem::Cooperate, rects[1], "COOPERATE", false };
|
||||||
menu.buttons[3] = Button{ BottomMenuItem::Options, rects[3], "OPTIONS", true };
|
menu.buttons[2] = Button{ BottomMenuItem::Challenge, rects[2], "CHALLENGE", false };
|
||||||
menu.buttons[4] = Button{ BottomMenuItem::Help, rects[4], "HELP", true };
|
menu.buttons[3] = Button{ BottomMenuItem::Level, rects[3], levelBtnText, true };
|
||||||
menu.buttons[5] = Button{ BottomMenuItem::About, rects[5], "ABOUT", true };
|
menu.buttons[4] = Button{ BottomMenuItem::Options, rects[4], "OPTIONS", true };
|
||||||
menu.buttons[6] = Button{ BottomMenuItem::Exit, rects[6], "EXIT", true };
|
menu.buttons[5] = Button{ BottomMenuItem::Help, rects[5], "HELP", true };
|
||||||
|
menu.buttons[6] = Button{ BottomMenuItem::About, rects[6], "ABOUT", true };
|
||||||
|
menu.buttons[7] = Button{ BottomMenuItem::Exit, rects[7], "EXIT", true };
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
@ -62,10 +64,15 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
|||||||
|
|
||||||
if (!b.textOnly) {
|
if (!b.textOnly) {
|
||||||
const bool isPlay = (i == 0);
|
const bool isPlay = (i == 0);
|
||||||
const bool isChallenge = (i == 1);
|
const bool isCoop = (i == 1);
|
||||||
|
const bool isChallenge = (i == 2);
|
||||||
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
SDL_Color bgCol{ 18, 22, 28, static_cast<Uint8>(std::round(180.0 * aMul)) };
|
||||||
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
SDL_Color bdCol{ 255, 200, 70, static_cast<Uint8>(std::round(220.0 * aMul)) };
|
||||||
if (isChallenge) {
|
if (isCoop) {
|
||||||
|
// Cooperative mode gets a cyan/magenta accent to separate from Endless/Challenge
|
||||||
|
bgCol = SDL_Color{ 22, 30, 40, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||||
|
bdCol = SDL_Color{ 160, 210, 255, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||||
|
} else if (isChallenge) {
|
||||||
// Give Challenge a teal accent to distinguish from Play
|
// Give Challenge a teal accent to distinguish from Play
|
||||||
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
bgCol = SDL_Color{ 18, 36, 36, static_cast<Uint8>(std::round(190.0 * aMul)) };
|
||||||
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
bdCol = SDL_Color{ 120, 255, 220, static_cast<Uint8>(std::round(230.0 * aMul)) };
|
||||||
@ -82,14 +89,14 @@ void renderBottomMenu(SDL_Renderer* renderer,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// '+' separators between the bottom HUD buttons (indices 2..last)
|
// '+' separators between the bottom HUD buttons (indices 3..last)
|
||||||
{
|
{
|
||||||
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
SDL_BlendMode prevBlend = SDL_BLENDMODE_NONE;
|
||||||
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
SDL_GetRenderDrawBlendMode(renderer, &prevBlend);
|
||||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||||
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
SDL_SetRenderDrawColor(renderer, 120, 220, 255, static_cast<Uint8>(std::round(180.0 * baseMul)));
|
||||||
|
|
||||||
const int firstSmall = 2;
|
const int firstSmall = 3;
|
||||||
const int lastSmall = MENU_BTN_COUNT - 1;
|
const int lastSmall = MENU_BTN_COUNT - 1;
|
||||||
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
float y = menu.buttons[firstSmall].rect.y + menu.buttons[firstSmall].rect.h * 0.5f;
|
||||||
for (int i = firstSmall; i < lastSmall; ++i) {
|
for (int i = firstSmall; i < lastSmall; ++i) {
|
||||||
|
|||||||
@ -15,12 +15,13 @@ namespace ui {
|
|||||||
|
|
||||||
enum class BottomMenuItem : int {
|
enum class BottomMenuItem : int {
|
||||||
Play = 0,
|
Play = 0,
|
||||||
Challenge = 1,
|
Cooperate = 1,
|
||||||
Level = 2,
|
Challenge = 2,
|
||||||
Options = 3,
|
Level = 3,
|
||||||
Help = 4,
|
Options = 4,
|
||||||
About = 5,
|
Help = 5,
|
||||||
Exit = 6,
|
About = 6,
|
||||||
|
Exit = 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Button {
|
struct Button {
|
||||||
@ -34,11 +35,11 @@ struct BottomMenu {
|
|||||||
std::array<Button, MENU_BTN_COUNT> buttons{};
|
std::array<Button, MENU_BTN_COUNT> buttons{};
|
||||||
};
|
};
|
||||||
|
|
||||||
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel);
|
BottomMenu buildBottomMenu(const MenuLayoutParams& params, int startLevel, bool coopVsAI);
|
||||||
|
|
||||||
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
|
// Draws the cockpit HUD menu (PLAY + 4 bottom items) using existing UIRenderer primitives.
|
||||||
// hoveredIndex: -1..5
|
// hoveredIndex: -1..7
|
||||||
// selectedIndex: 0..5 (keyboard selection)
|
// selectedIndex: 0..7 (keyboard selection)
|
||||||
// alphaMul: 0..1 (overall group alpha)
|
// alphaMul: 0..1 (overall group alpha)
|
||||||
void renderBottomMenu(SDL_Renderer* renderer,
|
void renderBottomMenu(SDL_Renderer* renderer,
|
||||||
FontAtlas* font,
|
FontAtlas* font,
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
#include "ui/MenuLayout.h"
|
#include "ui/MenuLayout.h"
|
||||||
#include "ui/UIConstants.h"
|
#include "ui/UIConstants.h"
|
||||||
#include <cmath>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
float contentOffsetY = (p.winH - LOGICAL_H * p.logicalScale) * 0.5f / p.logicalScale;
|
||||||
|
|
||||||
// Cockpit HUD layout (matches main_screen art):
|
// Cockpit HUD layout (matches main_screen art):
|
||||||
// - Top row: PLAY and CHALLENGE (big buttons)
|
// - Top row: PLAY / COOPERATE / CHALLENGE (big buttons)
|
||||||
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
// - Second row: LEVEL / OPTIONS / HELP / ABOUT / EXIT (smaller buttons)
|
||||||
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
const float marginX = std::max(24.0f, LOGICAL_W * 0.03f);
|
||||||
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
const float marginBottom = std::max(26.0f, LOGICAL_H * 0.03f);
|
||||||
@ -26,9 +27,10 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
float smallSpacing = 26.0f;
|
float smallSpacing = 26.0f;
|
||||||
|
|
||||||
// Scale down for narrow windows so nothing goes offscreen.
|
// Scale down for narrow windows so nothing goes offscreen.
|
||||||
const int smallCount = MENU_BTN_COUNT - 2;
|
const int bigCount = 3;
|
||||||
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
const int smallCount = MENU_BTN_COUNT - bigCount;
|
||||||
float topRowTotal = playW * 2.0f + bigGap;
|
float smallTotal = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(std::max(smallCount - 1, 0));
|
||||||
|
float topRowTotal = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||||
if (smallTotal > availableW || topRowTotal > availableW) {
|
if (smallTotal > availableW || topRowTotal > availableW) {
|
||||||
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
float s = availableW / std::max(std::max(smallTotal, topRowTotal), 1.0f);
|
||||||
smallW *= s;
|
smallW *= s;
|
||||||
@ -48,11 +50,13 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
float playCY = smallCY - smallH * 0.5f - rowGap - playH * 0.5f;
|
||||||
|
|
||||||
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
std::array<SDL_FRect, MENU_BTN_COUNT> rects{};
|
||||||
// Top row big buttons
|
// Top row big buttons (PLAY / COOPERATE / CHALLENGE)
|
||||||
float playLeft = centerX - (playW + bigGap * 0.5f);
|
float bigRowW = playW * static_cast<float>(bigCount) + bigGap * static_cast<float>(bigCount - 1);
|
||||||
float challengeLeft = centerX + bigGap * 0.5f;
|
float leftBig = centerX - bigRowW * 0.5f;
|
||||||
rects[0] = SDL_FRect{ playLeft, playCY - playH * 0.5f, playW, playH };
|
for (int i = 0; i < bigCount; ++i) {
|
||||||
rects[1] = SDL_FRect{ challengeLeft, playCY - playH * 0.5f, playW, playH };
|
float x = leftBig + i * (playW + bigGap);
|
||||||
|
rects[i] = SDL_FRect{ x, playCY - playH * 0.5f, playW, playH };
|
||||||
|
}
|
||||||
|
|
||||||
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
float rowW = smallW * static_cast<float>(smallCount) + smallSpacing * static_cast<float>(smallCount - 1);
|
||||||
float left = centerX - rowW * 0.5f;
|
float left = centerX - rowW * 0.5f;
|
||||||
@ -63,7 +67,7 @@ std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutPar
|
|||||||
|
|
||||||
for (int i = 0; i < smallCount; ++i) {
|
for (int i = 0; i < smallCount; ++i) {
|
||||||
float x = left + i * (smallW + smallSpacing);
|
float x = left + i * (smallW + smallSpacing);
|
||||||
rects[i + 2] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
rects[i + bigCount] = SDL_FRect{ x, smallCY - smallH * 0.5f, smallW, smallH };
|
||||||
}
|
}
|
||||||
return rects;
|
return rects;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ struct MenuLayoutParams {
|
|||||||
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
|
std::array<SDL_FRect, MENU_BTN_COUNT> computeMenuButtonRects(const MenuLayoutParams& p);
|
||||||
|
|
||||||
// Hit test a point given in logical content-local coordinates against menu buttons
|
// Hit test a point given in logical content-local coordinates against menu buttons
|
||||||
// Returns index 0..4 or -1 if none
|
// Returns index 0..(MENU_BTN_COUNT-1) or -1 if none
|
||||||
int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
|
int hitTestMenuButtons(const MenuLayoutParams& p, float localX, float localY);
|
||||||
|
|
||||||
// Return settings button rect (logical coords)
|
// Return settings button rect (logical coords)
|
||||||
|
|||||||
@ -83,6 +83,6 @@ void menu_drawSettingsPopup(SDL_Renderer* renderer, FontAtlas& font, bool musicE
|
|||||||
bool sfxOn = true;
|
bool sfxOn = true;
|
||||||
font.draw(renderer, popupX + 140, popupY + 100, sfxOn ? "ON" : "OFF", 1.5f, sfxOn ? SDL_Color{0,255,0,255} : SDL_Color{255,0,0,255});
|
font.draw(renderer, popupX + 140, popupY + 100, sfxOn ? "ON" : "OFF", 1.5f, sfxOn ? SDL_Color{0,255,0,255} : SDL_Color{255,0,0,255});
|
||||||
font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, SDL_Color{200,200,220,255});
|
font.draw(renderer, popupX + 20, popupY + 150, "M = TOGGLE MUSIC", 1.0f, SDL_Color{200,200,220,255});
|
||||||
font.draw(renderer, popupX + 20, popupY + 170, "S = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255});
|
font.draw(renderer, popupX + 20, popupY + 170, "K = TOGGLE SOUND FX", 1.0f, SDL_Color{200,200,220,255});
|
||||||
font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, SDL_Color{200,200,220,255});
|
font.draw(renderer, popupX + 20, popupY + 190, "ESC = CLOSE", 1.0f, SDL_Color{200,200,220,255});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
static constexpr int MENU_BTN_COUNT = 7;
|
static constexpr int MENU_BTN_COUNT = 8;
|
||||||
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
static constexpr float MENU_SMALL_THRESHOLD = 700.0f;
|
||||||
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
static constexpr float MENU_BTN_WIDTH_LARGE = 300.0f;
|
||||||
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
static constexpr float MENU_BTN_WIDTH_SMALL_FACTOR = 0.4f; // multiplied by LOGICAL_W
|
||||||
|
|||||||
172
src/video/VideoPlayer.cpp
Normal file
172
src/video/VideoPlayer.cpp
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
#include "VideoPlayer.h"
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libswscale/swscale.h>
|
||||||
|
#include <libavutil/imgutils.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayer::VideoPlayer() {}
|
||||||
|
|
||||||
|
VideoPlayer::~VideoPlayer() {
|
||||||
|
if (m_texture) SDL_DestroyTexture(m_texture);
|
||||||
|
if (m_rgbBuffer) av_free(m_rgbBuffer);
|
||||||
|
if (m_frame) av_frame_free(&m_frame);
|
||||||
|
if (m_sws) sws_freeContext(m_sws);
|
||||||
|
if (m_dec) avcodec_free_context(&m_dec);
|
||||||
|
if (m_fmt) avformat_close_input(&m_fmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::open(const std::string& path, SDL_Renderer* renderer) {
|
||||||
|
m_path = path;
|
||||||
|
avformat_network_init();
|
||||||
|
if (avformat_open_input(&m_fmt, path.c_str(), nullptr, nullptr) != 0) {
|
||||||
|
std::cerr << "VideoPlayer: failed to open " << path << "\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (avformat_find_stream_info(m_fmt, nullptr) < 0) {
|
||||||
|
std::cerr << "VideoPlayer: failed to find stream info\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Find video stream
|
||||||
|
m_videoStream = -1;
|
||||||
|
for (unsigned i = 0; i < m_fmt->nb_streams; ++i) {
|
||||||
|
if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { m_videoStream = (int)i; break; }
|
||||||
|
}
|
||||||
|
if (m_videoStream < 0) { std::cerr << "VideoPlayer: no video stream\n"; return false; }
|
||||||
|
|
||||||
|
AVCodecParameters* codecpar = m_fmt->streams[m_videoStream]->codecpar;
|
||||||
|
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
|
||||||
|
if (!codec) { std::cerr << "VideoPlayer: decoder not found\n"; return false; }
|
||||||
|
m_dec = avcodec_alloc_context3(codec);
|
||||||
|
if (!m_dec) { std::cerr << "VideoPlayer: failed to alloc codec ctx\n"; return false; }
|
||||||
|
if (avcodec_parameters_to_context(m_dec, codecpar) < 0) { std::cerr << "VideoPlayer: param to ctx failed\n"; return false; }
|
||||||
|
if (avcodec_open2(m_dec, codec, nullptr) < 0) { std::cerr << "VideoPlayer: open codec failed\n"; return false; }
|
||||||
|
|
||||||
|
m_width = m_dec->width;
|
||||||
|
m_height = m_dec->height;
|
||||||
|
m_frame = av_frame_alloc();
|
||||||
|
m_sws = sws_getContext(m_width, m_height, m_dec->pix_fmt, m_width, m_height, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||||
|
m_rgbBufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGBA, m_width, m_height, 1);
|
||||||
|
m_rgbBuffer = (uint8_t*)av_malloc(m_rgbBufferSize);
|
||||||
|
|
||||||
|
if (renderer) {
|
||||||
|
m_texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, m_width, m_height);
|
||||||
|
if (!m_texture) { std::cerr << "VideoPlayer: failed create texture\n"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
m_finished = false;
|
||||||
|
m_textureReady = false;
|
||||||
|
m_started = false;
|
||||||
|
m_frameAccumulatorMs = 0.0;
|
||||||
|
|
||||||
|
// Estimate frame interval.
|
||||||
|
m_frameIntervalMs = 33.333;
|
||||||
|
if (m_fmt && m_videoStream >= 0) {
|
||||||
|
AVRational fr = m_fmt->streams[m_videoStream]->avg_frame_rate;
|
||||||
|
if (fr.num > 0 && fr.den > 0) {
|
||||||
|
const double fps = av_q2d(fr);
|
||||||
|
if (fps > 1.0) {
|
||||||
|
m_frameIntervalMs = 1000.0 / fps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek to start
|
||||||
|
av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (m_dec) avcodec_flush_buffers(m_dec);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::decodeOneFrame() {
|
||||||
|
if (m_finished || !m_fmt) return false;
|
||||||
|
|
||||||
|
AVPacket* pkt = av_packet_alloc();
|
||||||
|
if (!pkt) {
|
||||||
|
m_finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = 0;
|
||||||
|
while (av_read_frame(m_fmt, pkt) >= 0) {
|
||||||
|
if (pkt->stream_index == m_videoStream) {
|
||||||
|
ret = avcodec_send_packet(m_dec, pkt);
|
||||||
|
if (ret < 0) {
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ret >= 0) {
|
||||||
|
ret = avcodec_receive_frame(m_dec, m_frame);
|
||||||
|
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
|
||||||
|
if (ret < 0) break;
|
||||||
|
|
||||||
|
uint8_t* dstData[4] = { m_rgbBuffer, nullptr, nullptr, nullptr };
|
||||||
|
int dstLinesize[4] = { m_width * 4, 0, 0, 0 };
|
||||||
|
sws_scale(m_sws, m_frame->data, m_frame->linesize, 0, m_height, dstData, dstLinesize);
|
||||||
|
m_textureReady = true;
|
||||||
|
if (m_texture) {
|
||||||
|
SDL_UpdateTexture(m_texture, nullptr, m_rgbBuffer, dstLinesize[0]);
|
||||||
|
}
|
||||||
|
av_frame_unref(m_frame);
|
||||||
|
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
av_packet_unref(pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
m_finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::decodeFirstFrame() {
|
||||||
|
if (!m_fmt || m_finished) return false;
|
||||||
|
if (m_textureReady) return true;
|
||||||
|
// Ensure we are at the beginning.
|
||||||
|
av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (m_dec) avcodec_flush_buffers(m_dec);
|
||||||
|
return decodeOneFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoPlayer::start() {
|
||||||
|
m_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::update(double deltaMs) {
|
||||||
|
if (m_finished || !m_fmt) return false;
|
||||||
|
if (!m_started) return true;
|
||||||
|
|
||||||
|
m_frameAccumulatorMs += deltaMs;
|
||||||
|
|
||||||
|
// Decode at most a small burst per frame to avoid spiral-of-death.
|
||||||
|
int framesDecoded = 0;
|
||||||
|
const int maxFramesPerTick = 4;
|
||||||
|
while (m_frameAccumulatorMs >= m_frameIntervalMs && framesDecoded < maxFramesPerTick) {
|
||||||
|
m_frameAccumulatorMs -= m_frameIntervalMs;
|
||||||
|
if (!decodeOneFrame()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
++framesDecoded;
|
||||||
|
}
|
||||||
|
return !m_finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VideoPlayer::update() {
|
||||||
|
// Legacy behavior: decode exactly one frame.
|
||||||
|
return decodeOneFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoPlayer::render(SDL_Renderer* renderer, int winW, int winH) {
|
||||||
|
if (!m_textureReady || !m_texture || !renderer) return;
|
||||||
|
if (winW <= 0 || winH <= 0) return;
|
||||||
|
SDL_FRect dst = { 0.0f, 0.0f, (float)winW, (float)winH };
|
||||||
|
SDL_RenderTexture(renderer, m_texture, nullptr, &dst);
|
||||||
|
}
|
||||||
59
src/video/VideoPlayer.h
Normal file
59
src/video/VideoPlayer.h
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Minimal FFmpeg-based video player (video) that decodes into an SDL texture.
|
||||||
|
// Audio for the intro is currently handled outside this class.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
struct AVFormatContext;
|
||||||
|
struct AVCodecContext;
|
||||||
|
struct SwsContext;
|
||||||
|
struct AVFrame;
|
||||||
|
|
||||||
|
class VideoPlayer {
|
||||||
|
public:
|
||||||
|
VideoPlayer();
|
||||||
|
~VideoPlayer();
|
||||||
|
|
||||||
|
// Open video file and attach to SDL_Renderer for texture creation
|
||||||
|
bool open(const std::string& path, SDL_Renderer* renderer);
|
||||||
|
// Decode the first frame immediately so it can be used for fade-in.
|
||||||
|
bool decodeFirstFrame();
|
||||||
|
|
||||||
|
// Start time-based playback.
|
||||||
|
void start();
|
||||||
|
|
||||||
|
// Update playback using elapsed time in milliseconds.
|
||||||
|
// Returns false if finished or error.
|
||||||
|
bool update(double deltaMs);
|
||||||
|
|
||||||
|
// Compatibility: advance by one decoded frame.
|
||||||
|
bool update();
|
||||||
|
|
||||||
|
// Render video frame fullscreen to the given renderer using provided output size.
|
||||||
|
void render(SDL_Renderer* renderer, int winW, int winH);
|
||||||
|
bool isFinished() const { return m_finished; }
|
||||||
|
bool isTextureReady() const { return m_textureReady; }
|
||||||
|
|
||||||
|
double getFrameIntervalMs() const { return m_frameIntervalMs; }
|
||||||
|
bool isStarted() const { return m_started; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool decodeOneFrame();
|
||||||
|
|
||||||
|
AVFormatContext* m_fmt = nullptr;
|
||||||
|
AVCodecContext* m_dec = nullptr;
|
||||||
|
SwsContext* m_sws = nullptr;
|
||||||
|
AVFrame* m_frame = nullptr;
|
||||||
|
int m_videoStream = -1;
|
||||||
|
double m_frameIntervalMs = 33.333;
|
||||||
|
double m_frameAccumulatorMs = 0.0;
|
||||||
|
bool m_started = false;
|
||||||
|
int m_width = 0, m_height = 0;
|
||||||
|
SDL_Texture* m_texture = nullptr;
|
||||||
|
uint8_t* m_rgbBuffer = nullptr;
|
||||||
|
int m_rgbBufferSize = 0;
|
||||||
|
bool m_textureReady = false;
|
||||||
|
bool m_finished = true;
|
||||||
|
std::string m_path;
|
||||||
|
};
|
||||||
@ -6,8 +6,10 @@
|
|||||||
"name": "sdl3-image",
|
"name": "sdl3-image",
|
||||||
"features": ["jpeg", "png", "webp"]
|
"features": ["jpeg", "png", "webp"]
|
||||||
},
|
},
|
||||||
|
"enet",
|
||||||
"catch2",
|
"catch2",
|
||||||
"cpr",
|
"cpr",
|
||||||
"nlohmann-json"
|
"nlohmann-json",
|
||||||
|
"ffmpeg"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user