Compare commits

...
12 Commits
Author SHA1 Message Date
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 0f1e0d66a3 Switch off save state 2026-11-10 21:06:52 +06:00
Vladislav Khorev 63bf981e21 Fixing minor bugs, some renaming 2026-11-10 21:06:27 +06:00
Vladislav Khorev f3cafd2bf7 Fixing minor bugs 2026-11-10 19:48:03 +06:00
Vladislav Khorev 6b8aa0efec Some fixes 2026-11-10 18:31:53 +06:00
Vladislav Khorev 8c4eeb9793 Working on server 2026-11-09 17:58:20 +00:00
36 changed files with 1976 additions and 396 deletions

No files matched your search

+16 -6
View File
@@ -144,14 +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
- `wss://` (TLS) is supported by the Windows client only. `proj-windows` also defines 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 `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 automatically at configure time (`cmake/ThirdParty.cmake`, section 12) using a
downloaded portable Strawberry Perl and the nmake from the installed Visual Studio 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` (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) and `install-Release`; later configures skip them. `NETWORK_TLS` unset (server tests)
means `wss://` endpoints fail with a logged error. 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.
@@ -170,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
+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")
+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);
}); });
+5
View File
@@ -174,6 +174,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
} }
] ]
} }
+36 -8
View File
@@ -1,4 +1,13 @@
{ {
"localState": {
"locations": {
"location_dorm": {
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002
}
}
},
"networkState": {
"campaignId": "main", "campaignId": "main",
"chatHistory": [ "chatHistory": [
[], [],
@@ -16,8 +25,7 @@
true true
], ],
"currentLocationId": "", "currentLocationId": "",
"globalFloats": { "globalFloats": {},
},
"globalInts": {}, "globalInts": {},
"inventory": { "inventory": {
"items": [] "items": []
@@ -494,6 +502,22 @@
-15.038200378417969 -15.038200378417969
], ],
"hp": 100.0, "hp": 100.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
10,
4,
5,
8,
11,
9,
6,
3,
2,
7
],
"navigationRetryTicks": 0,
"pathWaypoints": [ "pathWaypoints": [
[ [
-6.296899795532227, -6.296899795532227,
@@ -530,8 +554,6 @@
}, },
"state": { "state": {
"activeNavigationIndex": 0, "activeNavigationIndex": 0,
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002,
"targetInteractNpcId": "", "targetInteractNpcId": "",
"targetInteractiveObjectId": "", "targetInteractiveObjectId": "",
"targetTeleportZoneId": "", "targetTeleportZoneId": "",
@@ -574,10 +596,9 @@
} }
}, },
"money": 5500, "money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0, "playerMaxHp": 200.0,
"players": { "players": {
"player": { "player1": {
"animationFrame": 0.0, "animationFrame": 0.0,
"attackTargetId": "", "attackTargetId": "",
"attack_cooldown": 0.0, "attack_cooldown": 0.0,
@@ -594,6 +615,11 @@
0.0 0.0
], ],
"hp": 200.0, "hp": 200.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [],
"navigationRetryTicks": 0,
"pathWaypoints": [ "pathWaypoints": [
[ [
6.8031005859375, 6.8031005859375,
@@ -764,12 +790,14 @@
} }
] ]
}, },
"savedAt": "2026-10-30 14:48", "savedAt": "2026-11-10 20:38",
"schemaVersion": 1, "schemaVersion": 5,
"taxiIsCalled": false, "taxiIsCalled": false,
"tutorialJournalScreenOpened": false, "tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false, "tutorialNeedOpenTaxiScreen": false,
"tutorialPhoneChatScreenOpened": false, "tutorialPhoneChatScreenOpened": false,
"tutorialStep": 0, "tutorialStep": 0,
"tutorialTaxiHintDone": false "tutorialTaxiHintDone": false
},
"persistenceVersion": 1
} }
+523 -95
View File
@@ -1,7 +1,7 @@
{ {
"localState": { "localState": {
"locations": { "locations": {
"uni_exterior": { "park_korea": {
"cameraAzimuth": -2.3499999046325684, "cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002 "cameraInclination": 1.103600025177002
} }
@@ -10,9 +10,12 @@
"networkState": { "networkState": {
"campaignId": "battle_zone", "campaignId": "battle_zone",
"chatHistory": [ "chatHistory": [
[], [
[], ],
[] [
],
[
]
], ],
"chatPreviewMsg": [ "chatPreviewMsg": [
"", "",
@@ -27,15 +30,17 @@
"currentLocationId": "", "currentLocationId": "",
"globalFloats": { "globalFloats": {
}, },
"globalInts": {}, "globalInts": {
},
"inventory": { "inventory": {
"items": [] "items": [
]
}, },
"isDarklands": true, "isDarklands": true,
"isDawn": false, "isDawn": false,
"isNight": true, "isNight": true,
"locations": { "locations": {
"uni_exterior": { "park_korea": {
"dialogue": { "dialogue": {
"active": false "active": false
}, },
@@ -44,7 +49,7 @@
"alpha": 1.0, "alpha": 1.0,
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 0.6000000238418579, "positionX": 0.60000002384185791,
"positionY": 0.45736101269721985, "positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684, "positionZ": 3.8499999046325684,
"rotationY": 0.0, "rotationY": 0.0,
@@ -54,7 +59,7 @@
"alpha": 1.0, "alpha": 1.0,
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 1.399999976158142, "positionX": 1.3999999761581421,
"positionY": 0.45736101269721985, "positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684, "positionZ": 3.8499999046325684,
"rotationY": 0.0, "rotationY": 0.0,
@@ -64,7 +69,7 @@
"alpha": 1.0, "alpha": 1.0,
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 2.200000047683716, "positionX": 2.2000000476837158,
"positionY": 0.45736101269721985, "positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684, "positionZ": 3.8499999046325684,
"rotationY": 0.0, "rotationY": 0.0,
@@ -85,7 +90,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 0.0, "positionX": 0.0,
"positionY": 3.200000047683716, "positionY": 3.2000000476837158,
"positionZ": 0.0, "positionZ": 0.0,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -95,7 +100,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": -1.0, "positionX": -1.0,
"positionY": 0.9150000214576721, "positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316, "positionZ": -3.9000000953674316,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -105,7 +110,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 0.0, "positionX": 0.0,
"positionY": 0.9150000214576721, "positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316, "positionZ": -3.9000000953674316,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -115,7 +120,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 1.0, "positionX": 1.0,
"positionY": 0.9150000214576721, "positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316, "positionZ": -3.9000000953674316,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -134,7 +139,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -155,14 +160,15 @@
11 11
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -170,7 +176,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
] ]
@@ -187,7 +193,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -208,14 +214,15 @@
6 6
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -223,7 +230,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
] ]
@@ -240,7 +247,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -261,14 +268,15 @@
11 11
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -276,7 +284,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
] ]
@@ -293,7 +301,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -314,14 +322,15 @@
6 6
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -329,7 +338,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
] ]
@@ -346,7 +355,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -367,14 +376,15 @@
11 11
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -382,7 +392,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
] ]
@@ -399,7 +409,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -420,14 +430,15 @@
2 2
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -435,13 +446,338 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
] ]
},
"ghost_07x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
3,
10,
5,
6,
8,
7,
9,
4,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.0
]
},
"ghost_08x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
11,
3,
10,
2,
9,
5,
4,
7,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.0
]
},
"ghost_09x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
4,
8,
3,
6,
10,
9,
7,
5,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.5
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.5
]
},
"ghost_10x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
3,
4,
7,
10,
5,
11,
9,
2,
8,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.5
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.5
]
},
"ghost_11x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
7,
2,
9,
4,
10,
6,
5,
3,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-15.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-15.0
]
},
"ghost_12x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
7,
11,
5,
4,
3,
10,
9,
8,
6,
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-15.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-15.0
]
} }
}, },
"scriptGlobals": {}, "scriptGlobals": {
},
"state": { "state": {
"activeNavigationIndex": 0, "activeNavigationIndex": 0,
"targetInteractNpcId": "", "targetInteractNpcId": "",
@@ -460,61 +796,23 @@
"rotation": 0.0, "rotation": 0.0,
"steeringAngle": 0.0, "steeringAngle": 0.0,
"velocity": 0.0, "velocity": 0.0,
"waypoints": [], "waypoints": [
],
"wheelAngle": 0.0 "wheelAngle": 0.0
} }
} }
}, },
"teleportZones": {}, "teleportZones": {
"triggerZones": {} },
"triggerZones": {
}
} }
}, },
"money": 5500, "money": 5500,
"playerHp": 200.0, "playerHp": 200.0,
"playerMaxHp": 200.0, "playerMaxHp": 200.0,
"players": { "players": {
"player2": { "player1": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [[
-6.4,
0.0,
13.0
]],
"position": [
-6.4,
0.0,
13.0
],
"requestedWalkTarget": [
-6.4,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.4,
0.0,
13.0
]
},
"player": {
"animationFrame": 0.0, "animationFrame": 0.0,
"attackTargetId": "", "attackTargetId": "",
"attack_cooldown": 0.0, "attack_cooldown": 0.0,
@@ -533,18 +831,18 @@
"hp": 200.0, "hp": 200.0,
"pathWaypoints": [ "pathWaypoints": [
[ [
-5.8, -5.4,
0.0, 0.0,
12.8 12.8
] ]
], ],
"position": [ "position": [
-5.8, -5.4,
0.0, 0.0,
12.8 12.8
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.8, -5.4,
0.0, 0.0,
12.8 12.8
], ],
@@ -552,14 +850,144 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.0, "visibleRange": 6.0,
"walkTarget": [ "walkTarget": [
-5.8, -5.4,
0.0, 0.0,
12.8 12.8
] ]
},
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
13.0
]
],
"position": [
-6.8,
0.0,
13.0
],
"requestedWalkTarget": [
-6.8,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
13.0
]
},
"player3": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-5.4,
0.0,
14.8
]
],
"position": [
-5.4,
0.0,
14.8
],
"requestedWalkTarget": [
-5.4,
0.0,
14.8
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-5.4,
0.0,
14.8
]
},
"player4": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
15.0
]
],
"position": [
-6.8,
0.0,
15.0
],
"requestedWalkTarget": [
-6.8,
0.0,
15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
15.0
]
} }
}, },
"questJournal": { "questJournal": {
"quests": [] "quests": [
]
}, },
"savedAt": "2026-11-05 17:43", "savedAt": "2026-11-05 17:43",
"schemaVersion": 5, "schemaVersion": 5,
@@ -1,7 +1,7 @@
{ {
"localState": { "localState": {
"locations": { "locations": {
"uni_exterior": { "park_korea": {
"cameraAzimuth": -2.3499999046325684, "cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002 "cameraInclination": 1.103600025177002
} }
@@ -10,9 +10,12 @@
"networkState": { "networkState": {
"campaignId": "battle_zone", "campaignId": "battle_zone",
"chatHistory": [ "chatHistory": [
[], [
[], ],
[] [
],
[
]
], ],
"chatPreviewMsg": [ "chatPreviewMsg": [
"", "",
@@ -27,15 +30,17 @@
"currentLocationId": "", "currentLocationId": "",
"globalFloats": { "globalFloats": {
}, },
"globalInts": {}, "globalInts": {
},
"inventory": { "inventory": {
"items": [] "items": [
]
}, },
"isDarklands": true, "isDarklands": true,
"isDawn": false, "isDawn": false,
"isNight": true, "isNight": true,
"locations": { "locations": {
"uni_exterior": { "park_korea": {
"dialogue": { "dialogue": {
"active": false "active": false
}, },
@@ -44,7 +49,7 @@
"alpha": 1.0, "alpha": 1.0,
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 0.6000000238418579, "positionX": 0.60000002384185791,
"positionY": 0.45736101269721985, "positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684, "positionZ": 3.8499999046325684,
"rotationY": 0.0, "rotationY": 0.0,
@@ -54,7 +59,7 @@
"alpha": 1.0, "alpha": 1.0,
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 1.399999976158142, "positionX": 1.3999999761581421,
"positionY": 0.45736101269721985, "positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684, "positionZ": 3.8499999046325684,
"rotationY": 0.0, "rotationY": 0.0,
@@ -64,7 +69,7 @@
"alpha": 1.0, "alpha": 1.0,
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 2.200000047683716, "positionX": 2.2000000476837158,
"positionY": 0.45736101269721985, "positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684, "positionZ": 3.8499999046325684,
"rotationY": 0.0, "rotationY": 0.0,
@@ -85,7 +90,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 0.0, "positionX": 0.0,
"positionY": 3.200000047683716, "positionY": 3.2000000476837158,
"positionZ": 0.0, "positionZ": 0.0,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -95,7 +100,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": -1.0, "positionX": -1.0,
"positionY": 0.9150000214576721, "positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316, "positionZ": -3.9000000953674316,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -105,7 +110,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 0.0, "positionX": 0.0,
"positionY": 0.9150000214576721, "positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316, "positionZ": -3.9000000953674316,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -115,7 +120,7 @@
"isActive": true, "isActive": true,
"isAnimating": false, "isAnimating": false,
"positionX": 1.0, "positionX": 1.0,
"positionY": 0.9150000214576721, "positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316, "positionZ": -3.9000000953674316,
"rotationY": 0.0, "rotationY": 0.0,
"scale": 1.0 "scale": 1.0
@@ -134,7 +139,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -155,14 +160,15 @@
11 11
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -170,7 +176,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.0 -12.0
] ]
@@ -187,7 +193,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -208,14 +214,15 @@
6 6
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
], ],
@@ -223,7 +230,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.0 -12.0
] ]
@@ -240,7 +247,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -261,14 +268,15 @@
11 11
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -276,7 +284,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-12.5 -12.5
] ]
@@ -293,7 +301,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -314,14 +322,15 @@
6 6
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
], ],
@@ -329,7 +338,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-12.5 -12.5
] ]
@@ -346,7 +355,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -367,14 +376,15 @@
11 11
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -382,7 +392,7 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-5.599999904632568, -5.5999999046325684,
0.0, 0.0,
-13.0 -13.0
] ]
@@ -399,7 +409,7 @@
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
"homePosition": [ "homePosition": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -420,14 +430,15 @@
2 2
], ],
"navigationRetryTicks": 0, "navigationRetryTicks": 0,
"pathWaypoints": [], "pathWaypoints": [
],
"position": [ "position": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
], ],
@@ -435,13 +446,338 @@
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.5, "visibleRange": 6.5,
"walkTarget": [ "walkTarget": [
-6.400000095367432, -6.4000000953674316,
0.0, 0.0,
-13.0 -13.0
] ]
},
"ghost_07x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
3,
10,
5,
6,
8,
7,
9,
4,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.0
]
},
"ghost_08x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
11,
3,
10,
2,
9,
5,
4,
7,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.0
]
},
"ghost_09x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
4,
8,
3,
6,
10,
9,
7,
5,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.5
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.5
]
},
"ghost_10x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
3,
4,
7,
10,
5,
11,
9,
2,
8,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.5
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.5
]
},
"ghost_11x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
7,
2,
9,
4,
10,
6,
5,
3,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-15.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-15.0
]
},
"ghost_12x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
7,
11,
5,
4,
3,
10,
9,
8,
6,
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-15.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-15.0
]
} }
}, },
"scriptGlobals": {}, "scriptGlobals": {
},
"state": { "state": {
"activeNavigationIndex": 0, "activeNavigationIndex": 0,
"targetInteractNpcId": "", "targetInteractNpcId": "",
@@ -460,61 +796,23 @@
"rotation": 0.0, "rotation": 0.0,
"steeringAngle": 0.0, "steeringAngle": 0.0,
"velocity": 0.0, "velocity": 0.0,
"waypoints": [], "waypoints": [
],
"wheelAngle": 0.0 "wheelAngle": 0.0
} }
} }
}, },
"teleportZones": {}, "teleportZones": {
"triggerZones": {} },
"triggerZones": {
}
} }
}, },
"money": 5500, "money": 5500,
"playerHp": 200.0, "playerHp": 200.0,
"playerMaxHp": 200.0, "playerMaxHp": 200.0,
"players": { "players": {
"player2": { "player1": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [[
-6.8,
0.0,
13.0
]],
"position": [
-6.8,
0.0,
13.0
],
"requestedWalkTarget": [
-6.8,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
13.0
]
},
"player": {
"animationFrame": 0.0, "animationFrame": 0.0,
"attackTargetId": "", "attackTargetId": "",
"attack_cooldown": 0.0, "attack_cooldown": 0.0,
@@ -556,10 +854,140 @@
0.0, 0.0,
12.8 12.8
] ]
},
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
13.0
]
],
"position": [
-6.8,
0.0,
13.0
],
"requestedWalkTarget": [
-6.8,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
13.0
]
},
"player3": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-5.4,
0.0,
14.8
]
],
"position": [
-5.4,
0.0,
14.8
],
"requestedWalkTarget": [
-5.4,
0.0,
14.8
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-5.4,
0.0,
14.8
]
},
"player4": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
15.0
]
],
"position": [
-6.8,
0.0,
15.0
],
"requestedWalkTarget": [
-6.8,
0.0,
15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
15.0
]
} }
}, },
"questJournal": { "questJournal": {
"quests": [] "quests": [
]
}, },
"savedAt": "2026-11-05 17:43", "savedAt": "2026-11-05 17:43",
"schemaVersion": 5, "schemaVersion": 5,
+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,
+33 -12
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)
if(WIN32) set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0")
set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0") if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
message(FATAL_ERROR message(FATAL_ERROR
"Boost was not found at thirdparty/boost_1_90_0. Configure the " "Boost was not found at thirdparty/boost_1_90_0 and could not be "
"graphical project once to populate it.") "downloaded.")
endif() endif()
target_sources(GameServer PRIVATE target_sources(GameServer PRIVATE
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp" "${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/NetworkProtocol.h" "${GAME_ROOT}/src/game_server/NetworkProtocol.h"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp" "${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.h") "${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( 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 game_server_core)
if(WIN32)
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE 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( 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");
+1 -1
View File
@@ -89,7 +89,7 @@ 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;
+9 -3
View File
@@ -558,12 +558,18 @@ namespace FRG
logger() << "[network] Campaign '" << campaignId logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl; << "' selected; creating WebSocket client" << std::endl;
// Use "ws://127.0.0.1:8080/" for a local development server. // 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; WebsocketEndpoint endpoint;
if (!endpoint.parse(kMultiplayerServerUrl)) { if (!endpoint.parse(kMultiplayerServerUrl)) {
logger() << "[network] Invalid multiplayer server URL: " logger() << "[network] Invalid multiplayer server URL: "
<< kMultiplayerServerUrl << std::endl; << kMultiplayerServerUrl << std::endl;
remoteCampaignActive = false;
gameServer.use(internalGameServer);
gameServerHost.use(localGameServerHost);
return;
} }
websocketGameClient = std::make_unique<WebsocketGameClient>(endpoint); websocketGameClient = std::make_unique<WebsocketGameClient>(endpoint);
remoteGameServerHost = remoteGameServerHost =
@@ -688,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();
} }
+3 -3
View File
@@ -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;
+82 -33
View File
@@ -140,11 +140,16 @@ std::vector<PathFinder::DynamicObstacle> LocationGameState::collectPathObstacles
std::sort(characters.begin(), characters.end(), std::sort(characters.begin(), characters.end(),
[](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; }); [](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; std::vector<PathFinder::DynamicObstacle> obstacles;
obstacles.reserve(characters.size()); obstacles.reserve(characters.size());
for (const auto& [id, character] : characters) { for (const auto& [id, character] : characters) {
if (id == requesterId || !character->enabled || character->hp <= 0.f) if (id == requesterId || !character->enabled || character->hp <= 0.f)
continue; continue;
if (id == attackTargetId && id != forcedObstacleId)
continue;
if (character->movedLastTick && id != forcedObstacleId) continue; if (character->movedLastTick && id != forcedObstacleId) continue;
obstacles.push_back({character->position, character->collisionRadius}); obstacles.push_back({character->position, character->collisionRadius});
} }
@@ -173,11 +178,84 @@ bool LocationGameState::replanCharacter(
{ {
CharacterState* character = findCharacter(id, players); CharacterState* character = findCharacter(id, players);
if (!character || !location) return false; if (!character || !location) return false;
if (!forcedObstacleId.empty() && !character->isPlayer &&
character->canAttack && character->hp > 0.f) {
const std::string selectedTarget =
selectNpcTarget(*character, players, false);
if (selectedTarget != character->attackTargetId) {
character->stopInPlace();
character->attackTargetId = selectedTarget;
character->battle_state = 0;
character->attack = 0;
}
if (!selectedTarget.empty()) {
const CharacterState* target = findCharacter(selectedTarget, players);
if (target) {
character->setTarget(
target->position, {}, *location, id,
collectPathObstacles(id, players, forcedObstacleId));
return true;
}
}
character->attackTargetId.clear();
character->battle_state = 0;
character->attack = 0;
const Eigen::Vector3f homeTarget(
character->homePosition.x(), 0.f, character->homePosition.z());
if ((homeTarget - character->position).norm() >
CharacterState::kWalkThreshold) {
character->setTarget(
homeTarget, {}, *location, id,
collectPathObstacles(id, players, forcedObstacleId));
}
return true;
}
character->forceReplan( character->forceReplan(
*location, id, collectPathObstacles(id, players, forcedObstacleId)); *location, id, collectPathObstacles(id, players, forcedObstacleId));
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 float visibleRangeSquared = npc.visibleRange * npc.visibleRange;
std::string selectedTarget;
float nearestDistanceSquared = visibleRangeSquared;
for (const std::string& playerId : playerIds) {
const Eigen::Vector3f delta =
players.at(playerId)->position - npc.position;
const float 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;
@@ -321,13 +399,6 @@ LocationSimulationEvents LocationGameState::update(
if (player) playerIds.push_back(id); if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end()); 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; std::vector<std::string> npcIds;
npcIds.reserve(npcs.size()); npcIds.reserve(npcs.size());
for (const auto& [id, npc] : npcs) for (const auto& [id, npc] : npcs)
@@ -343,30 +414,8 @@ LocationSimulationEvents LocationGameState::update(
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();
@@ -474,7 +523,7 @@ LocationSimulationEvents LocationGameState::update(
source.events.attackDirection}); source.events.attackDirection});
} }
auto player = players.find("player"); auto player = players.find("player1");
if (!interactiveObjectsLocked && if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() && !location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second && player->second->enabled) { player != players.end() && player->second && player->second->enabled) {
@@ -502,7 +551,7 @@ LocationSimulationEvents LocationGameState::update(
(player->second->position - npc->position).norm(); (player->second->position - npc->position).norm();
if (distance <= NPC_TALK_DISTANCE) { if (distance <= NPC_TALK_DISTANCE) {
player->second->stopInPlace(); player->second->stopInPlace();
npc->faceTargetId = "player"; npc->faceTargetId = "player1";
result.npcInteractionId = location->targetInteractNpcId; result.npcInteractionId = location->targetInteractNpcId;
location->targetInteractNpcId.clear(); location->targetInteractNpcId.clear();
} }
+4
View File
@@ -86,6 +86,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);
}; };
+3
View File
@@ -408,6 +408,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
} }
+2 -1
View File
@@ -80,6 +80,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;
@@ -172,7 +173,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",
+1 -1
View File
@@ -293,7 +293,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 " +
+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;
+17 -5
View File
@@ -145,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;
} }
@@ -205,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;
} }
@@ -226,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_;
+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");