Compare commits

...
16 Commits
58 changed files with 5705 additions and 2787 deletions

No files matched your search

+26 -6
View File
@@ -41,7 +41,7 @@ configured player slot connects or disconnects.
`WebsocketGameClient` implements `IGameServer`. `WebsocketGameClient` implements `IGameServer`.
- It connects asynchronously to `ws://127.0.0.1:8080/`. - It connects asynchronously to a `ws://` or `wss://` endpoint (`WebsocketEndpoint::parse`).
- Received messages are queued by the network thread and applied on the game - Received messages are queued by the network thread and applied on the game
thread from `update()`. thread from `update()`.
- It maintains an internal `InternalGameServer` replica so existing snapshot - It maintains an internal `InternalGameServer` replica so existing snapshot
@@ -144,7 +144,15 @@ Networking is enabled for Windows and the Emscripten web client:
- `proj-web/CMakeLists.txt` defines `NETWORK`, compiles the shared protocol, - `proj-web/CMakeLists.txt` defines `NETWORK`, compiles the shared protocol,
replica, and remote-host code, and links Emscripten's `websocket.js` library. replica, and remote-host code, and links Emscripten's `websocket.js` library.
`WebsocketGameClient.cpp` selects the browser WebSocket API on Emscripten; it `WebsocketGameClient.cpp` selects the browser WebSocket API on Emscripten; it
does not use Boost.Asio or Boost.Beast there. does not use Boost.Asio, Boost.Beast, or OpenSSL there. The browser performs
the TLS handshake and certificate/hostname validation for `wss://`.
- `proj-windows` defines
`NETWORK_TLS` and links static OpenSSL 3.4.1, which CMake downloads and builds
automatically at configure time (`cmake/ThirdParty.cmake`, section 12) using a
downloaded portable Strawberry Perl and the nmake from the installed Visual Studio
(located through `vswhere`). Builds go to `thirdparty/openssl-3.4.1/install-Debug`
and `install-Release`; later configures skip them. `NETWORK_TLS` unset (server tests)
means native `wss://` endpoints fail with a logged error.
- Linux and Android builds do not define `NETWORK` and do not compile the - Linux and Android builds do not define `NETWORK` and do not compile the
network transport. network transport.
@@ -163,15 +171,24 @@ Run it from the repository root:
.\server\build\Debug\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior .\server\build\Debug\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
``` ```
The listener defaults to `0.0.0.0:8080`. It can be changed with: The listener defaults to `0.0.0.0:8080` and serves plain WebSocket traffic. It can be changed with:
```text ```text
--address IP --address IP
--port N --port N
``` ```
Launch the Windows game and select `battle_zone_multiplayer`. The client connects to Launch the Windows game and select `battle_zone_multiplayer`. The production client
`127.0.0.1:8080`. Selecting another campaign continues to use the local server. default is `wss://api.shadowoverbishkek.com/server001/`; the endpoint is currently
configured in `Game.cpp`. Selecting another campaign continues to use the local
server.
For the Web build, serve the page over HTTPS and ensure the TLS-terminating reverse
proxy presents a valid certificate for `api.shadowoverbishkek.com`, accepts the
WebSocket upgrade at `/server001/`, and forwards the upgraded connection to the
plain server listener. A page served over HTTPS cannot connect to a `ws://` endpoint
because browsers block that mixed-content request. Certificate and hostname errors
are enforced by the browser and cannot be bypassed by the C++ client.
## Verification ## Verification
@@ -196,7 +213,10 @@ submission, and contiguous tick consumption.
not implemented. not implemented.
- There is no authentication or reconnect/replay history. - There is no authentication or reconnect/replay history.
- A hash mismatch is logged but does not yet trigger checkpoint resynchronization. - A hash mismatch is logged but does not yet trigger checkpoint resynchronization.
- The endpoint is hardcoded in the graphical client. - The endpoint is hardcoded in the graphical client (`kMultiplayerServerUrl` in
`Game.cpp`, currently `wss://api.shadowoverbishkek.com/server001/`).
- TLS certificate or hostname problems are logged but deliberately do not abort the
connection. `GameServer` itself has no TLS; it must sit behind a TLS-terminating proxy.
- Multiplayer Lua and dialogue behavior remains unsupported. - Multiplayer Lua and dialogue behavior remains unsupported.
- Determinism is supported for identical executable/content builds. The - Determinism is supported for identical executable/content builds. The
floating-point simulation is not guaranteed bit-identical across platforms floating-point simulation is not guaranteed bit-identical across platforms
+27
View File
@@ -56,3 +56,30 @@ check_and_download("https://github.com/ThePhD/sol2/archive/refs/tags/v3.3.0.zip"
# 11) SDL2_mixer # 11) SDL2_mixer
check_and_download("https://github.com/libsdl-org/SDL_mixer/archive/refs/tags/release-2.8.0.zip" "SDL_mixer-release-2.8.0.zip" "SDL_mixer-release-2.8.0" "CMakeLists.txt") check_and_download("https://github.com/libsdl-org/SDL_mixer/archive/refs/tags/release-2.8.0.zip" "SDL_mixer-release-2.8.0.zip" "SDL_mixer-release-2.8.0" "CMakeLists.txt")
# 12) OpenSSL (TLS for wss://) and a native Windows Perl needed to build it
if(WIN32)
check_and_download("https://github.com/openssl/openssl/releases/download/openssl-3.4.1/openssl-3.4.1.tar.gz" "openssl-3.4.1.tar.gz" "openssl-3.4.1" "Configure")
# Strawberry Perl portable. The zip has no top-level folder, so it is
# extracted into its own directory. (Git's MSYS perl cannot build OpenSSL.)
set(STRAWBERRY_PERL_DIR "${THIRDPARTY_DIR}/strawberry-perl")
set(STRAWBERRY_PERL_ARCHIVE "${THIRDPARTY_DIR}/strawberry-perl-5.42.3.1-64bit-portable.zip")
if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe")
if(NOT EXISTS "${STRAWBERRY_PERL_ARCHIVE}")
message(STATUS "Downloading Strawberry Perl...")
file(DOWNLOAD
"https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_54231_64bit/strawberry-perl-5.42.3.1-64bit-portable.zip"
"${STRAWBERRY_PERL_ARCHIVE}" SHOW_PROGRESS)
endif()
message(STATUS "Extracting Strawberry Perl...")
file(MAKE_DIRECTORY "${STRAWBERRY_PERL_DIR}")
execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xf "${STRAWBERRY_PERL_ARCHIVE}"
WORKING_DIRECTORY "${STRAWBERRY_PERL_DIR}"
)
if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe")
message(FATAL_ERROR "Strawberry Perl extraction failed")
endif()
endif()
endif()
+47
View File
@@ -0,0 +1,47 @@
# cmake/FetchDependenciesServer.cmake
#
# Downloads only what the headless GameServer needs (Eigen + Boost headers).
# Skips anything already present in thirdparty/. Works on Windows and Linux.
set(THIRDPARTY_DIR "${CMAKE_CURRENT_LIST_DIR}/../thirdparty")
file(MAKE_DIRECTORY "${THIRDPARTY_DIR}")
macro(server_check_and_download URL ARCHIVE_NAME EXTRACTED_DIR_NAME CHECK_FILE)
set(_archive_path "${THIRDPARTY_DIR}/${ARCHIVE_NAME}")
set(_src_path "${THIRDPARTY_DIR}/${EXTRACTED_DIR_NAME}")
if(NOT EXISTS "${_src_path}/${CHECK_FILE}")
if(NOT EXISTS "${_archive_path}")
message(STATUS "Downloading ${ARCHIVE_NAME}...")
file(DOWNLOAD "${URL}" "${_archive_path}"
SHOW_PROGRESS STATUS _dl_status)
list(GET _dl_status 0 _dl_code)
if(NOT _dl_code EQUAL 0)
file(REMOVE "${_archive_path}")
message(FATAL_ERROR "Failed to download ${URL}: ${_dl_status}")
endif()
endif()
message(STATUS "Extracting ${ARCHIVE_NAME}...")
execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xf "${_archive_path}"
WORKING_DIRECTORY "${THIRDPARTY_DIR}"
RESULT_VARIABLE _tar_res)
if(NOT _tar_res EQUAL 0)
message(FATAL_ERROR "Failed to extract ${_archive_path}")
endif()
endif()
endmacro()
# Eigen: only needed if no system install is available.
find_package(Eigen3 3.3 CONFIG QUIET)
if(NOT TARGET Eigen3::Eigen)
server_check_and_download(
"https://gitlab.com/libeigen/eigen/-/archive/5.0.0/eigen-5.0.0.zip"
"eigen-5.0.0.zip" "eigen-5.0.0" "signature_of_eigen3_matrix_library")
endif()
# Boost (header-only usage: asio + beast). Use bundled copy always so the
# version matches the Windows build.
server_check_and_download(
"https://archives.boost.io/release/1.90.0/source/boost_1_90_0.zip"
"boost_1_90_0.zip" "boost_1_90_0" "boost/asio.hpp")
+102
View File
@@ -726,3 +726,105 @@ set_target_properties(SDL2_mixer_external_lib PROPERTIES
INTERFACE_LINK_LIBRARIES INTERFACE_LINK_LIBRARIES
"SDL2_external_lib" "SDL2_external_lib"
) )
# ===========================================
# 12) OpenSSL (3.4.1) - TLS for wss:// (Windows only, static libs)
# ===========================================
if(WIN32)
set(OPENSSL_SRC_DIR "${THIRDPARTY_DIR}/openssl-3.4.1")
set(OPENSSL_BASE_DIR "${OPENSSL_SRC_DIR}/install")
set(_have_openssl TRUE)
foreach(cfg IN LISTS BUILD_CONFIGS)
if(NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libssl.lib" OR
NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libcrypto.lib")
set(_have_openssl FALSE)
endif()
endforeach()
if(NOT _have_openssl)
# Locate Visual Studio's vcvarsall.bat (provides cl, link, nmake).
set(_vswhere "$ENV{ProgramFiles\(x86\)}/Microsoft Visual Studio/Installer/vswhere.exe")
set(_vcvarsall "")
if(EXISTS "${_vswhere}")
execute_process(
COMMAND "${_vswhere}" -latest -products * -property installationPath
OUTPUT_VARIABLE _vs_path
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(_vs_path AND EXISTS "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat")
set(_vcvarsall "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat")
endif()
endif()
if(_vcvarsall STREQUAL "")
message(FATAL_ERROR "Visual Studio (vcvarsall.bat) not found; it is required to build OpenSSL")
endif()
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(_ossl_vc_arch x64)
set(_ossl_target VC-WIN64A)
else()
set(_ossl_vc_arch x86)
set(_ossl_target VC-WIN32)
endif()
file(TO_NATIVE_PATH "${_vcvarsall}" _vcvarsall_native)
file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/bin" _perl_bin_native)
file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/site/bin" _perl_site_native)
foreach(cfg IN LISTS BUILD_CONFIGS)
set(_ossl_build "${OPENSSL_SRC_DIR}/build-${cfg}")
file(REMOVE_RECURSE "${_ossl_build}")
file(MAKE_DIRECTORY "${_ossl_build}")
file(TO_NATIVE_PATH "${_ossl_build}" _ossl_build_native)
file(TO_NATIVE_PATH "${OPENSSL_SRC_DIR}/Configure" _ossl_configure_native)
file(TO_NATIVE_PATH "${OPENSSL_BASE_DIR}-${cfg}" _ossl_prefix_native)
set(_ossl_cfg_flags "")
if(cfg STREQUAL "Debug")
# /MDd: link the dynamic debug CRT like the game (static libs default to /MTd)
set(_ossl_cfg_flags "--debug /MDd")
else()
set(_ossl_cfg_flags "--release /MD")
endif()
# A batch file avoids cmd.exe quoting problems.
set(_ossl_bat "${_ossl_build}/build_openssl.bat")
file(WRITE "${_ossl_bat}"
"@echo off
call \"${_vcvarsall_native}\" ${_ossl_vc_arch} || exit /b 1
set \"PATH=${_perl_bin_native};${_perl_site_native};%PATH%\"
set CL=/MP
cd /d \"${_ossl_build_native}\" || exit /b 1
perl \"${_ossl_configure_native}\" ${_ossl_target} no-shared no-asm no-tests no-docs ${_ossl_cfg_flags} --prefix=\"${_ossl_prefix_native}\" --openssldir=\"${_ossl_prefix_native}/ssl\" || exit /b 1
nmake || exit /b 1
nmake install_sw || exit /b 1
")
log("Building OpenSSL (${cfg}) - this takes several minutes ...")
execute_process(
COMMAND cmd /c "${_ossl_bat}"
RESULT_VARIABLE _ossl_res
)
if(NOT _ossl_res EQUAL 0)
message(FATAL_ERROR "OpenSSL build failed for ${cfg} (see output above)")
endif()
endforeach()
endif()
add_library(openssl_crypto_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(openssl_crypto_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libcrypto.lib"
IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libcrypto.lib"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES "ws2_32;crypt32;advapi32;user32;gdi32"
)
add_library(openssl_ssl_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(openssl_ssl_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libssl.lib"
IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libssl.lib"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES "openssl_crypto_external_lib"
)
endif()
+85
View File
@@ -0,0 +1,85 @@
# Hostile NPC Behavior
## Authority and update order
Hostile NPC behavior is simulated by `LocationGameState` and
`CharacterState`. Rendering objects do not select targets and do not change
NPC combat state.
During each authoritative simulation tick:
1. The location gathers the enabled players and NPCs visible to the
simulation.
2. Each hostile NPC selects or retains a player target.
3. Every character advances using the same tick-start observations.
4. Movement reservations and attack damage are resolved deterministically.
5. Navigation recovery events are applied by `InternalGameServer`.
The resulting state and recovery decisions are replicated through the existing
WebSocket lockstep stream. NPC target changes are simulation consequences, not
client commands.
## Eligible players and nearest selection
An eligible target must:
- exist in the campaign player map;
- be enabled;
- have positive health; and
- be within the NPC's `visibleRange` when a new target is selected.
Distance is measured in the X/Z plane. If two players are equally close,
their stable player IDs are compared lexicographically. This gives every
lockstep replica the same result regardless of hash-map iteration order.
## Ordinary target locking
Once a hostile NPC has a valid target, it keeps that target during ordinary
ticks. The target remains locked even if it moves outside `visibleRange`.
The lock ends when the player dies or becomes disabled.
When a lock ends, the NPC selects the nearest eligible player in range. If no
player qualifies, it clears the attack target, leaves combat state, and
requests a route to its `homePosition`.
## Movement blockage and target reconsideration
NPC movement uses a retry schedule after a character blocks its path. The
blocked NPC first stops and waits for its deterministic navigation retry
cooldown. When the retry event emits `forceNavigationReplan`, the authoritative
location state performs a fresh target selection across all eligible players.
This blocked-replan selection differs from ordinary target locking:
- the current target is not privileged;
- the nearest eligible player may remain the target;
- a nearer eligible player replaces the current target;
- the route is rebuilt from the NPC's current position toward the selected
player's current position.
If no eligible player is in range during blocked recovery, the NPC clears its
stale chase target and returns toward its home position instead. The blocker is
still passed to path planning so the immediate replan does not route through
the character that caused the recovery event.
## Death, disconnect, and attack safety
Dead or disabled players are excluded from target acquisition. A target that
dies or disconnects is therefore invalidated on the next authoritative
selection pass, and the NPC either switches to another player or returns home.
Attack impacts are also validated at application time. Damage is ignored if
the recorded target no longer exists, is disabled, or has already reached
zero health. This prevents a queued animation impact from damaging a stale
target.
## Regression expectations
The state and pathfinding tests should preserve these guarantees:
- nearest in-range player selection is deterministic;
- a valid ordinary lock is retained;
- blocked NPC recovery can replace the target and rebuild the chase route;
- no-target blocked recovery clears combat and routes home;
- dead and disabled players cannot be selected or damaged;
- retry cooldowns and blocker identity remain deterministic.
+1 -1
View File
@@ -246,7 +246,7 @@ set(EMSCRIPTEN_LINK_FLAGS
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_uni_interior.lua@resources/start_uni_interior.lua" "--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_uni_interior.lua@resources/start_uni_interior.lua"
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_uni_exterior.lua@resources/start_uni_exterior.lua" "--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_uni_exterior.lua@resources/start_uni_exterior.lua"
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_dorm.lua@resources/start_dorm.lua" "--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_dorm.lua@resources/start_dorm.lua"
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_dorm.lua@resources/start_battle_zone.lua" "--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_battle_zone.lua@resources/start_battle_zone.lua"
) )
# Применяем настройки линковки # Применяем настройки линковки
+34 -3
View File
@@ -5,17 +5,28 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Shadow Over Bishkek</title> <title>Shadow Over Bishkek</title>
<style> <style>
:root {
/* Visible viewport size, kept in sync by syncViewport() below.
100vh on mobile is the *largest* viewport (URL bar hidden) and
would push the bottom of the canvas off-screen. */
--app-w: 100%;
--app-h: 100dvh;
}
body, html { body, html {
margin: 0; padding: 0; width: 100%; height: 100%; margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background-color: #000; overflow: hidden; background-color: #000;
position: fixed; position: fixed; top: 0; left: 0;
} }
#canvas { #canvas {
display: block; display: block;
position: absolute; position: fixed;
top: 0; left: 0; top: 0; left: 0;
width: 100vw; height: 100vh; width: var(--app-w); height: var(--app-h);
border: none; border: none;
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
} }
#status { color: white; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } #status { color: white; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
@@ -49,8 +60,28 @@
loadGameScript(); loadGameScript();
}); });
function syncViewport() {
var vv = window.visualViewport;
var w = (vv && vv.width) || window.innerWidth;
var h = (vv && vv.height) || window.innerHeight;
var root = document.documentElement;
root.style.setProperty('--app-w', Math.round(w) + 'px');
root.style.setProperty('--app-h', Math.round(h) + 'px');
}
syncViewport();
// Registered before the game script loads, so the CSS size is updated
// before the engine's own resize handler reads it.
window.addEventListener('resize', syncViewport);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', function () {
syncViewport();
window.dispatchEvent(new Event('resize'));
});
}
window.addEventListener("orientationchange", function() { window.addEventListener("orientationchange", function() {
setTimeout(() => { setTimeout(() => {
syncViewport();
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}, 200); }, 200);
}); });
+14
View File
@@ -131,8 +131,14 @@ set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT S
# include-пути проекта # include-пути проекта
target_include_directories(ShadowOverBishkekDemo PRIVATE target_include_directories(ShadowOverBishkekDemo PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/../src" "${CMAKE_CURRENT_SOURCE_DIR}/../src"
"${CMAKE_CURRENT_SOURCE_DIR}/../thirdparty/Clipper2-Clipper2_2.0.1/CPP/build/Release/include"
) )
#target_link_directories(ShadowOverBishkekDemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../thirdparty/Clipper2-Clipper2_2.0.1/CPP/build/Release/lib")
target_link_directories(ShadowOverBishkekDemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../thirdparty/Clipper2-Clipper2_2.0.1/CPP/build_debug/Debug/lib")
set_target_properties(ShadowOverBishkekDemo PROPERTIES set_target_properties(ShadowOverBishkekDemo PROPERTIES
OUTPUT_NAME "ShadowOverBishkekDemo" OUTPUT_NAME "ShadowOverBishkekDemo"
) )
@@ -143,6 +149,7 @@ target_compile_definitions(ShadowOverBishkekDemo PRIVATE
_WIN32_WINNT=0x0A00 _WIN32_WINNT=0x0A00
PNG_ENABLED PNG_ENABLED
NETWORK NETWORK
NETWORK_TLS
) )
# Линкуем с SDL2main, если он вообще установлен # Линкуем с SDL2main, если он вообще установлен
@@ -160,6 +167,8 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
boost_external_lib boost_external_lib
sol2_external_lib sol2_external_lib
SDL2_mixer_external_lib SDL2_mixer_external_lib
openssl_ssl_external_lib
Clipper2.lib
) )
# Линкуем OpenGL (Windows) # Линкуем OpenGL (Windows)
@@ -172,6 +181,11 @@ if(WIN32)
../src/game_server/RemoteGameServerHost.h ../src/game_server/RemoteGameServerHost.h
../src/game_server/RemoteGameServerHost.cpp ../src/game_server/RemoteGameServerHost.cpp
) )
if(MSVC)
set_source_files_properties(
../src/game_server/WebsocketGameClient.cpp
PROPERTIES COMPILE_OPTIONS "/bigobj")
endif()
target_link_libraries( target_link_libraries(
ShadowOverBishkekDemo PRIVATE opengl32 ws2_32 mswsock) ShadowOverBishkekDemo PRIVATE opengl32 ws2_32 mswsock)
endif() endif()
+24 -10
View File
@@ -2,7 +2,10 @@
"campaigns": [ "campaigns": [
{ {
"id": "main", "id": "main",
"title": { "ru": "Основная игра", "en": "Main Game" }, "title": {
"ru": "Основная игра",
"en": "Main Game"
},
"locations": [ "locations": [
"resources/config/locations/location_dorm.json", "resources/config/locations/location_dorm.json",
"resources/config/locations/uni_exterior.json", "resources/config/locations/uni_exterior.json",
@@ -17,12 +20,15 @@
}, },
{ {
"id": "battle_zone", "id": "battle_zone",
"title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" }, "title": {
"ru": "Зона боя (одиночная)",
"en": "Battle Zone (Single Player)"
},
"locations": [ "locations": [
"resources/config/locations/battle_zone_uni_exterior.json" "resources/config/locations/battle_zone_korea.json"
], ],
"startLocation": "uni_exterior", "startLocation": "park_korea",
"startMusic": "music/bishkek univer day.ogg", "startMusic": "music/bishkek fight calm.ogg",
"startState": "resources/config/start_state_battle_zone_singleplayer.json", "startState": "resources/config/start_state_battle_zone_singleplayer.json",
"quests": "resources/config/quests_battle_zone.json", "quests": "resources/config/quests_battle_zone.json",
"tutorial": false, "tutorial": false,
@@ -30,16 +36,24 @@
}, },
{ {
"id": "battle_zone_multiplayer", "id": "battle_zone_multiplayer",
"title": { "ru": "Зона боя (сетевая)", "en": "Battle Zone (Multiplayer)" }, "title": {
"ru": "Зона боя (сетевая)",
"en": "Battle Zone (Multiplayer)"
},
"locations": [ "locations": [
"resources/config/locations/battle_zone_uni_exterior.json" "resources/config/locations/battle_zone_korea.json"
], ],
"startLocation": "uni_exterior", "startLocation": "park_korea",
"startMusic": "music/bishkek univer day.ogg", "startMusic": "music/bishkek fight calm.ogg",
"startState": "resources/config/start_state_battle_zone.json", "startState": "resources/config/start_state_battle_zone.json",
"quests": "resources/config/quests_battle_zone.json", "quests": "resources/config/quests_battle_zone.json",
"tutorial": false, "tutorial": false,
"networkPlayerSlots": ["player", "player2"], "networkPlayerSlots": [
"player1",
"player2",
"player3",
"player4"
],
"networkCompatibility": "battle-zone-lockstep-v5", "networkCompatibility": "battle-zone-lockstep-v5",
"loadingScreen": "resources/w/ui/screen_loading.json" "loadingScreen": "resources/w/ui/screen_loading.json"
} }
@@ -1,5 +1,5 @@
{ {
"id": "uni_exterior", "id": "park_korea",
"gameObjects": "resources/w/shelter/game_objects.json", "gameObjects": "resources/w/shelter/game_objects.json",
"interactiveObjects": "resources/w/shelter/interactive_objects.json", "interactiveObjects": "resources/w/shelter/interactive_objects.json",
"npcs": "resources/config/npcs_battle_zone.json", "npcs": "resources/config/npcs_battle_zone.json",
+162
View File
@@ -161,6 +161,168 @@
"hp": 35, "hp": 35,
"visibleRange" : 6.5, "visibleRange" : 6.5,
"canAttack": true "canAttack": true
},
{
"id": "ghost_07x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -14.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_08x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -14.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_09x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -14.5,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_10x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -14.5,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_11x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -15.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_12x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -15.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
} }
] ]
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -125,7 +125,7 @@
"y": 10, "y": 10,
"width": 160, "width": 160,
"height": 36, "height": 36,
"text": "v. 1.0.7", "text": "v. 1.1.2",
"fontSize": 24, "fontSize": 24,
"textCentered": false, "textCentered": false,
"topAligned": true, "topAligned": true,
+1 -1
View File
@@ -116,7 +116,7 @@
"y": 10, "y": 10,
"width": 160, "width": 160,
"height": 36, "height": 36,
"text": "v. 1.0.7", "text": "v. 1.1.2",
"fontSize": 24, "fontSize": 24,
"textCentered": false, "textCentered": false,
"topAligned": true, "topAligned": true,
+1 -1
View File
@@ -125,7 +125,7 @@
"y": 10, "y": 10,
"width": 160, "width": 160,
"height": 36, "height": 36,
"text": "v. 1.0.7", "text": "v. 1.1.2",
"fontSize": 24, "fontSize": 24,
"textCentered": false, "textCentered": false,
"topAligned": true, "topAligned": true,
@@ -125,7 +125,7 @@
"y": 10, "y": 10,
"width": 160, "width": 160,
"height": 36, "height": 36,
"text": "v. 1.0.7", "text": "v. 1.1.2",
"fontSize": 24, "fontSize": 24,
"textCentered": false, "textCentered": false,
"topAligned": true, "topAligned": true,
+38 -17
View File
@@ -9,6 +9,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..") set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..")
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
include("${GAME_ROOT}/cmake/FetchDependenciesServer.cmake")
find_package(Eigen3 3.3 CONFIG QUIET) find_package(Eigen3 3.3 CONFIG QUIET)
if(NOT TARGET Eigen3::Eigen) if(NOT TARGET Eigen3::Eigen)
set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0") set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0")
@@ -105,24 +106,32 @@ endif()
add_executable(GameServer src/main.cpp README.md) add_executable(GameServer src/main.cpp README.md)
source_group("Server" FILES src/main.cpp README.md) source_group("Server" FILES src/main.cpp README.md)
target_link_libraries(GameServer PRIVATE game_server_core) target_link_libraries(GameServer PRIVATE game_server_core)
set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0")
if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
message(FATAL_ERROR
"Boost was not found at thirdparty/boost_1_90_0 and could not be "
"downloaded.")
endif()
target_sources(GameServer PRIVATE
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/NetworkProtocol.h"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.h")
target_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(GameServer PRIVATE NETWORK)
if(WIN32) if(WIN32)
set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0")
if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
message(FATAL_ERROR
"Boost was not found at thirdparty/boost_1_90_0. Configure the "
"graphical project once to populate it.")
endif()
target_sources(GameServer PRIVATE
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/NetworkProtocol.h"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.h")
target_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions( target_compile_definitions(
GameServer PRIVATE NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) GameServer PRIVATE WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(GameServer PRIVATE ws2_32 mswsock) target_link_libraries(GameServer PRIVATE ws2_32 mswsock)
endif() endif()
# The server reads configs from ./resources relative to its working directory,
# so keep a copy next to the binary after every build.
add_custom_command(TARGET GameServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${GAME_ROOT}/resources" "$<TARGET_FILE_DIR:GameServer>/resources"
COMMENT "Copying resources next to GameServer")
include(CTest) include(CTest)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable( add_executable(
@@ -141,7 +150,8 @@ if(BUILD_TESTING)
add_test( add_test(
NAME GameServer.LocationState NAME GameServer.LocationState
COMMAND GameServerStateTests) COMMAND GameServerStateTests)
if(WIN32) # network tests (all platforms)
if(TRUE)
add_executable( add_executable(
GameServerNetworkProtocolTests GameServerNetworkProtocolTests
tests/NetworkProtocolTests.cpp tests/NetworkProtocolTests.cpp
@@ -163,9 +173,20 @@ if(BUILD_TESTING)
target_compile_definitions( target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE GameServerWebsocketLockstepTests PRIVATE
NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
if(MSVC)
set_source_files_properties(
"${GAME_ROOT}/src/game_server/WebsocketGameClient.cpp"
PROPERTIES COMPILE_OPTIONS "/bigobj")
endif()
target_link_libraries( target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE GameServerWebsocketLockstepTests PRIVATE game_server_core)
game_server_core ws2_32 mswsock) if(WIN32)
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE
WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE ws2_32 mswsock)
endif()
add_test( add_test(
NAME GameServer.WebsocketLockstep NAME GameServer.WebsocketLockstep
COMMAND GameServerWebsocketLockstepTests) COMMAND GameServerWebsocketLockstepTests)
@@ -175,7 +196,7 @@ if(BUILD_TESTING)
COMMAND GameServer --ticks 2 --no-sleep) COMMAND GameServer --ticks 2 --no-sleep)
add_test( add_test(
NAME GameServer.BattleZoneSharedPlayer NAME GameServer.BattleZoneSharedPlayer
COMMAND GameServer --campaign battle_zone --location uni_exterior COMMAND GameServer --campaign battle_zone --location park_korea
--ticks 2 --no-sleep) --ticks 2 --no-sleep)
set_tests_properties( set_tests_properties(
GameServer.BattleZoneSharedPlayer GameServer.BattleZoneSharedPlayer
+123 -62
View File
@@ -1,11 +1,37 @@
# Headless GameServer # Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL, This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
audio, Lua, or rendering dependencies. On Windows it exposes a deterministic audio, Lua, or rendering dependencies. It exposes a deterministic lockstep
lockstep session through a Boost.Beast WebSocket listener. session through a Boost.Beast WebSocket listener (Windows and Linux).
## Requirements
- CMake >= 3.16, a C++17 compiler (MSVC, or GCC/Clang on Linux)
- Ubuntu: `sudo apt install build-essential cmake git unzip curl`
- Internet access on the first configure (see below)
Eigen and Boost are **downloaded automatically** by
`cmake/FetchDependenciesServer.cmake` into `thirdparty/` the first time CMake
configures the project (Boost zip is ~200 MB). A system Eigen3 is used if
installed; Boost is always taken from `thirdparty/boost_1_90_0` so versions match
the Windows build. Nothing else (SDL, zlib, libzip, ...) is needed.
## Build ## Build
Linux (recommended helper, 1 compile job to stay within low RAM):
```bash
server/scripts/build.sh # JOBS=2 server/scripts/build.sh for more
```
Or manually:
```bash
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release
cmake --build server/build --target GameServer --parallel 1
ctest --test-dir server/build --output-on-failure
```
Windows: Windows:
```powershell ```powershell
@@ -14,91 +40,126 @@ cmake --build server/build --config Release --target GameServer
ctest --test-dir server/build -C Release --output-on-failure ctest --test-dir server/build -C Release --output-on-failure
``` ```
Linux: > **Memory note:** compiling Boost.Beast code can use 1-1.5 GB per translation
> unit. On a small VDS keep `--parallel 1`; if the compiler gets OOM-killed, add
> temporary swap (`sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile &&
> sudo mkswap /swapfile && sudo swapon /swapfile`). The running server itself
> needs only a few tens of MB.
After every build the repository's `resources/` directory is copied next to the
binary (`server/build/resources`, or `server/build/Release/resources` on
Windows). Content paths are resolved from the process working directory, so the
server must be started from the directory that contains `resources/`.
## Run
```bash ```bash
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release server/scripts/run-server.sh
cmake --build server/build --target GameServer --parallel
ctest --test-dir server/build --output-on-failure
``` ```
Eigen3 must either be installed or be available at This does `cd server/build` and runs
`thirdparty/eigen-5.0.0`. The headless target reads ordinary filesystem resources
only; ZIP-backed campaign loading belongs to the later content-loading
milestone.
Run until interrupted:
```bash ```bash
./server/build/GameServer ./GameServer --campaign battle_zone_multiplayer --address 127.0.0.1 --port 8081
``` ```
For a multi-configuration Windows build, the executable is normally Environment overrides: `CAMPAIGN`, `LOCATION`, `ADDRESS`, `PORT`, `BUILD_DIR`.
`server/build/Release/GameServer.exe`.
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the Windows equivalent:
real-time delay.
Start the multiplayer lockstep server:
```powershell ```powershell
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior .\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer
``` ```
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game Command line options: `--campaign ID`, `--location ID`, `--campaigns PATH`
afterward and select `battle_zone_multiplayer`; that campaign connects to (default `resources/config/campaigns.json`), `--address IP` (default `0.0.0.0`),
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server. `--port N` (default `8080`), `--ticks N` and `--no-sleep` (smoke tests).
The server sends one state snapshot when a player connects, then broadcasts one The server sends one state snapshot when a player connects, then broadcasts one
numbered command bundle every 100 ms. Bundles may be empty. Destination input is numbered command bundle every 100 ms. Bundles may be empty. Destination input is
scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or
client wall-clock values, define simulation time. client wall-clock values, define simulation time.
If `--location` is omitted, the campaign's configured start location is used. ## Production deployment (Ubuntu, systemd + nginx)
`--campaigns PATH` selects a different campaign registry. Content paths are
resolved from the process working directory, so run from the repository root
when using the bundled `resources/` tree.
Available startup options: The server listens on loopback only (`127.0.0.1:8081`); nginx terminates TLS and
proxies WebSocket traffic.
```text ### 1. systemd service
--campaign ID
--location ID ```bash
--campaigns PATH sudo cp server/deploy/shadow-gameserver.service /etc/systemd/system/
--address IP sudo systemctl daemon-reload
--port N sudo systemctl enable --now shadow-gameserver
--tick-ms 100 systemctl status shadow-gameserver
--ticks N journalctl -u shadow-gameserver -f # logs
--no-sleep
``` ```
Network mode requires a 100 ms tick; another `--tick-ms` value is rejected. The unit caps memory at 512 MB and restarts on failure. After rebuilding run
The selected campaign's `networkPlayerSlots` lists pre-existing character IDs `sudo systemctl restart shadow-gameserver`. Edit `User=`/paths in the unit if the
assigned to connections in order. `networkCompatibility` is a release token repository lives elsewhere. To run a second server, copy the unit with another
that must be changed whenever deterministic content changes; clients reject a `PORT` (and campaign) and add a matching `/server002/` location.
different token. The bundled multiplayer Battle Zone has two slots (`player`
and `player2`) and rejects additional clients. Disconnecting
releases the slot. The bot's random autonomous controller is disabled in
multiplayer.
The graphical client executes presentation behavior after building its runtime ### 2. nginx
`Location`; the headless content pass loads authoritative navigation, NPC,
interactive-object, trigger, teleport, taxi, and animation metadata. Lua and
dialogue-driven shared multiplayer behavior is not yet supported.
## Tests ```bash
sudo cp server/deploy/nginx-api.shadowoverbishkek.com.conf \
/etc/nginx/sites-available/api.shadowoverbishkek.com
sudo ln -s /etc/nginx/sites-available/api.shadowoverbishkek.com \
/etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
CTest covers: `location /server001/` proxies to `http://127.0.0.1:8081/` with WebSocket
upgrade headers and a 1 hour read/send timeout (the trailing slashes strip the
`/server001` prefix).
- repeated `LocationGameState::setup()` preserving mutable runtime state; ### 3. HTTPS certificate
- ticking a prepared location with zero players;
- deterministic fixed-100 ms replay behavior;
- protocol-v2 parsing, validation, target normalization, state hashing, and
two-tick command ordering;
- a WebSocket initial-state/slot/destination/tick integration test;
- idle server startup;
- headless loading of `battle_zone_multiplayer/uni_exterior`.
The lockstep implementation currently targets identical executable and content DNS for `api.shadowoverbishkek.com` must point at the VDS first.
builds. Diagnostic state hashes detect divergence, but there is no automatic
checkpoint resynchronization or reconnect input history yet. ```bash
sudo certbot --nginx -d api.shadowoverbishkek.com
```
Certbot issues a Let's Encrypt certificate, adds the `443 ssl` block and an HTTP
to HTTPS redirect, and installs auto-renewal (`systemctl list-timers | grep certbot`).
## Connecting
Public endpoint: **`wss://api.shadowoverbishkek.com/server001/`**
Handshake check from any machine (expects `HTTP/1.1 101 Switching Protocols`):
```bash
curl -i -N --max-time 3 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
https://api.shadowoverbishkek.com/server001/
```
Or interactively: `npx wscat -c wss://api.shadowoverbishkek.com/server001/`.
Locally on the VDS, bypassing nginx: `ws://127.0.0.1:8081`.
> **Client limitation:** `src/game_server/WebsocketGameClient.cpp` currently
> speaks plain `ws://host:port` to `/` (no TLS, no path). Until TLS and path
> support are added to the client, the graphical game can only connect directly
> to a non-proxied server (e.g. `ws://127.0.0.1:8080`), not to the `wss://` URL.
## Troubleshooting
- `Animation metadata failed ... Invalid animation file`: the `.anim` files are
Git LFS pointer stubs (`version https://git-lfs...`). Fetch the real files with
`git lfs pull` (or copy `resources/` from a full checkout) and rebuild.
- `GameServer.LocationState` test currently fails on every platform: it expects
`battle_zone` and `battle_zone_multiplayer` to share a start-state file, but
`campaigns.json` points them to different files.
- `Server content setup failed`: the server was not started from the directory
containing `resources/` (use `run-server.sh`).
- `WebSocket listener setup failed`: port already in use (`ss -ltnp | grep 8081`).
- `502 Bad Gateway` from nginx: service not running (`systemctl status
shadow-gameserver`).
- Connection drops after ~60 s: check `proxy_read_timeout` in the nginx location.
- Compiler "Killed" / `internal compiler error`: out of memory, see the memory note.
@@ -0,0 +1,47 @@
# Install to /etc/nginx/sites-available/api.shadowoverbishkek.com and symlink
# into sites-enabled. Then: sudo certbot --nginx -d api.shadowoverbishkek.com
server {
server_name api.shadowoverbishkek.com;
access_log /var/log/nginx/api.shadowoverbishkek.com-access.log;
error_log /var/log/nginx/api.shadowoverbishkek.com-error.log;
# wss://api.shadowoverbishkek.com/server001/ -> 127.0.0.1:8081
location /server001/ {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
return 404;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/api.shadowoverbishkek.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/api.shadowoverbishkek.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = api.shadowoverbishkek.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name api.shadowoverbishkek.com;
listen 80;
return 404; # managed by Certbot
}
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Shadow Over Bishkek GameServer (server001)
After=network.target
[Service]
Type=simple
User=ubuntu
ExecStart=/home/ubuntu/shadowoverbishkek-work/shadow-over-bishkek001/server/scripts/run-server.sh
Environment=PORT=8081
Restart=on-failure
RestartSec=3
MemoryMax=512M
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Configure and build the headless GameServer (Release).
# Usage: server/scripts/build.sh (uses 1 job: safe for 2 GB RAM hosts)
# JOBS=4 server/scripts/build.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cmake -S "$ROOT/server" -B "$ROOT/server/build" -DCMAKE_BUILD_TYPE=Release
cmake --build "$ROOT/server/build" --target GameServer --parallel "${JOBS:-1}"
echo "Built: $ROOT/server/build/GameServer"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Start the multiplayer GameServer. Listens on loopback only; nginx proxies
# wss://api.shadowoverbishkek.com/server001/ to it.
# Overrides: CAMPAIGN, LOCATION, ADDRESS, PORT, BUILD_DIR
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${BUILD_DIR:-$ROOT/server/build}"
CAMPAIGN="${CAMPAIGN:-battle_zone_multiplayer}"
ADDRESS="${ADDRESS:-127.0.0.1}"
PORT="${PORT:-8081}"
# Content paths are resolved from the working directory.
cd "$BUILD_DIR"
ARGS=(--campaign "$CAMPAIGN" --address "$ADDRESS" --port "$PORT")
[ -n "${LOCATION:-}" ] && ARGS+=(--location "$LOCATION")
exec ./GameServer "${ARGS[@]}"
+68 -10
View File
@@ -79,7 +79,7 @@ int main()
return fail("Battle Zone modes do not share their initial state"); return fail("Battle Zone modes do not share their initial state");
} }
if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 || if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 ||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player" || multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player1" ||
multiplayerBattleZoneConfig->networkPlayerSlots[1] != multiplayerBattleZoneConfig->networkPlayerSlots[1] !=
"player2" || "player2" ||
multiplayerBattleZoneConfig->networkCompatibility.empty()) { multiplayerBattleZoneConfig->networkCompatibility.empty()) {
@@ -140,7 +140,7 @@ int main()
disabledPlayer->enabled = false; disabledPlayer->enabled = false;
state.location->targetInteractiveObjectId = "object"; state.location->targetInteractiveObjectId = "object";
state.location->targetInteractNpcId = "npc"; state.location->targetInteractNpcId = "npc";
FRG::PlayerStateMap disabledPlayers{{"player", disabledPlayer}}; FRG::PlayerStateMap disabledPlayers{{"player1", disabledPlayer}};
const FRG::LocationSimulationEvents disabledEvents = const FRG::LocationSimulationEvents disabledEvents =
state.update(50, disabledPlayers, false); state.update(50, disabledPlayers, false);
if (!disabledEvents.interactiveObjects.empty() || if (!disabledEvents.interactiveObjects.empty() ||
@@ -165,7 +165,7 @@ int main()
playerTwo->enabled = true; playerTwo->enabled = true;
playerTwo->position = {2.f, 0.f, 0.f}; playerTwo->position = {2.f, 0.f, 0.f};
FRG::PlayerStateMap combatPlayers{ FRG::PlayerStateMap combatPlayers{
{"player", playerOne}, {"player2", playerTwo}}; {"player1", playerOne}, {"player2", playerTwo}};
state.update(100, combatPlayers, false); state.update(100, combatPlayers, false);
if (hostileNpc.attackTargetId != "player2") { if (hostileNpc.attackTargetId != "player2") {
@@ -181,7 +181,7 @@ int main()
playerTwo->hp = 0.f; playerTwo->hp = 0.f;
state.update(100, combatPlayers, false); state.update(100, combatPlayers, false);
if (hostileNpc.attackTargetId != "player") { if (hostileNpc.attackTargetId != "player1") {
return fail("Hostile NPC did not acquire a replacement after target death"); return fail("Hostile NPC did not acquire a replacement after target death");
} }
@@ -194,6 +194,59 @@ int main()
return fail("Hostile NPC did not return home after losing its target"); return fail("Hostile NPC did not return home after losing its target");
} }
auto blocker = std::make_shared<FRG::CharacterState>();
blocker->enabled = true;
blocker->hp = 50.f;
blocker->position = {0.8f, 0.f, 0.f};
state.npcs["blocker"] = blocker;
hostileNpc.position = Eigen::Vector3f::Zero();
hostileNpc.homePosition = {5.f, 0.f, 0.f};
hostileNpc.attackTargetId = "player1";
hostileNpc.requestedWalkTarget = playerOne->position;
hostileNpc.walkTarget = playerOne->position;
hostileNpc.pathWaypoints = {playerOne->position};
hostileNpc.currentWaypointIndex = 0;
hostileNpc.movementBlockedTicks = 0;
playerOne->enabled = true;
playerOne->hp = 75.f;
playerOne->position = {4.f, 0.f, 0.f};
playerTwo->enabled = true;
playerTwo->hp = 75.f;
playerTwo->position = {2.f, 0.f, 0.f};
bool blockedRetryRequested = false;
for (int tick = 0; tick < 10 && !blockedRetryRequested; ++tick) {
const FRG::LocationSimulationEvents events =
state.update(100, combatPlayers, false);
for (const auto& event : events.characters) {
if (event.characterId == "npc" &&
event.events.forceNavigationReplan) {
blockedRetryRequested = true;
if (!state.replanCharacter(
"npc", combatPlayers,
event.events.blockingCharacterId)) {
return fail("Blocked NPC replan was rejected");
}
}
}
}
if (!blockedRetryRequested ||
hostileNpc.attackTargetId != "player2" ||
(hostileNpc.requestedWalkTarget - playerTwo->position).norm() >
FRG::CharacterState::kWalkThreshold) {
return fail("Blocked NPC did not switch to the nearest player");
}
playerOne->enabled = false;
playerTwo->enabled = false;
if (!state.replanCharacter("npc", combatPlayers, "blocker") ||
!hostileNpc.attackTargetId.empty() ||
hostileNpc.battle_state != 0 ||
(hostileNpc.requestedWalkTarget - hostileNpc.homePosition).norm() >
FRG::CharacterState::kWalkThreshold) {
return fail("Blocked NPC did not return home without an eligible target");
}
FRG::CampaignConfig battleZone; FRG::CampaignConfig battleZone;
battleZone.id = "battle_zone"; battleZone.id = "battle_zone";
battleZone.questsPath = "resources/config/quests_battle_zone.json"; battleZone.questsPath = "resources/config/quests_battle_zone.json";
@@ -214,22 +267,22 @@ int main()
FRG::CampaignConfig multiplayerBattleZone = battleZone; FRG::CampaignConfig multiplayerBattleZone = battleZone;
multiplayerBattleZone.id = "battle_zone_multiplayer"; multiplayerBattleZone.id = "battle_zone_multiplayer";
multiplayerBattleZone.networkPlayerSlots = { multiplayerBattleZone.networkPlayerSlots = {
"player", "player2"}; "player1", "player2"};
networkServer.startCampaign(multiplayerBattleZone, {}, true, false); networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
const auto networkSnapshot = networkServer.snapshot(); const auto networkSnapshot = networkServer.snapshot();
if (networkSnapshot->campaignId != "battle_zone_multiplayer") if (networkSnapshot->campaignId != "battle_zone_multiplayer")
return fail("Network campaign ID was not retained"); return fail("Network campaign ID was not retained");
if (networkSnapshot->players.count("player") != 1) if (networkSnapshot->players.count("player1") != 1)
return fail("Network campaign shared player was not created"); return fail("Network campaign shared player was not created");
if (networkSnapshot->players.count("player2") != 0) if (networkSnapshot->players.count("player2") != 0)
return fail("Network campaign unexpectedly created the test bot"); return fail("Network campaign unexpectedly created the test bot");
if (networkSnapshot->players.at("player").state.enabled) if (networkSnapshot->players.at("player1").state.enabled)
return fail("Network campaign player slot was enabled before connection"); return fail("Network campaign player slot was enabled before connection");
nlohmann::json loadedNetworkState; nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState); networkServer.save(loadedNetworkState);
loadedNetworkState["players"]["player2"] = loadedNetworkState["players"]["player2"] =
loadedNetworkState["players"]["player"]; loadedNetworkState["players"]["player1"];
loadedNetworkState["players"]["player2"]["position"] = loadedNetworkState["players"]["player2"]["position"] =
{4.f, 0.f, 5.f}; {4.f, 0.f, 5.f};
networkServer.load(loadedNetworkState); networkServer.load(loadedNetworkState);
@@ -255,7 +308,7 @@ int main()
networkServer.submit(FRG::SetCharacterFacingAngleCommand{ networkServer.submit(FRG::SetCharacterFacingAngleCommand{
locationId, "player2", 1.5f, true}); locationId, "player2", 1.5f, true});
networkServer.submit(FRG::SetCharacterFaceTargetCommand{ networkServer.submit(FRG::SetCharacterFaceTargetCommand{
locationId, "player2", "player"}); locationId, "player2", "player1"});
networkServer.submit(FRG::SetCharacterEnabledCommand{ networkServer.submit(FRG::SetCharacterEnabledCommand{
locationId, "player2", false}); locationId, "player2", false});
const auto commandSnapshot = networkServer.snapshot(); const auto commandSnapshot = networkServer.snapshot();
@@ -318,6 +371,7 @@ int main()
moving->hp = 10.f; moving->hp = 10.f;
moving->pathWaypoints = {Eigen::Vector3f(8.f, 0.f, 0.f)}; moving->pathWaypoints = {Eigen::Vector3f(8.f, 0.f, 0.f)};
moving->movedLastTick = true; moving->movedLastTick = true;
moving->attackTargetId = "stationary";
FRG::PlayerStateMap occupancyPlayers{ FRG::PlayerStateMap occupancyPlayers{
{"stationary", stationary}, {"moving", moving}}; {"stationary", stationary}, {"moving", moving}};
if (occupancyState.collectPathObstacles( if (occupancyState.collectPathObstacles(
@@ -325,7 +379,11 @@ int main()
occupancyState.collectPathObstacles( occupancyState.collectPathObstacles(
"requester", occupancyPlayers, "moving").size() != 2 || "requester", occupancyPlayers, "moving").size() != 2 ||
!occupancyState.collectPathObstacles( !occupancyState.collectPathObstacles(
"stationary", occupancyPlayers).empty()) { "stationary", occupancyPlayers).empty() ||
!occupancyState.collectPathObstacles(
"moving", occupancyPlayers).empty() ||
occupancyState.collectPathObstacles(
"moving", occupancyPlayers, "stationary").size() != 1) {
return fail("Stationary obstacle collection policy is incorrect"); return fail("Stationary obstacle collection policy is incorrect");
} }
+13 -13
View File
@@ -23,12 +23,12 @@ int main()
const nlohmann::json original{ const nlohmann::json original{
{"schemaVersion", 2}, {"schemaVersion", 2},
{"campaignId", "battle_zone_multiplayer"}, {"campaignId", "battle_zone_multiplayer"},
{"players", {{"player", {{"hp", 200.0}}}}}, {"players", {{"player1", {{"hp", 200.0}}}}},
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}} {"locations", {{"park_korea", {{"state", savedLocationState}}}}}
}; };
FRG::InitialStateMessage initial; FRG::InitialStateMessage initial;
initial.state = original; initial.state = original;
initial.assignedPlayerId = "player"; initial.assignedPlayerId = "player1";
initial.snapshotTick = 42; initial.snapshotTick = 42;
initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer"; initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer";
const std::string message = FRG::makeInitialStateMessage(initial); const std::string message = FRG::makeInitialStateMessage(initial);
@@ -40,11 +40,11 @@ int main()
require(parsed.state == original, "state payload did not round trip"); require(parsed.state == original, "state payload did not round trip");
require(parsed.snapshotTick == 42, "snapshot tick did not round trip"); require(parsed.snapshotTick == 42, "snapshot tick did not round trip");
require( require(
parsed.assignedPlayerId == "player", parsed.assignedPlayerId == "player1",
"assigned player did not round trip"); "assigned player did not round trip");
require( require(
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") && !parsed.state["locations"]["park_korea"]["state"].contains("cameraAzimuth") &&
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraInclination"), !parsed.state["locations"]["park_korea"]["state"].contains("cameraInclination"),
"local camera state leaked into the network payload"); "local camera state leaked into the network payload");
FRG::DestinationInputMessage input; FRG::DestinationInputMessage input;
@@ -87,11 +87,11 @@ int main()
FRG::TickMessage tick; FRG::TickMessage tick;
tick.tickId = 43; tick.tickId = 43;
tick.commands.push_back(FRG::ScheduledDestinationCommand{ tick.commands.push_back(FRG::ScheduledDestinationCommand{
1, "player", parsedInput.target}); 1, "player1", parsedInput.target});
tick.commands.push_back(FRG::ScheduledCharacterEnabledCommand{ tick.commands.push_back(FRG::ScheduledCharacterEnabledCommand{
2, "uni_exterior", "player2", true}); 2, "park_korea", "player2", true});
tick.commands.push_back(FRG::ScheduledCharacterActionCommand{ tick.commands.push_back(FRG::ScheduledCharacterActionCommand{
3, "player", "set_target", "hostile_npc"}); 3, "player1", "set_target", "hostile_npc"});
tick.stateHash = "0123456789abcdef"; tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick; FRG::TickMessage parsedTick;
require( require(
@@ -147,9 +147,9 @@ int main()
FRG::LockstepTimeline timeline(2); FRG::LockstepTimeline timeline(2);
const std::uint64_t firstSequence = const std::uint64_t firstSequence =
timeline.schedule("player", Eigen::Vector3f(1.f, 0.f, 1.f)); timeline.schedule("player1", Eigen::Vector3f(1.f, 0.f, 1.f));
const std::uint64_t secondSequence = const std::uint64_t secondSequence =
timeline.schedule("player_2", Eigen::Vector3f(2.f, 0.f, 2.f)); timeline.schedule("player2", Eigen::Vector3f(2.f, 0.f, 2.f));
const FRG::TickMessage emptyTick = timeline.advance(); const FRG::TickMessage emptyTick = timeline.advance();
require( require(
emptyTick.tickId == 1 && emptyTick.commands.empty(), emptyTick.tickId == 1 && emptyTick.commands.empty(),
@@ -163,8 +163,8 @@ int main()
commandTick.commands[1]).commandSequence == secondSequence, commandTick.commands[1]).commandSequence == secondSequence,
"scheduled commands were not finalized in stable order"); "scheduled commands were not finalized in stable order");
timeline.scheduleCharacterEnabled("uni_exterior", "player2", true); timeline.scheduleCharacterEnabled("park_korea", "player2", true);
timeline.scheduleCharacterAction("player", "clear_attack_target"); timeline.scheduleCharacterAction("player1", "clear_attack_target");
const FRG::TickMessage presenceTick = timeline.advance(); const FRG::TickMessage presenceTick = timeline.advance();
require( require(
presenceTick.commands.size() == 1 && presenceTick.commands.size() == 1 &&
+44 -19
View File
@@ -32,15 +32,40 @@ bool waitFor(Predicate predicate)
int main() int main()
{ {
FRG::WebsocketEndpoint secureEndpoint;
require(
secureEndpoint.parse(
"wss://api.shadowoverbishkek.com/server001/"),
"secure WebSocket endpoint was rejected");
require(
secureEndpoint.host == "api.shadowoverbishkek.com" &&
secureEndpoint.port == "443" &&
secureEndpoint.path == "/server001/" &&
secureEndpoint.useTls,
"secure WebSocket endpoint was parsed incorrectly");
FRG::WebsocketEndpoint insecureEndpoint;
require(
insecureEndpoint.parse("ws://127.0.0.1:8080/"),
"insecure WebSocket endpoint was rejected");
require(
insecureEndpoint.host == "127.0.0.1" &&
insecureEndpoint.port == "8080" &&
!insecureEndpoint.useTls,
"insecure WebSocket endpoint was parsed incorrectly");
FRG::WebsocketEndpoint invalidEndpoint;
require(
!invalidEndpoint.parse("https://api.shadowoverbishkek.com/"),
"non-WebSocket endpoint was accepted");
FRG::CampaignConfig campaign; FRG::CampaignConfig campaign;
campaign.id = "test_lockstep"; campaign.id = "test_lockstep";
campaign.networkPlayerSlots = {"player", "player2"}; campaign.networkPlayerSlots = {"player1", "player2"};
FRG::InternalGameServer authoritative; FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false); authoritative.startCampaign(campaign, {}, true, false);
nlohmann::json initialState; nlohmann::json initialState;
authoritative.save(initialState); authoritative.save(initialState);
initialState["players"]["player2"] = initialState["players"]["player"]; initialState["players"]["player2"] = initialState["players"]["player1"];
FRG::LocationGameState testLocation; FRG::LocationGameState testLocation;
auto hostileNpc = std::make_shared<FRG::CharacterState>(); auto hostileNpc = std::make_shared<FRG::CharacterState>();
hostileNpc->enabled = true; hostileNpc->enabled = true;
@@ -52,7 +77,7 @@ int main()
initialState["locations"]["test_location"] = std::move(testLocationState); initialState["locations"]["test_location"] = std::move(testLocationState);
authoritative.load(initialState); authoritative.load(initialState);
FRG::WebsocketGameServer server( FRG::WebsocketGameServer server(
authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep", authoritative, {"player1", "player2"}, "lockstep-v2:test_lockstep",
"127.0.0.1", 0); "127.0.0.1", 0);
auto client = std::make_unique<FRG::WebsocketGameClient>( auto client = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/"); "127.0.0.1", std::to_string(server.port()), "/");
@@ -66,7 +91,7 @@ int main()
return client->initialized(); return client->initialized();
}), }),
"client did not receive initial state"); "client did not receive initial state");
require(client->localPlayerId() == "player", "wrong player slot assigned"); require(client->localPlayerId() == "player1", "wrong player slot assigned");
auto secondClient = std::make_unique<FRG::WebsocketGameClient>( auto secondClient = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/"); "127.0.0.1", std::to_string(server.port()), "/");
@@ -95,9 +120,9 @@ int main()
client->update(0); client->update(0);
secondClient->update(0); secondClient->update(0);
return client->snapshot()->players.at("player2").state.enabled && return client->snapshot()->players.at("player2").state.enabled &&
secondClient->snapshot()->players.at("player").state.enabled; secondClient->snapshot()->players.at("player1").state.enabled;
}) && }) &&
authoritative.snapshot()->players.at("player").state.enabled && authoritative.snapshot()->players.at("player1").state.enabled &&
authoritative.snapshot()->players.at("player2").state.enabled, authoritative.snapshot()->players.at("player2").state.enabled,
"connection presence was not broadcast to all replicas"); "connection presence was not broadcast to all replicas");
@@ -115,16 +140,16 @@ int main()
const auto first = client->snapshot(); const auto first = client->snapshot();
const auto second = secondClient->snapshot(); const auto second = secondClient->snapshot();
const auto host = authoritative.snapshot(); const auto host = authoritative.snapshot();
return first->players.at("player").state.requestedWalkTarget.isApprox( return first->players.at("player1").state.requestedWalkTarget.isApprox(
Eigen::Vector3f(1.23f, 0.f, 2.35f)) && Eigen::Vector3f(1.23f, 0.f, 2.35f)) &&
second->players.at("player2").state.requestedWalkTarget.isApprox( second->players.at("player2").state.requestedWalkTarget.isApprox(
Eigen::Vector3f(-1.23f, 0.f, 3.46f)) && Eigen::Vector3f(-1.23f, 0.f, 3.46f)) &&
first->players.at("player2").state.requestedWalkTarget.isApprox( first->players.at("player2").state.requestedWalkTarget.isApprox(
second->players.at("player2").state.requestedWalkTarget) && second->players.at("player2").state.requestedWalkTarget) &&
second->players.at("player").state.requestedWalkTarget.isApprox( second->players.at("player1").state.requestedWalkTarget.isApprox(
first->players.at("player").state.requestedWalkTarget) && first->players.at("player1").state.requestedWalkTarget) &&
host->players.at("player").state.requestedWalkTarget.isApprox( host->players.at("player1").state.requestedWalkTarget.isApprox(
first->players.at("player").state.requestedWalkTarget) && first->players.at("player1").state.requestedWalkTarget) &&
host->players.at("player2").state.requestedWalkTarget.isApprox( host->players.at("player2").state.requestedWalkTarget.isApprox(
second->players.at("player2").state.requestedWalkTarget); second->players.at("player2").state.requestedWalkTarget);
}); });
@@ -135,9 +160,9 @@ int main()
std::cerr << "destination revisions " << first->revision << ", " std::cerr << "destination revisions " << first->revision << ", "
<< second->revision << ", " << host->revision << "\n" << second->revision << ", " << host->revision << "\n"
<< "player targets " << "player targets "
<< first->players.at("player").state.requestedWalkTarget.transpose() << first->players.at("player1").state.requestedWalkTarget.transpose()
<< " / " << " / "
<< host->players.at("player").state.requestedWalkTarget.transpose() << host->players.at("player1").state.requestedWalkTarget.transpose()
<< "\nplayer2 targets " << "\nplayer2 targets "
<< second->players.at("player2").state.requestedWalkTarget.transpose() << second->players.at("player2").state.requestedWalkTarget.transpose()
<< " / " << " / "
@@ -158,11 +183,11 @@ int main()
waitFor([&]() { waitFor([&]() {
client->update(0); client->update(0);
secondClient->update(0); secondClient->update(0);
return client->snapshot()->players.at("player").state.attackTargetId == return client->snapshot()->players.at("player1").state.attackTargetId ==
"hostile_npc" && "hostile_npc" &&
secondClient->snapshot()->players.at("player").state.attackTargetId == secondClient->snapshot()->players.at("player1").state.attackTargetId ==
"hostile_npc" && "hostile_npc" &&
authoritative.snapshot()->players.at("player").state.attackTargetId == authoritative.snapshot()->players.at("player1").state.attackTargetId ==
"hostile_npc"; "hostile_npc";
}), }),
"target command was not delayed and rebroadcast"); "target command was not delayed and rebroadcast");
@@ -177,9 +202,9 @@ int main()
waitFor([&]() { waitFor([&]() {
client->update(0); client->update(0);
secondClient->update(0); secondClient->update(0);
return client->snapshot()->players.at("player").state.attackTargetId.empty() && return client->snapshot()->players.at("player1").state.attackTargetId.empty() &&
secondClient->snapshot()->players.at("player").state.attackTargetId.empty() && secondClient->snapshot()->players.at("player1").state.attackTargetId.empty() &&
authoritative.snapshot()->players.at("player").state.attackTargetId.empty(); authoritative.snapshot()->players.at("player1").state.attackTargetId.empty();
}), }),
"clear attack target command was not delayed and rebroadcast"); "clear attack target command was not delayed and rebroadcast");
+7 -1
View File
@@ -28,6 +28,12 @@ namespace FRG {
return Eigen::Vector3f(j[0].get<float>(), j[1].get<float>(), j[2].get<float>()); return Eigen::Vector3f(j[0].get<float>(), j[1].get<float>(), j[2].get<float>());
} }
static Vec3fp readVec3fp(const json& j) {
if (!j.is_array() || j.size() != 3) return Vec3fp();
return Vec3fp(static_cast<fp>(j[0].get<double>()), static_cast<fp>(j[1].get<double>()), static_cast<fp>(j[2].get<double>()));
}
bool LocationConfig::fromFile(const std::string& path, const std::string& zipFile, LocationConfig& out) { bool LocationConfig::fromFile(const std::string& path, const std::string& zipFile, LocationConfig& out) {
json j; json j;
if (!parseJsonFile(path, zipFile, j)) return false; if (!parseJsonFile(path, zipFile, j)) return false;
@@ -43,7 +49,7 @@ namespace FRG {
out.teleportsJsonPath = j.value("teleports", ""); out.teleportsJsonPath = j.value("teleports", "");
out.triggerZonesJsonPath = j.value("triggerZones", ""); out.triggerZonesJsonPath = j.value("triggerZones", "");
out.lightsJsonPath = j.value("lights", ""); out.lightsJsonPath = j.value("lights", "");
if (j.contains("playerPosition")) out.playerPosition = readVec3(j["playerPosition"]); if (j.contains("playerPosition")) out.playerPosition = readVec3fp(j["playerPosition"]);
out.lockInteractiveObjectsDuringTutorial = j.value("lockInteractiveObjectsDuringTutorial", false); out.lockInteractiveObjectsDuringTutorial = j.value("lockInteractiveObjectsDuringTutorial", false);
out.loadingScreenPath = j.value("loadingScreen", ""); out.loadingScreenPath = j.value("loadingScreen", "");
+2 -1
View File
@@ -5,6 +5,7 @@
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
#include "navigation/PathFinder.h"
namespace FRG { namespace FRG {
@@ -21,7 +22,7 @@ namespace FRG {
std::string teleportsJsonPath; std::string teleportsJsonPath;
std::string triggerZonesJsonPath; std::string triggerZonesJsonPath;
std::string lightsJsonPath; std::string lightsJsonPath;
Eigen::Vector3f playerPosition = Eigen::Vector3f::Zero(); Vec3fp playerPosition;
// Where the taxi drives in when the player calls it. No taxi when absent. // Where the taxi drives in when the player calls it. No taxi when absent.
struct Taxi { struct Taxi {
+33 -43
View File
@@ -42,19 +42,9 @@ bool Character::isMoving() const {
return state && state->isMoving(); return state && state->isMoving();
} }
Eigen::Vector3f Character::getRequestedWalkTarget() const {
auto state = stateSnapshot();
return state ? state->getRequestedWalkTarget() : Eigen::Vector3f::Zero();
}
Eigen::Vector3f Character::getCurrentNavigationTarget() const {
auto state = stateSnapshot();
return state ? state->getCurrentNavigationTarget() : Eigen::Vector3f::Zero();
}
Eigen::Vector3f Character::renderPosition() const { Eigen::Vector3f Character::renderPosition() const {
auto state = stateSnapshot(); auto state = stateSnapshot();
return state ? interpolatedState(*state).position : Eigen::Vector3f::Zero(); return state ? interpolatedState(*state).position.toEigen() : Eigen::Vector3f::Zero();
} }
float Character::getHp() const { float Character::getHp() const {
@@ -181,8 +171,8 @@ CharacterState Character::interpolatedState(const CharacterState& state) const {
const float alpha = (std::max)(0.0f, (std::min)(1.0f, elapsedMs / kSimulationTickMs)); const float alpha = (std::max)(0.0f, (std::min)(1.0f, elapsedMs / kSimulationTickMs));
CharacterState result = state; CharacterState result = state;
result.position = previousPresentationSample_.position + result.position = Vec3fp::fromEigen(previousPresentationSample_.position +
alpha * (currentPresentationSample_.position - previousPresentationSample_.position); alpha * (currentPresentationSample_.position - previousPresentationSample_.position));
constexpr float kPi = 3.14159265358979323846f; constexpr float kPi = 3.14159265358979323846f;
constexpr float kTwoPi = 2.0f * kPi; constexpr float kTwoPi = 2.0f * kPi;
@@ -190,7 +180,7 @@ CharacterState Character::interpolatedState(const CharacterState& state) const {
previousPresentationSample_.facingAngle; previousPresentationSample_.facingAngle;
while (angleDelta > kPi) angleDelta -= kTwoPi; while (angleDelta > kPi) angleDelta -= kTwoPi;
while (angleDelta < -kPi) angleDelta += kTwoPi; while (angleDelta < -kPi) angleDelta += kTwoPi;
result.facingAngle = previousPresentationSample_.facingAngle + alpha * angleDelta; result.facingAngle = static_cast<fp>(previousPresentationSample_.facingAngle + alpha * angleDelta);
if (previousPresentationSample_.currentState == state.currentState && if (previousPresentationSample_.currentState == state.currentState &&
currentPresentationSample_.currentState == state.currentState) { currentPresentationSample_.currentState == state.currentState) {
float previousFrame = previousPresentationSample_.animationFrame; float previousFrame = previousPresentationSample_.animationFrame;
@@ -235,8 +225,8 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
PresentationSample sample; PresentationSample sample;
sample.position = state.position; sample.position = state.position.toEigen();
sample.facingAngle = state.facingAngle; sample.facingAngle = static_cast<float>(state.facingAngle);
sample.animationFrame = state.animationFrame; sample.animationFrame = state.animationFrame;
sample.currentState = state.currentState; sample.currentState = state.currentState;
sample.enabled = state.enabled; sample.enabled = state.enabled;
@@ -299,8 +289,8 @@ void Character::draw(Renderer& renderer) {
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -353,8 +343,8 @@ void Character::drawGpuSkinning(Renderer& renderer, const CharacterState& state)
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -417,8 +407,8 @@ void Character::drawAttachedWeapon(Renderer& renderer, const CharacterState& sta
Eigen::Matrix4f currentBone = anim.gpuSkinningShaderData.skinningMatrices[boneIdx] * bindBone; Eigen::Matrix4f currentBone = anim.gpuSkinningShaderData.skinningMatrices[boneIdx] * bindBone;
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
renderer.TranslateMatrix(Eigen::Vector3f(currentBone.block<3, 1>(0, 3))); renderer.TranslateMatrix(Eigen::Vector3f(currentBone.block<3, 1>(0, 3)));
@@ -452,8 +442,8 @@ void Character::drawShadowDepthCpu(Renderer& renderer, const CharacterState& sta
if (it == animations.end()) return; if (it == animations.end()) return;
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -488,8 +478,8 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterSt
renderer.shaderManager.PushShader(shadowSkinningShader); renderer.shaderManager.PushShader(shadowSkinningShader);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -561,8 +551,8 @@ void Character::drawCpuWithShadow(Renderer& renderer, const CharacterState& stat
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -623,8 +613,8 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const CharacterSta
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -726,8 +716,8 @@ void Character::drawNightGpuSkinning(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -798,8 +788,8 @@ void Character::drawNightCpu(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -900,8 +890,8 @@ void Character::drawNightGpuSkinningWithShadow(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -985,8 +975,8 @@ void Character::drawNightCpuWithShadow(Renderer& renderer,
Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR); Environment::CONST_Z_NEAR, Environment::CONST_Z_FAR);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.TranslateMatrix({ state.position.x(), state.position.y(), state.position.z() }); renderer.TranslateMatrix({ static_cast<float>(state.position.x), static_cast<float>(state.position.y), static_cast<float>(state.position.z) });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-state.facingAngle, Eigen::Vector3f::UnitY())).toRotationMatrix()); renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(-static_cast<float>(state.facingAngle), Eigen::Vector3f::UnitY())).toRotationMatrix());
renderer.ScaleMatrix(state.modelScale); renderer.ScaleMatrix(state.modelScale);
renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix()); renderer.RotateMatrix(state.modelCorrectionRotation.toRotationMatrix());
@@ -1027,7 +1017,7 @@ void Character::setupHitSparks(std::shared_ptr<Texture> sparkTexture) {
} }
void Character::presentDamage(float hp, float maxHp, void Character::presentDamage(float hp, float maxHp,
const Eigen::Vector3f& attackDirection, const Vec3fp& attackDirection,
const std::string& attackerId) { const std::string& attackerId) {
if (onHpChanged) if (onHpChanged)
onHpChanged(hp, maxHp, attackerId); onHpChanged(hp, maxHp, attackerId);
@@ -1037,9 +1027,9 @@ void Character::presentDamage(float hp, float maxHp,
if (!stateSnapshot) return; if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot; const CharacterState& state = *stateSnapshot;
Eigen::Vector3f emitPos = state.position + Eigen::Vector3f(0.f, 1.f, 0.f); Eigen::Vector3f emitPos = state.position.toEigen() + Eigen::Vector3f(0.f, 1.f, 0.f);
hitSparkEmitter.resetEmissionPoints({ emitPos }); hitSparkEmitter.resetEmissionPoints({ emitPos });
hitSparkEmitter.setEmissionDirection(attackDirection); hitSparkEmitter.setEmissionDirection(attackDirection.toEigen());
static std::mt19937 gen(std::random_device{}()); static std::mt19937 gen(std::random_device{}());
std::uniform_int_distribution<int> countDist(10, 15); std::uniform_int_distribution<int> countDist(10, 15);
@@ -1065,7 +1055,7 @@ static void drawCharacterNameLabel(
if (!state.enabled || state.hp <= 0.f || displayName.empty()) return; if (!state.enabled || state.hp <= 0.f || displayName.empty()) return;
Eigen::Vector4f worldPos( Eigen::Vector4f worldPos(
state.position.x(), state.position.y() + 1.83f, state.position.z(), 1.f); static_cast<float>(state.position.x), static_cast<float>(state.position.y) + 1.83f, static_cast<float>(state.position.z), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos; Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return; if (clip.w() <= 0.f) return;
@@ -1126,7 +1116,7 @@ void Character::drawHealthBar(Renderer& renderer,
if (state.hp <= 0.f) return; if (state.hp <= 0.f) return;
if (state.initialHp <= 0.f) return; if (state.initialHp <= 0.f) return;
Eigen::Vector4f worldPos(state.position.x(), state.position.y() + 1.75f, state.position.z(), 1.f); Eigen::Vector4f worldPos(static_cast<float>(state.position.x), static_cast<float>(state.position.y) + 1.75f, static_cast<float>(state.position.z), 1.f);
Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos; Eigen::Vector4f clip = projectionMatrix * cameraViewMatrix * worldPos;
if (clip.w() <= 0.f) return; if (clip.w() <= 0.f) return;
float ndcX = clip.x() / clip.w(); float ndcX = clip.x() / clip.w();
+1 -3
View File
@@ -55,7 +55,7 @@ public:
void update(int64_t deltaMs, const CharacterUpdateEvents* events = nullptr); void update(int64_t deltaMs, const CharacterUpdateEvents* events = nullptr);
// ---- Damage ---- // ---- Damage ----
void presentDamage(float hp, float maxHp, const Eigen::Vector3f& attackDirection, void presentDamage(float hp, float maxHp, const Vec3fp& attackDirection,
const std::string& attackerId); const std::string& attackerId);
// ---- Spark setup ---- // ---- Spark setup ----
@@ -93,8 +93,6 @@ public:
// ---- Query helpers (read immutable snapshots) ---- // ---- Query helpers (read immutable snapshots) ----
bool isMoving() const; bool isMoving() const;
Eigen::Vector3f getRequestedWalkTarget() const;
Eigen::Vector3f getCurrentNavigationTarget() const;
Eigen::Vector3f renderPosition() const; Eigen::Vector3f renderPosition() const;
float getHp() const; float getHp() const;
+220 -189
View File
@@ -9,7 +9,13 @@ namespace {
// Tick counts are authoritative and therefore independent of render rate. // Tick counts are authoritative and therefore independent of render rate.
constexpr float ATTACK_COOLDOWN_TIME = 1.6f; constexpr float ATTACK_COOLDOWN_TIME = 1.6f;
constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f; constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
fp HOME_DRIFT_CHECK_INTERVAL_FP = static_cast<fp>(HOME_DRIFT_CHECK_INTERVAL);
constexpr float TARGET_REPLAN_THRESHOLD = 0.25f; constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
fp TARGET_REPLAN_THRESHOLD_SQUARED = static_cast<fp>(TARGET_REPLAN_THRESHOLD * TARGET_REPLAN_THRESHOLD);
constexpr float PI = 3.14159265358979323846f; constexpr float PI = 3.14159265358979323846f;
constexpr int BLOCKED_REPLAN_TICKS = 3; constexpr int BLOCKED_REPLAN_TICKS = 3;
@@ -25,8 +31,8 @@ std::uint32_t stableIdHash(const std::string& value)
} }
void CharacterState::configureAsPlayer() { void CharacterState::configureAsPlayer() {
walkSpeed = 3.0f; walkSpeed = static_cast<fp>(3.0f);
rotationSpeed = 8.0f; rotationSpeed = static_cast < fp>(8.0f);
modelScale = 1.f; modelScale = 1.f;
modelCorrectionRotation = Eigen::Quaternionf( modelCorrectionRotation = Eigen::Quaternionf(
Eigen::AngleAxisf(PI, Eigen::Vector3f::UnitY()) * Eigen::AngleAxisf(PI, Eigen::Vector3f::UnitY()) *
@@ -66,12 +72,12 @@ void CharacterState::scheduleNextNavigationRetry()
} }
bool CharacterState::isMoving() const { bool CharacterState::isMoving() const {
Eigen::Vector3f toTarget = walkTarget - position; Vec3fp toTarget = walkTarget - position;
toTarget.y() = 0.f; toTarget.y = static_cast<fp>(0.f);
return !pathWaypoints.empty() || toTarget.norm() > kWalkThreshold; return !pathWaypoints.empty() || toTarget.normSquared() > kWalkThreshold_SQUARED();
} }
Eigen::Vector3f CharacterState::getCurrentNavigationTarget() const { Vec3fp CharacterState::getCurrentNavigationTarget() const {
if (!pathWaypoints.empty() && currentWaypointIndex < pathWaypoints.size()) { if (!pathWaypoints.empty() && currentWaypointIndex < pathWaypoints.size()) {
return pathWaypoints[currentWaypointIndex]; return pathWaypoints[currentWaypointIndex];
} }
@@ -79,32 +85,34 @@ Eigen::Vector3f CharacterState::getCurrentNavigationTarget() const {
} }
void CharacterState::stopInPlace() { void CharacterState::stopInPlace() {
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z()); walkTarget = Vec3fp(position.x, static_cast<fp>(0.f), position.z);
requestedWalkTarget = walkTarget; requestedWalkTarget = walkTarget;
pathWaypoints.clear(); pathWaypoints.clear();
currentWaypointIndex = 0; currentWaypointIndex = 0;
onArrivedCallbackName.clear(); onArrivedCallbackName.clear();
movementBlockedTicks = 0; movementBlockedTicks = 0;
navigationRetryTicks = 0; navigationRetryTicks = 0;
blockingCharacterId.clear(); velocity = Vec2fp(fp(0), fp(0));
preferredVelocity = Vec2fp(fp(0), fp(0));
} }
void CharacterState::setTarget( void CharacterState::setTarget(
const Eigen::Vector3f& target, const Vec3fp& target,
std::string callbackName, std::string callbackName,
const LocationNetworkState& location, const LocationNetworkState& location,
const std::string& entityId, const std::string& entityId)
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles)
{ {
const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const Vec3fp normalizedTarget(target.x, static_cast<fp>(0.f), target.z);
const bool sameRequestedTarget = const bool sameRequestedTarget =
(normalizedTarget - requestedWalkTarget).norm() <= TARGET_REPLAN_THRESHOLD; (normalizedTarget - requestedWalkTarget).normSquared() <= TARGET_REPLAN_THRESHOLD_SQUARED;
const bool alreadyMovingToTarget = const bool alreadyMovingToTarget =
!pathWaypoints.empty() || !pathWaypoints.empty() ||
(walkTarget - normalizedTarget).norm() <= TARGET_REPLAN_THRESHOLD; (walkTarget - normalizedTarget).normSquared() <= TARGET_REPLAN_THRESHOLD_SQUARED;
const bool stoppedAfterFailedPath = const bool stoppedAfterFailedPath =
pathWaypoints.empty() && pathWaypoints.empty() &&
(walkTarget - position).norm() <= TARGET_REPLAN_THRESHOLD; (walkTarget - position).normSquared() <= TARGET_REPLAN_THRESHOLD_SQUARED;
if (callbackName.empty() && sameRequestedTarget && if (callbackName.empty() && sameRequestedTarget &&
(alreadyMovingToTarget || stoppedAfterFailedPath)) { (alreadyMovingToTarget || stoppedAfterFailedPath)) {
return; return;
@@ -114,44 +122,62 @@ void CharacterState::setTarget(
onArrivedCallbackName = std::move(callbackName); onArrivedCallbackName = std::move(callbackName);
movementBlockedTicks = 0; movementBlockedTicks = 0;
navigationRetryTicks = 0; navigationRetryTicks = 0;
blockingCharacterId.clear();
pathWaypoints = location.navigationMaps.empty() // В setTarget и forceReplan:
? std::vector<Eigen::Vector3f>{} if (location.activeNavigationIndex < 0 ||
: location.planPathExceptMyself( location.activeNavigationIndex >= static_cast<int>(location.navigationMaps.size())) {
position, normalizedTarget, entityId, dynamicObstacles); walkTarget = Vec3fp(position.x, static_cast<fp>(0.f), position.z);
currentWaypointIndex = 0; return;
}
pathWaypoints = location.navigationMaps[location.activeNavigationIndex]
.findPathToNearest(this->position, normalizedTarget);
// Сразу целимся во второй элемент (индекс 1), так как индекс 0 — это точка старта
currentWaypointIndex = (pathWaypoints.size() > 1) ? 1 : 0;
if (!pathWaypoints.empty()) { if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f; for (Vec3fp& waypoint : pathWaypoints)
{
waypoint.y = static_cast<fp>(0.f);
}
walkTarget = pathWaypoints.back(); walkTarget = pathWaypoints.back();
return; return;
} }
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z()); walkTarget = Vec3fp(position.x, static_cast<fp>(0.f), position.z);
if (!isPlayer) scheduleNextNavigationRetry(); if (!isPlayer) scheduleNextNavigationRetry();
} }
void CharacterState::forceReplan( void CharacterState::forceReplan(
const LocationNetworkState& location, const LocationNetworkState& location,
const std::string& entityId, const std::string& entityId)
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles)
{ {
const Eigen::Vector3f normalizedTarget( const Vec3fp normalizedTarget(
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z()); requestedWalkTarget.x, static_cast<fp>(0.f), requestedWalkTarget.z);
pathWaypoints = location.navigationMaps.empty()
? std::vector<Eigen::Vector3f>{} if (location.activeNavigationIndex < 0 ||
: location.planPathExceptMyself( location.activeNavigationIndex >= static_cast<int>(location.navigationMaps.size())) {
position, normalizedTarget, entityId, dynamicObstacles); walkTarget = Vec3fp(position.x, static_cast<fp>(0.f), position.z);
currentWaypointIndex = 0;
movementBlockedTicks = 0;
navigationRetryTicks = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back();
blockingCharacterId.clear();
return; return;
} }
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z()); pathWaypoints = location.navigationMaps[location.activeNavigationIndex]
.findPathToNearest(this->position, normalizedTarget);
currentWaypointIndex = (pathWaypoints.size() > 1) ? 1 : 0;
movementBlockedTicks = 0;
navigationRetryTicks = 0;
if (!pathWaypoints.empty()) {
for (Vec3fp& waypoint : pathWaypoints) {
waypoint.y = static_cast<fp>(0.f);
}
walkTarget = pathWaypoints.back();
return;
}
walkTarget = Vec3fp(position.x, static_cast<fp>(0.f), position.z);
if (!isPlayer) scheduleNextNavigationRetry(); if (!isPlayer) scheduleNextNavigationRetry();
} }
@@ -213,145 +239,134 @@ CharacterUpdateEvents CharacterState::update(
attack = 0; attack = 0;
attackTargetId.clear(); attackTargetId.clear();
} else { } else {
const float distance = (attackTarget->position - position).norm(); const fp distanceSquared = (attackTarget->position - position).normSquared();
if (distance >= visibleRange) { if (distanceSquared >= visibleRange* visibleRange) {
if ((attackTarget->position - requestedWalkTarget).norm() > if ((attackTarget->position - requestedWalkTarget).normSquared() >
TARGET_REPLAN_THRESHOLD) { TARGET_REPLAN_THRESHOLD_SQUARED) {
events.navigationRequested = true; events.navigationRequested = true;
events.navigationTarget = attackTarget->position; events.navigationTarget = attackTarget->position;
} }
battle_state = 0; battle_state = 0;
} else if (distance >= 1.25f) { } else if (distanceSquared >= static_cast<fp>(1.25f*1.25f)) {
if ((attackTarget->position - requestedWalkTarget).norm() > TARGET_REPLAN_THRESHOLD) { if ((attackTarget->position - requestedWalkTarget).normSquared() > TARGET_REPLAN_THRESHOLD_SQUARED) {
events.navigationRequested = true; events.navigationRequested = true;
events.navigationTarget = attackTarget->position; events.navigationTarget = attackTarget->position;
} }
battle_state = 0; battle_state = 0;
} else { } else {
battle_state = 1; battle_state = 1;
velocity = Vec2fp(fp(0), fp(0));
} }
} }
const Eigen::Vector3f activeTarget = getCurrentNavigationTarget(); const Vec3fp activeTarget = getCurrentNavigationTarget();
const Eigen::Vector3f lookTarget =
battle_state == 1 && attackTarget ? attackTarget->position : activeTarget;
if (currentState == AnimationState::WALK || currentState == AnimationState::STAND) { preferredVelocity = Vec2fp(fp(0), fp(0));
Eigen::Vector3f toTarget = activeTarget - position;
toTarget.y() = 0.f; if (hp > 0.f && battle_state == 0 && !pathWaypoints.empty() &&
const float distance = toTarget.norm(); (currentState == AnimationState::WALK || currentState == AnimationState::STAND))
if (distance > kWalkThreshold) { {
const Eigen::Vector3f direction = toTarget / distance; // Если мы уже достигли промежуточного waypoint'а, сразу переключаемся на следующий в этом же тике
const float moveAmount = walkSpeed * static_cast<float>(deltaMs) / 1000.f; while (!pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size()) {
Eigen::Vector3f candidate = Vec3fp diff = pathWaypoints[currentWaypointIndex] - position;
moveAmount >= distance ? activeTarget : position + direction * moveAmount; diff.y = fp(0);
candidate.y() = 0.f; if (diff.normSquared() <= kWalkThreshold_SQUARED()) {
const CharacterMovementObstacle* blocker = nullptr;
if (context.movementObstacles) {
for (const CharacterMovementObstacle& obstacle :
*context.movementObstacles) {
if (obstacle.id == context.characterId) continue;
Eigen::Vector3f startDelta = position - obstacle.position;
Eigen::Vector3f endDelta = candidate - obstacle.position;
startDelta.y() = 0.f;
endDelta.y() = 0.f;
const float minimumDistance =
movementContactRadius + obstacle.radius;
const float minimumDistanceSq =
minimumDistance * minimumDistance;
const Eigen::Vector3f segment = candidate - position;
const float segmentLengthSq =
segment.x() * segment.x() + segment.z() * segment.z();
float t = 0.f;
if (segmentLengthSq > 1e-8f) {
t = -(startDelta.x() * segment.x() +
startDelta.z() * segment.z()) / segmentLengthSq;
t = (std::max)(0.f, (std::min)(1.f, t));
}
Eigen::Vector3f closest = startDelta + segment * t;
closest.y() = 0.f;
const bool movingCloser =
endDelta.squaredNorm() + 1e-6f < startDelta.squaredNorm();
if (movingCloser &&
closest.squaredNorm() < minimumDistanceSq) {
blocker = &obstacle;
break;
}
}
}
if (blocker) {
blockingCharacterId = blocker->id;
currentState = AnimationState::STAND;
const bool sameDirection =
blocker->moving &&
blocker->movementDirection.dot(direction) > 0.5f;
if (sameDirection) {
movementBlockedTicks = 0;
} else if (++movementBlockedTicks >= BLOCKED_REPLAN_TICKS) {
if (isPlayer) {
stopInPlace();
} else {
pathWaypoints.clear();
currentWaypointIndex = 0;
walkTarget =
Eigen::Vector3f(position.x(), 0.f, position.z());
movementBlockedTicks = 0;
scheduleNextNavigationRetry();
navigationPausedThisTick = true;
}
}
} else {
movedLastTick =
(candidate - position).squaredNorm() > 1e-8f;
position = candidate;
movementBlockedTicks = 0;
blockingCharacterId.clear();
}
targetFacingAngle = std::atan2(direction.x(), -direction.z());
if (!blocker) currentState = AnimationState::WALK;
} else {
currentState = AnimationState::STAND;
const bool completedPath = !pathWaypoints.empty();
const bool hasNextWaypoint =
!pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size();
if (hasNextWaypoint) {
++currentWaypointIndex; ++currentWaypointIndex;
} else { }
pathWaypoints.clear(); else {
currentWaypointIndex = 0; break;
if (completedPath && }
(requestedWalkTarget - position).norm() > }
TARGET_REPLAN_THRESHOLD &&
!isPlayer) const Vec3fp currentTarget = getCurrentNavigationTarget();
scheduleNextNavigationRetry(); Vec3fp toTarget = currentTarget - position;
if (completedPath && !onArrivedCallbackName.empty()) { toTarget.y = fp(0);
events.arrivedCallbackName = onArrivedCallbackName; const fp distanceSquared = toTarget.normSquared();
onArrivedCallbackName.clear();
} if (distanceSquared > kWalkThreshold_SQUARED()) {
const fp distance = fpm::sqrt(distanceSquared);
const fp dtSec = static_cast<fp>(deltaMs) / fp(1000);
fp desiredSpeed = distance / dtSec;
if (desiredSpeed > walkSpeed) {
desiredSpeed = walkSpeed;
} }
preferredVelocity = Vec2fp(
(toTarget.x / distance) * desiredSpeed,
(toTarget.z / distance) * desiredSpeed
);
// Если стартуем с места — сразу целим поворот на точку,
// а в движении угол скорректирует Фаза 3 по реальному вектору ORCA (newVel)
if (velocity.lengthSq() <= static_cast<fp>(0.05f)) {
targetFacingAngle = fpm::atan2(toTarget.x, -toTarget.z);
}
}
else {
// Дошли до ФИНАЛЬНОЙ точки маршрута
velocity = Vec2fp(fp(0), fp(0));
if (currentState == AnimationState::WALK) {
currentState = AnimationState::STAND;
resetAnim = true;
}
const bool completedPath = !pathWaypoints.empty();
pathWaypoints.clear();
currentWaypointIndex = 0;
if (completedPath &&
(requestedWalkTarget - position).normSquared() > TARGET_REPLAN_THRESHOLD_SQUARED &&
!isPlayer) {
scheduleNextNavigationRetry();
}
if (completedPath && !onArrivedCallbackName.empty()) {
events.arrivedCallbackName = onArrivedCallbackName;
onArrivedCallbackName.clear();
}
if (faceTarget) { if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->position - position; Vec3fp toFace = faceTarget->position - position;
toFace.y() = 0.f; toFace.y = fp(0);
const float distanceToFace = toFace.norm(); const fp distanceToFace = fpm::sqrt(toFace.normSquared());
targetFacingAngle = distanceToFace > 1e-3f targetFacingAngle = distanceToFace > static_cast<fp>(1e-3f)
? std::atan2(toFace.x() / distanceToFace, -toFace.z() / distanceToFace) ? fpm::atan2(toFace.x / distanceToFace, -toFace.z / distanceToFace)
: facingAngle; : facingAngle;
} else if (!faceTargetId.empty()) { }
else if (!faceTargetId.empty()) {
faceTargetId.clear(); faceTargetId.clear();
} }
} }
} }
else if (hp > 0.f && battle_state == 0 && pathWaypoints.empty() &&
(currentState == AnimationState::WALK || currentState == AnimationState::STAND))
{
// Маршрута нет — стоим на месте и при необходимости поворачиваемся к faceTarget
velocity = Vec2fp(fp(0), fp(0));
if (currentState == AnimationState::WALK) {
currentState = AnimationState::STAND;
resetAnim = true;
}
if (faceTarget) {
Vec3fp toFace = faceTarget->position - position;
toFace.y = fp(0);
const fp distanceToFace = fpm::sqrt(toFace.normSquared());
targetFacingAngle = distanceToFace > static_cast<fp>(1e-3f)
? fpm::atan2(toFace.x / distanceToFace, -toFace.z / distanceToFace)
: facingAngle;
}
else if (!faceTargetId.empty()) {
faceTargetId.clear();
}
}
if (!isPlayer && !navigationPausedThisTick && pathWaypoints.empty() && if (!isPlayer && !navigationPausedThisTick && pathWaypoints.empty() &&
(requestedWalkTarget - position).norm() > TARGET_REPLAN_THRESHOLD && (requestedWalkTarget - position).normSquared() > TARGET_REPLAN_THRESHOLD_SQUARED &&
!events.navigationRequested) { !events.navigationRequested) {
if (navigationRetryTicks > 0) --navigationRetryTicks; if (navigationRetryTicks > 0) --navigationRetryTicks;
if (navigationRetryTicks == 0) { if (navigationRetryTicks == 0) {
events.navigationRequested = true; events.navigationRequested = true;
events.forceNavigationReplan = true; events.forceNavigationReplan = true;
events.navigationTarget = requestedWalkTarget; events.navigationTarget = requestedWalkTarget;
events.blockingCharacterId = blockingCharacterId;
} }
} }
@@ -362,9 +377,11 @@ CharacterUpdateEvents CharacterState::update(
resetAnim = true; resetAnim = true;
attackTargetId.clear(); attackTargetId.clear();
} }
} else if (battle_state == 1 && attackTarget) { }
targetFacingAngle = std::atan2( else if (battle_state == 1 && attackTarget) {
lookTarget.x() - position.x(), -(lookTarget.z() - position.z())); targetFacingAngle = fpm::atan2(
attackTarget->position.x - position.x, -(attackTarget->position.z - position.z));
if (currentState == AnimationState::STAND || currentState == AnimationState::WALK) { if (currentState == AnimationState::STAND || currentState == AnimationState::WALK) {
currentState = AnimationState::STAND_TO_ACTION; currentState = AnimationState::STAND_TO_ACTION;
resetAnim = true; resetAnim = true;
@@ -388,24 +405,25 @@ CharacterUpdateEvents CharacterState::update(
resetAnim = true; resetAnim = true;
} }
float angleDiff = targetFacingAngle - facingAngle; fp angleDiff = targetFacingAngle - facingAngle;
while (angleDiff > PI) angleDiff -= 2.f * PI; const fp PIfp = static_cast<fp>(PI);
while (angleDiff < -PI) angleDiff += 2.f * PI; while (angleDiff > PIfp) angleDiff -= static_cast<fp>(2.f) * PIfp;
const float rotationStep = rotationSpeed * static_cast<float>(deltaMs) / 1000.f; while (angleDiff < -PIfp) angleDiff += static_cast<fp>(2.f) * PIfp;
if (std::fabs(angleDiff) <= rotationStep) { const fp rotationStep = rotationSpeed * static_cast<fp>(deltaMs) / static_cast<fp>(1000.f);
if (fpm::abs(angleDiff) <= rotationStep) {
facingAngle = targetFacingAngle; facingAngle = targetFacingAngle;
faceTargetId.clear(); faceTargetId.clear();
} else { } else {
facingAngle += angleDiff > 0.f ? rotationStep : -rotationStep; facingAngle += angleDiff > static_cast<fp>(0.f) ? rotationStep : -rotationStep;
} }
if (!isPlayer && hp > 0.f && battle_state == 0 && maxHomeDrift > 0.f) { if (!isPlayer && hp > 0.f && battle_state == 0 && maxHomeDriftSquared > static_cast<fp>(0.f)) {
homeDriftCheckTimer -= static_cast<float>(deltaMs) / 1000.f; homeDriftCheckTimer -= static_cast<fp>(deltaMs) / static_cast<fp>(1000.f);
if (homeDriftCheckTimer <= 0.f) { if (homeDriftCheckTimer <= static_cast <fp>(0.f)) {
homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL; homeDriftCheckTimer = HOME_DRIFT_CHECK_INTERVAL_FP;
Eigen::Vector3f toHome = homePosition - position; Vec3fp toHome = homePosition - position;
toHome.y() = 0.f; toHome.y = static_cast<fp>(0.f);
if (!isMoving() && toHome.norm() > maxHomeDrift) { if (!isMoving() && toHome.normSquared() > maxHomeDriftSquared) {
events.navigationRequested = true; events.navigationRequested = true;
events.navigationTarget = homePosition; events.navigationTarget = homePosition;
} }
@@ -428,10 +446,18 @@ CharacterUpdateEvents CharacterState::update(
events.attackImpact = true; events.attackImpact = true;
events.attackTargetId = attackTargetId; events.attackTargetId = attackTargetId;
events.attackDirection = attackTarget->position - position; events.attackDirection = attackTarget->position - position;
events.attackDirection.y() = 0.f; events.attackDirection.y = static_cast<fp>(0.f);
const float length = events.attackDirection.norm(); const fp lengthSquared = events.attackDirection.normSquared();
if (length > 1e-3f) events.attackDirection /= length; if (lengthSquared > static_cast<fp>(0.001f))
else events.attackDirection = Eigen::Vector3f::Zero(); {
const fp length = fpm::sqrt(lengthSquared); //FP SQRT
events.attackDirection = events.attackDirection / length;
}
else
{
events.attackDirection = Vec3fp{};
}
} }
showWeapon = showWeapon =
@@ -467,9 +493,9 @@ CharacterUpdateEvents CharacterState::update(
void CharacterState::save(nlohmann::json& out) const void CharacterState::save(nlohmann::json& out) const
{ {
out["position"] = { position.x(), position.y(), position.z() }; out["position"] = { static_cast<double>(position.x), static_cast<double>(position.y), static_cast<double>(position.z) };
out["facingAngle"] = facingAngle; out["facingAngle"] = static_cast<double>(facingAngle);
out["targetFacingAngle"] = targetFacingAngle; out["targetFacingAngle"] = static_cast<double>(targetFacingAngle);
out["hp"] = hp; out["hp"] = hp;
out["enabled"] = enabled; out["enabled"] = enabled;
out["battle_state"] = battle_state; out["battle_state"] = battle_state;
@@ -478,25 +504,24 @@ void CharacterState::save(nlohmann::json& out) const
out["showWeapon"] = showWeapon; out["showWeapon"] = showWeapon;
out["attackTargetId"] = attackTargetId; out["attackTargetId"] = attackTargetId;
out["faceTargetId"] = faceTargetId; out["faceTargetId"] = faceTargetId;
out["homePosition"] = { homePosition.x(), homePosition.y(), homePosition.z() }; out["homePosition"] = { static_cast<double>(homePosition.x), static_cast<double>(homePosition.y), static_cast<double>(homePosition.z) };
out["walkTarget"] = { walkTarget.x(), walkTarget.y(), walkTarget.z() }; out["walkTarget"] = { static_cast<double>(walkTarget.x), static_cast<double>(walkTarget.y), static_cast<double>(walkTarget.z) };
out["requestedWalkTarget"] = { requestedWalkTarget.x(), requestedWalkTarget.y(), requestedWalkTarget.z() }; out["requestedWalkTarget"] = { static_cast<double>(requestedWalkTarget.x), static_cast<double>(requestedWalkTarget.y), static_cast<double>(requestedWalkTarget.z) };
nlohmann::json wpArr = nlohmann::json::array(); nlohmann::json wpArr = nlohmann::json::array();
for (const auto& wp : pathWaypoints) { for (const auto& wp : pathWaypoints) {
wpArr.push_back({ wp.x(), wp.y(), wp.z() }); wpArr.push_back({ static_cast<double>(wp.x), static_cast<double>(wp.y), static_cast<double>(wp.z) });
} }
out["pathWaypoints"] = std::move(wpArr); out["pathWaypoints"] = std::move(wpArr);
out["currentWaypointIndex"] = currentWaypointIndex; out["currentWaypointIndex"] = currentWaypointIndex;
out["homeDriftCheckTimer"] = homeDriftCheckTimer; out["homeDriftCheckTimer"] = static_cast<double>(homeDriftCheckTimer);
out["movementBlockedTicks"] = movementBlockedTicks; out["movementBlockedTicks"] = movementBlockedTicks;
out["navigationRetryTicks"] = navigationRetryTicks; out["navigationRetryTicks"] = navigationRetryTicks;
out["movedLastTick"] = movedLastTick; out["movedLastTick"] = movedLastTick;
out["navigationRetryCooldownTicks"] = navigationRetryCooldownTicks; out["navigationRetryCooldownTicks"] = navigationRetryCooldownTicks;
out["navigationRetryCooldownIndex"] = navigationRetryCooldownIndex; out["navigationRetryCooldownIndex"] = navigationRetryCooldownIndex;
if (!blockingCharacterId.empty())
out["blockingCharacterId"] = blockingCharacterId;
out["animationFrame"] = animationFrame; out["animationFrame"] = animationFrame;
out["visibleRange"] = visibleRange; out["visibleRange"] = static_cast<double>(visibleRange);
if (!onArrivedCallbackName.empty()) if (!onArrivedCallbackName.empty())
out["onArrivedCallbackName"] = onArrivedCallbackName; out["onArrivedCallbackName"] = onArrivedCallbackName;
} }
@@ -509,11 +534,19 @@ void CharacterState::load(const nlohmann::json& in)
return def; return def;
}; };
if (in.contains("position")) position = readVec3(in["position"], position); auto readVec3fp = [](const nlohmann::json& j, Vec3fp def) -> Vec3fp {
facingAngle = in.value("facingAngle", facingAngle); if (j.is_array() && j.size() == 3)
targetFacingAngle = in.value("targetFacingAngle", targetFacingAngle); return Vec3fp{ static_cast<fp>(j[0].get<double>()), static_cast<fp>(j[1].get<double>()), static_cast<fp>(j[2].get<double>()) };
return def;
};
if (in.contains("position")) {
position = readVec3fp(in["position"], position);
}
facingAngle = static_cast<fp>(in.value("facingAngle", static_cast<double>(facingAngle)));
targetFacingAngle = static_cast<fp>(in.value("targetFacingAngle", static_cast<double>(targetFacingAngle)));
hp = in.value("hp", hp); hp = in.value("hp", hp);
visibleRange = in.value("visibleRange", visibleRange); visibleRange = static_cast<fp>(in.value("visibleRange", static_cast<double>(visibleRange)));
enabled = in.value("enabled", enabled); enabled = in.value("enabled", enabled);
battle_state = in.value("battle_state", battle_state); battle_state = in.value("battle_state", battle_state);
currentState = static_cast<AnimationState>(in.value("currentState", static_cast<int>(currentState))); currentState = static_cast<AnimationState>(in.value("currentState", static_cast<int>(currentState)));
@@ -521,17 +554,17 @@ void CharacterState::load(const nlohmann::json& in)
showWeapon = in.value("showWeapon", showWeapon); showWeapon = in.value("showWeapon", showWeapon);
attackTargetId = in.value("attackTargetId", attackTargetId); attackTargetId = in.value("attackTargetId", attackTargetId);
faceTargetId = in.value("faceTargetId", faceTargetId); faceTargetId = in.value("faceTargetId", faceTargetId);
if (in.contains("homePosition")) homePosition = readVec3(in["homePosition"], homePosition); if (in.contains("homePosition")) homePosition = readVec3fp(in["homePosition"], homePosition);
if (in.contains("walkTarget")) walkTarget = readVec3(in["walkTarget"], walkTarget); if (in.contains("walkTarget")) walkTarget = readVec3fp(in["walkTarget"], walkTarget);
if (in.contains("requestedWalkTarget")) requestedWalkTarget = readVec3(in["requestedWalkTarget"], requestedWalkTarget); if (in.contains("requestedWalkTarget")) requestedWalkTarget = readVec3fp(in["requestedWalkTarget"], requestedWalkTarget);
pathWaypoints.clear(); pathWaypoints.clear();
if (in.contains("pathWaypoints") && in["pathWaypoints"].is_array()) { if (in.contains("pathWaypoints") && in["pathWaypoints"].is_array()) {
for (const auto& wp : in["pathWaypoints"]) { for (const auto& wp : in["pathWaypoints"]) {
pathWaypoints.push_back(readVec3(wp, Eigen::Vector3f::Zero())); pathWaypoints.push_back(readVec3fp(wp, Vec3fp()));
} }
} }
currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex); currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex);
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer); homeDriftCheckTimer = static_cast<fp>(in.value("homeDriftCheckTimer", static_cast<double>(homeDriftCheckTimer)));
movementBlockedTicks = in.value("movementBlockedTicks", movementBlockedTicks); movementBlockedTicks = in.value("movementBlockedTicks", movementBlockedTicks);
navigationRetryTicks = in.value("navigationRetryTicks", navigationRetryTicks); navigationRetryTicks = in.value("navigationRetryTicks", navigationRetryTicks);
movedLastTick = in.value("movedLastTick", movedLastTick); movedLastTick = in.value("movedLastTick", movedLastTick);
@@ -541,8 +574,6 @@ void CharacterState::load(const nlohmann::json& in)
"navigationRetryCooldownIndex", navigationRetryCooldownIndex); "navigationRetryCooldownIndex", navigationRetryCooldownIndex);
if (!navigationRetryCooldownTicks.empty()) if (!navigationRetryCooldownTicks.empty())
navigationRetryCooldownIndex %= navigationRetryCooldownTicks.size(); navigationRetryCooldownIndex %= navigationRetryCooldownTicks.size();
blockingCharacterId =
in.value("blockingCharacterId", std::string());
animationFrame = in.value("animationFrame", animationFrame); animationFrame = in.value("animationFrame", animationFrame);
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string()); onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
} }
+35 -41
View File
@@ -61,6 +61,9 @@ public:
// Distance below which the character is considered to have reached its target. // Distance below which the character is considered to have reached its target.
static constexpr float kWalkThreshold = 0.05f; static constexpr float kWalkThreshold = 0.05f;
static fp kWalkThreshold_SQUARED() { return static_cast<fp>(kWalkThreshold * kWalkThreshold); };
// --- Identity --- // --- Identity ---
std::string npcName; std::string npcName;
bool isPlayer = false; bool isPlayer = false;
@@ -68,15 +71,15 @@ public:
bool canAttack = false; bool canAttack = false;
// --- Position / orientation --- // --- Position / orientation ---
Eigen::Vector3f position = Eigen::Vector3f::Zero(); Vec3fp position;
float facingAngle = 0.0f; fp facingAngle;
float targetFacingAngle = 0.0f; fp targetFacingAngle;
// --- Health --- // --- Health ---
float hp = 200.f; float hp = 200.f;
float initialHp = 200.f; float initialHp = 200.f;
float visibleRange = VISIBLE_RANGE; fp visibleRange = static_cast<fp>(VISIBLE_RANGE);
// --- Combat flags --- // --- Combat flags ---
int battle_state = 0; int battle_state = 0;
@@ -89,14 +92,14 @@ public:
bool showWeapon = true; bool showWeapon = true;
// --- Stable character references --- // --- Stable character references ---
// Empty means no target. "player" is the campaign player; NPC IDs are // Empty means no target. "player1" is the campaign player; NPC IDs are
// location-local and validated while loading location content. // location-local and validated while loading location content.
std::string attackTargetId; std::string attackTargetId;
std::string faceTargetId; std::string faceTargetId;
// --- Home position for drift recovery --- // --- Home position for drift recovery ---
Eigen::Vector3f homePosition = Eigen::Vector3f::Zero(); Vec3fp homePosition;
float maxHomeDrift = 3.0f; fp maxHomeDriftSquared = static_cast<fp>(3.f*3.f);
// --- Authoritative logical animation clock --- // --- Authoritative logical animation clock ---
// Frame counts are immutable metadata copied from loaded clips during // Frame counts are immutable metadata copied from loaded clips during
@@ -106,29 +109,33 @@ public:
std::array<int, 9> animationFrameCounts = {{1, 1, 1, 1, 1, 1, 1, 1, 1}}; std::array<int, 9> animationFrameCounts = {{1, 1, 1, 1, 1, 1, 1, 1, 1}};
// --- Movement / visual parameters --- // --- Movement / visual parameters ---
float walkSpeed = 3.0f; fp walkSpeed = static_cast<fp>(3.0f);
float rotationSpeed = 4.0f; fp rotationSpeed = static_cast<fp>(4.0f);
float modelScale = 0.12f; float modelScale = 0.12f;
Eigen::Quaternionf modelCorrectionRotation = Eigen::Quaternionf::Identity(); Eigen::Quaternionf modelCorrectionRotation = Eigen::Quaternionf::Identity();
bool useGpuSkinning = true; bool useGpuSkinning = true;
// Full clearance used while planning around stationary characters.
float collisionRadius = 0.45f; fp collisionRadius = static_cast<fp>(0.4f); // Радиус капсулы персонажа
// Smaller physical contact threshold used by the movement guard.
float movementContactRadius = 0.32f; // Векторы скорости в плоскости XZ (сериализовать в JSON не обязательно, если считаются каждый тик,
float interactionRadius = 0.0f; // но для строгого lockstep-снапшота velocity лучше сохранять)
Vec2fp velocity{ fp(0), fp(0) }; // Реальная скорость с прошлого тика (нужна соседям в ORCA)
Vec2fp preferredVelocity{ fp(0), fp(0) }; // Желаемая скорость к waypoint'у на текущем тике
fp interactionRadius = static_cast<fp>(0.0f);
bool movedLastTick = false; bool movedLastTick = false;
// --- Pathfinding data (serializable parts) --- // --- Pathfinding data (serializable parts) ---
Eigen::Vector3f walkTarget = Eigen::Vector3f::Zero(); Vec3fp walkTarget;
Eigen::Vector3f requestedWalkTarget = Eigen::Vector3f::Zero(); Vec3fp requestedWalkTarget;
std::vector<Eigen::Vector3f> pathWaypoints; std::vector<Vec3fp> pathWaypoints;
size_t currentWaypointIndex = 0; size_t currentWaypointIndex = 0;
// Internal drift-check timer (serializable so it survives save/load). // Internal drift-check timer (serializable so it survives save/load).
float homeDriftCheckTimer = 5.0f; fp homeDriftCheckTimer = static_cast<fp>(5.0f);
int movementBlockedTicks = 0; int movementBlockedTicks = 0;
int navigationRetryTicks = 0; int navigationRetryTicks = 0;
std::string blockingCharacterId;
std::vector<int> navigationRetryCooldownTicks; std::vector<int> navigationRetryCooldownTicks;
std::size_t navigationRetryCooldownIndex = 0; std::size_t navigationRetryCooldownIndex = 0;
@@ -145,19 +152,15 @@ public:
const std::string& entityId, const std::string& entityId,
std::vector<int> cooldownTicks = {}); std::vector<int> cooldownTicks = {});
bool isMoving() const; bool isMoving() const;
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; } const Vec3fp& getRequestedWalkTarget() const { return requestedWalkTarget; }
Eigen::Vector3f getCurrentNavigationTarget() const; Vec3fp getCurrentNavigationTarget() const;
void stopInPlace(); void stopInPlace();
void setTarget(const Eigen::Vector3f& target, void setTarget(const Vec3fp& target,
std::string callbackName, std::string callbackName,
const LocationNetworkState& location, const LocationNetworkState& location,
const std::string& entityId, const std::string& entityId);
const std::vector<PathFinder::DynamicObstacle>&
dynamicObstacles = {});
void forceReplan(const LocationNetworkState& location, void forceReplan(const LocationNetworkState& location,
const std::string& entityId, const std::string& entityId);
const std::vector<PathFinder::DynamicObstacle>&
dynamicObstacles = {});
void stopAndRotateToTarget(std::string characterId); void stopAndRotateToTarget(std::string characterId);
void resetPlayerAfterDeath(); void resetPlayerAfterDeath();
void tryAttackCombo(); void tryAttackCombo();
@@ -177,23 +180,14 @@ using PlayerStateMap =
std::unordered_map<std::string, std::shared_ptr<CharacterState>>; std::unordered_map<std::string, std::shared_ptr<CharacterState>>;
struct CharacterObservation { struct CharacterObservation {
Eigen::Vector3f position = Eigen::Vector3f::Zero(); Vec3fp position;
float hp = 0.f; float hp = 0.f;
}; };
struct CharacterMovementObstacle {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float radius = 0.f;
bool moving = false;
Eigen::Vector3f movementDirection = Eigen::Vector3f::Zero();
};
struct CharacterUpdateContext { struct CharacterUpdateContext {
std::string characterId; std::string characterId;
const CharacterObservation* attackTarget = nullptr; const CharacterObservation* attackTarget = nullptr;
const CharacterObservation* faceTarget = nullptr; const CharacterObservation* faceTarget = nullptr;
const std::vector<CharacterMovementObstacle>* movementObstacles = nullptr;
}; };
struct CharacterUpdateEvents { struct CharacterUpdateEvents {
@@ -201,11 +195,11 @@ struct CharacterUpdateEvents {
bool deathAnimationComplete = false; bool deathAnimationComplete = false;
bool navigationRequested = false; bool navigationRequested = false;
bool forceNavigationReplan = false; bool forceNavigationReplan = false;
Eigen::Vector3f navigationTarget = Eigen::Vector3f::Zero(); Vec3fp navigationTarget;
std::string blockingCharacterId;
std::string arrivedCallbackName; std::string arrivedCallbackName;
std::string attackTargetId; std::string attackTargetId;
Eigen::Vector3f attackDirection = Eigen::Vector3f::Zero(); Vec3fp attackDirection;
}; };
} // namespace FRG } // namespace FRG
+18 -4
View File
@@ -557,7 +557,21 @@ namespace FRG
if (remoteCampaignActive) { if (remoteCampaignActive) {
logger() << "[network] Campaign '" << campaignId logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl; << "' selected; creating WebSocket client" << std::endl;
websocketGameClient = std::make_unique<WebsocketGameClient>(); // Use "ws://127.0.0.1:8080/" for a local development server.
static const char* const kMultiplayerServerUrl = "wss://api.shadowoverbishkek.com/server001/";
//static const char* const kMultiplayerServerUrl = "ws://127.0.0.1:8080/";
WebsocketEndpoint endpoint;
if (!endpoint.parse(kMultiplayerServerUrl)) {
logger() << "[network] Invalid multiplayer server URL: "
<< kMultiplayerServerUrl << std::endl;
remoteCampaignActive = false;
gameServer.use(internalGameServer);
gameServerHost.use(localGameServerHost);
return;
}
websocketGameClient = std::make_unique<WebsocketGameClient>(endpoint);
remoteGameServerHost = remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*websocketGameClient); std::make_unique<RemoteGameServerHost>(*websocketGameClient);
gameServer.use(*websocketGameClient); gameServer.use(*websocketGameClient);
@@ -650,9 +664,9 @@ namespace FRG
if (!loc) return; if (!loc) return;
if (loc->player) { if (loc->player) {
server().submit(SetCharacterPositionCommand{ server().submit(SetCharacterPositionCommand{
destName, loc->player->entityId(), destPos}); destName, loc->player->entityId(), Vec3fp::fromEigen(destPos)});
server().submit(SetCharacterFacingAngleCommand{ server().submit(SetCharacterFacingAngleCommand{
destName, loc->player->entityId(), destRotY, true}); destName, loc->player->entityId(), static_cast<fp>(destRotY), true});
loc->stopCharacter(*loc->player); loc->stopCharacter(*loc->player);
} }
loc->localState().cameraAzimuth = destRotY; loc->localState().cameraAzimuth = destRotY;
@@ -680,7 +694,7 @@ namespace FRG
// Developer workflow: uncomment to (re)generate the campaign's start-state JSON // Developer workflow: uncomment to (re)generate the campaign's start-state JSON
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua. // from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
// Re-comment before shipping. // Re-comment before shipping.
saveInitialState(); //saveInitialState();
} }
+29 -61
View File
@@ -126,7 +126,7 @@ namespace FRG
} }
void Location::setCharacterTarget( void Location::setCharacterTarget(
Character& character, const Eigen::Vector3f& target, std::string callbackName) Character& character, const Vec3fp& target, std::string callbackName)
{ {
gameServer_.submit(SetCharacterDestinationCommand{ gameServer_.submit(SetCharacterDestinationCommand{
locationId_, character.entityId(), target, std::move(callbackName)}); locationId_, character.entityId(), target, std::move(callbackName)});
@@ -851,8 +851,8 @@ namespace FRG
float t0 = (worldMin[axis] - rayOrigin[axis]) * invD; float t0 = (worldMin[axis] - rayOrigin[axis]) * invD;
float t1 = (worldMax[axis] - rayOrigin[axis]) * invD; float t1 = (worldMax[axis] - rayOrigin[axis]) * invD;
if (invD < 0.0f) std::swap(t0, t1); if (invD < 0.0f) std::swap(t0, t1);
tMin = max(tMin, t0); tMin = std::max(tMin, t0);
tMax = min(tMax, t1); tMax = std::min(tMax, t1);
if (tMax < tMin) { miss = true; break; } if (tMax < tMin) { miss = true; break; }
} }
} }
@@ -890,11 +890,11 @@ namespace FRG
} }
Character* Location::raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance) { Character* Location::raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance) {
// Every NPC is treated as a vertical cylinder: radius 0.25m, height 1.6m, // Every NPC is treated as a vertical cylinder
// base at npc->position (the model's foot). Intersection = circle hit in the // base at npc->position (the model's foot). Intersection = circle hit in the
// XZ plane, then clip the entry/exit t against the [yFoot, yFoot+height] slab. // XZ plane, then clip the entry/exit t against the [yFoot, yFoot+height] slab.
static constexpr float NPC_CLICK_RADIUS = 0.25f; static constexpr float NPC_CLICK_RADIUS = 0.35f;
static constexpr float NPC_CLICK_HEIGHT = 1.6f; static constexpr float NPC_CLICK_HEIGHT = 1.8f;
static constexpr float NPC_PICK_EPSILON = 1e-6f; static constexpr float NPC_PICK_EPSILON = 1e-6f;
Character* closestNpc = nullptr; Character* closestNpc = nullptr;
@@ -1597,7 +1597,7 @@ namespace FRG
if (playerState && !pointLights.empty()) { if (playerState && !pointLights.empty()) {
float minDist = FLT_MAX; float minDist = FLT_MAX;
for (int i = 0; i < static_cast<int>(pointLights.size()); ++i) { for (int i = 0; i < static_cast<int>(pointLights.size()); ++i) {
const float d = (pointLights[i].position - playerState->position).norm(); const float d = (pointLights[i].position - playerState->position.toEigen()).norm();
if (d < minDist) { minDist = d; nearestLightIdx = i; } if (d < minDist) { minDist = d; nearestLightIdx = i; }
} }
} }
@@ -1611,12 +1611,12 @@ namespace FRG
if (!active && playerState) { if (!active && playerState) {
if (pl.autoLightLimitX > 0.0f && pl.autoLightLimitY > 0.0f) { if (pl.autoLightLimitX > 0.0f && pl.autoLightLimitY > 0.0f) {
// Rectangular activation zone — independent of nearest-light heuristic // Rectangular activation zone — independent of nearest-light heuristic
const float dx = std::abs(playerState->position.x() - pl.position.x()); const float dx = std::abs(static_cast<float>(playerState->position.x) - pl.position.x());
const float dz = std::abs(playerState->position.z() - pl.position.z()); const float dz = std::abs(static_cast<float>(playerState->position.z) - pl.position.z());
active = (dx <= pl.autoLightLimitX && dz <= pl.autoLightLimitY); active = (dx <= pl.autoLightLimitX && dz <= pl.autoLightLimitY);
} else { } else {
// only the nearest autoLight activates, with optional distance cap // only the nearest autoLight activates, with optional distance cap
const float dist = (pl.position - playerState->position).norm(); const float dist = (pl.position - playerState->position.toEigen()).norm();
active = (i == nearestLightIdx) && active = (i == nearestLightIdx) &&
(pl.autoLightDistance <= 0.0f || dist <= pl.autoLightDistance); (pl.autoLightDistance <= 0.0f || dist <= pl.autoLightDistance);
} }
@@ -1632,7 +1632,7 @@ namespace FRG
if (playerState && !activeLightIndices.empty()) { if (playerState && !activeLightIndices.empty()) {
float minDist = FLT_MAX; float minDist = FLT_MAX;
for (int idx : activeLightIndices) { for (int idx : activeLightIndices) {
const float d = (pointLights[idx].position - playerState->position).norm(); const float d = (pointLights[idx].position - playerState->position.toEigen()).norm();
if (d < minDist) { minDist = d; shadowLight = &pointLights[idx]; } if (d < minDist) { minDist = d; shadowLight = &pointLights[idx]; }
} }
} }
@@ -1838,50 +1838,18 @@ namespace FRG
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) { state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) {
return false; return false;
} }
const auto& areas = const auto& area =
state->navigationMaps[state->activeNavigationIndex].getAreas(); state->navigationMaps[state->activeNavigationIndex].getArea();
const bool found = std::any_of( if (area.name != areaName)
areas.begin(), areas.end(), {
[&](const PathFinder::NavigationArea& area) { return false;
return area.name == areaName; }
});
if (!found) return false;
gameServer_.submit(SetNavigationAreaAvailableCommand{ gameServer_.submit(SetNavigationAreaAvailableCommand{
locationId_, areaName, available}); locationId_, areaName, available});
return true; return true;
} }
void Location::nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom)
{
auto standingState = standing ? standing->stateSnapshot() : nullptr;
if (!standingState || standingState->isPlayer) return;
auto locationState = stateSnapshot();
if (!locationState || locationState->activeNavigationIndex < 0 ||
locationState->activeNavigationIndex >=
static_cast<int>(locationState->navigationMaps.size())) return;
const PathFinder& navigation =
locationState->navigationMaps[locationState->activeNavigationIndex];
if (navigation.isReady()) return;
static constexpr float kNudgeDist = 1.2f;
Eigen::Vector3f dir = standingState->position - awayFrom;
dir.y() = 0.f;
if (dir.norm() < 1e-3f) dir = Eigen::Vector3f(1.f, 0.f, 0.f);
else dir.normalize();
const float angles[] = { 0.f, static_cast<float>(M_PI * 0.5), static_cast<float>(-M_PI * 0.5), static_cast<float>(M_PI) };
for (float angle : angles) {
Eigen::Vector3f rotated = Eigen::AngleAxisf(angle, Eigen::Vector3f::UnitY()) * dir;
Eigen::Vector3f candidate = standingState->position + rotated * kNudgeDist;
candidate.y() = 0.f;
if (navigation.isWalkable(candidate)) {
setCharacterTarget(*standing, candidate);
return;
}
}
}
void Location::update(int64_t delta, const LocationSimulationEvents& events) void Location::update(int64_t delta, const LocationSimulationEvents& events)
{ {
synchronizeRemotePlayers(); synchronizeRemotePlayers();
@@ -1952,7 +1920,7 @@ namespace FRG
player->update(delta, eventFor(gameServer_.localPlayerId())); player->update(delta, eventFor(gameServer_.localPlayerId()));
auto playerState = player->stateSnapshot(); auto playerState = player->stateSnapshot();
if (playerState && playerState->enabled) if (playerState && playerState->enabled)
updateTriggerZones(playerState->position); updateTriggerZones(playerState->position.toEigen());
} }
for (auto& [id, remote] : remotePlayers_) for (auto& [id, remote] : remotePlayers_)
if (remote) remote->update(delta, eventFor(id)); if (remote) remote->update(delta, eventFor(id));
@@ -1989,7 +1957,7 @@ namespace FRG
auto playerState = player ? player->stateSnapshot() : nullptr; auto playerState = player ? player->stateSnapshot() : nullptr;
if (auto* tz = getTargetTeleportZone(*locationState); if (auto* tz = getTargetTeleportZone(*locationState);
tz && playerState && playerState->enabled) { tz && playerState && playerState->enabled) {
float dist = (playerState->position - tz->position).norm(); float dist = (playerState->position.toEigen() - tz->position).norm();
if (dist <= tz->radius) { if (dist <= tz->radius) {
logger() << "[TELEPORT] Player reached teleport zone '" << tz->id << "'" << std::endl; logger() << "[TELEPORT] Player reached teleport zone '" << tz->id << "'" << std::endl;
gameServer_.submit(SetLocationTargetsCommand{ gameServer_.submit(SetLocationTargetsCommand{
@@ -2006,7 +1974,7 @@ namespace FRG
for (auto& tz : teleportZones) { for (auto& tz : teleportZones) {
if (!tz.active || !tz.automatic) continue; if (!tz.active || !tz.automatic) continue;
const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius; const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius;
const float dist = (playerState->position - tz.position).norm(); const float dist = (playerState->position.toEigen() - tz.position).norm();
if (!tz.automaticInitialized) { if (!tz.automaticInitialized) {
// If the player starts inside the zone (e.g. just arrived here via teleport), // If the player starts inside the zone (e.g. just arrived here via teleport),
@@ -2102,7 +2070,7 @@ namespace FRG
const Eigen::Vector3f interactionTarget = clickedObject->state.hasInteractionPosition const Eigen::Vector3f interactionTarget = clickedObject->state.hasInteractionPosition
? clickedObject->state.interactionPosition ? clickedObject->state.interactionPosition
: clickedObject->state.position; : clickedObject->state.position;
setCharacterTarget(*player, interactionTarget); setCharacterTarget(*player, Vec3fp::fromEigen(interactionTarget));
pathVisualization.start(*player, interactionTarget); pathVisualization.start(*player, interactionTarget);
clearCharacterAttackTarget(*player); clearCharacterAttackTarget(*player);
//logger() << "[CLICK] Player moving to object..." << std::endl; //logger() << "[CLICK] Player moving to object..." << std::endl;
@@ -2115,7 +2083,7 @@ namespace FRG
if (clickedNpc && player) { if (clickedNpc && player) {
auto clickedNpcState = clickedNpc->stateSnapshot(); auto clickedNpcState = clickedNpc->stateSnapshot();
if (!clickedNpcState) return; if (!clickedNpcState) return;
float distance = (playerState->position - clickedNpcState->position).norm(); float distance = (playerState->position - clickedNpcState->position).toEigen().norm();
int npcIndex = -1; int npcIndex = -1;
for (size_t i = 0; i < npcs.size(); ++i) { for (size_t i = 0; i < npcs.size(); ++i) {
if (npcs[i].get() == clickedNpc) { if (npcs[i].get() == clickedNpc) {
@@ -2145,11 +2113,11 @@ namespace FRG
setCharacterTarget(*player, clickedNpcState->position); setCharacterTarget(*player, clickedNpcState->position);
pathVisualization.start( pathVisualization.start(
*player, *player,
clickedNpcState->position, clickedNpcState->position.toEigen(),
PathVisualization::Style::Hostile); PathVisualization::Style::Hostile);
} }
if (distance <= clickedNpcState->interactionRadius) { if (distance <= static_cast<float>(clickedNpcState->interactionRadius)) {
//logger() << "[CLICK] Hostile NPC " << npcIndex << " in range; firing on_npc_interact" << std::endl; //logger() << "[CLICK] Hostile NPC " << npcIndex << " in range; firing on_npc_interact" << std::endl;
scriptEngine.callNpcInteractCallback(clickedNpc->entityId()); scriptEngine.callNpcInteractCallback(clickedNpc->entityId());
} }
@@ -2173,7 +2141,7 @@ namespace FRG
//logger() << "[CLICK] NPC " << npcIndex << " out of talk range (distance " << distance //logger() << "[CLICK] NPC " << npcIndex << " out of talk range (distance " << distance
// << " > " << NPC_TALK_DISTANCE << "); walking to NPC..." << std::endl; // << " > " << NPC_TALK_DISTANCE << "); walking to NPC..." << std::endl;
setCharacterTarget(*player, clickedNpcState->position); setCharacterTarget(*player, clickedNpcState->position);
pathVisualization.start(*player, clickedNpcState->position); pathVisualization.start(*player, clickedNpcState->position.toEigen());
gameServer_.submit(SetLocationTargetsCommand{ gameServer_.submit(SetLocationTargetsCommand{
locationId_, std::string(), clickedNpc->entityId(), locationId_, std::string(), clickedNpc->entityId(),
std::string()}); std::string()});
@@ -2200,7 +2168,7 @@ namespace FRG
gameServer_.submit(SetLocationTargetsCommand{ gameServer_.submit(SetLocationTargetsCommand{
locationId_, std::string(), std::string(), locationId_, std::string(), std::string(),
clickedTeleport->id}); clickedTeleport->id});
setCharacterTarget(*player, clickedTeleport->position); setCharacterTarget(*player, Vec3fp::fromEigen(clickedTeleport->position));
pathVisualization.start( pathVisualization.start(
*player, *player,
clickedTeleport->position, clickedTeleport->position,
@@ -2208,7 +2176,7 @@ namespace FRG
clearCharacterAttackTarget(*player); clearCharacterAttackTarget(*player);
} else { } else {
const Eigen::Vector3f clickedDestination(hit.x(), 0.f, hit.z()); const Eigen::Vector3f clickedDestination(hit.x(), 0.f, hit.z());
setCharacterTarget(*player, clickedDestination); setCharacterTarget(*player, Vec3fp::fromEigen(clickedDestination));
pathVisualization.start(*player, clickedDestination); pathVisualization.start(*player, clickedDestination);
clearCharacterAttackTarget(*player); clearCharacterAttackTarget(*player);
gameServer_.submit(SetLocationTargetsCommand{ gameServer_.submit(SetLocationTargetsCommand{
@@ -2253,7 +2221,7 @@ namespace FRG
const float minInclination = M_PI * 30.f / 180.f; const float minInclination = M_PI * 30.f / 180.f;
const float maxInclination = M_PI * 0.5f; const float maxInclination = M_PI * 0.5f;
localState_->cameraInclination = localState_->cameraInclination =
max(minInclination, min(maxInclination, inclination)); std::max(minInclination, std::min(maxInclination, inclination));
} }
} }
+2 -3
View File
@@ -64,7 +64,7 @@ namespace FRG
std::string teleportsJsonPath; std::string teleportsJsonPath;
std::string triggerZonesJsonPath; std::string triggerZonesJsonPath;
std::string lightsJsonPath; std::string lightsJsonPath;
Eigen::Vector3f playerPosition = Eigen::Vector3f::Zero(); Vec3fp playerPosition;
// Player shared by all locations of a campaign (see Location::createPlayer); // Player shared by all locations of a campaign (see Location::createPlayer);
// setup() creates a private one when this is null. // setup() creates a private one when this is null.
std::shared_ptr<Character> player; std::shared_ptr<Character> player;
@@ -174,7 +174,7 @@ namespace FRG
void setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer); void setup(const LocationSetup& params, AudioPlayerAsync* audioPlayer);
//void setupNavigation(const std::vector<std::string>& paths); //void setupNavigation(const std::vector<std::string>& paths);
bool switchNavigation(int index); bool switchNavigation(int index);
void setCharacterTarget(Character& character, const Eigen::Vector3f& target, void setCharacterTarget(Character& character, const Vec3fp& target,
std::string callbackName = ""); std::string callbackName = "");
void clearCharacterAttackTarget(Character& character); void clearCharacterAttackTarget(Character& character);
void stopCharacter(Character& character); void stopCharacter(Character& character);
@@ -259,7 +259,6 @@ namespace FRG
void loadTriggerZones(const std::string& jsonPath, const char* zipFile); void loadTriggerZones(const std::string& jsonPath, const char* zipFile);
void loadPointLights(const std::string& jsonPath, const char* zipFile); void loadPointLights(const std::string& jsonPath, const char* zipFile);
void updateTriggerZones(const Eigen::Vector3f& playerPos); void updateTriggerZones(const Eigen::Vector3f& playerPos);
void nudgeCharacterAside(Character* standing, const Eigen::Vector3f& awayFrom);
void captureAuxiliaryState(LocationGameState& out) const; void captureAuxiliaryState(LocationGameState& out) const;
void applyAuxiliaryState(const LocationGameState& in); void applyAuxiliaryState(const LocationGameState& in);
+11 -11
View File
@@ -30,12 +30,12 @@ namespace FRG
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return; state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const PathFinder& navigation = const PathFinder& navigation =
state->navigationMaps[state->activeNavigationIndex]; state->navigationMaps[state->activeNavigationIndex];
const float y = navigation.getFloorY() + 0.02f; const float y = static_cast<float>(navigation.getFloorY()) + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f); const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
for (const auto& obs : navigation.getObstaclePolygons()) { for (const auto& obs : navigation.getObstaclePolygons()) {
if (obs.polygon.size() < 3) continue; if (obs.polygon.size() < 3) continue;
VertexRenderStruct mesh; VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(obs.polygon, y, red); mesh.data = CreatePolygonFloor(toEigenVector(obs.polygon), y, red);
mesh.RefreshVBO(); mesh.RefreshVBO();
navigationEditorNavMeshes.push_back(std::move(mesh)); navigationEditorNavMeshes.push_back(std::move(mesh));
} }
@@ -60,7 +60,7 @@ namespace FRG
if (!state || state->activeNavigationIndex < 0 || if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return; state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const float y = const float y =
state->navigationMaps[state->activeNavigationIndex].getFloorY() + 0.05f; static_cast<float>(state->navigationMaps[state->activeNavigationIndex].getFloorY()) + 0.05f;
const float s = 0.2f; const float s = 0.2f;
for (const auto& pt : navigationEditorPoints) { for (const auto& pt : navigationEditorPoints) {
@@ -133,7 +133,7 @@ namespace FRG
PathFinder::ObstaclePolygon poly; PathFinder::ObstaclePolygon poly;
poly.name = "editor_obstacle_" + std::to_string(++navigationEditorObstacleCounter); poly.name = "editor_obstacle_" + std::to_string(++navigationEditorObstacleCounter);
for (const auto& pt : navigationEditorPoints) { for (const auto& pt : navigationEditorPoints) {
poly.polygon.emplace_back(pt.x(), pt.z()); poly.polygon.emplace_back(static_cast<FRG::fp>(pt.x()), static_cast<FRG::fp>(pt.z()));
} }
loc.gameServer_.submit(AddNavigationObstacleCommand{ loc.gameServer_.submit(AddNavigationObstacleCommand{
@@ -146,10 +146,10 @@ namespace FRG
if (!state || state->activeNavigationIndex < 0 || if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return; state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const float y = const float y =
state->navigationMaps[state->activeNavigationIndex].getFloorY() + 0.02f; static_cast<float>(state->navigationMaps[state->activeNavigationIndex].getFloorY()) + 0.02f;
const Eigen::Vector3f red(1.0f, 0.0f, 0.0f); const Eigen::Vector3f red(1.0f, 0.0f, 0.0f);
VertexRenderStruct mesh; VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(poly.polygon, y, red); mesh.data = CreatePolygonFloor(toEigenVector(poly.polygon), y, red);
mesh.RefreshVBO(); mesh.RefreshVBO();
navigationEditorNavMeshes.push_back(std::move(mesh)); navigationEditorNavMeshes.push_back(std::move(mesh));
} }
@@ -379,9 +379,9 @@ namespace FRG
auto& target = loc.interactiveObjects[selectedInteractiveObjectIndex]; auto& target = loc.interactiveObjects[selectedInteractiveObjectIndex];
target.state.boundsMin = Eigen::Vector3f( target.state.boundsMin = Eigen::Vector3f(
min(localA.x(), localB.x()), 0.f, min(localA.z(), localB.z())); std::min(localA.x(), localB.x()), 0.f, std::min(localA.z(), localB.z()));
target.state.boundsMax = Eigen::Vector3f( target.state.boundsMax = Eigen::Vector3f(
max(localA.x(), localB.x()), 1.f, max(localA.z(), localB.z())); std::max(localA.x(), localB.x()), 1.f, std::max(localA.z(), localB.z()));
boundsClickCount = 0; boundsClickCount = 0;
buildInteractiveObjectBoundsMeshes(); buildInteractiveObjectBoundsMeshes();
@@ -484,9 +484,9 @@ namespace FRG
data.rotationZ = 0.0f; data.rotationZ = 0.0f;
auto playerState = loc.player ? loc.player->stateSnapshot() : nullptr; auto playerState = loc.player ? loc.player->stateSnapshot() : nullptr;
if (!playerState) return; if (!playerState) return;
data.positionX = playerState->position.x(); data.positionX = static_cast<float>(playerState->position.x);
data.positionY = playerState->position.y();//-0.1; data.positionY = static_cast<float>(playerState->position.y);
data.positionZ = playerState->position.z(); data.positionZ = static_cast<float>(playerState->position.z);
data.scale = scaleDist(rng); data.scale = scaleDist(rng);
GameObjectData data2; GameObjectData data2;
+174 -186
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <stdexcept> #include <stdexcept>
#include "OrcaSolver.h"
namespace FRG { namespace FRG {
@@ -126,34 +127,9 @@ const CharacterState* LocationGameState::findCharacter(
return nullptr; return nullptr;
} }
std::vector<PathFinder::DynamicObstacle> LocationGameState::collectPathObstacles(
const std::string& requesterId,
const PlayerStateMap& players,
const std::string& forcedObstacleId) const
{
std::vector<std::pair<std::string, const CharacterState*>> characters;
characters.reserve(players.size() + npcs.size());
for (const auto& [id, character] : players)
if (character) characters.emplace_back(id, character.get());
for (const auto& [id, character] : npcs)
if (character) characters.emplace_back(id, character.get());
std::sort(characters.begin(), characters.end(),
[](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; });
std::vector<PathFinder::DynamicObstacle> obstacles;
obstacles.reserve(characters.size());
for (const auto& [id, character] : characters) {
if (id == requesterId || !character->enabled || character->hp <= 0.f)
continue;
if (character->movedLastTick && id != forcedObstacleId) continue;
obstacles.push_back({character->position, character->collisionRadius});
}
return obstacles;
}
bool LocationGameState::setCharacterDestination( bool LocationGameState::setCharacterDestination(
const std::string& id, const std::string& id,
const Eigen::Vector3f& target, const Vec3fp& target,
std::string callbackName, std::string callbackName,
PlayerStateMap& players, PlayerStateMap& players,
const std::string& forcedObstacleId) const std::string& forcedObstacleId)
@@ -161,23 +137,60 @@ bool LocationGameState::setCharacterDestination(
CharacterState* character = findCharacter(id, players); CharacterState* character = findCharacter(id, players);
if (!character || !location) return false; if (!character || !location) return false;
character->setTarget( character->setTarget(
target, std::move(callbackName), *location, id, target, std::move(callbackName), *location, id);
collectPathObstacles(id, players, forcedObstacleId));
return true; return true;
} }
bool LocationGameState::replanCharacter( bool LocationGameState::replanCharacter(
const std::string& id, const std::string& id,
PlayerStateMap& players, PlayerStateMap& players)
const std::string& forcedObstacleId)
{ {
CharacterState* character = findCharacter(id, players); CharacterState* character = findCharacter(id, players);
if (!character || !location) return false; if (!character || !location) return false;
character->forceReplan(
*location, id, collectPathObstacles(id, players, forcedObstacleId)); character->forceReplan(*location, id);
return true; return true;
} }
std::string LocationGameState::selectNpcTarget(
const CharacterState& npc,
const PlayerStateMap& players,
bool preserveValidLock) const
{
if (preserveValidLock) {
const auto locked = players.find(npc.attackTargetId);
if (locked != players.end() && locked->second &&
locked->second->enabled && locked->second->hp > 0.f)
return npc.attackTargetId;
}
std::vector<std::string> playerIds;
playerIds.reserve(players.size());
for (const auto& [id, player] : players)
if (player && player->enabled && player->hp > 0.f)
playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
const fp visibleRangeSquared = npc.visibleRange * npc.visibleRange;
std::string selectedTarget;
fp nearestDistanceSquared = visibleRangeSquared;
for (const std::string& playerId : playerIds) {
const Vec3fp delta =
players.at(playerId)->position - npc.position;
const fp distanceSquared =
delta.x * delta.x + delta.z * delta.z;
if (distanceSquared <= nearestDistanceSquared &&
(selectedTarget.empty() ||
distanceSquared < nearestDistanceSquared ||
(distanceSquared == nearestDistanceSquared &&
playerId < selectedTarget))) {
selectedTarget = playerId;
nearestDistanceSquared = distanceSquared;
}
}
return selectedTarget;
}
void LocationGameState::startTaxi() void LocationGameState::startTaxi()
{ {
location->taxiCar.waypoints = location->taxiCarWaypoints; location->taxiCar.waypoints = location->taxiCarWaypoints;
@@ -289,12 +302,19 @@ LocationSimulationEvents LocationGameState::update(
std::int64_t deltaMs, PlayerStateMap& players, bool interactiveObjectsLocked) std::int64_t deltaMs, PlayerStateMap& players, bool interactiveObjectsLocked)
{ {
LocationSimulationEvents result; LocationSimulationEvents result;
//Taxi cars update:
result.taxiArrived = updateNpcCar(deltaMs); result.taxiArrived = updateNpcCar(deltaMs);
//Interactive objects update:
std::vector<std::string> objectIds; std::vector<std::string> objectIds;
objectIds.reserve(interactiveObjects.size()); objectIds.reserve(interactiveObjects.size());
for (const auto& [id, object] : interactiveObjects) for (const auto& [id, object] : interactiveObjects)
{
if (object) objectIds.push_back(id); if (object) objectIds.push_back(id);
}
std::sort(objectIds.begin(), objectIds.end()); std::sort(objectIds.begin(), objectIds.end());
for (const std::string& id : objectIds) { for (const std::string& id : objectIds) {
InteractiveObjectUpdateEvents events = InteractiveObjectUpdateEvents events =
interactiveObjects.at(id)->update(deltaMs); interactiveObjects.at(id)->update(deltaMs);
@@ -303,70 +323,55 @@ LocationSimulationEvents LocationGameState::update(
{id, std::move(events.onCompleteName), false, {}}); {id, std::move(events.onCompleteName), false, {}});
} }
// All characters observe the same tick-start state. Damage is applied only //Character update:
// after every state has advanced, so vector order cannot suppress an attack. const fp dtSec = static_cast<fp>(deltaMs) / fp(1000);
// 1. Собираем всех персонажей в единый массив и сортируем по ID
// (чтобы даже порядок вызовов CharacterState::update был строго детерминирован!)
struct Entry {
std::string id;
CharacterState* state;
};
std::vector<Entry> allChars;
allChars.reserve(players.size() + npcs.size());
for (auto& [id, ptr] : players) {
if (ptr && ptr->enabled) allChars.push_back({ id, ptr.get() });
}
for (auto& [id, ptr] : npcs) {
if (ptr && ptr->enabled) allChars.push_back({ id, ptr.get() });
}
std::sort(allChars.begin(), allChars.end(), [](const Entry& a, const Entry& b) {
return a.id < b.id;
});
std::unordered_map<std::string, CharacterObservation> observations; std::unordered_map<std::string, CharacterObservation> observations;
for (const auto& [id, player] : players) for (const auto& [id, player] : players)
if (player && player->enabled) if (player && player->enabled)
observations.emplace( observations.emplace(
id, CharacterObservation{player->position, player->hp}); id, CharacterObservation{ player->position, player->hp });
for (const auto& [id, npc] : npcs) for (const auto& [id, npc] : npcs)
if (npc && npc->enabled) if (npc && npc->enabled)
observations.emplace( observations.emplace(
id, CharacterObservation{npc->position, npc->hp}); id, CharacterObservation{ npc->position, npc->hp });
std::vector<std::string> playerIds;
playerIds.reserve(players.size());
for (const auto& [id, player] : players)
if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
std::vector<std::string> eligiblePlayerIds;
for (const std::string& id : playerIds) {
const auto& player = players.at(id);
if (player->enabled && player->hp > 0.f)
eligiblePlayerIds.push_back(id);
}
std::vector<std::string> npcIds;
npcIds.reserve(npcs.size());
for (const auto& [id, npc] : npcs)
if (npc) npcIds.push_back(id);
std::sort(npcIds.begin(), npcIds.end());
// Target selection is authoritative and runs before all characters observe // Target selection is authoritative and runs before all characters observe
// the tick. A valid target is a lock: it is retained until the player dies // the tick. A valid target is a lock: it is retained until the player dies
// or becomes disabled, even when the player leaves visibleRange. // or becomes disabled, even when the player leaves visibleRange.
for (const std::string& id : npcIds) { for (auto& entry : allChars) {
CharacterState& npc = *npcs.at(id); if (entry.state->isPlayer)
{
continue;
}
CharacterState& npc = *entry.state;
if (!npc.enabled || !npc.canAttack || npc.hp <= 0.f) if (!npc.enabled || !npc.canAttack || npc.hp <= 0.f)
continue; continue;
const std::string previousTarget = npc.attackTargetId; const std::string previousTarget = npc.attackTargetId;
const auto locked = players.find(previousTarget); const std::string selectedTarget =
const bool lockValid = selectNpcTarget(npc, players, true);
locked != players.end() && locked->second &&
locked->second->enabled && locked->second->hp > 0.f;
std::string selectedTarget = lockValid ? previousTarget : std::string();
if (!lockValid) {
float nearestDistanceSquared = npc.visibleRange * npc.visibleRange;
for (const std::string& playerId : eligiblePlayerIds) {
const Eigen::Vector3f delta =
players.at(playerId)->position - npc.position;
const float distanceSquared =
delta.x() * delta.x() + delta.z() * delta.z();
if (distanceSquared <= nearestDistanceSquared) {
if (selectedTarget.empty() ||
distanceSquared < nearestDistanceSquared ||
(distanceSquared == nearestDistanceSquared &&
playerId < selectedTarget)) {
selectedTarget = playerId;
nearestDistanceSquared = distanceSquared;
}
}
}
}
if (selectedTarget != previousTarget) { if (selectedTarget != previousTarget) {
npc.stopInPlace(); npc.stopInPlace();
@@ -376,88 +381,105 @@ LocationSimulationEvents LocationGameState::update(
if (selectedTarget.empty()) { if (selectedTarget.empty()) {
npc.battle_state = 0; npc.battle_state = 0;
npc.attack = 0; npc.attack = 0;
const Eigen::Vector3f homeTarget( const Vec3fp homeTarget(
npc.homePosition.x(), 0.f, npc.homePosition.z()); npc.homePosition.x, static_cast<fp>(0.f), npc.homePosition.z);
const Eigen::Vector3f toHome = homeTarget - npc.position; const Vec3fp toHome = homeTarget - npc.position;
if (toHome.norm() > CharacterState::kWalkThreshold && if (toHome.normSquared() > CharacterState::kWalkThreshold_SQUARED() &&
(npc.requestedWalkTarget - homeTarget).norm() > (npc.requestedWalkTarget - homeTarget).normSquared() >
CharacterState::kWalkThreshold) { CharacterState::kWalkThreshold_SQUARED()) {
setCharacterDestination(id, homeTarget, {}, players); setCharacterDestination(entry.id, homeTarget, {}, players);
} }
} }
} }
std::vector<CharacterMovementObstacle> movementObstacles; // 2. ФАЗА 1: Обновляем логику каждого персонажа (вычисляется preferredVelocity)
movementObstacles.reserve(playerIds.size() + npcIds.size()); for (auto& entry : allChars) {
auto addMovementObstacle =
[&movementObstacles](const std::string& id,
const CharacterState& character) {
Eigen::Vector3f direction =
character.getCurrentNavigationTarget() - character.position;
direction.y() = 0.f;
const float length = direction.norm();
if (length > CharacterState::kWalkThreshold) direction /= length;
else direction = Eigen::Vector3f::Zero();
movementObstacles.push_back({
id,
character.position,
character.movementContactRadius,
character.movedLastTick,
direction});
};
for (const std::string& id : playerIds) {
const CharacterState& character = *players.at(id);
if (character.enabled && character.hp > 0.f)
addMovementObstacle(id, character);
}
for (const std::string& id : npcIds) {
const CharacterState& character = *npcs.at(id);
if (character.enabled && character.hp > 0.f)
addMovementObstacle(id, character);
}
// One global ID order also makes blocker selection deterministic.
std::sort(
movementObstacles.begin(), movementObstacles.end(),
[](const CharacterMovementObstacle& lhs,
const CharacterMovementObstacle& rhs) { return lhs.id < rhs.id; });
auto advance = [&](const std::string& id, CharacterState& state) {
CharacterUpdateContext context; CharacterUpdateContext context;
context.characterId = id; context.characterId = entry.id;
context.movementObstacles = &movementObstacles;
auto attack = observations.find(state.attackTargetId);
auto attack = observations.find(entry.state->attackTargetId);
if (attack != observations.end()) context.attackTarget = &attack->second; if (attack != observations.end()) context.attackTarget = &attack->second;
auto face = observations.find(state.faceTargetId); auto face = observations.find(entry.state->faceTargetId);
if (face != observations.end()) context.faceTarget = &face->second; if (face != observations.end()) context.faceTarget = &face->second;
CharacterUpdateEvents events = state.update(deltaMs, context);
auto reservation = std::lower_bound( CharacterUpdateEvents events = entry.state->update(deltaMs, context);
movementObstacles.begin(), movementObstacles.end(), id,
[](const CharacterMovementObstacle& obstacle,
const std::string& value) { return obstacle.id < value; });
if (reservation != movementObstacles.end() && reservation->id == id) {
reservation->position = state.position;
reservation->moving = state.movedLastTick;
Eigen::Vector3f direction =
state.getCurrentNavigationTarget() - state.position;
direction.y() = 0.f;
const float length = direction.norm();
if (length > CharacterState::kWalkThreshold)
reservation->movementDirection = direction / length;
else
reservation->movementDirection = Eigen::Vector3f::Zero();
}
if (!events.arrivedCallbackName.empty() || events.attackImpact || if (!events.arrivedCallbackName.empty() || events.attackImpact ||
events.deathAnimationComplete || events.navigationRequested) { events.deathAnimationComplete || events.navigationRequested) {
result.characters.push_back({id, std::move(events)}); result.characters.push_back({ entry.id, std::move(events) });
} }
}; }
for (const std::string& id : playerIds) std::vector<OrcaAgent> orcaAgents;
if (players.at(id)->enabled) orcaAgents.reserve(allChars.size());
advance(id, *players.at(id));
for (const std::string& id : npcIds) for (auto& entry : allChars) {
if (npcs.at(id)->enabled) CharacterState* st = entry.state;
advance(id, *npcs.at(id)); if (st->hp <= 0.f) continue; // Мертвые не участвуют в коллизиях
const bool wantsToMove = st->preferredVelocity.lengthSq() > fp(0.001f);
const bool inCombat = (st->battle_state == 1);
OrcaAgent agent;
agent.id = entry.id;
agent.state = st;
agent.pos = Vec2fp(st->position.x, st->position.z);
agent.vel = st->velocity;
agent.prefVel = st->preferredVelocity;
agent.radius = st->collisionRadius;
agent.maxSpeed = st->walkSpeed;
agent.isStationary = (!wantsToMove || inCombat);
agent.isGhost = (st->canAttack && !st->isPlayer);
agent.constantSpeed = !agent.isGhost;
orcaAgents.push_back(agent);
}
const PathFinder* activeNavMesh = nullptr;
if (location && location->activeNavigationIndex >= 0 &&
location->activeNavigationIndex < static_cast<int>(location->navigationMaps.size())) {
activeNavMesh = &location->navigationMaps[location->activeNavigationIndex];
}
OrcaSolver::step(orcaAgents, dtSec, activeNavMesh);
// 4. ФАЗА 3: Записываем новые позиции, скорости и обновляем анимацию/поворот
for (const auto& agent : orcaAgents) {
CharacterState* st = agent.state;
const Vec2fp oldPos(st->position.x, st->position.z);
const Vec2fp displacement = agent.pos - oldPos;
const fp movedDistSq = displacement.lengthSq();
st->position.x = agent.pos.x;
st->position.z = agent.pos.y;
st->velocity = agent.newVel;
st->movedLastTick = (movedDistSq > fp(0.0005f));
// Если персонаж хотел идти, проверяем — продвинулся ли он или уперся в глухую пробку
const bool wantedToMove = st->preferredVelocity.lengthSq() > fp(0.01f);
if (wantedToMove) {
if (st->movedLastTick) {
st->movementBlockedTicks = 0;
if (st->currentState == AnimationState::STAND) {
st->currentState = AnimationState::WALK;
st->resetAnim = true;
}
if (agent.newVel.lengthSq() > fp(0.05f)) {
st->targetFacingAngle = fpm::atan2(agent.newVel.x, -agent.newVel.y);
}
}
else {
st->movementBlockedTicks++;
if (st->currentState == AnimationState::WALK) {
st->currentState = AnimationState::STAND;
st->resetAnim = true;
}
}
}
}
for (const CharacterSimulationEvent& source : result.characters) { for (const CharacterSimulationEvent& source : result.characters) {
if (!source.events.attackImpact) continue; if (!source.events.attackImpact) continue;
@@ -471,41 +493,7 @@ LocationSimulationEvents LocationGameState::update(
damage, damage,
target->hp, target->hp,
target->initialHp, target->initialHp,
source.events.attackDirection}); source.events.attackDirection });
}
auto player = players.find("player");
if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second && player->second->enabled) {
auto target = interactiveObjects.find(location->targetInteractiveObjectId);
const auto object =
target == interactiveObjects.end() ? nullptr : target->second;
if (object && !object->isAnimating) {
const Eigen::Vector3f& approachTarget = object->hasInteractionPosition
? object->interactionPosition : object->position;
if ((player->second->position - approachTarget).norm() <= object->approachRadius) {
result.interactiveObjects.push_back(
{location->targetInteractiveObjectId, {}, true,
object->activateFunctionName});
location->targetInteractiveObjectId.clear();
}
}
}
if (!location->targetInteractNpcId.empty() &&
player != players.end() && player->second && player->second->enabled &&
npcs.count(location->targetInteractNpcId) != 0 &&
npcs.at(location->targetInteractNpcId)) {
const auto& npc = npcs.at(location->targetInteractNpcId);
const float distance =
(player->second->position - npc->position).norm();
if (distance <= NPC_TALK_DISTANCE) {
player->second->stopInPlace();
npc->faceTargetId = "player";
result.npcInteractionId = location->targetInteractNpcId;
location->targetInteractNpcId.clear();
}
} }
return result; return result;
+7 -8
View File
@@ -27,7 +27,7 @@ struct CharacterDamageEvent {
float amount = 0.f; float amount = 0.f;
float hp = 0.f; float hp = 0.f;
float maxHp = 0.f; float maxHp = 0.f;
Eigen::Vector3f direction = Eigen::Vector3f::Zero(); Vec3fp direction;
}; };
struct InteractiveObjectSimulationEvent { struct InteractiveObjectSimulationEvent {
@@ -64,20 +64,15 @@ struct LocationGameState {
CharacterState* findCharacter(const std::string& id, PlayerStateMap& players); CharacterState* findCharacter(const std::string& id, PlayerStateMap& players);
const CharacterState* findCharacter( const CharacterState* findCharacter(
const std::string& id, const PlayerStateMap& players) const; const std::string& id, const PlayerStateMap& players) const;
std::vector<PathFinder::DynamicObstacle> collectPathObstacles(
const std::string& requesterId,
const PlayerStateMap& players,
const std::string& forcedObstacleId = {}) const;
bool setCharacterDestination( bool setCharacterDestination(
const std::string& id, const std::string& id,
const Eigen::Vector3f& target, const Vec3fp& target,
std::string callbackName, std::string callbackName,
PlayerStateMap& players, PlayerStateMap& players,
const std::string& forcedObstacleId = {}); const std::string& forcedObstacleId = {});
bool replanCharacter( bool replanCharacter(
const std::string& id, const std::string& id,
PlayerStateMap& players, PlayerStateMap& players);
const std::string& forcedObstacleId = {});
void startTaxi(); void startTaxi();
void resetTaxi(); void resetTaxi();
LocationSimulationEvents update( LocationSimulationEvents update(
@@ -86,6 +81,10 @@ struct LocationGameState {
void load(const nlohmann::json& in); void load(const nlohmann::json& in);
private: private:
std::string selectNpcTarget(
const CharacterState& npc,
const PlayerStateMap& players,
bool preserveValidLock) const;
bool updateNpcCar(std::int64_t deltaMs); bool updateNpcCar(std::int64_t deltaMs);
}; };
+2 -2
View File
@@ -123,7 +123,7 @@ void loadSinglePlayerPersistence(
// Entity ownership stays above this navigation-only layer. // Entity ownership stays above this navigation-only layer.
std::vector<Eigen::Vector3f> LocationNetworkState::planPathExceptMyself( /**td::vector<Eigen::Vector3f> LocationNetworkState::planPathExceptMyself(
const Eigen::Vector3f& start, const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::string& entityId, const std::string& entityId,
@@ -135,7 +135,7 @@ void loadSinglePlayerPersistence(
return {}; return {};
return navigationMaps[activeNavigationIndex].findPathToNearest( return navigationMaps[activeNavigationIndex].findPathToNearest(
start, end, dynamicObstacles); start, end, dynamicObstacles);
} }*/
void LocationNetworkState::initNavigation( void LocationNetworkState::initNavigation(
const std::vector<std::string>& paths, const std::string& zipFile) const std::vector<std::string>& paths, const std::string& zipFile)
+2 -2
View File
@@ -41,11 +41,11 @@ struct LocationNetworkState : public ISaveable {
//This value is populated from the JSON configs and should not be changed during the regular gameplay. //This value is populated from the JSON configs and should not be changed during the regular gameplay.
//It might be changed only in the editor mode during development. //It might be changed only in the editor mode during development.
std::vector<PathFinder> navigationMaps; std::vector<PathFinder> navigationMaps;
std::vector<Eigen::Vector3f> planPathExceptMyself( /*std::vector<Eigen::Vector3f> planPathExceptMyself(
const Eigen::Vector3f& start, const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::string& entityId, const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles = {}) const; const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles = {}) const;*/
void initNavigation( void initNavigation(
const std::vector<std::string>& paths, const std::vector<std::string>& paths,
const std::string& zipFile = std::string()); const std::string& zipFile = std::string());
+6 -7
View File
@@ -17,10 +17,6 @@
namespace FRG { namespace FRG {
#ifdef __EMSCRIPTEN__
//extern Game* Game::s_instance;
#endif
// Localization // Localization
static const std::string EMPTY_LANGUAGE_RU = u8"(пусто)"; static const std::string EMPTY_LANGUAGE_RU = u8"(пусто)";
static const std::string EMPTY_LANGUAGE_EN = "(empty)"; static const std::string EMPTY_LANGUAGE_EN = "(empty)";
@@ -81,7 +77,7 @@ namespace FRG {
pushWord(currentWord); pushWord(currentWord);
if (!currentLine.empty()) flushLine(); if (!currentLine.empty()) flushLine();
return max(1, lines); return std::max(1, lines);
} }
static std::string trimChatPreview(const std::string& msg, int maxChars = 22) { static std::string trimChatPreview(const std::string& msg, int maxChars = 22) {
@@ -408,6 +404,9 @@ namespace FRG {
const std::string& loc = snapshot->currentLocationId; const std::string& loc = snapshot->currentLocationId;
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot; if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot; if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
// Other locations (e.g. campaigns that start directly in Darklands) have no
// dedicated HUD; reuse the Darklands one so the health bar is shown.
if (snapshot->isDarklands && !loc.empty()) return hudUniExtDarkRoot;
return hudDormRoot; // dorm, or a new game before the location is set return hudDormRoot; // dorm, or a new game before the location is set
} }
@@ -2254,9 +2253,9 @@ namespace FRG {
float MenuManager::ToastEntry::currentAlpha() const { float MenuManager::ToastEntry::currentAlpha() const {
switch (state) { switch (state) {
case State::FadeIn: return min(timer / TOAST_FADE_MS, 1.0f); case State::FadeIn: return std::min(timer / TOAST_FADE_MS, 1.0f);
case State::Visible: return 1.0f; case State::Visible: return 1.0f;
case State::FadeOut: return max(1.0f - timer / TOAST_FADE_MS, 0.0f); case State::FadeOut: return std::max(1.0f - timer / TOAST_FADE_MS, 0.0f);
} }
return 0.0f; return 0.0f;
} }
+419
View File
@@ -0,0 +1,419 @@
#pragma once
#include "navigation/PathFinder.h"
#include <algorithm>
#include <string>
#include <vector>
namespace FRG {
// Линейное ограничение (полуплоскость допустимых скоростей ORCA)
struct OrcaLine {
Vec2fp point; // Точка на граничной прямой
Vec2fp direction; // Единичный вектор направления прямой (допустимая полуплоскость СЛЕВА)
};
struct OrcaAgent {
std::string id;
CharacterState* state = nullptr;
Vec2fp pos;
Vec2fp vel;
Vec2fp prefVel;
fp radius;
fp maxSpeed;
bool isStationary = false;
bool isGhost = false; // Враги-духи (парят в воздухе, легко расталкиваются)
bool constantSpeed = true; // Люди (ходят только с полной скоростью либо стоят)
Vec2fp newVel;
};
class OrcaSolver {
public:
// Горизонт упреждения 0.6 сек (~1.8 метра при скорости 3 м/с)
static fp timeHorizon() { return fp(0.6f); }
static void step(
std::vector<OrcaAgent>& agents,
fp dtSec,
const PathFinder* navMesh)
{
if (dtSec <= fp(0) || agents.empty()) return;
// 1. Обязательная сортировка по ID для 100% детерминизма Lockstep
std::sort(agents.begin(), agents.end(), [](const OrcaAgent& a, const OrcaAgent& b) {
return a.id < b.id;
});
const fp invTimeHorizon = fp(1) / timeHorizon();
const fp invDt = fp(1) / dtSec;
const fp neighborDist = fp(4.0f);
const fp neighborDistSq = neighborDist * neighborDist;
std::vector<OrcaLine> orcaLines;
orcaLines.reserve(16);
// 2. Для каждого агента строим линии ORCA относительно соседей
for (std::size_t i = 0; i < agents.size(); ++i) {
OrcaAgent& agent = agents[i];
if (agent.maxSpeed <= fp(0) || agent.prefVel.lengthSq() <= fp(0.001f)) {
agent.newVel = Vec2fp(fp(0), fp(0));
continue;
}
orcaLines.clear();
// ВАЖНО: Если человек стартует с места, используем prefVel как базу для построения VO,
// чтобы стоящие рядом NPC не запирали его в нулевой скорости!
const Vec2fp effectiveAgentVel = (agent.constantSpeed && agent.vel.lengthSq() < fp(0.1f))
? agent.prefVel
: agent.vel;
for (std::size_t j = 0; j < agents.size(); ++j) {
if (i == j) continue;
const OrcaAgent& other = agents[j];
// Человек НЕ строит тормозящих линий ORCA об духов —
// духи сами расступаются перед человеком и расталкиваются в resolveHardCollisions!
if (!agent.isGhost && other.isGhost) {
continue;
}
const Vec2fp relativePos = other.pos - agent.pos;
const fp distSq = relativePos.lengthSq();
if (distSq > neighborDistSq) continue;
const Vec2fp relativeVel = effectiveAgentVel - other.vel;
// Для проверки конуса обхода используем чуть меньший радиус (85%),
// чтобы персонажи легче протискивались мимо друг друга
const fp combinedRadius = (agent.radius + other.radius) * fp(0.85f);
const fp combinedRadiusSq = combinedRadius * combinedRadius;
OrcaLine line;
Vec2fp u;
if (distSq > combinedRadiusSq) {
const Vec2fp w = relativeVel - relativePos * invTimeHorizon;
const fp wLengthSq = w.lengthSq();
const fp dotProduct1 = w.dot(relativePos);
if (dotProduct1 < fp(0) && dotProduct1 * dotProduct1 > combinedRadiusSq * wLengthSq) {
const fp wLength = fpm::sqrt(wLengthSq);
const Vec2fp unitW = wLength > fp(0.001f) ? (w / wLength) : Vec2fp(fp(1), fp(0));
line.direction = Vec2fp(unitW.y, -unitW.x);
u = unitW * (combinedRadius * invTimeHorizon - wLength);
}
else {
const fp leg = fpm::sqrt(std::max(fp(0), distSq - combinedRadiusSq));
if (relativePos.cross(w) > fp(0)) {
line.direction = Vec2fp(
relativePos.x * leg - relativePos.y * combinedRadius,
relativePos.x * combinedRadius + relativePos.y * leg
) / distSq;
}
else {
line.direction = Vec2fp(
-(relativePos.x * leg + relativePos.y * combinedRadius),
-(-relativePos.x * combinedRadius + relativePos.y * leg)
) / distSq;
}
const fp dotProduct2 = relativeVel.dot(line.direction);
u = line.direction * dotProduct2 - relativeVel;
}
}
else {
const Vec2fp w = relativeVel - relativePos * invDt;
const fp wLength = w.length();
const Vec2fp unitW = wLength > fp(0.001f)
? (w / wLength)
: ((i < j) ? Vec2fp(fp(1), fp(0)) : Vec2fp(fp(-1), fp(0)));
line.direction = Vec2fp(unitW.y, -unitW.x);
u = unitW * (combinedRadius * invDt - wLength);
}
fp responsibility = fp(0.5f);
if (agent.isGhost && !other.isGhost) {
responsibility = fp(0.95f); // Дух берет 95% уклонения от человека
}
else if (other.isStationary && !agent.isStationary) {
responsibility = fp(1.0f); // Обходящий берет 100% маневра мимо стоящего
}
else if (!other.isStationary && agent.isStationary) {
responsibility = fp(0.0f);
}
line.point = effectiveAgentVel + u * responsibility;
orcaLines.push_back(line);
}
// Решаем стандартную задачу ORCA
Vec2fp resultVel = agent.prefVel;
int lineFail = linearProgram2(orcaLines, agent.maxSpeed, agent.prefVel, false, resultVel);
if (lineFail < static_cast<int>(orcaLines.size())) {
linearProgram3(orcaLines, lineFail, agent.maxSpeed, resultVel);
}
// Если это человек (constantSpeed == true), ищем угол обхода с сохранением полной скорости:
const fp prefSpeedSq = agent.prefVel.lengthSq();
if (agent.constantSpeed && prefSpeedSq > fp(0.01f)) {
const fp prefSpeed = fpm::sqrt(prefSpeedSq);
resultVel = findConstantSpeedVelocity(orcaLines, agent.prefVel, prefSpeed, resultVel);
}
agent.newVel = resultVel;
}
// 3. Применяем скорости и корректируем жесткие наложения
for (auto& agent : agents) {
agent.pos = agent.pos + agent.newVel * dtSec;
}
resolveHardCollisions(agents);
// 4. Удерживаем всех внутри NavMesh
if (navMesh && navMesh->isReady()) {
for (auto& agent : agents) {
Vec3fp pt3D(agent.pos.x, navMesh->getFloorY(), agent.pos.y);
if (!navMesh->isWalkable(pt3D)) {
int dummyTri = -1;
agent.pos = navMesh->clampToNavMesh(agent.pos, dummyTri);
}
}
}
}
private:
// Подбирает вектор скорости строго длины prefSpeed, который огибает препятствие без замедления
static Vec2fp findConstantSpeedVelocity(
const std::vector<OrcaLine>& orcaLines,
const Vec2fp& prefVel,
fp prefSpeed,
const Vec2fp& orcaVel)
{
if (orcaLines.empty()) return prefVel;
const Vec2fp forwardDir = prefVel / prefSpeed;
// Перпендикуляр для отступа влево/вправо при лобовом столкновении
const Vec2fp rightDir(forwardDir.y, -forwardDir.x);
// Перебираем веер углов отклонения от прямого курса (0°, ±15°, ±30°, ±45°, ±60°, ±75°, ±90°)
// Синусы и косинусы заданы константами для скорости и детерминизма
struct AngleOffset { fp cosA; fp sinA; };
static const AngleOffset offsets[] = {
{ fp(1.000f), fp(0.000f) },
{ fp(0.966f), fp(0.259f) }, { fp(0.966f), fp(-0.259f) }, // ±15°
{ fp(0.866f), fp(0.500f) }, { fp(0.866f), fp(-0.500f) }, // ±30°
{ fp(0.707f), fp(0.707f) }, { fp(0.707f), fp(-0.707f) }, // ±45°
{ fp(0.500f), fp(0.866f) }, { fp(0.500f), fp(-0.866f) }, // ±60°
{ fp(0.259f), fp(0.966f) }, { fp(0.259f), fp(-0.966f) }, // ±75°
{ fp(0.000f), fp(1.000f) }, { fp(0.000f), fp(-1.000f) } // ±90°
};
// Определяем, в какую сторону (влево или вправо) нам выгоднее обходить по подсказке от ORCA
const fp sideHint = rightDir.dot(orcaVel);
for (std::size_t k = 0; k < sizeof(offsets) / sizeof(offsets[0]); ++k) {
// Если k нечетный (пара ±угол), проверяем сначала ту сторону, куда склоняется ORCA
std::size_t idx = k;
if (k >= 1 && (k % 2 == 1) && sideHint < fp(0)) {
idx = k + 1;
}
else if (k >= 1 && (k % 2 == 0) && sideHint < fp(0)) {
idx = k - 1;
}
const Vec2fp candidateDir = forwardDir * offsets[idx].cosA + rightDir * offsets[idx].sinA;
const Vec2fp candidateVel = candidateDir * prefSpeed;
bool valid = true;
for (const auto& line : orcaLines) {
// Допускаем погрешность 0.1f м/с
if (line.direction.cross(line.point - candidateVel) > fp(0.1f)) {
valid = false;
break;
}
}
if (valid) {
return candidateVel;
}
}
// Если ни один угол до ±90° не свободен (проход полностью заблокирован вплотную) — стоим
return Vec2fp(fp(0), fp(0));
}
static void resolveHardCollisions(std::vector<OrcaAgent>& agents) {
const std::size_t n = agents.size();
for (int iter = 0; iter < 2; ++iter) {
for (std::size_t i = 0; i < n; ++i) {
for (std::size_t j = i + 1; j < n; ++j) {
Vec2fp delta = agents[j].pos - agents[i].pos;
fp distSq = delta.lengthSq();
fp minDist = agents[i].radius + agents[j].radius;
if (distSq < minDist * minDist) {
fp dist = fpm::sqrt(distSq);
Vec2fp normal = (dist > fp(0.001f))
? (delta / dist)
: Vec2fp(fp(1), fp(0));
fp penetration = (minDist - dist) * fp(0.5f);
fp weightI = fp(0.5f);
fp weightJ = fp(0.5f);
if (!agents[i].isGhost && agents[j].isGhost) {
weightI = fp(0.0f); weightJ = fp(1.0f); // Человек на 100% выталкивает духа
}
else if (agents[i].isGhost && !agents[j].isGhost) {
weightI = fp(1.0f); weightJ = fp(0.0f);
}
else {
weightI = agents[i].isStationary ? fp(0.1f) : fp(0.5f);
weightJ = agents[j].isStationary ? fp(0.1f) : fp(0.5f);
fp sumW = weightI + weightJ;
weightI /= sumW;
weightJ /= sumW;
}
agents[i].pos = agents[i].pos - normal * (penetration * weightI);
agents[j].pos = agents[j].pos + normal * (penetration * weightJ);
}
}
}
}
}
static bool linearProgram1(
const std::vector<OrcaLine>& lines,
int lineNo,
fp radius,
const Vec2fp& optVelocity,
bool directionOpt,
Vec2fp& result)
{
const fp dotProduct = lines[lineNo].point.dot(lines[lineNo].direction);
const fp discriminant = dotProduct * dotProduct + radius * radius - lines[lineNo].point.lengthSq();
if (discriminant < fp(0)) {
return false;
}
const fp sqrtDiscriminant = fpm::sqrt(discriminant);
fp tLeft = -dotProduct - sqrtDiscriminant;
fp tRight = -dotProduct + sqrtDiscriminant;
for (int i = 0; i < lineNo; ++i) {
const fp denominator = lines[lineNo].direction.cross(lines[i].direction);
const fp numerator = lines[i].direction.cross(lines[lineNo].point - lines[i].point);
if (fpm::abs(denominator) <= fp(0.001f)) {
if (numerator < fp(0)) return false;
continue;
}
const fp t = numerator / denominator;
if (denominator >= fp(0)) {
tRight = std::min(tRight, t);
}
else {
tLeft = std::max(tLeft, t);
}
if (tLeft > tRight) return false;
}
if (directionOpt) {
if (optVelocity.dot(lines[lineNo].direction) > fp(0)) {
result = lines[lineNo].point + lines[lineNo].direction * tRight;
}
else {
result = lines[lineNo].point + lines[lineNo].direction * tLeft;
}
}
else {
const fp t = lines[lineNo].direction.dot(optVelocity - lines[lineNo].point);
const fp tClamped = std::max(tLeft, std::min(tRight, t));
result = lines[lineNo].point + lines[lineNo].direction * tClamped;
}
return true;
}
static int linearProgram2(
const std::vector<OrcaLine>& lines,
fp radius,
const Vec2fp& optVelocity,
bool directionOpt,
Vec2fp& result)
{
if (directionOpt) {
result = optVelocity * radius;
}
else if (optVelocity.lengthSq() > radius * radius) {
result = (optVelocity / optVelocity.length()) * radius;
}
else {
result = optVelocity;
}
for (int i = 0; i < static_cast<int>(lines.size()); ++i) {
if (lines[i].direction.cross(lines[i].point - result) > fp(0)) {
const Vec2fp tempResult = result;
if (!linearProgram1(lines, i, radius, optVelocity, directionOpt, result)) {
result = tempResult;
return i;
}
}
}
return static_cast<int>(lines.size());
}
static void linearProgram3(
const std::vector<OrcaLine>& lines,
int beginLine,
fp radius,
Vec2fp& result)
{
fp distance = fp(0);
for (int i = beginLine; i < static_cast<int>(lines.size()); ++i) {
if (lines[i].direction.cross(lines[i].point - result) > distance) {
std::vector<OrcaLine> projLines;
projLines.reserve(i);
for (int j = 0; j < i; ++j) {
OrcaLine line;
fp determinant = lines[i].direction.cross(lines[j].direction);
if (fpm::abs(determinant) <= fp(0.001f)) {
if (lines[i].direction.dot(lines[j].direction) > fp(0)) continue;
line.point = (lines[i].point + lines[j].point) * fp(0.5f);
}
else {
line.point = lines[i].point + lines[i].direction *
(lines[j].direction.cross(lines[i].point - lines[j].point) / determinant);
}
Vec2fp dirDiff = lines[j].direction - lines[i].direction;
fp dirLen = dirDiff.length();
if (dirLen <= fp(0.001f)) continue;
line.direction = dirDiff / dirLen;
projLines.push_back(line);
}
const Vec2fp tempResult = result;
const Vec2fp optDir(-lines[i].direction.y, lines[i].direction.x);
if (linearProgram2(projLines, radius, optDir, true, result) < static_cast<int>(projLines.size())) {
result = tempResult;
}
distance = lines[i].direction.cross(lines[i].point - result);
}
}
}
};
} // namespace FRG
+21 -15
View File
@@ -13,6 +13,9 @@
namespace FRG { namespace FRG {
constexpr float kPi = 3.14159265358979323846f;
namespace { namespace {
// Reverse-looks-up the global name a Lua function is registered under, by // Reverse-looks-up the global name a Lua function is registered under, by
@@ -80,6 +83,7 @@ namespace FRG {
ScriptEngine::~ScriptEngine() = default; ScriptEngine::~ScriptEngine() = default;
void ScriptEngine::init(Location* loc, AudioPlayerAsync* audioPlayer, const std::string& scriptPath) { void ScriptEngine::init(Location* loc, AudioPlayerAsync* audioPlayer, const std::string& scriptPath) {
logger() << "ScriptEngine::init with script path: " << scriptPath << std::endl;
impl = std::make_unique<Impl>(); impl = std::make_unique<Impl>();
sol::state& lua = impl->lua; sol::state& lua = impl->lua;
@@ -125,9 +129,9 @@ namespace FRG {
std::cerr << "[script] npc_walk_to: on_arrived must be a global Lua function\n"; std::cerr << "[script] npc_walk_to: on_arrived must be a global Lua function\n";
} }
loc->gameServer().submit(SetCharacterHomePositionCommand{ loc->gameServer().submit(SetCharacterHomePositionCommand{
loc->locationId(), npc->entityId(), Eigen::Vector3f(x, 0.f, z)}); loc->locationId(), npc->entityId(), Vec3fp(static_cast<fp>(x), static_cast<fp>(0.f), static_cast<fp>(z))});
loc->setCharacterTarget( loc->setCharacterTarget(
*npc, Eigen::Vector3f(x, y, z), std::move(cbName)); *npc, Vec3fp(static_cast<fp>(x), static_cast<fp>(y), static_cast<fp>(z)), std::move(cbName));
}); });
@@ -139,16 +143,16 @@ namespace FRG {
return; return;
} }
const float rad = angle * static_cast<float>(M_PI) / 180.f; const fp rad = static_cast<fp>(angle) * static_cast<fp>(kPi) / static_cast < fp>(180.f);
logger() << "[script] npc_rotate_to: " << npcId << " angle " << angle << " degrees (" << rad << " radians)" << std::endl; logger() << "[script] npc_rotate_to: " << npcId << " angle " << angle << " degrees (" << static_cast<float>(rad) << " radians)" << std::endl;
loc->gameServer().submit(SetCharacterFacingAngleCommand{ loc->gameServer().submit(SetCharacterFacingAngleCommand{
loc->locationId(), npc->entityId(), rad, false}); loc->locationId(), npc->entityId(), rad, false});
}); });
api.set_function("player_rotate_to", api.set_function("player_rotate_to",
[loc](float angle) { [loc](float angle) {
const float rad = angle * static_cast<float>(M_PI) / 180.f; const fp rad = static_cast<fp>(angle) * static_cast<fp>(kPi) / static_cast <fp>(180.f);
logger() << "[script] player_rotate_to: angle " << angle << " degrees (" << rad << " radians)" << std::endl; logger() << "[script] player_rotate_to: angle " << angle << " degrees (" << static_cast<float>(rad) << " radians)" << std::endl;
loc->gameServer().submit(SetCharacterFacingAngleCommand{ loc->gameServer().submit(SetCharacterFacingAngleCommand{
loc->locationId(), loc->player->entityId(), rad, false}); loc->locationId(), loc->player->entityId(), rad, false});
}); });
@@ -172,7 +176,7 @@ namespace FRG {
<< npcId << "'\n"; << npcId << "'\n";
return; return;
} }
loc->faceCharacter(*npc, "player"); loc->faceCharacter(*npc, "player1");
}); });
api.set_function("player_walk_to", api.set_function("player_walk_to",
@@ -187,9 +191,9 @@ namespace FRG {
} }
loc->gameServer().submit(SetCharacterHomePositionCommand{ loc->gameServer().submit(SetCharacterHomePositionCommand{
loc->locationId(), loc->player->entityId(), loc->locationId(), loc->player->entityId(),
Eigen::Vector3f(x, 0.f, z)}); Vec3fp(static_cast<fp>(x), static_cast<fp>(0.f), static_cast<fp>(z))});
loc->setCharacterTarget( loc->setCharacterTarget(
*loc->player, Eigen::Vector3f(x, y, z), std::move(cbName)); *loc->player, Vec3fp(static_cast<fp>(x), static_cast<fp>(y), static_cast<fp>(z)), std::move(cbName));
}); });
@@ -423,12 +427,12 @@ namespace FRG {
api.set_function("get_player_x", api.set_function("get_player_x",
[loc]() -> float { [loc]() -> float {
auto state = loc->player ? loc->player->stateSnapshot() : nullptr; auto state = loc->player ? loc->player->stateSnapshot() : nullptr;
return state ? state->position.x() : 0.0f; return state ? static_cast<float>(state->position.x) : 0.0f;
}); });
api.set_function("get_player_z", api.set_function("get_player_z",
[loc]() -> float { [loc]() -> float {
auto state = loc->player ? loc->player->stateSnapshot() : nullptr; auto state = loc->player ? loc->player->stateSnapshot() : nullptr;
return state ? state->position.z() : 0.0f; return state ? static_cast<float>(state->position.z) : 0.0f;
}); });
api.set_function("refill_current_player_hp", api.set_function("refill_current_player_hp",
@@ -592,7 +596,7 @@ namespace FRG {
std::cerr << "[script] npc_set_position: unknown NPC id '" << npcId << "'\n"; std::cerr << "[script] npc_set_position: unknown NPC id '" << npcId << "'\n";
return; return;
} }
Eigen::Vector3f pos(x, y, z); Vec3fp pos(static_cast<fp>(x), static_cast<fp>(y), static_cast<fp>(z));
loc->gameServer().submit(SetCharacterPositionCommand{ loc->gameServer().submit(SetCharacterPositionCommand{
loc->locationId(), npc->entityId(), pos, true}); loc->locationId(), npc->entityId(), pos, true});
loc->setCharacterTarget(*npc, pos); loc->setCharacterTarget(*npc, pos);
@@ -606,7 +610,8 @@ namespace FRG {
std::cerr << "[script] npc_set_rotation: unknown NPC id '" << npcId << "'\n"; std::cerr << "[script] npc_set_rotation: unknown NPC id '" << npcId << "'\n";
return; return;
} }
const float rad = angle * static_cast<float>(M_PI) / 180.f; const fp rad = static_cast<fp>(angle) * static_cast<fp>(kPi) / static_cast <fp>(180.f);
loc->gameServer().submit(SetCharacterFacingAngleCommand{ loc->gameServer().submit(SetCharacterFacingAngleCommand{
loc->locationId(), npc->entityId(), rad, true}); loc->locationId(), npc->entityId(), rad, true});
}); });
@@ -625,7 +630,7 @@ namespace FRG {
api.set_function("player_set_position", api.set_function("player_set_position",
[loc](float x, float y, float z) { [loc](float x, float y, float z) {
Eigen::Vector3f pos(x, y, z); Vec3fp pos(static_cast<fp>(x), static_cast<fp>(y), static_cast<fp>(z));
loc->gameServer().submit(SetCharacterPositionCommand{ loc->gameServer().submit(SetCharacterPositionCommand{
loc->locationId(), loc->player->entityId(), pos, true}); loc->locationId(), loc->player->entityId(), pos, true});
loc->setCharacterTarget(*loc->player, pos); loc->setCharacterTarget(*loc->player, pos);
@@ -633,7 +638,8 @@ namespace FRG {
api.set_function("player_set_rotation", api.set_function("player_set_rotation",
[loc](float angle) { [loc](float angle) {
const float rad = angle * static_cast<float>(M_PI) / 180.f; const fp rad = static_cast<fp>(angle) * static_cast<fp>(kPi) / static_cast <fp>(180.f);
loc->gameServer().submit(SetCharacterFacingAngleCommand{ loc->gameServer().submit(SetCharacterFacingAngleCommand{
loc->locationId(), loc->player->entityId(), rad, true}); loc->locationId(), loc->player->entityId(), rad, true});
}); });
+17 -14
View File
@@ -6,6 +6,7 @@
#include <cstring> #include <cstring>
#include <stdexcept> #include <stdexcept>
#include <unordered_set> #include <unordered_set>
#include <math.h>
namespace FRG { namespace FRG {
namespace { namespace {
@@ -172,7 +173,7 @@ CharacterState NpcData::toCharacterState() const
CharacterState state; CharacterState state;
state.npcName = name; state.npcName = name;
state.position = {positionX, positionY, positionZ}; state.position = {positionX, positionY, positionZ};
state.homePosition = {positionX, 0.f, positionZ}; state.homePosition = {positionX, static_cast<fp>(0.f), positionZ};
state.stopInPlace(); state.stopInPlace();
state.facingAngle = facingAngle; state.facingAngle = facingAngle;
state.walkSpeed = walkSpeed; state.walkSpeed = walkSpeed;
@@ -183,9 +184,9 @@ CharacterState NpcData::toCharacterState() const
state.visibleRange = visibleRange; state.visibleRange = visibleRange;
state.canAttack = canAttack; state.canAttack = canAttack;
state.enabled = enabled; state.enabled = enabled;
state.collisionRadius = navigationAvoidanceRadius; //state.collisionRadius = navigationAvoidanceRadius;
state.movementContactRadius = //state.movementContactRadius =
(std::min)(movementContactRadius, navigationAvoidanceRadius); // (std::min)(movementContactRadius, navigationAvoidanceRadius);
state.interactionRadius = interactionRadius; state.interactionRadius = interactionRadius;
state.configureNavigationRetryCooldowns( state.configureNavigationRetryCooldowns(
id, navigationRetryCooldownTicks); id, navigationRetryCooldownTicks);
@@ -293,7 +294,7 @@ LocationDefinition LocationContentLoader::load(
for (const auto& item : root.value("npcs", json::array())) { for (const auto& item : root.value("npcs", json::array())) {
NpcData data; NpcData data;
data.id = item.at("id").get<std::string>(); data.id = item.at("id").get<std::string>();
if (data.id.empty() || data.id == "player" || if (data.id.empty() || data.id == "player1" ||
!ids.insert(data.id).second) { !ids.insert(data.id).second) {
throw std::runtime_error( throw std::runtime_error(
"Missing, duplicate, or reserved NPC id in " + "Missing, duplicate, or reserved NPC id in " +
@@ -309,8 +310,8 @@ LocationDefinition LocationContentLoader::load(
data.meshTextures[name] = path.get<std::string>(); data.meshTextures[name] = path.get<std::string>();
} }
} }
data.interactionRadius = data.interactionRadius = static_cast<fp>(
item.value("interactionRadius", 0.f); item.value<double>("interactionRadius", 0.0));
data.animationIdlePath = data.animationIdlePath =
item.value("animationIdlePath", ""); item.value("animationIdlePath", "");
data.animationWalkPath = data.animationWalkPath =
@@ -327,11 +328,11 @@ LocationDefinition LocationContentLoader::load(
item.value("animationActionToDeathPath", ""); item.value("animationActionToDeathPath", "");
data.animationDeathIdlePath = data.animationDeathIdlePath =
item.value("animationDeathIdlePath", ""); item.value("animationDeathIdlePath", "");
data.positionX = item.value("positionX", 0.f); data.positionX = static_cast<fp>(item.value<double>("positionX", 0.0));
data.positionY = item.value("positionY", 0.f); data.positionY = static_cast<fp>(item.value<double>("positionY", 0.0));
data.positionZ = item.value("positionZ", 0.f); data.positionZ = static_cast<fp>(item.value<double>("positionZ", 0.0));
data.walkSpeed = item.value("walkSpeed", 1.5f); data.walkSpeed = static_cast<fp>(item.value<double>("walkSpeed", 1.5));
data.rotationSpeed = item.value("rotationSpeed", 8.f); data.rotationSpeed = static_cast<fp>(item.value<double>("rotationSpeed", 8.0));
data.modelScale = item.value("modelScale", 0.01f); data.modelScale = item.value("modelScale", 0.01f);
const float degrees = kPi / 180.f; const float degrees = kPi / 180.f;
data.modelCorrectionRotX = data.modelCorrectionRotX =
@@ -341,12 +342,13 @@ LocationDefinition LocationContentLoader::load(
data.modelCorrectionRotZ = data.modelCorrectionRotZ =
item.value("modelCorrectionRotZ", 0.f) * degrees; item.value("modelCorrectionRotZ", 0.f) * degrees;
data.facingAngle = data.facingAngle =
item.value("facingAngle", 0.f) * degrees; static_cast<fp>(item.value<double>("facingAngle", 0.0)) * static_cast<fp>(kPi) / static_cast<fp>(180.0);
data.hp = item.value("hp", 100.f); data.hp = item.value("hp", 100.f);
data.visibleRange = data.visibleRange =
item.value("visibleRange", VISIBLE_RANGE); static_cast<fp>(item.value<double>("visibleRange", static_cast<double>(VISIBLE_RANGE)));
data.canAttack = item.value("canAttack", false); data.canAttack = item.value("canAttack", false);
data.enabled = item.value("enabled", true); data.enabled = item.value("enabled", true);
/*
data.navigationAvoidanceRadius = item.value( data.navigationAvoidanceRadius = item.value(
"navigationAvoidanceRadius", "navigationAvoidanceRadius",
item.value("collisionRadius", 0.45f)); item.value("collisionRadius", 0.45f));
@@ -359,6 +361,7 @@ LocationDefinition LocationContentLoader::load(
0.05f, (std::min)( 0.05f, (std::min)(
data.movementContactRadius, data.movementContactRadius,
data.navigationAvoidanceRadius)); data.navigationAvoidanceRadius));
*/
const char* cooldownKey = const char* cooldownKey =
item.contains("navigationRetryCooldownTicks") item.contains("navigationRetryCooldownTicks")
? "navigationRetryCooldownTicks" ? "navigationRetryCooldownTicks"
+10 -10
View File
@@ -58,23 +58,23 @@ struct NpcData {
std::string animationActionToStandPath; std::string animationActionToStandPath;
std::string animationActionToDeathPath; std::string animationActionToDeathPath;
std::string animationDeathIdlePath; std::string animationDeathIdlePath;
float positionX = 0.f; fp positionX = static_cast<fp>(0.f);
float positionY = 0.f; fp positionY = static_cast<fp>(0.f);
float positionZ = 0.f; fp positionZ = static_cast<fp>(0.f);
float walkSpeed = 1.5f; fp walkSpeed = static_cast<fp>(1.5f);
float rotationSpeed = 8.f; fp rotationSpeed = static_cast<fp>(8.f);
float modelScale = 0.01f; float modelScale = 0.01f;
float modelCorrectionRotX = 0.f; float modelCorrectionRotX = 0.f;
float modelCorrectionRotY = 0.f; float modelCorrectionRotY = 0.f;
float modelCorrectionRotZ = 0.f; float modelCorrectionRotZ = 0.f;
float facingAngle = 0.f; fp facingAngle = static_cast<fp>(0.f);
float hp = 100.f; float hp = 100.f;
float visibleRange = VISIBLE_RANGE; fp visibleRange = static_cast<fp>(VISIBLE_RANGE);
bool canAttack = false; bool canAttack = false;
bool enabled = true; bool enabled = true;
float navigationAvoidanceRadius = 0.45f; //fp navigationAvoidanceRadius = static_cast<fp>(0.45f);
float movementContactRadius = 0.32f; //fp movementContactRadius = static_cast<fp>(0.32f);
float interactionRadius = 0.f; fp interactionRadius = static_cast<fp>(0.f);
std::vector<int> navigationRetryCooldownTicks; std::vector<int> navigationRetryCooldownTicks;
CharacterState toCharacterState() const; CharacterState toCharacterState() const;
+6 -5
View File
@@ -7,6 +7,7 @@
#include <string> #include <string>
#include <variant> #include <variant>
#include <vector> #include <vector>
#include "../navigation/PathFinder.h"
namespace FRG { namespace FRG {
@@ -110,7 +111,7 @@ struct SetNavigationAreaAvailableCommand {
struct AddNavigationObstacleCommand { struct AddNavigationObstacleCommand {
std::string locationId; std::string locationId;
std::string name; std::string name;
std::vector<Eigen::Vector2f> polygon; std::vector<Vec2fp> polygon;
}; };
struct AddInventoryItemCommand { std::string itemId; }; struct AddInventoryItemCommand { std::string itemId; };
struct RemoveInventoryItemCommand { std::string itemId; }; struct RemoveInventoryItemCommand { std::string itemId; };
@@ -136,19 +137,19 @@ struct SetGlobalFloatCommand { std::string key; float value; };
struct SetCharacterPositionCommand { struct SetCharacterPositionCommand {
std::string locationId; std::string locationId;
std::string characterId; std::string characterId;
Eigen::Vector3f position; Vec3fp position;
bool updateHomePosition = false; bool updateHomePosition = false;
}; };
struct SetCharacterFacingAngleCommand { struct SetCharacterFacingAngleCommand {
std::string locationId; std::string locationId;
std::string characterId; std::string characterId;
float angle; fp angle;
bool setCurrentAngle = false; bool setCurrentAngle = false;
}; };
struct SetCharacterHomePositionCommand { struct SetCharacterHomePositionCommand {
std::string locationId; std::string locationId;
std::string characterId; std::string characterId;
Eigen::Vector3f position; Vec3fp position;
}; };
struct SetCharacterTargetCommand { struct SetCharacterTargetCommand {
std::string locationId; std::string locationId;
@@ -172,7 +173,7 @@ struct ClearCharacterAttackTargetCommand {
struct SetCharacterDestinationCommand { struct SetCharacterDestinationCommand {
std::string locationId; std::string locationId;
std::string characterId; std::string characterId;
Eigen::Vector3f target; Vec3fp target;
std::string callbackName; std::string callbackName;
}; };
struct StopCharacterCommand { struct StopCharacterCommand {
+3 -4
View File
@@ -70,11 +70,11 @@ void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs)
battleZoneTestBotElapsedMs_ %= kBattleZoneTestBotIntervalMs; battleZoneTestBotElapsedMs_ %= kBattleZoneTestBotIntervalMs;
static std::mt19937 randomEngine(std::random_device{}()); static std::mt19937 randomEngine(std::random_device{}());
static std::uniform_real_distribution<float> coordinate(-10.0f, 10.0f); static std::uniform_int_distribution<int> coordinate(-100, 100); //Divide by 10 then.
submit(SetCharacterDestinationCommand{ submit(SetCharacterDestinationCommand{
state_.currentLocationName, state_.currentLocationName,
kBattleZonePlayer2Id, kBattleZonePlayer2Id,
Eigen::Vector3f(coordinate(randomEngine), 0.f, coordinate(randomEngine)), Vec3fp(static_cast<fp>(coordinate(randomEngine)) / static_cast<fp>(10), static_cast<fp>(0.f), coordinate(randomEngine) / static_cast<fp>(10)),
std::string()}); std::string()});
} }
@@ -520,8 +520,7 @@ void InternalGameServer::update(std::int64_t deltaMs)
if (!event.events.navigationRequested) continue; if (!event.events.navigationRequested) continue;
if (event.events.forceNavigationReplan) if (event.events.forceNavigationReplan)
stored->second.replanCharacter( stored->second.replanCharacter(
event.characterId, state_.players, event.characterId, state_.players);
event.events.blockingCharacterId);
else else
stored->second.setCharacterDestination( stored->second.setCharacterDestination(
event.characterId, event.events.navigationTarget, {}, event.characterId, event.events.navigationTarget, {},
+1 -1
View File
@@ -51,7 +51,7 @@ private:
GameSnapshot buildSnapshot() const; GameSnapshot buildSnapshot() const;
GameState state_; GameState state_;
std::string localPlayerId_ = "player"; std::string localPlayerId_ = "player1";
std::string resourceZipFile_; std::string resourceZipFile_;
std::vector<std::string> networkPlayerSlots_; std::vector<std::string> networkPlayerSlots_;
std::int64_t battleZoneTestBotElapsedMs_ = 0; std::int64_t battleZoneTestBotElapsedMs_ = 0;
+43 -5
View File
@@ -91,6 +91,44 @@ bool readTarget(
return true; return true;
} }
bool readTargetFp(
const nlohmann::json& value, FRG::Vec3fp& target, std::string& error)
{
if (!value.is_array() || value.size() != 3) {
error = "target must contain three coordinates";
return false;
}
FRG::fp coords[3];
for (std::size_t index = 0; index < 3; ++index) {
if (!value[index].is_number()) {
error = "target coordinates must be numbers";
return false;
}
const double coordinate = value[index].get<double>();
if (!std::isfinite(coordinate) || std::abs(coordinate) > 100000.0) {
error = "target coordinate is not finite or is out of range";
return false;
}
// Округляем до сотых в double и конвертируем в Q20.12 (fp)
coords[index] = FRG::fp(coordinate);
}
target.x = coords[0];
target.y = coords[1];
target.z = coords[2];
return true;
}
nlohmann::json targetJsonFp(const FRG::Vec3fp& target)
{
return nlohmann::json::array({
static_cast<double>(target.x),
static_cast<double>(target.y),
static_cast<double>(target.z)
});
}
nlohmann::json targetJson(const Eigen::Vector3f& target) nlohmann::json targetJson(const Eigen::Vector3f& target)
{ {
return nlohmann::json::array({target.x(), target.y(), target.z()}); return nlohmann::json::array({target.x(), target.y(), target.z()});
@@ -161,7 +199,7 @@ bool parseInitialStateMessage(
} }
std::uint64_t LockstepTimeline::schedule( std::uint64_t LockstepTimeline::schedule(
const std::string& playerId, const Eigen::Vector3f& target) const std::string& playerId, const Vec3fp& target)
{ {
const std::uint64_t sequence = nextCommandSequence_++; const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + inputDelayTicks_].push_back( pending_[currentTick_ + inputDelayTicks_].push_back(
@@ -206,7 +244,7 @@ std::string makeDestinationInputMessage(const DestinationInputMessage& message)
{ {
return envelope("destination_input", { return envelope("destination_input", {
{"clientSequence", message.clientSequence}, {"clientSequence", message.clientSequence},
{"target", targetJson(message.target)} {"target", targetJsonFp(message.target)}
}); });
} }
@@ -219,7 +257,7 @@ bool parseDestinationInputMessage(
return false; return false;
if (!payload.contains("target")) if (!payload.contains("target"))
return error = "target is required", false; return error = "target is required", false;
return readTarget(payload["target"], message.target, error); return readTargetFp(payload["target"], message.target, error);
} }
std::string makeCharacterActionInputMessage( std::string makeCharacterActionInputMessage(
@@ -265,7 +303,7 @@ std::string makeTickMessage(const TickMessage& message)
{"type", "destination"}, {"type", "destination"},
{"commandSequence", command.commandSequence}, {"commandSequence", command.commandSequence},
{"playerId", command.playerId}, {"playerId", command.playerId},
{"target", targetJson(command.target)} {"target", targetJsonFp(command.target)}
}); });
} else { } else {
if constexpr (std::is_same_v<T, ScheduledCharacterEnabledCommand>) { if constexpr (std::is_same_v<T, ScheduledCharacterEnabledCommand>) {
@@ -320,7 +358,7 @@ bool parseTickMessage(
destination.commandSequence = previousSequence; destination.commandSequence = previousSequence;
if (!value.contains("playerId") || !value["playerId"].is_string() || if (!value.contains("playerId") || !value["playerId"].is_string() ||
!value.contains("target") || !value.contains("target") ||
!readTarget(value["target"], destination.target, error)) { !readTargetFp(value["target"], destination.target, error)) {
if (error.empty()) error = "invalid destination command"; if (error.empty()) error = "invalid destination command";
return false; return false;
} }
+4 -3
View File
@@ -7,6 +7,7 @@
#include <string> #include <string>
#include <variant> #include <variant>
#include <vector> #include <vector>
#include "../navigation/PathFinder.h"
namespace FRG { namespace FRG {
@@ -25,7 +26,7 @@ struct InitialStateMessage {
struct DestinationInputMessage { struct DestinationInputMessage {
std::uint64_t clientSequence = 0; std::uint64_t clientSequence = 0;
Eigen::Vector3f target = Eigen::Vector3f::Zero(); Vec3fp target;
}; };
struct CharacterActionInputMessage { struct CharacterActionInputMessage {
@@ -37,7 +38,7 @@ struct CharacterActionInputMessage {
struct ScheduledDestinationCommand { struct ScheduledDestinationCommand {
std::uint64_t commandSequence = 0; std::uint64_t commandSequence = 0;
std::string playerId; std::string playerId;
Eigen::Vector3f target = Eigen::Vector3f::Zero(); Vec3fp target;
}; };
struct ScheduledCharacterEnabledCommand { struct ScheduledCharacterEnabledCommand {
@@ -73,7 +74,7 @@ public:
std::uint64_t currentTick() const { return currentTick_; } std::uint64_t currentTick() const { return currentTick_; }
std::uint64_t schedule( std::uint64_t schedule(
const std::string& playerId, const Eigen::Vector3f& target); const std::string& playerId, const Vec3fp& target);
std::uint64_t scheduleCharacterEnabled( std::uint64_t scheduleCharacterEnabled(
const std::string& locationId, const std::string& characterId, const std::string& locationId, const std::string& characterId,
bool enabled); bool enabled);
+257 -57
View File
@@ -5,6 +5,21 @@
#else #else
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <boost/beast.hpp> #include <boost/beast.hpp>
#ifdef NETWORK_TLS
#include <wincrypt.h>
// wincrypt.h defines macros that collide with OpenSSL identifiers.
#undef X509_NAME
#undef X509_EXTENSIONS
#undef X509_CERT_PAIR
#undef PKCS7_ISSUER_AND_SERIAL
#undef OCSP_REQUEST
#undef OCSP_RESPONSE
#include <boost/asio/ssl.hpp>
#include <boost/beast/ssl.hpp>
#include <openssl/err.h>
#include <openssl/x509.h>
#include <openssl/x509_vfy.h>
#endif
#endif #endif
#include <atomic> #include <atomic>
#include <deque> #include <deque>
@@ -23,19 +38,104 @@ namespace asio = boost::asio;
namespace beast = boost::beast; namespace beast = boost::beast;
namespace websocket = beast::websocket; namespace websocket = beast::websocket;
using tcp = asio::ip::tcp; using tcp = asio::ip::tcp;
#ifdef NETWORK_TLS
// Loads the Windows "ROOT" certificate store into the OpenSSL context.
void loadWindowsRootCertificates(asio::ssl::context& context)
{
HCERTSTORE store = CertOpenSystemStoreA(0, "ROOT");
if (!store) {
std::cerr << "[network] Could not open the Windows root certificate store\n";
return;
}
X509_STORE* x509Store = SSL_CTX_get_cert_store(context.native_handle());
PCCERT_CONTEXT cert = nullptr;
while ((cert = CertEnumCertificatesInStore(store, cert)) != nullptr) {
const unsigned char* data = cert->pbCertEncoded;
X509* x509 = d2i_X509(nullptr, &data, static_cast<long>(cert->cbCertEncoded));
if (x509) {
X509_STORE_add_cert(x509Store, x509);
X509_free(x509);
}
}
CertCloseStore(store, 0);
}
// Certificate problems are logged but deliberately do not abort the connection.
bool logAndAcceptCertificate(bool preverified, asio::ssl::verify_context& context)
{
if (!preverified) {
X509_STORE_CTX* storeContext = context.native_handle();
const int error = X509_STORE_CTX_get_error(storeContext);
const int depth = X509_STORE_CTX_get_error_depth(storeContext);
char subject[256] = "?";
if (X509* cert = X509_STORE_CTX_get_current_cert(storeContext))
X509_NAME_oneline(X509_get_subject_name(cert), subject, sizeof(subject));
std::cerr << "[network] TLS certificate warning (depth " << depth
<< ", subject " << subject << "): "
<< X509_verify_cert_error_string(error)
<< " - continuing anyway\n";
}
return true;
}
#endif
#endif #endif
} // namespace } // namespace
bool WebsocketEndpoint::parse(const std::string& url)
{
bool tls = false;
std::string rest;
if (url.rfind("wss://", 0) == 0) {
tls = true;
rest = url.substr(6);
} else if (url.rfind("ws://", 0) == 0) {
rest = url.substr(5);
} else {
return false;
}
std::string authority = rest;
std::string newPath = "/";
const std::size_t slash = rest.find('/');
if (slash != std::string::npos) {
authority = rest.substr(0, slash);
newPath = rest.substr(slash);
}
std::string newHost = authority;
std::string newPort = tls ? "443" : "80";
if (!authority.empty() && authority.front() == '[') { // [ipv6]:port
const std::size_t close = authority.find(']');
if (close == std::string::npos) return false;
newHost = authority.substr(1, close - 1);
if (close + 1 < authority.size()) {
if (authority[close + 1] != ':') return false;
newPort = authority.substr(close + 2);
}
} else {
const std::size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
newHost = authority.substr(0, colon);
newPort = authority.substr(colon + 1);
}
}
if (newHost.empty() || newPort.empty()) return false;
host = std::move(newHost);
port = std::move(newPort);
path = std::move(newPath);
useTls = tls;
return true;
}
class WebsocketGameClient::Impl { class WebsocketGameClient::Impl {
public: public:
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
Impl(std::string host, std::string port, std::string path) Impl(std::string host, std::string port, std::string path, bool useTls)
: host_(std::move(host)) : host_(std::move(host))
, port_(std::move(port)) , port_(std::move(port))
, path_(std::move(path)) , path_(std::move(path))
{ {
url_ = "ws://" + host_ + ':' + port_ + url_ = std::string(useTls ? "wss://" : "ws://") + host_ + ':' + port_ +
(path_.empty() || path_.front() == '/' ? path_ : "/" + path_); (path_.empty() || path_.front() == '/' ? path_ : "/" + path_);
std::cout << "[network] Opening " << url_ << std::endl; std::cout << "[network] Opening " << url_ << std::endl;
@@ -45,7 +145,7 @@ public:
attributes.createOnMainThread = EM_TRUE; attributes.createOnMainThread = EM_TRUE;
socket_ = emscripten_websocket_new(&attributes); socket_ = emscripten_websocket_new(&attributes);
if (socket_ <= 0) { if (socket_ <= 0) {
fail("create WebSocket"); fail("create WebSocket", socket_);
return; return;
} }
@@ -105,11 +205,21 @@ private:
} }
static EM_BOOL onClose( static EM_BOOL onClose(
int, const EmscriptenWebSocketCloseEvent*, void* userData) int, const EmscriptenWebSocketCloseEvent* event, void* userData)
{ {
auto* self = static_cast<Impl*>(userData); auto* self = static_cast<Impl*>(userData);
self->connected_ = false; self->connected_ = false;
if (!self->closing_) self->fail("WebSocket close"); if (!self->closing_) {
std::cerr << "[network] WebSocket closed";
if (event) {
std::cerr << " (code " << event->code;
if (event->reason[0] != '\0')
std::cerr << ", reason: " << event->reason;
std::cerr << ')';
}
std::cerr << '\n';
self->connectionFailed_ = true;
}
return EM_TRUE; return EM_TRUE;
} }
@@ -126,11 +236,13 @@ private:
return EM_TRUE; return EM_TRUE;
} }
void fail(const char* operation) void fail(const char* operation, int result = 0)
{ {
connected_ = false; connected_ = false;
connectionFailed_ = true; connectionFailed_ = true;
std::cerr << "[network] " << operation << " failed" << std::endl; std::cerr << "[network] " << operation << " failed";
if (result != 0) std::cerr << " (result " << result << ')';
std::cerr << std::endl;
} }
std::string host_; std::string host_;
@@ -143,14 +255,38 @@ private:
bool closing_ = false; bool closing_ = false;
std::vector<std::string> messages_; std::vector<std::string> messages_;
#else #else
Impl(std::string host, std::string port, std::string path) Impl(std::string host, std::string port, std::string path, bool useTls)
: resolver_(io_) :
#ifdef NETWORK_TLS
sslContext_(asio::ssl::context::tls_client)
, resolver_(io_)
, secureSocket_(io_, sslContext_)
#else
resolver_(io_)
#endif
, socket_(io_) , socket_(io_)
, host_(std::move(host)) , host_(std::move(host))
, port_(std::move(port)) , port_(std::move(port))
, path_(std::move(path)) , path_(std::move(path))
, useTls_(useTls)
{ {
std::cout << "[network] Resolving ws://" << host_ << ':' << port_ #ifdef NETWORK_TLS
sslContext_.set_options(
asio::ssl::context::default_workarounds |
asio::ssl::context::no_sslv2 | asio::ssl::context::no_sslv3 |
asio::ssl::context::no_tlsv1 | asio::ssl::context::no_tlsv1_1);
loadWindowsRootCertificates(sslContext_);
sslContext_.set_verify_mode(asio::ssl::verify_peer);
sslContext_.set_verify_callback(&logAndAcceptCertificate);
#else
if (useTls_) {
std::cerr << "[network] wss:// requested but TLS support was not "
"compiled in (NETWORK_TLS)" << std::endl;
connectionFailed_.store(true);
return;
}
#endif
std::cout << "[network] Resolving " << scheme() << host_ << ':' << port_
<< path_ << std::endl; << path_ << std::endl;
resolve(); resolve();
thread_ = std::thread([this]() { io_.run(); }); thread_ = std::thread([this]() { io_.run(); });
@@ -184,6 +320,20 @@ private:
} }
private: private:
const char* scheme() const { return useTls_ ? "wss://" : "ws://"; }
template <class F>
void withStream(F&& f)
{
#ifdef NETWORK_TLS
if (useTls_) {
f(secureSocket_);
return;
}
#endif
f(socket_);
}
void resolve() void resolve()
{ {
resolver_.async_resolve( resolver_.async_resolve(
@@ -192,60 +342,97 @@ private:
if (error) return fail("resolve", error); if (error) return fail("resolve", error);
std::cout << "[network] WebSocket host resolved; connecting" std::cout << "[network] WebSocket host resolved; connecting"
<< std::endl; << std::endl;
asio::async_connect( withStream([&](auto& stream) {
socket_.next_layer(), results, asio::async_connect(
[this](beast::error_code connectError, beast::get_lowest_layer(stream), results,
const tcp::endpoint&) { [this](beast::error_code connectError,
if (connectError) return fail("connect", connectError); const tcp::endpoint&) {
std::cout << "[network] TCP connected; starting WebSocket handshake" if (connectError) return fail("connect", connectError);
<< std::endl; std::cout << "[network] TCP connected" << std::endl;
socket_.set_option( startTlsOrWebsocketHandshake();
websocket::stream_base::timeout::suggested( });
beast::role_type::client)); });
socket_.async_handshake(
host_, path_,
[this](beast::error_code handshakeError) {
if (handshakeError)
return fail("handshake", handshakeError);
connected_.store(true);
std::cout
<< "[network] Connected to ws://"
<< host_ << ':' << port_ << path_ << std::endl;
read();
});
});
}); });
} }
void startTlsOrWebsocketHandshake()
{
#ifdef NETWORK_TLS
if (useTls_) {
SSL* ssl = secureSocket_.next_layer().native_handle();
// SNI, and hostname verification (failures are only logged).
SSL_set_tlsext_host_name(ssl, const_cast<char*>(host_.c_str()));
SSL_set1_host(ssl, host_.c_str());
std::cout << "[network] Starting TLS handshake" << std::endl;
secureSocket_.next_layer().async_handshake(
asio::ssl::stream_base::client,
[this](beast::error_code tlsError) {
if (tlsError) return fail("TLS handshake", tlsError);
std::cout << "[network] TLS established" << std::endl;
startWebsocketHandshake();
});
return;
}
#endif
startWebsocketHandshake();
}
void startWebsocketHandshake()
{
const bool defaultPort = port_ == (useTls_ ? "443" : "80");
const std::string hostHeader = defaultPort ? host_ : host_ + ':' + port_;
std::cout << "[network] Starting WebSocket handshake" << std::endl;
withStream([&](auto& stream) {
stream.set_option(
websocket::stream_base::timeout::suggested(
beast::role_type::client));
stream.async_handshake(
hostHeader, path_,
[this](beast::error_code handshakeError) {
if (handshakeError)
return fail("handshake", handshakeError);
connected_.store(true);
std::cout
<< "[network] Connected to " << scheme()
<< host_ << ':' << port_ << path_ << std::endl;
read();
});
});
}
void read() void read()
{ {
socket_.async_read( withStream([this](auto& stream) {
buffer_, stream.async_read(
[this](beast::error_code error, std::size_t) { buffer_,
if (error) { [this](beast::error_code error, std::size_t) {
connected_.store(false); if (error) {
if (error != websocket::error::closed) fail("read", error); connected_.store(false);
return; if (error != websocket::error::closed) fail("read", error);
} return;
std::string message = beast::buffers_to_string(buffer_.data()); }
buffer_.consume(buffer_.size()); std::string message = beast::buffers_to_string(buffer_.data());
{ buffer_.consume(buffer_.size());
std::lock_guard<std::mutex> lock(messagesMutex_); {
messages_.push_back(std::move(message)); std::lock_guard<std::mutex> lock(messagesMutex_);
} messages_.push_back(std::move(message));
read(); }
}); read();
});
});
} }
void write() void write()
{ {
socket_.async_write( withStream([this](auto& stream) {
asio::buffer(outbound_.front()), stream.async_write(
[this](beast::error_code error, std::size_t) { asio::buffer(outbound_.front()),
if (error) return fail("write", error); [this](beast::error_code error, std::size_t) {
outbound_.pop_front(); if (error) return fail("write", error);
if (!outbound_.empty()) write(); outbound_.pop_front();
}); if (!outbound_.empty()) write();
});
});
} }
void fail(const char* operation, beast::error_code error) void fail(const char* operation, beast::error_code error)
@@ -257,13 +444,20 @@ private:
} }
asio::io_context io_; asio::io_context io_;
#ifdef NETWORK_TLS
asio::ssl::context sslContext_;
#endif
tcp::resolver resolver_; tcp::resolver resolver_;
#ifdef NETWORK_TLS
websocket::stream<beast::ssl_stream<tcp::socket>> secureSocket_;
#endif
websocket::stream<tcp::socket> socket_; websocket::stream<tcp::socket> socket_;
beast::flat_buffer buffer_; beast::flat_buffer buffer_;
std::thread thread_; std::thread thread_;
std::string host_; std::string host_;
std::string port_; std::string port_;
std::string path_; std::string path_;
bool useTls_ = false;
std::atomic_bool connected_{false}; std::atomic_bool connected_{false};
std::atomic_bool connectionFailed_{false}; std::atomic_bool connectionFailed_{false};
std::mutex messagesMutex_; std::mutex messagesMutex_;
@@ -273,9 +467,15 @@ private:
}; };
WebsocketGameClient::WebsocketGameClient( WebsocketGameClient::WebsocketGameClient(
std::string host, std::string port, std::string path) std::string host, std::string port, std::string path, bool useTls)
: impl_(std::make_unique<Impl>( : impl_(std::make_unique<Impl>(
std::move(host), std::move(port), std::move(path))) std::move(host), std::move(port), std::move(path), useTls))
{
}
WebsocketGameClient::WebsocketGameClient(const WebsocketEndpoint& endpoint)
: WebsocketGameClient(
endpoint.host, endpoint.port, endpoint.path, endpoint.useTls)
{ {
} }
+15 -1
View File
@@ -11,12 +11,26 @@ namespace FRG {
class RemoteGameServerHost; class RemoteGameServerHost;
// Parsed "ws://host[:port][/path]" or "wss://host[:port][/path]".
struct WebsocketEndpoint {
std::string host = "127.0.0.1";
std::string port = "8080";
std::string path = "/";
bool useTls = false;
// Returns false (and leaves *this untouched) for an unsupported/malformed URL.
// Default ports: 80 for ws://, 443 for wss://.
bool parse(const std::string& url);
};
class WebsocketGameClient final : public IGameServer { class WebsocketGameClient final : public IGameServer {
public: public:
WebsocketGameClient( WebsocketGameClient(
std::string host = "127.0.0.1", std::string host = "127.0.0.1",
std::string port = "8080", std::string port = "8080",
std::string path = "/"); std::string path = "/",
bool useTls = false);
explicit WebsocketGameClient(const WebsocketEndpoint& endpoint);
~WebsocketGameClient(); ~WebsocketGameClient();
WebsocketGameClient(const WebsocketGameClient&) = delete; WebsocketGameClient(const WebsocketGameClient&) = delete;
+12 -9
View File
@@ -13,6 +13,9 @@
namespace FRG { namespace FRG {
constexpr float kPi = 3.14159265358979323846f;
using json = nlohmann::json; using json = nlohmann::json;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -341,7 +344,7 @@ namespace FRG {
data.meshTextures[it.key()] = it.value().get<std::string>(); data.meshTextures[it.key()] = it.value().get<std::string>();
} }
} }
data.interactionRadius = item.value("interactionRadius", 0.0f); data.interactionRadius = static_cast<fp>(item.value<double>("interactionRadius", 0.0));
data.animationIdlePath = item.value("animationIdlePath", ""); data.animationIdlePath = item.value("animationIdlePath", "");
data.animationWalkPath = item.value("animationWalkPath", ""); data.animationWalkPath = item.value("animationWalkPath", "");
data.animationActionIdlePath = item.value("animationActionIdlePath", ""); data.animationActionIdlePath = item.value("animationActionIdlePath", "");
@@ -350,18 +353,18 @@ namespace FRG {
data.animationActionToStandPath = item.value("animationActionToStandPath", ""); data.animationActionToStandPath = item.value("animationActionToStandPath", "");
data.animationActionToDeathPath = item.value("animationActionToDeathPath", ""); data.animationActionToDeathPath = item.value("animationActionToDeathPath", "");
data.animationDeathIdlePath = item.value("animationDeathIdlePath", ""); data.animationDeathIdlePath = item.value("animationDeathIdlePath", "");
data.positionX = item.value("positionX", 0.0f); data.positionX = static_cast<fp>(item.value<double>("positionX", 0.0));
data.positionY = item.value("positionY", 0.0f); data.positionY = static_cast<fp>(item.value<double>("positionY", 0.0));
data.positionZ = item.value("positionZ", 0.0f); data.positionZ = static_cast<fp>(item.value<double>("positionZ", 0.0));
data.walkSpeed = item.value("walkSpeed", 1.5f); data.walkSpeed = static_cast<fp>(item.value<double>("walkSpeed", 1.5));
data.rotationSpeed = item.value("rotationSpeed", 8.0f); data.rotationSpeed = static_cast<fp>(item.value<double>("rotationSpeed", 8.0));
data.modelScale = item.value("modelScale", 0.01f); data.modelScale = item.value("modelScale", 0.01f);
data.modelCorrectionRotX = item.value("modelCorrectionRotX", 0.0f) * static_cast<float>(M_PI) / 180.0f; data.modelCorrectionRotX = item.value("modelCorrectionRotX", 0.0f) * static_cast<float>(M_PI) / 180.0f;
data.modelCorrectionRotY = item.value("modelCorrectionRotY", 0.0f) * static_cast<float>(M_PI) / 180.0f; data.modelCorrectionRotY = item.value("modelCorrectionRotY", 0.0f) * static_cast<float>(M_PI) / 180.0f;
data.modelCorrectionRotZ = item.value("modelCorrectionRotZ", 0.0f) * static_cast<float>(M_PI) / 180.0f; data.modelCorrectionRotZ = item.value("modelCorrectionRotZ", 0.0f) * static_cast<float>(M_PI) / 180.0f;
data.facingAngle = item.value("facingAngle", 0.0f) * static_cast<float>(M_PI) / 180.0f; data.facingAngle = static_cast<fp>(item.value("facingAngle", 0.0f)) * static_cast<fp>(kPi) / static_cast<fp>(180.0f);
data.hp = item.value("hp", 100.0f); data.hp = item.value("hp", 100.0f);
data.visibleRange = item.value("visibleRange", VISIBLE_RANGE); data.visibleRange = static_cast<fp>(item.value<double>("visibleRange", static_cast<double>(VISIBLE_RANGE)));
data.canAttack = item.value("canAttack", false); data.canAttack = item.value("canAttack", false);
data.enabled = item.value("enabled", true); data.enabled = item.value("enabled", true);
@@ -401,7 +404,7 @@ namespace FRG {
renderer, zipPath.c_str()); renderer, zipPath.c_str());
if (npc) { if (npc) {
logger() << "Successfully loaded NPC: " << npcData.name << " at (" logger() << "Successfully loaded NPC: " << npcData.name << " at ("
<< npcData.positionX << ", " << npcData.positionY << ", " << npcData.positionZ << ")" << std::endl; << static_cast<float>(npcData.positionX) << ", " << static_cast<float>(npcData.positionY) << ", " << static_cast<float>(npcData.positionZ) << ")" << std::endl;
npcs.push_back(std::move(npc)); npcs.push_back(std::move(npc));
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
+28 -13
View File
@@ -2,6 +2,7 @@
#include "Environment.h" #include "Environment.h"
#include "utils/Utils.h" #include "utils/Utils.h"
#include <iostream> #include <iostream>
#include <cmath>
#ifdef __ANDROID__ #ifdef __ANDROID__
#include <android/log.h> #include <android/log.h>
#endif #endif
@@ -75,14 +76,27 @@ extern "C" void EMSCRIPTEN_KEEPALIVE onFileSystemLoaded() {
} }
// Single definition of the "logical" viewport size: the *visible* area of the page
// (visualViewport), not 100vh, which on mobile includes the area under the URL bar.
static void getViewportCssSize(int& w, int& h) {
w = EM_ASM_INT({
var vv = window.visualViewport;
return Math.round((vv && vv.width) || window.innerWidth);
});
h = EM_ASM_INT({
var vv = window.visualViewport;
return Math.round((vv && vv.height) || window.innerHeight);
});
}
static void applyResize(int logicalW, int logicalH) { static void applyResize(int logicalW, int logicalH) {
// Получаем коэффициент плотности пикселей (например, 2.625 на Pixel или 3.0 на Samsung) // Получаем коэффициент плотности пикселей (например, 2.625 на Pixel или 3.0 на Samsung)
double dpr = emscripten_get_device_pixel_ratio(); double dpr = emscripten_get_device_pixel_ratio();
//double dpr = 1; // low quality //double dpr = 1; // low quality
// Вычисляем реальные физические пиксели // Вычисляем реальные физические пиксели
int physicalW = static_cast<int>(logicalW * dpr); int physicalW = static_cast<int>(std::lround(logicalW * dpr));
int physicalH = static_cast<int>(logicalH * dpr); int physicalH = static_cast<int>(std::lround(logicalH * dpr));
// Устанавливаем размер внутреннего буфера канваса // Устанавливаем размер внутреннего буфера канваса
emscripten_set_canvas_element_size("#canvas", physicalW, physicalH); emscripten_set_canvas_element_size("#canvas", physicalW, physicalH);
@@ -94,6 +108,11 @@ static void applyResize(int logicalW, int logicalH) {
SDL_SetWindowSize(FRG::Environment::window, physicalW, physicalH); SDL_SetWindowSize(FRG::Environment::window, physicalW, physicalH);
} }
// SDL may have written an inline CSS size derived from the physical pixels, which
// overrides the stylesheet. Re-assert the CSS box so its aspect always matches the
// drawing buffer (otherwise the image is stretched / cropped on mobile).
emscripten_set_element_css_size("#canvas", logicalW, logicalH);
// Пушим событие, чтобы движок пересчитал матрицы проекции // Пушим событие, чтобы движок пересчитал матрицы проекции
SDL_Event e = {}; SDL_Event e = {};
e.type = SDL_WINDOWEVENT; e.type = SDL_WINDOWEVENT;
@@ -118,8 +137,8 @@ void MainLoop() {
// Теперь DOM перестроен, можно забирать актуальные размеры. // Теперь DOM перестроен, можно забирать актуальные размеры.
// Если canvas должен занимать все окно браузера: // Если canvas должен занимать все окно браузера:
int canvasW = EM_ASM_INT({ return window.innerWidth; }); int canvasW, canvasH;
int canvasH = EM_ASM_INT({ return window.innerHeight; }); getViewportCssSize(canvasW, canvasH);
applyResize(canvasW, canvasH); applyResize(canvasW, canvasH);
} }
@@ -141,14 +160,10 @@ EM_BOOL onWebGLContextRestored(int /*eventType*/, const void* /*reserved*/, void
EM_BOOL onWindowResized(int /*eventType*/, const EmscriptenUiEvent* e, void* /*userData*/) { EM_BOOL onWindowResized(int /*eventType*/, const EmscriptenUiEvent* e, void* /*userData*/) {
double w, h; int w, h;
emscripten_get_element_css_size("window", &w, &h); getViewportCssSize(w, h);
// Если "window" не поддерживается вашей версией SDK, используем данные из e: applyResize(w, h);
// int logicalW = e->windowInnerWidth;
// int logicalH = e->windowInnerHeight;
applyResize(static_cast<int>(w), static_cast<int>(h));
return EM_FALSE; return EM_FALSE;
} }
@@ -181,8 +196,8 @@ int main(int argc, char* argv[]) {
emscripten_set_resize_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, nullptr, EM_FALSE, onWindowResized); emscripten_set_resize_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, nullptr, EM_FALSE, onWindowResized);
emscripten_set_fullscreenchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, EM_TRUE, onFullscreenChanged); emscripten_set_fullscreenchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, EM_TRUE, onFullscreenChanged);
int canvasW = EM_ASM_INT({ return window.innerWidth; }); int canvasW, canvasH;
int canvasH = EM_ASM_INT({ return window.innerHeight; }); getViewportCssSize(canvasW, canvasH);
applyResize(canvasW, canvasH); applyResize(canvasW, canvasH);
SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0"); SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0");
+64 -42
View File
@@ -12,8 +12,27 @@
namespace FRG { namespace FRG {
using json = nlohmann::json; using json = nlohmann::json;
Vec3fp operator-(Vec3fp a, Vec3fp b)
{
return Vec3fp{
a.x - b.x,
a.y - b.y,
a.z - b.z
};
}
Vec3fp operator+(Vec3fp a, Vec3fp b)
{
return Vec3fp{
a.x + b.x,
a.y + b.y,
a.z + b.z
};
}
namespace { namespace {
std::vector<Eigen::Vector2f> readPolygon(const json& item) std::vector<Eigen::Vector2f> readPolygon(const json& item)
{ {
std::vector<Eigen::Vector2f> polygon; std::vector<Eigen::Vector2f> polygon;
@@ -79,7 +98,7 @@ namespace {
} }
} }
void PathFinder::build(const std::string& configPath, void PathFinderOld::build(const std::string& configPath,
const std::string& zipPath) const std::string& zipPath)
{ {
ready = false; ready = false;
@@ -115,22 +134,23 @@ void PathFinder::build(const std::string& configPath,
<< ", cell=" << cellSize << ", areas=" << areas.size() << "\n"; << ", cell=" << cellSize << ", areas=" << areas.size() << "\n";
} }
std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start, std::vector<Eigen::Vector3f> PathFinderOld::findPath(const Eigen::Vector3f& start,
const Eigen::Vector3f& end) const const Eigen::Vector3f& end) const
{ {
const std::lock_guard<std::mutex> lock(*queryMutex); //const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(start, end, 0, false); //return runSearch(start, end, 0, false);
return std::vector<Eigen::Vector3f>{start, end};
} }
std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start, /*
std::vector<Eigen::Vector3f> PathFinderOld::findPath(const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::vector<DynamicObstacle>& dynamicObstacles) const const std::vector<DynamicObstacle>& dynamicObstacles) const
{ {
const std::lock_guard<std::mutex> lock(*queryMutex); const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(start, end, buildDynamicOverlay(dynamicObstacles), false); return runSearch(start, end, buildDynamicOverlay(dynamicObstacles), false);
} }*/
std::uint32_t PathFinderOld::buildDynamicOverlay(
std::uint32_t PathFinder::buildDynamicOverlay(
const std::vector<DynamicObstacle>& dynamicObstacles) const const std::vector<DynamicObstacle>& dynamicObstacles) const
{ {
const size_t cellCount = walkable.size(); const size_t cellCount = walkable.size();
@@ -172,7 +192,7 @@ std::uint32_t PathFinder::buildDynamicOverlay(
return dynamicGeneration; return dynamicGeneration;
} }
bool PathFinder::isCellWalkableOverlay( bool PathFinderOld::isCellWalkableOverlay(
const Cell& cell, std::uint32_t generation) const const Cell& cell, std::uint32_t generation) const
{ {
if (!isCellWalkable(cell)) return false; if (!isCellWalkable(cell)) return false;
@@ -180,7 +200,7 @@ bool PathFinder::isCellWalkableOverlay(
dynamicBlockedGeneration[static_cast<size_t>(indexOf(cell))] != generation; dynamicBlockedGeneration[static_cast<size_t>(indexOf(cell))] != generation;
} }
bool PathFinder::findNearestWalkableCellOverlay( bool PathFinderOld::findNearestWalkableCellOverlay(
const Eigen::Vector3f& point, Cell& out, std::uint32_t generation) const const Eigen::Vector3f& point, Cell& out, std::uint32_t generation) const
{ {
Cell origin; Cell origin;
@@ -212,7 +232,7 @@ bool PathFinder::findNearestWalkableCellOverlay(
return false; return false;
} }
bool PathFinder::hasLineOfSightOverlay( bool PathFinderOld::hasLineOfSightOverlay(
const Cell& from, const Cell& to, std::uint32_t generation) const const Cell& from, const Cell& to, std::uint32_t generation) const
{ {
int x = from.x; int x = from.x;
@@ -244,7 +264,7 @@ bool PathFinder::hasLineOfSightOverlay(
} }
} }
std::vector<PathFinder::Cell> PathFinder::smoothCellsOverlay( std::vector<PathFinderOld::Cell> PathFinderOld::smoothCellsOverlay(
const std::vector<Cell>& cells, std::uint32_t generation) const const std::vector<Cell>& cells, std::uint32_t generation) const
{ {
if (cells.size() <= 2) return cells; if (cells.size() <= 2) return cells;
@@ -263,7 +283,7 @@ std::vector<PathFinder::Cell> PathFinder::smoothCellsOverlay(
return result; return result;
} }
std::vector<Eigen::Vector3f> PathFinder::runSearch( std::vector<Eigen::Vector3f> PathFinderOld::runSearch(
const Eigen::Vector3f& start, const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
std::uint32_t obstacleGeneration, std::uint32_t obstacleGeneration,
@@ -385,23 +405,25 @@ std::vector<Eigen::Vector3f> PathFinder::runSearch(
return path; return path;
} }
std::vector<Eigen::Vector3f> PathFinder::findPathToNearest( std::vector<Eigen::Vector3f> PathFinderOld::findPathToNearest(
const Eigen::Vector3f& start, const Eigen::Vector3f& end) const const Eigen::Vector3f& start, const Eigen::Vector3f& end) const
{ {
const std::lock_guard<std::mutex> lock(*queryMutex); //const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(start, end, 0, true); // return runSearch(start, end, 0, true);
return std::vector<Eigen::Vector3f>{start, end};
} }
std::vector<Eigen::Vector3f> PathFinder::findPathToNearest( /*
std::vector<Eigen::Vector3f> PathFinderOld::findPathToNearest(
const Eigen::Vector3f& start, const Eigen::Vector3f& end, const Eigen::Vector3f& start, const Eigen::Vector3f& end,
const std::vector<DynamicObstacle>& dynamicObstacles) const const std::vector<DynamicObstacle>& dynamicObstacles) const
{ {
const std::lock_guard<std::mutex> lock(*queryMutex); const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch( return runSearch(
start, end, buildDynamicOverlay(dynamicObstacles), true); start, end, buildDynamicOverlay(dynamicObstacles), true);
} }*/
bool PathFinder::setAreaAvailable(const std::string& areaName, bool available) bool PathFinderOld::setAreaAvailable(const std::string& areaName, bool available)
{ {
bool found = false; bool found = false;
bool changed = false; bool changed = false;
@@ -420,13 +442,13 @@ bool PathFinder::setAreaAvailable(const std::string& areaName, bool available)
return found; return found;
} }
bool PathFinder::isWalkable(const Eigen::Vector3f& point) const bool PathFinderOld::isWalkable(const Eigen::Vector3f& point) const
{ {
Cell cell; Cell cell;
return worldToCell(point, cell) && isCellWalkable(cell, walkable); return worldToCell(point, cell) && isCellWalkable(cell, walkable);
} }
void PathFinder::addObstaclePolygon(const ObstaclePolygon& polygon) void PathFinderOld::addObstaclePolygon(const ObstaclePolygon& polygon)
{ {
obstaclePolygons.push_back(polygon); obstaclePolygons.push_back(polygon);
if (ready) { if (ready) {
@@ -434,7 +456,7 @@ void PathFinder::addObstaclePolygon(const ObstaclePolygon& polygon)
} }
} }
bool PathFinder::saveConfig(const std::string& path) const bool PathFinderOld::saveConfig(const std::string& path) const
{ {
json root; json root;
root["cellSize"] = cellSize; root["cellSize"] = cellSize;
@@ -481,7 +503,7 @@ bool PathFinder::saveConfig(const std::string& path) const
return saveJsonToFile(root, path); return saveJsonToFile(root, path);
} }
bool PathFinder::saveGrid(const std::string& path) const bool PathFinderOld::saveGrid(const std::string& path) const
{ {
#if defined(_WIN32) #if defined(_WIN32)
if (!ready) return false; if (!ready) return false;
@@ -510,7 +532,7 @@ bool PathFinder::saveGrid(const std::string& path) const
#endif #endif
} }
bool PathFinder::loadGrid(const std::string& path, const std::string& zipPath) bool PathFinderOld::loadGrid(const std::string& path, const std::string& zipPath)
{ {
std::string content; std::string content;
try { try {
@@ -565,7 +587,7 @@ bool PathFinder::loadGrid(const std::string& path, const std::string& zipPath)
return true; return true;
} }
void PathFinder::loadConfig(const std::string& configPath, const std::string& zipPath) void PathFinderOld::loadConfig(const std::string& configPath, const std::string& zipPath)
{ {
cellSize = 0.4f; cellSize = 0.4f;
agentRadius = 0.45f; agentRadius = 0.45f;
@@ -640,7 +662,7 @@ void PathFinder::loadConfig(const std::string& configPath, const std::string& zi
boundaryPadding = (std::max)(boundaryPadding, 0.0f); boundaryPadding = (std::max)(boundaryPadding, 0.0f);
} }
void PathFinder::resetGridBounds() void PathFinderOld::resetGridBounds()
{ {
if (areas.empty()) { if (areas.empty()) {
gridWidth = 0; gridWidth = 0;
@@ -682,14 +704,14 @@ void PathFinder::resetGridBounds()
gridDepth = static_cast<int>(std::ceil((maxZ - minZ) / cellSize)); gridDepth = static_cast<int>(std::ceil((maxZ - minZ) / cellSize));
} }
void PathFinder::rebuildWalkableGrid() void PathFinderOld::rebuildWalkableGrid()
{ {
walkable.assign(static_cast<size_t>(gridWidth * gridDepth), 0); walkable.assign(static_cast<size_t>(gridWidth * gridDepth), 0);
markAvailableAreasWalkable(); markAvailableAreasWalkable();
markObstaclePolygonsBlocked(); markObstaclePolygonsBlocked();
} }
void PathFinder::markAvailableAreasWalkable() void PathFinderOld::markAvailableAreasWalkable()
{ {
const auto insideAvailableArea = [this](const Eigen::Vector2f& point) { const auto insideAvailableArea = [this](const Eigen::Vector2f& point) {
for (const NavigationArea& area : areas) { for (const NavigationArea& area : areas) {
@@ -763,7 +785,7 @@ void PathFinder::markAvailableAreasWalkable()
} }
} }
void PathFinder::markObstaclePolygonsBlocked() void PathFinderOld::markObstaclePolygonsBlocked()
{ {
if (obstaclePolygons.empty()) { if (obstaclePolygons.empty()) {
return; return;
@@ -800,7 +822,7 @@ void PathFinder::markObstaclePolygonsBlocked()
} }
} }
bool PathFinder::worldToCell(const Eigen::Vector3f& point, Cell& out) const bool PathFinderOld::worldToCell(const Eigen::Vector3f& point, Cell& out) const
{ {
if (gridWidth <= 0 || gridDepth <= 0) { if (gridWidth <= 0 || gridDepth <= 0) {
out = {}; out = {};
@@ -812,7 +834,7 @@ bool PathFinder::worldToCell(const Eigen::Vector3f& point, Cell& out) const
return isInsideGrid(out); return isInsideGrid(out);
} }
Eigen::Vector3f PathFinder::cellCenter(const Cell& cell) const Eigen::Vector3f PathFinderOld::cellCenter(const Cell& cell) const
{ {
return { return {
minX + (static_cast<float>(cell.x) + 0.5f) * cellSize, minX + (static_cast<float>(cell.x) + 0.5f) * cellSize,
@@ -821,17 +843,17 @@ Eigen::Vector3f PathFinder::cellCenter(const Cell& cell) const
}; };
} }
bool PathFinder::isInsideGrid(const Cell& cell) const bool PathFinderOld::isInsideGrid(const Cell& cell) const
{ {
return cell.x >= 0 && cell.z >= 0 && cell.x < gridWidth && cell.z < gridDepth; return cell.x >= 0 && cell.z >= 0 && cell.x < gridWidth && cell.z < gridDepth;
} }
bool PathFinder::isCellWalkable(const Cell& cell) const bool PathFinderOld::isCellWalkable(const Cell& cell) const
{ {
return isCellWalkable(cell, walkable); return isCellWalkable(cell, walkable);
} }
bool PathFinder::isCellWalkable(const Cell& cell, const std::vector<unsigned char>& walkableGrid) const bool PathFinderOld::isCellWalkable(const Cell& cell, const std::vector<unsigned char>& walkableGrid) const
{ {
const int idx = indexOf(cell); const int idx = indexOf(cell);
return isInsideGrid(cell) && return isInsideGrid(cell) &&
@@ -840,17 +862,17 @@ bool PathFinder::isCellWalkable(const Cell& cell, const std::vector<unsigned cha
walkableGrid[static_cast<size_t>(idx)] != 0; walkableGrid[static_cast<size_t>(idx)] != 0;
} }
int PathFinder::indexOf(const Cell& cell) const int PathFinderOld::indexOf(const Cell& cell) const
{ {
return cell.z * gridWidth + cell.x; return cell.z * gridWidth + cell.x;
} }
bool PathFinder::findNearestWalkableCell(const Eigen::Vector3f& point, Cell& out) const bool PathFinderOld::findNearestWalkableCell(const Eigen::Vector3f& point, Cell& out) const
{ {
return findNearestWalkableCell(point, out, walkable); return findNearestWalkableCell(point, out, walkable);
} }
bool PathFinder::findNearestWalkableCell(const Eigen::Vector3f& point, Cell& out, const std::vector<unsigned char>& walkableGrid) const bool PathFinderOld::findNearestWalkableCell(const Eigen::Vector3f& point, Cell& out, const std::vector<unsigned char>& walkableGrid) const
{ {
Cell origin; Cell origin;
if (!worldToCell(point, origin)) { if (!worldToCell(point, origin)) {
@@ -885,12 +907,12 @@ bool PathFinder::findNearestWalkableCell(const Eigen::Vector3f& point, Cell& out
return false; return false;
} }
bool PathFinder::hasLineOfSight(const Cell& from, const Cell& to) const bool PathFinderOld::hasLineOfSight(const Cell& from, const Cell& to) const
{ {
return hasLineOfSight(from, to, walkable); return hasLineOfSight(from, to, walkable);
} }
bool PathFinder::hasLineOfSight(const Cell& from, const Cell& to, const std::vector<unsigned char>& walkableGrid) const bool PathFinderOld::hasLineOfSight(const Cell& from, const Cell& to, const std::vector<unsigned char>& walkableGrid) const
{ {
const Eigen::Vector3f a = cellCenter(from); const Eigen::Vector3f a = cellCenter(from);
const Eigen::Vector3f b = cellCenter(to); const Eigen::Vector3f b = cellCenter(to);
@@ -909,12 +931,12 @@ bool PathFinder::hasLineOfSight(const Cell& from, const Cell& to, const std::vec
return true; return true;
} }
std::vector<PathFinder::Cell> PathFinder::smoothCells(const std::vector<Cell>& cells) const std::vector<PathFinderOld::Cell> PathFinderOld::smoothCells(const std::vector<Cell>& cells) const
{ {
return smoothCells(cells, walkable); return smoothCells(cells, walkable);
} }
std::vector<PathFinder::Cell> PathFinder::smoothCells(const std::vector<Cell>& cells, const std::vector<unsigned char>& walkableGrid) const std::vector<PathFinderOld::Cell> PathFinderOld::smoothCells(const std::vector<Cell>& cells, const std::vector<unsigned char>& walkableGrid) const
{ {
if (cells.size() <= 2) { if (cells.size() <= 2) {
return cells; return cells;
@@ -937,7 +959,7 @@ std::vector<PathFinder::Cell> PathFinder::smoothCells(const std::vector<Cell>& c
return result; return result;
} }
bool PathFinder::pointInPolygon(float x, float z, const std::vector<Eigen::Vector2f>& polygon) bool PathFinderOld::pointInPolygon(float x, float z, const std::vector<Eigen::Vector2f>& polygon)
{ {
bool inside = false; bool inside = false;
size_t previous = polygon.size() - 1; size_t previous = polygon.size() - 1;
+577 -5
View File
@@ -1,15 +1,587 @@
#pragma once #pragma once
#include <Eigen/Core> #include <Eigen/Core>
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <limits>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <queue>
#include <string> #include <string>
#include <unordered_map>
#include <vector> #include <vector>
#ifdef min
#undef min
#endif
#ifdef max
#undef max
#endif
#include "../../thirdparty/fpm-1.1.0/include/fpm/fixed.hpp"
#include "../../thirdparty/fpm-1.1.0/include/fpm/math.hpp"
// Сторонние библиотеки для геометрии (Header-only / легко подключаемые):
// 1. Clipper2: https://github.com/PolyClipping/Clipper2/releases/tag/Clipper2_2.0.1
// 2. CDT (Constrained Delaunay Triangulation): https://github.com/artem-ogre/CDT
#include "../../thirdparty/Clipper2-Clipper2_2.0.1/CPP/build/Release/include/clipper2/clipper.h"
#include "../../thirdparty/CDT-2.0.2/CDT/build/release/include/CDT.h"
namespace FRG { namespace FRG {
class PathFinder {
//using fp = fpm::fixed<std::int32_t, std::int64_t, 16>;
inline constexpr unsigned int FP_FRACTION_BITS = 12;
using fp = fpm::fixed<std::int32_t, std::int64_t, FP_FRACTION_BITS>;
struct Vec2fp {
fp x{ 0 };
fp y{ 0 };
Vec2fp() = default;
Vec2fp(fp _x, fp _y) : x(_x), y(_y) {}
// Конвертация из float только на этапе загрузки/ввода
static Vec2fp fromFloat(float fx, float fy) {
return Vec2fp(fp(fx), fp(fy));
}
Vec2fp operator+(const Vec2fp& o) const { return { x + o.x, y + o.y }; }
Vec2fp operator-(const Vec2fp& o) const { return { x - o.x, y - o.y }; }
Vec2fp operator*(fp s) const { return { x * s, y * s }; }
Vec2fp operator/(fp s) const { return { x / s, y / s }; }
bool operator==(const Vec2fp& o) const { return x == o.x && y == o.y; }
bool operator!=(const Vec2fp& o) const { return !(*this == o); }
fp dot(const Vec2fp& o) const { return x * o.x + y * o.y; }
// 2D Векторное произведение (Z-компонента): определяет, с какой стороны лежит точка
fp cross(const Vec2fp& o) const { return x * o.y - y * o.x; }
fp lengthSq() const { return x * x + y * y; }
fp length() const { return fpm::sqrt(lengthSq()); }
Eigen::Vector2f toEigen() const {
return Eigen::Vector2f(static_cast<float>(x), static_cast<float>(y));
}
static Vec2fp fromEigen(const Eigen::Vector2f& v) {
return Vec2fp(fp(v.x()), fp(v.y()));
}
};
struct Vec3fp {
fp x{ 0 }, y{ 0 }, z{ 0 };
Vec3fp() = default;
Vec3fp(fp _x, fp _y, fp _z) : x(_x), y(_y), z(_z) {}
static Vec3fp fromEigen(const Eigen::Vector3f& v) {
return { fp(v.x()), fp(v.y()), fp(v.z()) };
}
Eigen::Vector3f toEigen() const {
return Eigen::Vector3f(static_cast<float>(x), static_cast<float>(y), static_cast<float>(z));
}
fp normSquared() const
{
return x * x + y * y + z * z;
}
Vec3fp operator/(fp value) const
{
if (value == static_cast<fp>(0.0))
{
throw std::runtime_error("Vec3fp error: division by zero");
return Vec3fp();
}
Vec3fp r;
r.x = x / value;
r.y = y / value;
r.z = z / value;
return r;
}
Vec3fp operator*(const fp value) const
{
Vec3fp r;
r.x = x * value;
r.y = y * value;
r.z = z * value;
return r;
}
};
Vec3fp operator-(Vec3fp a, Vec3fp b);
Vec3fp operator+(Vec3fp a, Vec3fp b);
inline std::vector<Eigen::Vector2f> toEigenVector(const std::vector<FRG::Vec2fp>& src) {
std::vector<Eigen::Vector2f> dst;
dst.reserve(src.size());
for (const auto& pt : src) {
dst.emplace_back(static_cast<float>(pt.x), static_cast<float>(pt.y));
}
return dst;
}
inline std::vector<FRG::Vec2fp> fromEigenVector(const std::vector<Eigen::Vector2f>& src) {
std::vector<FRG::Vec2fp> dst;
dst.reserve(src.size());
for (const auto& pt : src) {
dst.emplace_back(FRG::fp(pt.x()), FRG::fp(pt.y()));
}
return dst;
}
class PathFinder {
public:
struct NavigationArea {
std::string name;
bool available = true;
std::vector<Vec2fp> polygon;
};
struct ObstaclePolygon {
std::string name;
std::vector<Vec2fp> polygon;
};
struct NavTriangle {
int id = -1;
Vec2fp vertices[3];
Vec2fp center;
int neighbors[3] = { -1, -1, -1 }; // Индексы соседних треугольников по ребрам (0-1, 1-2, 2-0)
};
struct Portal {
Vec2fp left;
Vec2fp right;
};
// Масштаб для Clipper2 (так как Clipper работает с целыми int64_t,
// сохраняем точность в 10000 единиц на 1 метр)
static constexpr int64_t CLIPPER_SCALE = 10000;
void build(const std::string& configPath = "", const std::string& zipPath = "") {
// Здесь может быть парсинг configPath в area и obstaclePolygons.
// После заполнения геометрии строим NavMesh:
area.name = "main";
area.polygon = {
Vec2fp::fromFloat(-100.f, -100.f),
Vec2fp::fromFloat(-100.f, 100.f),
Vec2fp::fromFloat(100.f, 100.f),
Vec2fp::fromFloat(100.f, -100.f)
};
area.available = true;
obstaclePolygons.push_back({
"p1", {Vec2fp::fromFloat(-10.f, -10.f),
Vec2fp::fromFloat(-10.f, 10.f),
Vec2fp::fromFloat(10.f, 10.f),
Vec2fp::fromFloat(10.f, -10.f)}
});
//ready = true;
generateNavMesh();
}
// Построение NavMesh: Объединение полигонов (Clipper2) + Триангуляция (CDT)
void generateNavMesh() {
navMesh.clear();
if (area.polygon.size() < 3) return;
using namespace Clipper2Lib;
// Вспомогательная лямбда: безопасно переводит fp в координаты Clipper2 (int64_t)
auto toClipperCoord = [](fp val) -> int64_t {
return (static_cast<int64_t>(val.raw_value()) * CLIPPER_SCALE) / (1LL << FP_FRACTION_BITS);
};
// 1. Переводим контур локации (NavigationArea) в формат Clipper2
Paths64 subject;
Path64 outerPath;
for (const auto& pt : area.polygon) {
outerPath.push_back(Point64(toClipperCoord(pt.x), toClipperCoord(pt.y)));
}
subject.push_back(outerPath);
// 2. Собираем все препятствия
Paths64 clips;
for (const auto& obs : obstaclePolygons) {
if (obs.polygon.size() < 3) continue;
Path64 clipPath;
for (const auto& pt : obs.polygon) {
clipPath.push_back(Point64(toClipperCoord(pt.x), toClipperCoord(pt.y)));
}
clips.push_back(clipPath);
}
// 3. Булево объединение пересекающихся препятствий и вычитание их из локации
// Clipper автоматически объединяет пересекающиеся полигоны внутри Difference
PolyTree64 solTree;
Clipper64 clipper;
clipper.AddSubject(subject);
clipper.AddClip(clips);
//clipper.Execute(ClipType::Difference, FillRule::NonZero, solTree);
clipper.Execute(ClipType::Difference, FillRule::EvenOdd, solTree);
// 4. Подготовка данных для Constrained Delaunay Triangulation (CDT)
std::vector<CDT::V2d<double>> cdtVertices;
std::vector<CDT::Edge> cdtEdges;
auto extractPolygons = [&](const PolyPath64* node, auto& selfRef) -> void {
if (!node->Polygon().empty()) {
const Path64& path = node->Polygon();
std::size_t startIdx = cdtVertices.size();
std::size_t count = path.size();
for (std::size_t i = 0; i < count; ++i) {
double x = static_cast<double>(path[i].x) / CLIPPER_SCALE;
double y = static_cast<double>(path[i].y) / CLIPPER_SCALE;
cdtVertices.push_back({ x, y });
cdtEdges.push_back(CDT::Edge(startIdx + i, startIdx + (i + 1) % count));
}
}
for (std::size_t i = 0; i < node->Count(); ++i) {
selfRef(node->Child(i), selfRef);
}
};
for (std::size_t i = 0; i < solTree.Count(); ++i) {
extractPolygons(solTree.Child(i), extractPolygons);
}
if (cdtVertices.empty()) return;
CDT::RemoveDuplicatesAndRemapEdges(cdtVertices, cdtEdges);
// 5. Выполняем триангуляцию и удаляем треугольники внутри дыр (препятствий)
CDT::Triangulation<double> cdt;
cdt.insertVertices(cdtVertices);
cdt.insertEdges(cdtEdges);
cdt.eraseOuterTrianglesAndHoles();
// 6. Конвертируем результат в детерминированный граф NavTriangle (Q32.32)
const auto& triangles = cdt.triangles;
const auto& vertices = cdt.vertices;
navMesh.resize(triangles.size());
for (std::size_t i = 0; i < triangles.size(); ++i) {
NavTriangle& navTri = navMesh[i];
navTri.id = static_cast<int>(i);
for (int j = 0; j < 3; ++j) {
const auto& v = vertices[triangles[i].vertices[j]];
navTri.vertices[j] = Vec2fp(fp(v.x), fp(v.y));
// Соседи в CDT хранятся напротив вершин, перепривязываем к ребрам (j, j+1)
auto neighborIdx = triangles[i].neighbors[j];
navTri.neighbors[j] = (neighborIdx != CDT::noNeighbor) ? static_cast<int>(neighborIdx) : -1;
}
navTri.center = (navTri.vertices[0] + navTri.vertices[1] + navTri.vertices[2]) / fp(3);
}
ready = !navMesh.empty();
}
// Основной метод поиска пути (плоскость XZ, Y — высота)
std::vector<Vec3fp> findPathToNearest(const Vec3fp& start3D, const Vec3fp& end3D) const {
if (!ready || !area.available) return { start3D };
Vec2fp startPt(start3D.x, start3D.z);
Vec2fp targetPt(end3D.x, end3D.z);
// 1. Находим треугольники для старта и финиша.
// Если точка попала в препятствие (вне NavMesh), ищем ближайшую точку на ребрах NavMesh
int startTri = findTriangleContaining(startPt);
if (startTri == -1) {
startPt = findClosestPointOnNavMesh(startPt, startTri);
}
int endTri = findTriangleContaining(targetPt);
if (endTri == -1) {
targetPt = findClosestPointOnNavMesh(targetPt, endTri);
}
if (startTri == -1 || endTri == -1) return { start3D };
// Если старт и конец в одном треугольнике — идем по прямой
if (startTri == endTri) {
return {
start3D,
Vec3fp(targetPt.x, floorY, targetPt.y)
};
}
// 2. Поиск коридора из треугольников через A*
std::vector<int> triangleCorridor = runAStar(startTri, endTri, startPt, targetPt);
if (triangleCorridor.empty()) return { start3D };
// 3. Сглаживание пути через Funnel Algorithm (Натянутая нить)
std::vector<Vec2fp> smoothPath2D = stringPullFunnel(startPt, targetPt, triangleCorridor);
// 4. Формируем выходной массив 3D точек
std::vector<Vec3fp> result;
result.reserve(smoothPath2D.size());
for (const auto& pt : smoothPath2D) {
result.emplace_back(pt.x, floorY, pt.y);
}
return result;
}
// Перегрузка для совместимости со старым Eigen-интерфейсом
std::vector<Eigen::Vector3f> findPathToNearest(const Eigen::Vector3f& start, const Eigen::Vector3f& end) const {
auto pathFp = findPathToNearest(Vec3fp::fromEigen(start), Vec3fp::fromEigen(end));
std::vector<Eigen::Vector3f> out;
out.reserve(pathFp.size());
for (const auto& p : pathFp) out.push_back(p.toEigen());
return out;
}
bool isWalkable(const Vec3fp& point) const {
return findTriangleContaining(Vec2fp(point.x, point.z)) != -1;
}
bool isWalkable(const Eigen::Vector3f& point) const {
return isWalkable(Vec3fp::fromEigen(point));
}
bool setAreaAvailable(const std::string& areaName, bool available) {
if (area.name == areaName) {
area.available = available;
return true;
}
return false;
}
bool isReady() const { return ready; }
const NavigationArea& getArea() const { return area; }
void setArea(const NavigationArea& a) { area = a; }
fp getFloorY() const { return floorY; }
void setFloorY(fp y) { floorY = y; }
void addObstaclePolygon(const ObstaclePolygon& polygon) {
obstaclePolygons.push_back(polygon);
}
const std::vector<ObstaclePolygon>& getObstaclePolygons() const { return obstaclePolygons; }
bool saveConfig(const std::string& path) const { return true; }
bool saveGrid(const std::string& path) const { return true; }
// Внутри public: класса PathFinder
Vec2fp clampToNavMesh(const Vec2fp& pt, int& outTriId) const {
return findClosestPointOnNavMesh(pt, outTriId);
}
// Удобная перегрузка без outTriId, если индекс треугольника не нужен:
Vec2fp clampToNavMesh(const Vec2fp& pt) const {
int dummyTri = -1;
return findClosestPointOnNavMesh(pt, dummyTri);
}
protected:
NavigationArea area;
std::vector<ObstaclePolygon> obstaclePolygons;
std::vector<NavTriangle> navMesh;
fp floorY{ 0 };
bool ready = false;
// Проверка нахождения точки внутри треугольника (через знаки векторных произведений)
static bool isPointInTriangle(const Vec2fp& p, const Vec2fp& a, const Vec2fp& b, const Vec2fp& c) {
fp d1 = (b - a).cross(p - a);
fp d2 = (c - b).cross(p - b);
fp d3 = (a - c).cross(p - c);
bool hasNeg = (d1 < fp(0)) || (d2 < fp(0)) || (d3 < fp(0));
bool hasPos = (d1 > fp(0)) || (d2 > fp(0)) || (d3 > fp(0));
return !(hasNeg && hasPos);
}
int findTriangleContaining(const Vec2fp& pt) const {
for (const auto& tri : navMesh) {
if (isPointInTriangle(pt, tri.vertices[0], tri.vertices[1], tri.vertices[2])) {
return tri.id;
}
}
return -1;
}
// Проекция точки на отрезок AB
static Vec2fp closestPointOnSegment(const Vec2fp& p, const Vec2fp& a, const Vec2fp& b) {
Vec2fp ab = b - a;
fp lenSq = ab.lengthSq();
if (lenSq == fp(0)) return a;
fp t = (p - a).dot(ab) / lenSq;
t = std::max(fp(0), std::min(fp(1), t));
return a + ab * t;
}
Vec2fp findClosestPointOnNavMesh(const Vec2fp& pt, int& outTriId) const {
fp minDistSq = std::numeric_limits<fp>::max();
Vec2fp bestPt = pt;
outTriId = -1;
for (const auto& tri : navMesh) {
for (int i = 0; i < 3; ++i) {
// В CDT neighbors[i] — это сосед через ребро (vertices[i], vertices[(i + 1) % 3])
if (tri.neighbors[i] == -1) {
const Vec2fp& a = tri.vertices[i];
const Vec2fp& b = tri.vertices[(i + 1) % 3];
Vec2fp proj = closestPointOnSegment(pt, a, b);
fp distSq = (pt - proj).lengthSq();
if (distSq < minDistSq) {
minDistSq = distSq;
bestPt = proj;
outTriId = tri.id;
}
}
}
}
return bestPt;
}
// Поиск пути A* по центрам треугольников
std::vector<int> runAStar(int startTri, int endTri, const Vec2fp& startPt, const Vec2fp& endPt) const {
struct NodeRecord {
int triId;
fp fScore;
// В случае равенства fScore сортируем по triId для 100% детерминизма
bool operator>(const NodeRecord& o) const {
if (fScore != o.fScore) return fScore > o.fScore;
return triId > o.triId;
}
};
std::priority_queue<NodeRecord, std::vector<NodeRecord>, std::greater<NodeRecord>> openSet;
std::vector<fp> gScore(navMesh.size(), std::numeric_limits<fp>::max());
std::vector<int> cameFrom(navMesh.size(), -1);
std::vector<bool> closed(navMesh.size(), false);
gScore[startTri] = fp(0);
openSet.push({ startTri, (navMesh[startTri].center - endPt).length() });
while (!openSet.empty()) {
int current = openSet.top().triId;
openSet.pop();
if (current == endTri) {
std::vector<int> path;
for (int curr = endTri; curr != -1; curr = cameFrom[curr]) {
path.push_back(curr);
}
std::reverse(path.begin(), path.end());
return path;
}
if (closed[current]) continue;
closed[current] = true;
const NavTriangle& currTri = navMesh[current];
for (int i = 0; i < 3; ++i) {
int neighbor = currTri.neighbors[i];
if (neighbor == -1 || closed[neighbor]) continue;
fp stepCost = (currTri.center - navMesh[neighbor].center).length();
fp tentativeG = gScore[current] + stepCost;
if (tentativeG < gScore[neighbor]) {
cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG;
fp h = (navMesh[neighbor].center - endPt).length();
openSet.push({ neighbor, tentativeG + h });
}
}
}
return {};
}
// Извлечение общих вершин (портала) между двумя соседними треугольниками
// Извлечение общих вершин (портала) между двумя соседними треугольниками
Portal getPortal(int fromTriId, int toTriId) const {
const NavTriangle& t1 = navMesh[fromTriId];
for (int i = 0; i < 3; ++i) {
if (t1.neighbors[i] == toTriId) {
// При обходе треугольника CCW и движении изнутри наружу через ребро i -> (i + 1) % 3:
// вершина i находится СЛЕВА, а вершина (i + 1) % 3 — СПРАВА
return Portal{ t1.vertices[i], t1.vertices[(i + 1) % 3] };
}
}
return Portal{ t1.center, t1.center };
}
// Алгоритм натянутой нити (Simple Stupid Funnel Algorithm)
std::vector<Vec2fp> stringPullFunnel(const Vec2fp& startPt, const Vec2fp& endPt, const std::vector<int>& corridor) const {
std::vector<Portal> portals;
portals.reserve(corridor.size() + 2);
portals.push_back({ startPt, startPt });
for (std::size_t i = 0; i + 1 < corridor.size(); ++i) {
portals.push_back(getPortal(corridor[i], corridor[i + 1]));
}
portals.push_back({ endPt, endPt });
std::vector<Vec2fp> pts;
pts.push_back(startPt);
Vec2fp portalApex = startPt;
Vec2fp portalLeft = startPt;
Vec2fp portalRight = startPt;
int apexIndex = 0, leftIndex = 0, rightIndex = 0;
for (int i = 1; i < static_cast<int>(portals.size()); ++i) {
const Vec2fp& left = portals[i].left;
const Vec2fp& right = portals[i].right;
// Обновляем правую границу воронки
if ((portalRight - portalApex).cross(right - portalApex) <= fp(0)) {
if (portalApex == portalRight || (portalLeft - portalApex).cross(right - portalApex) > fp(0)) {
portalRight = right;
rightIndex = i;
}
else {
pts.push_back(portalLeft);
portalApex = portalLeft;
apexIndex = leftIndex;
portalLeft = portalApex;
portalRight = portalApex;
leftIndex = apexIndex;
rightIndex = apexIndex;
i = apexIndex;
continue;
}
}
// Обновляем левую границу воронки
if ((portalLeft - portalApex).cross(left - portalApex) >= fp(0)) {
if (portalApex == portalLeft || (portalRight - portalApex).cross(left - portalApex) < fp(0)) {
portalLeft = left;
leftIndex = i;
}
else {
pts.push_back(portalRight);
portalApex = portalRight;
apexIndex = rightIndex;
portalLeft = portalApex;
portalRight = portalApex;
leftIndex = apexIndex;
rightIndex = apexIndex;
i = apexIndex;
continue;
}
}
}
if (pts.back() != endPt) {
pts.push_back(endPt);
}
return pts;
}
};
class PathFinderOld {
public: public:
struct ObstaclePolygon { struct ObstaclePolygon {
@@ -39,18 +611,18 @@ public:
std::vector<Eigen::Vector3f> findPath(const Eigen::Vector3f& start, std::vector<Eigen::Vector3f> findPath(const Eigen::Vector3f& start,
const Eigen::Vector3f& end) const; const Eigen::Vector3f& end) const;
std::vector<Eigen::Vector3f> findPath(const Eigen::Vector3f& start, /*std::vector<Eigen::Vector3f> findPath(const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::vector<DynamicObstacle>& dynamicObstacles) const; const std::vector<DynamicObstacle>& dynamicObstacles) const;
*/
// Like findPath, but when the destination is unreachable the character // Like findPath, but when the destination is unreachable the character
// walks as close as possible instead of not moving at all. // walks as close as possible instead of not moving at all.
std::vector<Eigen::Vector3f> findPathToNearest(const Eigen::Vector3f& start, std::vector<Eigen::Vector3f> findPathToNearest(const Eigen::Vector3f& start,
const Eigen::Vector3f& end) const; const Eigen::Vector3f& end) const;
std::vector<Eigen::Vector3f> findPathToNearest(const Eigen::Vector3f& start, /*std::vector<Eigen::Vector3f> findPathToNearest(const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::vector<DynamicObstacle>& dynamicObstacles) const; const std::vector<DynamicObstacle>& dynamicObstacles) const;
*/
bool setAreaAvailable(const std::string& areaName, bool available); bool setAreaAvailable(const std::string& areaName, bool available);
bool isReady() const { return ready; } bool isReady() const { return ready; }
bool isWalkable(const Eigen::Vector3f& point) const; bool isWalkable(const Eigen::Vector3f& point) const;
+2 -2
View File
@@ -109,7 +109,7 @@ namespace FRG {
const size_t firstWaypoint = (std::min)( const size_t firstWaypoint = (std::min)(
state->currentWaypointIndex, state->pathWaypoints.size()); state->currentWaypointIndex, state->pathWaypoints.size());
for (size_t i = firstWaypoint; i < state->pathWaypoints.size(); ++i) { for (size_t i = firstWaypoint; i < state->pathWaypoints.size(); ++i) {
const Eigen::Vector3f& waypoint = state->pathWaypoints[i]; const Eigen::Vector3f& waypoint = state->pathWaypoints[i].toEigen();
route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z())); route.push_back(Eigen::Vector3f(waypoint.x(), 0.0f, waypoint.z()));
} }
const Eigen::Vector3f clickedPosition( const Eigen::Vector3f clickedPosition(
@@ -140,7 +140,7 @@ namespace FRG {
constexpr float kPlayerClearanceMs = 20.0f; constexpr float kPlayerClearanceMs = 20.0f;
constexpr float kPlayerMaximumSpeedMultiplier = 1.5f; constexpr float kPlayerMaximumSpeedMultiplier = 1.5f;
const float playerSpeed = (std::max)( const float playerSpeed = (std::max)(
state->walkSpeed * kPlayerMaximumSpeedMultiplier, 0.01f); static_cast<float>(state->walkSpeed) * kPlayerMaximumSpeedMultiplier, 0.01f);
const float playerRouteTimeMs = totalLength / playerSpeed * 1000.0f; const float playerRouteTimeMs = totalLength / playerSpeed * 1000.0f;
const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f); const float revealDurationMs = (std::min)(kMaximumRevealMs, playerRouteTimeMs / 3.0f);
const float waveSpeed = totalLength / (revealDurationMs / 1000.0f); const float waveSpeed = totalLength / (revealDurationMs / 1000.0f);