Compare commits

..
Author SHA1 Message Date
Vladislav Khorev 8df016c0a4 Working on pathfinding, testing the Q16.16 format 2026-11-13 12:56:50 +06:00
Vladislav Khorev 2fe2ee6c2a Bump version 2026-11-10 23:36:07 +06:00
Vladislav Khorev cf714d4133 Fixing stupid bug 2026-11-10 23:31:54 +06:00
Vladislav Khorev 255181cf31 Fixing stupid bug 2026-11-10 23:11:14 +06:00
Vladislav Khorev ef966a2197 change path to remote 2026-11-10 22:48:32 +06:00
Vladislav Khorev 24c963c0a5 Minor fixing 2026-11-10 22:47:01 +06:00
Vladislav Khorev 6cfd47e80d merge 2026-11-10 15:09:56 +00:00
Vladislav Khorev 0be23ca291 Merge branch 'networking' into server 2026-11-10 15:08:52 +00:00
Vladislav Khorev 8c4eeb9793 Working on server 2026-11-09 17:58:20 +00:00
33 changed files with 1079 additions and 254 deletions

No files matched your search

+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")
+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_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_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">
<title>Shadow Over Bishkek</title>
<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 {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background-color: #000;
position: fixed;
position: fixed; top: 0; left: 0;
}
#canvas {
display: block;
position: absolute;
position: fixed;
top: 0; left: 0;
width: 100vw; height: 100vh;
width: var(--app-w); height: var(--app-h);
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%); }
@@ -49,8 +60,28 @@
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() {
setTimeout(() => {
syncViewport();
window.dispatchEvent(new Event('resize'));
}, 200);
});
+7
View File
@@ -131,8 +131,14 @@ set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT S
# include-пути проекта
target_include_directories(ShadowOverBishkekDemo PRIVATE
"${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
OUTPUT_NAME "ShadowOverBishkekDemo"
)
@@ -162,6 +168,7 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
sol2_external_lib
SDL2_mixer_external_lib
openssl_ssl_external_lib
Clipper2.lib
)
# Линкуем OpenGL (Windows)
+1 -1
View File
@@ -125,7 +125,7 @@
"y": 10,
"width": 160,
"height": 36,
"text": "v. 1.0.7",
"text": "v. 1.1.2",
"fontSize": 24,
"textCentered": false,
"topAligned": true,
+1 -1
View File
@@ -116,7 +116,7 @@
"y": 10,
"width": 160,
"height": 36,
"text": "v. 1.0.7",
"text": "v. 1.1.2",
"fontSize": 24,
"textCentered": false,
"topAligned": true,
+1 -1
View File
@@ -125,7 +125,7 @@
"y": 10,
"width": 160,
"height": 36,
"text": "v. 1.0.7",
"text": "v. 1.1.2",
"fontSize": 24,
"textCentered": false,
"topAligned": true,
@@ -125,7 +125,7 @@
"y": 10,
"width": 160,
"height": 36,
"text": "v. 1.0.7",
"text": "v. 1.1.2",
"fontSize": 24,
"textCentered": false,
"topAligned": true,
+28 -12
View File
@@ -9,6 +9,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..")
find_package(Threads REQUIRED)
include("${GAME_ROOT}/cmake/FetchDependenciesServer.cmake")
find_package(Eigen3 3.3 CONFIG QUIET)
if(NOT TARGET Eigen3::Eigen)
set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0")
@@ -105,24 +106,32 @@ endif()
add_executable(GameServer src/main.cpp README.md)
source_group("Server" FILES src/main.cpp README.md)
target_link_libraries(GameServer PRIVATE game_server_core)
if(WIN32)
set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0")
if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
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
"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_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(GameServer PRIVATE NETWORK)
if(WIN32)
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)
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)
if(BUILD_TESTING)
add_executable(
@@ -141,7 +150,8 @@ if(BUILD_TESTING)
add_test(
NAME GameServer.LocationState
COMMAND GameServerStateTests)
if(WIN32)
# network tests (all platforms)
if(TRUE)
add_executable(
GameServerNetworkProtocolTests
tests/NetworkProtocolTests.cpp
@@ -169,8 +179,14 @@ if(BUILD_TESTING)
PROPERTIES COMPILE_OPTIONS "/bigobj")
endif()
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE game_server_core)
if(WIN32)
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE
game_server_core ws2_32 mswsock)
WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE ws2_32 mswsock)
endif()
add_test(
NAME GameServer.WebsocketLockstep
COMMAND GameServerWebsocketLockstepTests)
@@ -180,7 +196,7 @@ if(BUILD_TESTING)
COMMAND GameServer --ticks 2 --no-sleep)
add_test(
NAME GameServer.BattleZoneSharedPlayer
COMMAND GameServer --campaign battle_zone --location uni_exterior
COMMAND GameServer --campaign battle_zone --location park_korea
--ticks 2 --no-sleep)
set_tests_properties(
GameServer.BattleZoneSharedPlayer
+123 -62
View File
@@ -1,11 +1,37 @@
# Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
audio, Lua, or rendering dependencies. On Windows it exposes a deterministic
lockstep session through a Boost.Beast WebSocket listener.
audio, Lua, or rendering dependencies. It exposes a deterministic lockstep
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
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:
```powershell
@@ -14,91 +40,126 @@ cmake --build server/build --config Release --target GameServer
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
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release
cmake --build server/build --target GameServer --parallel
ctest --test-dir server/build --output-on-failure
server/scripts/run-server.sh
```
Eigen3 must either be installed or be available at
`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:
This does `cd server/build` and runs
```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
`server/build/Release/GameServer.exe`.
Environment overrides: `CAMPAIGN`, `LOCATION`, `ADDRESS`, `PORT`, `BUILD_DIR`.
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
real-time delay.
Start the multiplayer lockstep server:
Windows equivalent:
```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
afterward and select `battle_zone_multiplayer`; that campaign connects to
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
Command line options: `--campaign ID`, `--location ID`, `--campaigns PATH`
(default `resources/config/campaigns.json`), `--address IP` (default `0.0.0.0`),
`--port N` (default `8080`), `--ticks N` and `--no-sleep` (smoke tests).
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
scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or
client wall-clock values, define simulation time.
If `--location` is omitted, the campaign's configured start location is used.
`--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.
## Production deployment (Ubuntu, systemd + nginx)
Available startup options:
The server listens on loopback only (`127.0.0.1:8081`); nginx terminates TLS and
proxies WebSocket traffic.
```text
--campaign ID
--location ID
--campaigns PATH
--address IP
--port N
--tick-ms 100
--ticks N
--no-sleep
### 1. systemd service
```bash
sudo cp server/deploy/shadow-gameserver.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now shadow-gameserver
systemctl status shadow-gameserver
journalctl -u shadow-gameserver -f # logs
```
Network mode requires a 100 ms tick; another `--tick-ms` value is rejected.
The selected campaign's `networkPlayerSlots` lists pre-existing character IDs
assigned to connections in order. `networkCompatibility` is a release token
that must be changed whenever deterministic content changes; clients reject a
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 unit caps memory at 512 MB and restarts on failure. After rebuilding run
`sudo systemctl restart shadow-gameserver`. Edit `User=`/paths in the unit if the
repository lives elsewhere. To run a second server, copy the unit with another
`PORT` (and campaign) and add a matching `/server002/` location.
The graphical client executes presentation behavior after building its runtime
`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.
### 2. nginx
## 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;
- 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`.
### 3. HTTPS certificate
The lockstep implementation currently targets identical executable and content
builds. Diagnostic state hashes detect divergence, but there is no automatic
checkpoint resynchronization or reconnect input history yet.
DNS for `api.shadowoverbishkek.com` must point at the VDS first.
```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[@]}"
+16 -11
View File
@@ -79,7 +79,7 @@ int main()
return fail("Battle Zone modes do not share their initial state");
}
if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 ||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player" ||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player1" ||
multiplayerBattleZoneConfig->networkPlayerSlots[1] !=
"player2" ||
multiplayerBattleZoneConfig->networkCompatibility.empty()) {
@@ -140,7 +140,7 @@ int main()
disabledPlayer->enabled = false;
state.location->targetInteractiveObjectId = "object";
state.location->targetInteractNpcId = "npc";
FRG::PlayerStateMap disabledPlayers{{"player", disabledPlayer}};
FRG::PlayerStateMap disabledPlayers{{"player1", disabledPlayer}};
const FRG::LocationSimulationEvents disabledEvents =
state.update(50, disabledPlayers, false);
if (!disabledEvents.interactiveObjects.empty() ||
@@ -165,7 +165,7 @@ int main()
playerTwo->enabled = true;
playerTwo->position = {2.f, 0.f, 0.f};
FRG::PlayerStateMap combatPlayers{
{"player", playerOne}, {"player2", playerTwo}};
{"player1", playerOne}, {"player2", playerTwo}};
state.update(100, combatPlayers, false);
if (hostileNpc.attackTargetId != "player2") {
@@ -181,7 +181,7 @@ int main()
playerTwo->hp = 0.f;
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");
}
@@ -201,7 +201,7 @@ int main()
state.npcs["blocker"] = blocker;
hostileNpc.position = Eigen::Vector3f::Zero();
hostileNpc.homePosition = {5.f, 0.f, 0.f};
hostileNpc.attackTargetId = "player";
hostileNpc.attackTargetId = "player1";
hostileNpc.requestedWalkTarget = playerOne->position;
hostileNpc.walkTarget = playerOne->position;
hostileNpc.pathWaypoints = {playerOne->position};
@@ -267,22 +267,22 @@ int main()
FRG::CampaignConfig multiplayerBattleZone = battleZone;
multiplayerBattleZone.id = "battle_zone_multiplayer";
multiplayerBattleZone.networkPlayerSlots = {
"player", "player2"};
"player1", "player2"};
networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
const auto networkSnapshot = networkServer.snapshot();
if (networkSnapshot->campaignId != "battle_zone_multiplayer")
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");
if (networkSnapshot->players.count("player2") != 0)
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");
nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState);
loadedNetworkState["players"]["player2"] =
loadedNetworkState["players"]["player"];
loadedNetworkState["players"]["player1"];
loadedNetworkState["players"]["player2"]["position"] =
{4.f, 0.f, 5.f};
networkServer.load(loadedNetworkState);
@@ -308,7 +308,7 @@ int main()
networkServer.submit(FRG::SetCharacterFacingAngleCommand{
locationId, "player2", 1.5f, true});
networkServer.submit(FRG::SetCharacterFaceTargetCommand{
locationId, "player2", "player"});
locationId, "player2", "player1"});
networkServer.submit(FRG::SetCharacterEnabledCommand{
locationId, "player2", false});
const auto commandSnapshot = networkServer.snapshot();
@@ -371,6 +371,7 @@ int main()
moving->hp = 10.f;
moving->pathWaypoints = {Eigen::Vector3f(8.f, 0.f, 0.f)};
moving->movedLastTick = true;
moving->attackTargetId = "stationary";
FRG::PlayerStateMap occupancyPlayers{
{"stationary", stationary}, {"moving", moving}};
if (occupancyState.collectPathObstacles(
@@ -378,7 +379,11 @@ int main()
occupancyState.collectPathObstacles(
"requester", occupancyPlayers, "moving").size() != 2 ||
!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");
}
+13 -13
View File
@@ -23,12 +23,12 @@ int main()
const nlohmann::json original{
{"schemaVersion", 2},
{"campaignId", "battle_zone_multiplayer"},
{"players", {{"player", {{"hp", 200.0}}}}},
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}}
{"players", {{"player1", {{"hp", 200.0}}}}},
{"locations", {{"park_korea", {{"state", savedLocationState}}}}}
};
FRG::InitialStateMessage initial;
initial.state = original;
initial.assignedPlayerId = "player";
initial.assignedPlayerId = "player1";
initial.snapshotTick = 42;
initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer";
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.snapshotTick == 42, "snapshot tick did not round trip");
require(
parsed.assignedPlayerId == "player",
parsed.assignedPlayerId == "player1",
"assigned player did not round trip");
require(
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
!parsed.state["locations"]["park_korea"]["state"].contains("cameraAzimuth") &&
!parsed.state["locations"]["park_korea"]["state"].contains("cameraInclination"),
"local camera state leaked into the network payload");
FRG::DestinationInputMessage input;
@@ -87,11 +87,11 @@ int main()
FRG::TickMessage tick;
tick.tickId = 43;
tick.commands.push_back(FRG::ScheduledDestinationCommand{
1, "player", parsedInput.target});
1, "player1", parsedInput.target});
tick.commands.push_back(FRG::ScheduledCharacterEnabledCommand{
2, "uni_exterior", "player2", true});
2, "park_korea", "player2", true});
tick.commands.push_back(FRG::ScheduledCharacterActionCommand{
3, "player", "set_target", "hostile_npc"});
3, "player1", "set_target", "hostile_npc"});
tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick;
require(
@@ -147,9 +147,9 @@ int main()
FRG::LockstepTimeline timeline(2);
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 =
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();
require(
emptyTick.tickId == 1 && emptyTick.commands.empty(),
@@ -163,8 +163,8 @@ int main()
commandTick.commands[1]).commandSequence == secondSequence,
"scheduled commands were not finalized in stable order");
timeline.scheduleCharacterEnabled("uni_exterior", "player2", true);
timeline.scheduleCharacterAction("player", "clear_attack_target");
timeline.scheduleCharacterEnabled("park_korea", "player2", true);
timeline.scheduleCharacterAction("player1", "clear_attack_target");
const FRG::TickMessage presenceTick = timeline.advance();
require(
presenceTick.commands.size() == 1 &&
+19 -19
View File
@@ -59,13 +59,13 @@ int main()
FRG::CampaignConfig campaign;
campaign.id = "test_lockstep";
campaign.networkPlayerSlots = {"player", "player2"};
campaign.networkPlayerSlots = {"player1", "player2"};
FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false);
nlohmann::json initialState;
authoritative.save(initialState);
initialState["players"]["player2"] = initialState["players"]["player"];
initialState["players"]["player2"] = initialState["players"]["player1"];
FRG::LocationGameState testLocation;
auto hostileNpc = std::make_shared<FRG::CharacterState>();
hostileNpc->enabled = true;
@@ -77,7 +77,7 @@ int main()
initialState["locations"]["test_location"] = std::move(testLocationState);
authoritative.load(initialState);
FRG::WebsocketGameServer server(
authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep",
authoritative, {"player1", "player2"}, "lockstep-v2:test_lockstep",
"127.0.0.1", 0);
auto client = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/");
@@ -91,7 +91,7 @@ int main()
return client->initialized();
}),
"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>(
"127.0.0.1", std::to_string(server.port()), "/");
@@ -120,9 +120,9 @@ int main()
client->update(0);
secondClient->update(0);
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,
"connection presence was not broadcast to all replicas");
@@ -140,16 +140,16 @@ int main()
const auto first = client->snapshot();
const auto second = secondClient->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)) &&
second->players.at("player2").state.requestedWalkTarget.isApprox(
Eigen::Vector3f(-1.23f, 0.f, 3.46f)) &&
first->players.at("player2").state.requestedWalkTarget.isApprox(
second->players.at("player2").state.requestedWalkTarget) &&
second->players.at("player").state.requestedWalkTarget.isApprox(
first->players.at("player").state.requestedWalkTarget) &&
host->players.at("player").state.requestedWalkTarget.isApprox(
first->players.at("player").state.requestedWalkTarget) &&
second->players.at("player1").state.requestedWalkTarget.isApprox(
first->players.at("player1").state.requestedWalkTarget) &&
host->players.at("player1").state.requestedWalkTarget.isApprox(
first->players.at("player1").state.requestedWalkTarget) &&
host->players.at("player2").state.requestedWalkTarget.isApprox(
second->players.at("player2").state.requestedWalkTarget);
});
@@ -160,9 +160,9 @@ int main()
std::cerr << "destination revisions " << first->revision << ", "
<< second->revision << ", " << host->revision << "\n"
<< "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 "
<< second->players.at("player2").state.requestedWalkTarget.transpose()
<< " / "
@@ -183,11 +183,11 @@ int main()
waitFor([&]() {
client->update(0);
secondClient->update(0);
return client->snapshot()->players.at("player").state.attackTargetId ==
return client->snapshot()->players.at("player1").state.attackTargetId ==
"hostile_npc" &&
secondClient->snapshot()->players.at("player").state.attackTargetId ==
secondClient->snapshot()->players.at("player1").state.attackTargetId ==
"hostile_npc" &&
authoritative.snapshot()->players.at("player").state.attackTargetId ==
authoritative.snapshot()->players.at("player1").state.attackTargetId ==
"hostile_npc";
}),
"target command was not delayed and rebroadcast");
@@ -202,9 +202,9 @@ int main()
waitFor([&]() {
client->update(0);
secondClient->update(0);
return client->snapshot()->players.at("player").state.attackTargetId.empty() &&
secondClient->snapshot()->players.at("player").state.attackTargetId.empty() &&
authoritative.snapshot()->players.at("player").state.attackTargetId.empty();
return client->snapshot()->players.at("player1").state.attackTargetId.empty() &&
secondClient->snapshot()->players.at("player1").state.attackTargetId.empty() &&
authoritative.snapshot()->players.at("player1").state.attackTargetId.empty();
}),
"clear attack target command was not delayed and rebroadcast");
+8 -12
View File
@@ -93,8 +93,7 @@ void CharacterState::setTarget(
const Eigen::Vector3f& target,
std::string callbackName,
const LocationNetworkState& location,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles)
const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget =
@@ -115,10 +114,9 @@ void CharacterState::setTarget(
movementBlockedTicks = 0;
navigationRetryTicks = 0;
blockingCharacterId.clear();
pathWaypoints = location.navigationMaps.empty()
? std::vector<Eigen::Vector3f>{}
: location.planPathExceptMyself(
position, normalizedTarget, entityId, dynamicObstacles);
pathWaypoints = location.navigationMaps[location.activeNavigationIndex].findPathToNearest(this->position, target);
currentWaypointIndex = 0;
if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
@@ -132,15 +130,13 @@ void CharacterState::setTarget(
void CharacterState::forceReplan(
const LocationNetworkState& location,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles)
const std::string& entityId)
{
const Eigen::Vector3f normalizedTarget(
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z());
pathWaypoints = location.navigationMaps.empty()
? std::vector<Eigen::Vector3f>{}
: location.planPathExceptMyself(
position, normalizedTarget, entityId, dynamicObstacles);
pathWaypoints = location.navigationMaps[location.activeNavigationIndex].findPathToNearest(this->position, normalizedTarget);
currentWaypointIndex = 0;
movementBlockedTicks = 0;
navigationRetryTicks = 0;
+3 -7
View File
@@ -89,7 +89,7 @@ public:
bool showWeapon = true;
// --- 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.
std::string attackTargetId;
std::string faceTargetId;
@@ -151,13 +151,9 @@ public:
void setTarget(const Eigen::Vector3f& target,
std::string callbackName,
const LocationNetworkState& location,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>&
dynamicObstacles = {});
const std::string& entityId);
void forceReplan(const LocationNetworkState& location,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>&
dynamicObstacles = {});
const std::string& entityId);
void stopAndRotateToTarget(std::string characterId);
void resetPlayerAfterDeath();
void tryAttackCombo();
+4 -2
View File
@@ -558,8 +558,10 @@ namespace FRG
logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl;
// 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 = "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: "
+10 -11
View File
@@ -851,8 +851,8 @@ namespace FRG
float t0 = (worldMin[axis] - rayOrigin[axis]) * invD;
float t1 = (worldMax[axis] - rayOrigin[axis]) * invD;
if (invD < 0.0f) std::swap(t0, t1);
tMin = max(tMin, t0);
tMax = min(tMax, t1);
tMin = std::max(tMin, t0);
tMax = std::min(tMax, t1);
if (tMax < tMin) { miss = true; break; }
}
}
@@ -1838,14 +1838,13 @@ namespace FRG
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) {
return false;
}
const auto& areas =
state->navigationMaps[state->activeNavigationIndex].getAreas();
const bool found = std::any_of(
areas.begin(), areas.end(),
[&](const PathFinder::NavigationArea& area) {
return area.name == areaName;
});
if (!found) return false;
const auto& area =
state->navigationMaps[state->activeNavigationIndex].getArea();
if (area.name != areaName)
{
return false;
}
gameServer_.submit(SetNavigationAreaAvailableCommand{
locationId_, areaName, available});
return true;
@@ -2253,7 +2252,7 @@ namespace FRG
const float minInclination = M_PI * 30.f / 180.f;
const float maxInclination = M_PI * 0.5f;
localState_->cameraInclination =
max(minInclination, min(maxInclination, inclination));
std::max(minInclination, std::min(maxInclination, inclination));
}
}
+8 -8
View File
@@ -30,12 +30,12 @@ namespace FRG
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
const PathFinder& navigation =
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);
for (const auto& obs : navigation.getObstaclePolygons()) {
if (obs.polygon.size() < 3) continue;
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(obs.polygon, y, red);
mesh.data = CreatePolygonFloor(toEigenVector(obs.polygon), y, red);
mesh.RefreshVBO();
navigationEditorNavMeshes.push_back(std::move(mesh));
}
@@ -60,7 +60,7 @@ namespace FRG
if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
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;
for (const auto& pt : navigationEditorPoints) {
@@ -133,7 +133,7 @@ namespace FRG
PathFinder::ObstaclePolygon poly;
poly.name = "editor_obstacle_" + std::to_string(++navigationEditorObstacleCounter);
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{
@@ -146,10 +146,10 @@ namespace FRG
if (!state || state->activeNavigationIndex < 0 ||
state->activeNavigationIndex >= static_cast<int>(state->navigationMaps.size())) return;
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);
VertexRenderStruct mesh;
mesh.data = CreatePolygonFloor(poly.polygon, y, red);
mesh.data = CreatePolygonFloor(toEigenVector(poly.polygon), y, red);
mesh.RefreshVBO();
navigationEditorNavMeshes.push_back(std::move(mesh));
}
@@ -379,9 +379,9 @@ namespace FRG
auto& target = loc.interactiveObjects[selectedInteractiveObjectIndex];
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(
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;
buildInteractiveObjectBoundsMeshes();
+41 -14
View File
@@ -126,6 +126,7 @@ const CharacterState* LocationGameState::findCharacter(
return nullptr;
}
/*
std::vector<PathFinder::DynamicObstacle> LocationGameState::collectPathObstacles(
const std::string& requesterId,
const PlayerStateMap& players,
@@ -140,16 +141,21 @@ std::vector<PathFinder::DynamicObstacle> LocationGameState::collectPathObstacles
std::sort(characters.begin(), characters.end(),
[](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; });
const CharacterState* requester = findCharacter(requesterId, players);
const std::string attackTargetId =
requester ? requester->attackTargetId : std::string();
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 (id == attackTargetId && id != forcedObstacleId)
continue;
if (character->movedLastTick && id != forcedObstacleId) continue;
obstacles.push_back({character->position, character->collisionRadius});
}
return obstacles;
}
}*/
bool LocationGameState::setCharacterDestination(
const std::string& id,
@@ -161,8 +167,7 @@ bool LocationGameState::setCharacterDestination(
CharacterState* character = findCharacter(id, players);
if (!character || !location) return false;
character->setTarget(
target, std::move(callbackName), *location, id,
collectPathObstacles(id, players, forcedObstacleId));
target, std::move(callbackName), *location, id);
return true;
}
@@ -188,8 +193,7 @@ bool LocationGameState::replanCharacter(
const CharacterState* target = findCharacter(selectedTarget, players);
if (target) {
character->setTarget(
target->position, {}, *location, id,
collectPathObstacles(id, players, forcedObstacleId));
target->position, {}, *location, id);
return true;
}
}
@@ -202,13 +206,12 @@ bool LocationGameState::replanCharacter(
if ((homeTarget - character->position).norm() >
CharacterState::kWalkThreshold) {
character->setTarget(
homeTarget, {}, *location, id,
collectPathObstacles(id, players, forcedObstacleId));
homeTarget, {}, *location, id);
}
return true;
}
character->forceReplan(
*location, id, collectPathObstacles(id, players, forcedObstacleId));
*location, id);
return true;
}
@@ -430,7 +433,7 @@ LocationSimulationEvents LocationGameState::update(
}
}
}
/*
std::vector<CharacterMovementObstacle> movementObstacles;
movementObstacles.reserve(playerIds.size() + npcIds.size());
auto addMovementObstacle =
@@ -463,8 +466,9 @@ LocationSimulationEvents LocationGameState::update(
std::sort(
movementObstacles.begin(), movementObstacles.end(),
[](const CharacterMovementObstacle& lhs,
const CharacterMovementObstacle& rhs) { return lhs.id < rhs.id; });
const CharacterMovementObstacle& rhs) { return lhs.id < rhs.id; });*/
/*
auto advance = [&](const std::string& id, CharacterState& state) {
CharacterUpdateContext context;
context.characterId = id;
@@ -494,14 +498,36 @@ LocationSimulationEvents LocationGameState::update(
events.deathAnimationComplete || events.navigationRequested) {
result.characters.push_back({id, std::move(events)});
}
};
};*/
/*
for (const std::string& id : playerIds)
if (players.at(id)->enabled)
advance(id, *players.at(id));
for (const std::string& id : npcIds)
if (npcs.at(id)->enablesd)
advance(id, *npcs.at(id));*/
for (const std::string& id : playerIds)
{
if (players.at(id)->enabled)
{
CharacterUpdateContext context;
context.characterId = id;
auto& state = *players.at(id);
CharacterUpdateEvents events = state.update(deltaMs, context);
//advance(id, *players.at(id));
}
}
for (const std::string& id : npcIds)
{
if (npcs.at(id)->enabled)
advance(id, *npcs.at(id));
{
//advance(id, *npcs.at(id));
}
}
for (const CharacterSimulationEvent& source : result.characters) {
if (!source.events.attackImpact) continue;
@@ -518,6 +544,7 @@ LocationSimulationEvents LocationGameState::update(
source.events.attackDirection});
}
/*
auto player = players.find("player1");
if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() &&
@@ -550,7 +577,7 @@ LocationSimulationEvents LocationGameState::update(
result.npcInteractionId = location->targetInteractNpcId;
location->targetInteractNpcId.clear();
}
}
}*/
return result;
}
+2 -2
View File
@@ -64,10 +64,10 @@ struct LocationGameState {
CharacterState* findCharacter(const std::string& id, PlayerStateMap& players);
const CharacterState* findCharacter(
const std::string& id, const PlayerStateMap& players) const;
std::vector<PathFinder::DynamicObstacle> collectPathObstacles(
/*std::vector<PathFinder::DynamicObstacle> collectPathObstacles(
const std::string& requesterId,
const PlayerStateMap& players,
const std::string& forcedObstacleId = {}) const;
const std::string& forcedObstacleId = {}) const;*/
bool setCharacterDestination(
const std::string& id,
const Eigen::Vector3f& target,
+2 -2
View File
@@ -123,7 +123,7 @@ void loadSinglePlayerPersistence(
// 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& end,
const std::string& entityId,
@@ -135,7 +135,7 @@ void loadSinglePlayerPersistence(
return {};
return navigationMaps[activeNavigationIndex].findPathToNearest(
start, end, dynamicObstacles);
}
}*/
void LocationNetworkState::initNavigation(
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.
//It might be changed only in the editor mode during development.
std::vector<PathFinder> navigationMaps;
std::vector<Eigen::Vector3f> planPathExceptMyself(
/*std::vector<Eigen::Vector3f> planPathExceptMyself(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles = {}) const;
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles = {}) const;*/
void initNavigation(
const std::vector<std::string>& paths,
const std::string& zipFile = std::string());
+6 -7
View File
@@ -17,10 +17,6 @@
namespace FRG {
#ifdef __EMSCRIPTEN__
//extern Game* Game::s_instance;
#endif
// Localization
static const std::string EMPTY_LANGUAGE_RU = u8"(пусто)";
static const std::string EMPTY_LANGUAGE_EN = "(empty)";
@@ -81,7 +77,7 @@ namespace FRG {
pushWord(currentWord);
if (!currentLine.empty()) flushLine();
return max(1, lines);
return std::max(1, lines);
}
static std::string trimChatPreview(const std::string& msg, int maxChars = 22) {
@@ -408,6 +404,9 @@ namespace FRG {
const std::string& loc = snapshot->currentLocationId;
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
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
}
@@ -2254,9 +2253,9 @@ namespace FRG {
float MenuManager::ToastEntry::currentAlpha() const {
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::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;
}
+1
View File
@@ -80,6 +80,7 @@ namespace FRG {
ScriptEngine::~ScriptEngine() = default;
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>();
sol::state& lua = impl->lua;
+1 -1
View File
@@ -293,7 +293,7 @@ LocationDefinition LocationContentLoader::load(
for (const auto& item : root.value("npcs", json::array())) {
NpcData data;
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) {
throw std::runtime_error(
"Missing, duplicate, or reserved NPC id in " +
+2 -1
View File
@@ -7,6 +7,7 @@
#include <string>
#include <variant>
#include <vector>
#include "../navigation/PathFinder.h"
namespace FRG {
@@ -110,7 +111,7 @@ struct SetNavigationAreaAvailableCommand {
struct AddNavigationObstacleCommand {
std::string locationId;
std::string name;
std::vector<Eigen::Vector2f> polygon;
std::vector<Vec2fp> polygon;
};
struct AddInventoryItemCommand { std::string itemId; };
struct RemoveInventoryItemCommand { std::string itemId; };
+28 -13
View File
@@ -2,6 +2,7 @@
#include "Environment.h"
#include "utils/Utils.h"
#include <iostream>
#include <cmath>
#ifdef __ANDROID__
#include <android/log.h>
#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) {
// Получаем коэффициент плотности пикселей (например, 2.625 на Pixel или 3.0 на Samsung)
double dpr = emscripten_get_device_pixel_ratio();
//double dpr = 1; // low quality
// Вычисляем реальные физические пиксели
int physicalW = static_cast<int>(logicalW * dpr);
int physicalH = static_cast<int>(logicalH * dpr);
int physicalW = static_cast<int>(std::lround(logicalW * dpr));
int physicalH = static_cast<int>(std::lround(logicalH * dpr));
// Устанавливаем размер внутреннего буфера канваса
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 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 = {};
e.type = SDL_WINDOWEVENT;
@@ -118,8 +137,8 @@ void MainLoop() {
// Теперь DOM перестроен, можно забирать актуальные размеры.
// Если canvas должен занимать все окно браузера:
int canvasW = EM_ASM_INT({ return window.innerWidth; });
int canvasH = EM_ASM_INT({ return window.innerHeight; });
int canvasW, canvasH;
getViewportCssSize(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*/) {
double w, h;
emscripten_get_element_css_size("window", &w, &h);
int w, h;
getViewportCssSize(w, h);
// Если "window" не поддерживается вашей версией SDK, используем данные из e:
// int logicalW = e->windowInnerWidth;
// int logicalH = e->windowInnerHeight;
applyResize(static_cast<int>(w), static_cast<int>(h));
applyResize(w, h);
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_fullscreenchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, EM_TRUE, onFullscreenChanged);
int canvasW = EM_ASM_INT({ return window.innerWidth; });
int canvasH = EM_ASM_INT({ return window.innerHeight; });
int canvasW, canvasH;
getViewportCssSize(canvasW, canvasH);
applyResize(canvasW, canvasH);
SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0");
+45 -42
View File
@@ -79,7 +79,7 @@ namespace {
}
}
void PathFinder::build(const std::string& configPath,
void PathFinderOld::build(const std::string& configPath,
const std::string& zipPath)
{
ready = false;
@@ -115,22 +115,23 @@ void PathFinder::build(const std::string& configPath,
<< ", 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 std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(start, end, 0, false);
//const std::lock_guard<std::mutex> lock(*queryMutex);
//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 std::vector<DynamicObstacle>& dynamicObstacles) const
{
const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(start, end, buildDynamicOverlay(dynamicObstacles), false);
}
std::uint32_t PathFinder::buildDynamicOverlay(
}*/
std::uint32_t PathFinderOld::buildDynamicOverlay(
const std::vector<DynamicObstacle>& dynamicObstacles) const
{
const size_t cellCount = walkable.size();
@@ -172,7 +173,7 @@ std::uint32_t PathFinder::buildDynamicOverlay(
return dynamicGeneration;
}
bool PathFinder::isCellWalkableOverlay(
bool PathFinderOld::isCellWalkableOverlay(
const Cell& cell, std::uint32_t generation) const
{
if (!isCellWalkable(cell)) return false;
@@ -180,7 +181,7 @@ bool PathFinder::isCellWalkableOverlay(
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
{
Cell origin;
@@ -212,7 +213,7 @@ bool PathFinder::findNearestWalkableCellOverlay(
return false;
}
bool PathFinder::hasLineOfSightOverlay(
bool PathFinderOld::hasLineOfSightOverlay(
const Cell& from, const Cell& to, std::uint32_t generation) const
{
int x = from.x;
@@ -244,7 +245,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
{
if (cells.size() <= 2) return cells;
@@ -263,7 +264,7 @@ std::vector<PathFinder::Cell> PathFinder::smoothCellsOverlay(
return result;
}
std::vector<Eigen::Vector3f> PathFinder::runSearch(
std::vector<Eigen::Vector3f> PathFinderOld::runSearch(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end,
std::uint32_t obstacleGeneration,
@@ -385,23 +386,25 @@ std::vector<Eigen::Vector3f> PathFinder::runSearch(
return path;
}
std::vector<Eigen::Vector3f> PathFinder::findPathToNearest(
std::vector<Eigen::Vector3f> PathFinderOld::findPathToNearest(
const Eigen::Vector3f& start, const Eigen::Vector3f& end) const
{
const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(start, end, 0, true);
//const std::lock_guard<std::mutex> lock(*queryMutex);
// 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 std::vector<DynamicObstacle>& dynamicObstacles) const
{
const std::lock_guard<std::mutex> lock(*queryMutex);
return runSearch(
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 changed = false;
@@ -420,13 +423,13 @@ bool PathFinder::setAreaAvailable(const std::string& areaName, bool available)
return found;
}
bool PathFinder::isWalkable(const Eigen::Vector3f& point) const
bool PathFinderOld::isWalkable(const Eigen::Vector3f& point) const
{
Cell cell;
return worldToCell(point, cell) && isCellWalkable(cell, walkable);
}
void PathFinder::addObstaclePolygon(const ObstaclePolygon& polygon)
void PathFinderOld::addObstaclePolygon(const ObstaclePolygon& polygon)
{
obstaclePolygons.push_back(polygon);
if (ready) {
@@ -434,7 +437,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;
root["cellSize"] = cellSize;
@@ -481,7 +484,7 @@ bool PathFinder::saveConfig(const std::string& path) const
return saveJsonToFile(root, path);
}
bool PathFinder::saveGrid(const std::string& path) const
bool PathFinderOld::saveGrid(const std::string& path) const
{
#if defined(_WIN32)
if (!ready) return false;
@@ -510,7 +513,7 @@ bool PathFinder::saveGrid(const std::string& path) const
#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;
try {
@@ -565,7 +568,7 @@ bool PathFinder::loadGrid(const std::string& path, const std::string& zipPath)
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;
agentRadius = 0.45f;
@@ -640,7 +643,7 @@ void PathFinder::loadConfig(const std::string& configPath, const std::string& zi
boundaryPadding = (std::max)(boundaryPadding, 0.0f);
}
void PathFinder::resetGridBounds()
void PathFinderOld::resetGridBounds()
{
if (areas.empty()) {
gridWidth = 0;
@@ -682,14 +685,14 @@ void PathFinder::resetGridBounds()
gridDepth = static_cast<int>(std::ceil((maxZ - minZ) / cellSize));
}
void PathFinder::rebuildWalkableGrid()
void PathFinderOld::rebuildWalkableGrid()
{
walkable.assign(static_cast<size_t>(gridWidth * gridDepth), 0);
markAvailableAreasWalkable();
markObstaclePolygonsBlocked();
}
void PathFinder::markAvailableAreasWalkable()
void PathFinderOld::markAvailableAreasWalkable()
{
const auto insideAvailableArea = [this](const Eigen::Vector2f& point) {
for (const NavigationArea& area : areas) {
@@ -763,7 +766,7 @@ void PathFinder::markAvailableAreasWalkable()
}
}
void PathFinder::markObstaclePolygonsBlocked()
void PathFinderOld::markObstaclePolygonsBlocked()
{
if (obstaclePolygons.empty()) {
return;
@@ -800,7 +803,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) {
out = {};
@@ -812,7 +815,7 @@ bool PathFinder::worldToCell(const Eigen::Vector3f& point, Cell& out) const
return isInsideGrid(out);
}
Eigen::Vector3f PathFinder::cellCenter(const Cell& cell) const
Eigen::Vector3f PathFinderOld::cellCenter(const Cell& cell) const
{
return {
minX + (static_cast<float>(cell.x) + 0.5f) * cellSize,
@@ -821,17 +824,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;
}
bool PathFinder::isCellWalkable(const Cell& cell) const
bool PathFinderOld::isCellWalkable(const Cell& cell) const
{
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);
return isInsideGrid(cell) &&
@@ -840,17 +843,17 @@ bool PathFinder::isCellWalkable(const Cell& cell, const std::vector<unsigned cha
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;
}
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);
}
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;
if (!worldToCell(point, origin)) {
@@ -885,12 +888,12 @@ bool PathFinder::findNearestWalkableCell(const Eigen::Vector3f& point, Cell& out
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);
}
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 b = cellCenter(to);
@@ -909,12 +912,12 @@ bool PathFinder::hasLineOfSight(const Cell& from, const Cell& to, const std::vec
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);
}
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) {
return cells;
@@ -937,7 +940,7 @@ std::vector<PathFinder::Cell> PathFinder::smoothCells(const std::vector<Cell>& c
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;
size_t previous = polygon.size() - 1;
+537 -5
View File
@@ -1,15 +1,547 @@
#pragma once
#include <Eigen/Core>
#include <algorithm>
#include <cstdint>
#include <limits>
#include <memory>
#include <mutex>
#include <queue>
#include <string>
#include <unordered_map>
#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 {
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));
}
};
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; }
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:
struct ObstaclePolygon {
@@ -39,18 +571,18 @@ public:
std::vector<Eigen::Vector3f> findPath(const Eigen::Vector3f& start,
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 std::vector<DynamicObstacle>& dynamicObstacles) const;
*/
// Like findPath, but when the destination is unreachable the character
// walks as close as possible instead of not moving at all.
std::vector<Eigen::Vector3f> findPathToNearest(const Eigen::Vector3f& start,
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 std::vector<DynamicObstacle>& dynamicObstacles) const;
*/
bool setAreaAvailable(const std::string& areaName, bool available);
bool isReady() const { return ready; }
bool isWalkable(const Eigen::Vector3f& point) const;