Compare commits
12
Commits
networking
..
server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe2ee6c2a | ||
|
|
cf714d4133 | ||
|
|
255181cf31 | ||
|
|
ef966a2197 | ||
|
|
24c963c0a5 | ||
|
|
6cfd47e80d | ||
|
|
0be23ca291 | ||
|
|
0f1e0d66a3 | ||
|
|
63bf981e21 | ||
|
|
f3cafd2bf7 | ||
|
|
6b8aa0efec | ||
|
|
8c4eeb9793 |
No files matched your search
+16
-6
@@ -144,14 +144,15 @@ Networking is enabled for Windows and the Emscripten web client:
|
||||
- `proj-web/CMakeLists.txt` defines `NETWORK`, compiles the shared protocol,
|
||||
replica, and remote-host code, and links Emscripten's `websocket.js` library.
|
||||
`WebsocketGameClient.cpp` selects the browser WebSocket API on Emscripten; it
|
||||
does not use Boost.Asio or Boost.Beast there.
|
||||
- `wss://` (TLS) is supported by the Windows client only. `proj-windows` also defines
|
||||
does not use Boost.Asio, Boost.Beast, or OpenSSL there. The browser performs
|
||||
the TLS handshake and certificate/hostname validation for `wss://`.
|
||||
- `proj-windows` defines
|
||||
`NETWORK_TLS` and links static OpenSSL 3.4.1, which CMake downloads and builds
|
||||
automatically at configure time (`cmake/ThirdParty.cmake`, section 12) using a
|
||||
downloaded portable Strawberry Perl and the nmake from the installed Visual Studio
|
||||
(located through `vswhere`). Builds go to `thirdparty/openssl-3.4.1/install-Debug`
|
||||
and `install-Release`; later configures skip them. `NETWORK_TLS` unset (server tests)
|
||||
means `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
|
||||
network transport.
|
||||
|
||||
@@ -170,15 +171,24 @@ Run it from the repository root:
|
||||
.\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
|
||||
--address IP
|
||||
--port N
|
||||
```
|
||||
|
||||
Launch the Windows game and select `battle_zone_multiplayer`. The client connects to
|
||||
`127.0.0.1:8080`. Selecting another campaign continues to use the local server.
|
||||
Launch the Windows game and select `battle_zone_multiplayer`. The production client
|
||||
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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
@@ -246,7 +246,7 @@ set(EMSCRIPTEN_LINK_FLAGS
|
||||
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_uni_interior.lua@resources/start_uni_interior.lua"
|
||||
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_uni_exterior.lua@resources/start_uni_exterior.lua"
|
||||
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_dorm.lua@resources/start_dorm.lua"
|
||||
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_dorm.lua@resources/start_battle_zone.lua"
|
||||
"--preload-file ${CMAKE_CURRENT_SOURCE_DIR}/../resources/start_battle_zone.lua@resources/start_battle_zone.lua"
|
||||
)
|
||||
|
||||
# Применяем настройки линковки
|
||||
|
||||
+34
-3
@@ -5,17 +5,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Shadow Over Bishkek</title>
|
||||
<style>
|
||||
:root {
|
||||
/* Visible viewport size, kept in sync by syncViewport() below.
|
||||
100vh on mobile is the *largest* viewport (URL bar hidden) and
|
||||
would push the bottom of the canvas off-screen. */
|
||||
--app-w: 100%;
|
||||
--app-h: 100dvh;
|
||||
}
|
||||
body, html {
|
||||
margin: 0; padding: 0; width: 100%; height: 100%;
|
||||
overflow: hidden; background-color: #000;
|
||||
position: fixed;
|
||||
position: fixed; top: 0; left: 0;
|
||||
}
|
||||
#canvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
width: var(--app-w); height: var(--app-h);
|
||||
border: none;
|
||||
touch-action: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#status { color: white; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||
@@ -49,8 +60,28 @@
|
||||
loadGameScript();
|
||||
});
|
||||
|
||||
function syncViewport() {
|
||||
var vv = window.visualViewport;
|
||||
var w = (vv && vv.width) || window.innerWidth;
|
||||
var h = (vv && vv.height) || window.innerHeight;
|
||||
var root = document.documentElement;
|
||||
root.style.setProperty('--app-w', Math.round(w) + 'px');
|
||||
root.style.setProperty('--app-h', Math.round(h) + 'px');
|
||||
}
|
||||
syncViewport();
|
||||
// Registered before the game script loads, so the CSS size is updated
|
||||
// before the engine's own resize handler reads it.
|
||||
window.addEventListener('resize', syncViewport);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', function () {
|
||||
syncViewport();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("orientationchange", function() {
|
||||
setTimeout(() => {
|
||||
syncViewport();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 200);
|
||||
});
|
||||
|
||||
@@ -174,6 +174,11 @@ if(WIN32)
|
||||
../src/game_server/RemoteGameServerHost.h
|
||||
../src/game_server/RemoteGameServerHost.cpp
|
||||
)
|
||||
if(MSVC)
|
||||
set_source_files_properties(
|
||||
../src/game_server/WebsocketGameClient.cpp
|
||||
PROPERTIES COMPILE_OPTIONS "/bigobj")
|
||||
endif()
|
||||
target_link_libraries(
|
||||
ShadowOverBishkekDemo PRIVATE opengl32 ws2_32 mswsock)
|
||||
endif()
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"campaigns": [
|
||||
{
|
||||
"id": "main",
|
||||
"title": { "ru": "Основная игра", "en": "Main Game" },
|
||||
"title": {
|
||||
"ru": "Основная игра",
|
||||
"en": "Main Game"
|
||||
},
|
||||
"locations": [
|
||||
"resources/config/locations/location_dorm.json",
|
||||
"resources/config/locations/uni_exterior.json",
|
||||
@@ -17,12 +20,15 @@
|
||||
},
|
||||
{
|
||||
"id": "battle_zone",
|
||||
"title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" },
|
||||
"title": {
|
||||
"ru": "Зона боя (одиночная)",
|
||||
"en": "Battle Zone (Single Player)"
|
||||
},
|
||||
"locations": [
|
||||
"resources/config/locations/battle_zone_uni_exterior.json"
|
||||
"resources/config/locations/battle_zone_korea.json"
|
||||
],
|
||||
"startLocation": "uni_exterior",
|
||||
"startMusic": "music/bishkek univer day.ogg",
|
||||
"startLocation": "park_korea",
|
||||
"startMusic": "music/bishkek fight calm.ogg",
|
||||
"startState": "resources/config/start_state_battle_zone_singleplayer.json",
|
||||
"quests": "resources/config/quests_battle_zone.json",
|
||||
"tutorial": false,
|
||||
@@ -30,16 +36,24 @@
|
||||
},
|
||||
{
|
||||
"id": "battle_zone_multiplayer",
|
||||
"title": { "ru": "Зона боя (сетевая)", "en": "Battle Zone (Multiplayer)" },
|
||||
"title": {
|
||||
"ru": "Зона боя (сетевая)",
|
||||
"en": "Battle Zone (Multiplayer)"
|
||||
},
|
||||
"locations": [
|
||||
"resources/config/locations/battle_zone_uni_exterior.json"
|
||||
"resources/config/locations/battle_zone_korea.json"
|
||||
],
|
||||
"startLocation": "uni_exterior",
|
||||
"startMusic": "music/bishkek univer day.ogg",
|
||||
"startLocation": "park_korea",
|
||||
"startMusic": "music/bishkek fight calm.ogg",
|
||||
"startState": "resources/config/start_state_battle_zone.json",
|
||||
"quests": "resources/config/quests_battle_zone.json",
|
||||
"tutorial": false,
|
||||
"networkPlayerSlots": ["player", "player2"],
|
||||
"networkPlayerSlots": [
|
||||
"player1",
|
||||
"player2",
|
||||
"player3",
|
||||
"player4"
|
||||
],
|
||||
"networkCompatibility": "battle-zone-lockstep-v5",
|
||||
"loadingScreen": "resources/w/ui/screen_loading.json"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "uni_exterior",
|
||||
"id": "park_korea",
|
||||
"gameObjects": "resources/w/shelter/game_objects.json",
|
||||
"interactiveObjects": "resources/w/shelter/interactive_objects.json",
|
||||
"npcs": "resources/config/npcs_battle_zone.json",
|
||||
@@ -161,6 +161,168 @@
|
||||
"hp": 35,
|
||||
"visibleRange" : 6.5,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"localState": {
|
||||
"locations": {
|
||||
"location_dorm": {
|
||||
"cameraAzimuth": -2.3499999046325684,
|
||||
"cameraInclination": 1.103600025177002
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkState": {
|
||||
"campaignId": "main",
|
||||
"chatHistory": [
|
||||
[],
|
||||
@@ -16,8 +25,7 @@
|
||||
true
|
||||
],
|
||||
"currentLocationId": "",
|
||||
"globalFloats": {
|
||||
},
|
||||
"globalFloats": {},
|
||||
"globalInts": {},
|
||||
"inventory": {
|
||||
"items": []
|
||||
@@ -494,6 +502,22 @@
|
||||
-15.038200378417969
|
||||
],
|
||||
"hp": 100.0,
|
||||
"movedLastTick": false,
|
||||
"movementBlockedTicks": 0,
|
||||
"navigationRetryCooldownIndex": 0,
|
||||
"navigationRetryCooldownTicks": [
|
||||
10,
|
||||
4,
|
||||
5,
|
||||
8,
|
||||
11,
|
||||
9,
|
||||
6,
|
||||
3,
|
||||
2,
|
||||
7
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [
|
||||
[
|
||||
-6.296899795532227,
|
||||
@@ -530,8 +554,6 @@
|
||||
},
|
||||
"state": {
|
||||
"activeNavigationIndex": 0,
|
||||
"cameraAzimuth": -2.3499999046325684,
|
||||
"cameraInclination": 1.103600025177002,
|
||||
"targetInteractNpcId": "",
|
||||
"targetInteractiveObjectId": "",
|
||||
"targetTeleportZoneId": "",
|
||||
@@ -574,10 +596,9 @@
|
||||
}
|
||||
},
|
||||
"money": 5500,
|
||||
"playerHp": 200.0,
|
||||
"playerMaxHp": 200.0,
|
||||
"players": {
|
||||
"player": {
|
||||
"player1": {
|
||||
"animationFrame": 0.0,
|
||||
"attackTargetId": "",
|
||||
"attack_cooldown": 0.0,
|
||||
@@ -594,6 +615,11 @@
|
||||
0.0
|
||||
],
|
||||
"hp": 200.0,
|
||||
"movedLastTick": false,
|
||||
"movementBlockedTicks": 0,
|
||||
"navigationRetryCooldownIndex": 0,
|
||||
"navigationRetryCooldownTicks": [],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [
|
||||
[
|
||||
6.8031005859375,
|
||||
@@ -764,12 +790,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-10-30 14:48",
|
||||
"schemaVersion": 1,
|
||||
"savedAt": "2026-11-10 20:38",
|
||||
"schemaVersion": 5,
|
||||
"taxiIsCalled": false,
|
||||
"tutorialJournalScreenOpened": false,
|
||||
"tutorialNeedOpenTaxiScreen": false,
|
||||
"tutorialPhoneChatScreenOpened": false,
|
||||
"tutorialStep": 0,
|
||||
"tutorialTaxiHintDone": false
|
||||
},
|
||||
"persistenceVersion": 1
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"localState": {
|
||||
"locations": {
|
||||
"uni_exterior": {
|
||||
"park_korea": {
|
||||
"cameraAzimuth": -2.3499999046325684,
|
||||
"cameraInclination": 1.103600025177002
|
||||
}
|
||||
@@ -10,9 +10,12 @@
|
||||
"networkState": {
|
||||
"campaignId": "battle_zone",
|
||||
"chatHistory": [
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
[
|
||||
],
|
||||
[
|
||||
],
|
||||
[
|
||||
]
|
||||
],
|
||||
"chatPreviewMsg": [
|
||||
"",
|
||||
@@ -27,15 +30,17 @@
|
||||
"currentLocationId": "",
|
||||
"globalFloats": {
|
||||
},
|
||||
"globalInts": {},
|
||||
"globalInts": {
|
||||
},
|
||||
"inventory": {
|
||||
"items": []
|
||||
"items": [
|
||||
]
|
||||
},
|
||||
"isDarklands": true,
|
||||
"isDawn": false,
|
||||
"isNight": true,
|
||||
"locations": {
|
||||
"uni_exterior": {
|
||||
"park_korea": {
|
||||
"dialogue": {
|
||||
"active": false
|
||||
},
|
||||
@@ -44,7 +49,7 @@
|
||||
"alpha": 1.0,
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 0.6000000238418579,
|
||||
"positionX": 0.60000002384185791,
|
||||
"positionY": 0.45736101269721985,
|
||||
"positionZ": 3.8499999046325684,
|
||||
"rotationY": 0.0,
|
||||
@@ -54,7 +59,7 @@
|
||||
"alpha": 1.0,
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 1.399999976158142,
|
||||
"positionX": 1.3999999761581421,
|
||||
"positionY": 0.45736101269721985,
|
||||
"positionZ": 3.8499999046325684,
|
||||
"rotationY": 0.0,
|
||||
@@ -64,7 +69,7 @@
|
||||
"alpha": 1.0,
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 2.200000047683716,
|
||||
"positionX": 2.2000000476837158,
|
||||
"positionY": 0.45736101269721985,
|
||||
"positionZ": 3.8499999046325684,
|
||||
"rotationY": 0.0,
|
||||
@@ -85,7 +90,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 0.0,
|
||||
"positionY": 3.200000047683716,
|
||||
"positionY": 3.2000000476837158,
|
||||
"positionZ": 0.0,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -95,7 +100,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": -1.0,
|
||||
"positionY": 0.9150000214576721,
|
||||
"positionY": 0.91500002145767212,
|
||||
"positionZ": -3.9000000953674316,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -105,7 +110,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 0.0,
|
||||
"positionY": 0.9150000214576721,
|
||||
"positionY": 0.91500002145767212,
|
||||
"positionZ": -3.9000000953674316,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -115,7 +120,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 1.0,
|
||||
"positionY": 0.9150000214576721,
|
||||
"positionY": 0.91500002145767212,
|
||||
"positionZ": -3.9000000953674316,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -134,7 +139,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -155,14 +160,15 @@
|
||||
11
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -170,7 +176,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
]
|
||||
@@ -187,7 +193,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -208,14 +214,15 @@
|
||||
6
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -223,7 +230,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
]
|
||||
@@ -240,7 +247,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -261,14 +268,15 @@
|
||||
11
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -276,7 +284,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
]
|
||||
@@ -293,7 +301,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -314,14 +322,15 @@
|
||||
6
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -329,7 +338,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
]
|
||||
@@ -346,7 +355,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -367,14 +376,15 @@
|
||||
11
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -382,7 +392,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
]
|
||||
@@ -399,7 +409,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -420,14 +430,15 @@
|
||||
2
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -435,13 +446,338 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.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": {
|
||||
"activeNavigationIndex": 0,
|
||||
"targetInteractNpcId": "",
|
||||
@@ -460,61 +796,23 @@
|
||||
"rotation": 0.0,
|
||||
"steeringAngle": 0.0,
|
||||
"velocity": 0.0,
|
||||
"waypoints": [],
|
||||
"waypoints": [
|
||||
],
|
||||
"wheelAngle": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"teleportZones": {},
|
||||
"triggerZones": {}
|
||||
"teleportZones": {
|
||||
},
|
||||
"triggerZones": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"money": 5500,
|
||||
"playerHp": 200.0,
|
||||
"playerMaxHp": 200.0,
|
||||
"players": {
|
||||
"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.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": {
|
||||
"player1": {
|
||||
"animationFrame": 0.0,
|
||||
"attackTargetId": "",
|
||||
"attack_cooldown": 0.0,
|
||||
@@ -533,18 +831,18 @@
|
||||
"hp": 200.0,
|
||||
"pathWaypoints": [
|
||||
[
|
||||
-5.8,
|
||||
-5.4,
|
||||
0.0,
|
||||
12.8
|
||||
]
|
||||
],
|
||||
"position": [
|
||||
-5.8,
|
||||
-5.4,
|
||||
0.0,
|
||||
12.8
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.8,
|
||||
-5.4,
|
||||
0.0,
|
||||
12.8
|
||||
],
|
||||
@@ -552,14 +850,144 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.0,
|
||||
"walkTarget": [
|
||||
-5.8,
|
||||
-5.4,
|
||||
0.0,
|
||||
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": {
|
||||
"quests": []
|
||||
"quests": [
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-11-05 17:43",
|
||||
"schemaVersion": 5,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"localState": {
|
||||
"locations": {
|
||||
"uni_exterior": {
|
||||
"park_korea": {
|
||||
"cameraAzimuth": -2.3499999046325684,
|
||||
"cameraInclination": 1.103600025177002
|
||||
}
|
||||
@@ -10,9 +10,12 @@
|
||||
"networkState": {
|
||||
"campaignId": "battle_zone",
|
||||
"chatHistory": [
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
[
|
||||
],
|
||||
[
|
||||
],
|
||||
[
|
||||
]
|
||||
],
|
||||
"chatPreviewMsg": [
|
||||
"",
|
||||
@@ -27,15 +30,17 @@
|
||||
"currentLocationId": "",
|
||||
"globalFloats": {
|
||||
},
|
||||
"globalInts": {},
|
||||
"globalInts": {
|
||||
},
|
||||
"inventory": {
|
||||
"items": []
|
||||
"items": [
|
||||
]
|
||||
},
|
||||
"isDarklands": true,
|
||||
"isDawn": false,
|
||||
"isNight": true,
|
||||
"locations": {
|
||||
"uni_exterior": {
|
||||
"park_korea": {
|
||||
"dialogue": {
|
||||
"active": false
|
||||
},
|
||||
@@ -44,7 +49,7 @@
|
||||
"alpha": 1.0,
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 0.6000000238418579,
|
||||
"positionX": 0.60000002384185791,
|
||||
"positionY": 0.45736101269721985,
|
||||
"positionZ": 3.8499999046325684,
|
||||
"rotationY": 0.0,
|
||||
@@ -54,7 +59,7 @@
|
||||
"alpha": 1.0,
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 1.399999976158142,
|
||||
"positionX": 1.3999999761581421,
|
||||
"positionY": 0.45736101269721985,
|
||||
"positionZ": 3.8499999046325684,
|
||||
"rotationY": 0.0,
|
||||
@@ -64,7 +69,7 @@
|
||||
"alpha": 1.0,
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 2.200000047683716,
|
||||
"positionX": 2.2000000476837158,
|
||||
"positionY": 0.45736101269721985,
|
||||
"positionZ": 3.8499999046325684,
|
||||
"rotationY": 0.0,
|
||||
@@ -85,7 +90,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 0.0,
|
||||
"positionY": 3.200000047683716,
|
||||
"positionY": 3.2000000476837158,
|
||||
"positionZ": 0.0,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -95,7 +100,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": -1.0,
|
||||
"positionY": 0.9150000214576721,
|
||||
"positionY": 0.91500002145767212,
|
||||
"positionZ": -3.9000000953674316,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -105,7 +110,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 0.0,
|
||||
"positionY": 0.9150000214576721,
|
||||
"positionY": 0.91500002145767212,
|
||||
"positionZ": -3.9000000953674316,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -115,7 +120,7 @@
|
||||
"isActive": true,
|
||||
"isAnimating": false,
|
||||
"positionX": 1.0,
|
||||
"positionY": 0.9150000214576721,
|
||||
"positionY": 0.91500002145767212,
|
||||
"positionZ": -3.9000000953674316,
|
||||
"rotationY": 0.0,
|
||||
"scale": 1.0
|
||||
@@ -134,7 +139,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -155,14 +160,15 @@
|
||||
11
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -170,7 +176,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.0
|
||||
]
|
||||
@@ -187,7 +193,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -208,14 +214,15 @@
|
||||
6
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
],
|
||||
@@ -223,7 +230,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.0
|
||||
]
|
||||
@@ -240,7 +247,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -261,14 +268,15 @@
|
||||
11
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -276,7 +284,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-12.5
|
||||
]
|
||||
@@ -293,7 +301,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -314,14 +322,15 @@
|
||||
6
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
],
|
||||
@@ -329,7 +338,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-12.5
|
||||
]
|
||||
@@ -346,7 +355,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -367,14 +376,15 @@
|
||||
11
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -382,7 +392,7 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-5.599999904632568,
|
||||
-5.5999999046325684,
|
||||
0.0,
|
||||
-13.0
|
||||
]
|
||||
@@ -399,7 +409,7 @@
|
||||
"facingAngle": 0.0,
|
||||
"homeDriftCheckTimer": 5.0,
|
||||
"homePosition": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -420,14 +430,15 @@
|
||||
2
|
||||
],
|
||||
"navigationRetryTicks": 0,
|
||||
"pathWaypoints": [],
|
||||
"pathWaypoints": [
|
||||
],
|
||||
"position": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
"requestedWalkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.0,
|
||||
-13.0
|
||||
],
|
||||
@@ -435,13 +446,338 @@
|
||||
"targetFacingAngle": 0.0,
|
||||
"visibleRange": 6.5,
|
||||
"walkTarget": [
|
||||
-6.400000095367432,
|
||||
-6.4000000953674316,
|
||||
0.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": {
|
||||
"activeNavigationIndex": 0,
|
||||
"targetInteractNpcId": "",
|
||||
@@ -460,61 +796,23 @@
|
||||
"rotation": 0.0,
|
||||
"steeringAngle": 0.0,
|
||||
"velocity": 0.0,
|
||||
"waypoints": [],
|
||||
"waypoints": [
|
||||
],
|
||||
"wheelAngle": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"teleportZones": {},
|
||||
"triggerZones": {}
|
||||
"teleportZones": {
|
||||
},
|
||||
"triggerZones": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"money": 5500,
|
||||
"playerHp": 200.0,
|
||||
"playerMaxHp": 200.0,
|
||||
"players": {
|
||||
"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
|
||||
]
|
||||
},
|
||||
"player": {
|
||||
"player1": {
|
||||
"animationFrame": 0.0,
|
||||
"attackTargetId": "",
|
||||
"attack_cooldown": 0.0,
|
||||
@@ -556,10 +854,140 @@
|
||||
0.0,
|
||||
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": {
|
||||
"quests": []
|
||||
"quests": [
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-11-05 17:43",
|
||||
"schemaVersion": 5,
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
"y": 10,
|
||||
"width": 160,
|
||||
"height": 36,
|
||||
"text": "v. 1.0.7",
|
||||
"text": "v. 1.1.2",
|
||||
"fontSize": 24,
|
||||
"textCentered": false,
|
||||
"topAligned": true,
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"y": 10,
|
||||
"width": 160,
|
||||
"height": 36,
|
||||
"text": "v. 1.0.7",
|
||||
"text": "v. 1.1.2",
|
||||
"fontSize": 24,
|
||||
"textCentered": false,
|
||||
"topAligned": true,
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
"y": 10,
|
||||
"width": 160,
|
||||
"height": 36,
|
||||
"text": "v. 1.0.7",
|
||||
"text": "v. 1.1.2",
|
||||
"fontSize": 24,
|
||||
"textCentered": false,
|
||||
"topAligned": true,
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
"y": 10,
|
||||
"width": 160,
|
||||
"height": 36,
|
||||
"text": "v. 1.0.7",
|
||||
"text": "v. 1.1.2",
|
||||
"fontSize": 24,
|
||||
"textCentered": false,
|
||||
"topAligned": true,
|
||||
|
||||
+28
-7
@@ -9,6 +9,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..")
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
include("${GAME_ROOT}/cmake/FetchDependenciesServer.cmake")
|
||||
find_package(Eigen3 3.3 CONFIG QUIET)
|
||||
if(NOT TARGET Eigen3::Eigen)
|
||||
set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0")
|
||||
@@ -105,12 +106,11 @@ endif()
|
||||
add_executable(GameServer src/main.cpp README.md)
|
||||
source_group("Server" FILES src/main.cpp README.md)
|
||||
target_link_libraries(GameServer PRIVATE game_server_core)
|
||||
if(WIN32)
|
||||
set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0")
|
||||
if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp")
|
||||
message(FATAL_ERROR
|
||||
"Boost was not found at thirdparty/boost_1_90_0. Configure the "
|
||||
"graphical project once to populate it.")
|
||||
"Boost was not found at thirdparty/boost_1_90_0 and could not be "
|
||||
"downloaded.")
|
||||
endif()
|
||||
target_sources(GameServer PRIVATE
|
||||
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
|
||||
@@ -118,11 +118,20 @@ if(WIN32)
|
||||
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp"
|
||||
"${GAME_ROOT}/src/game_server/WebsocketGameServer.h")
|
||||
target_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}")
|
||||
target_compile_definitions(GameServer PRIVATE NETWORK)
|
||||
if(WIN32)
|
||||
target_compile_definitions(
|
||||
GameServer PRIVATE NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
|
||||
GameServer PRIVATE WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(GameServer PRIVATE ws2_32 mswsock)
|
||||
endif()
|
||||
|
||||
# The server reads configs from ./resources relative to its working directory,
|
||||
# so keep a copy next to the binary after every build.
|
||||
add_custom_command(TARGET GameServer POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${GAME_ROOT}/resources" "$<TARGET_FILE_DIR:GameServer>/resources"
|
||||
COMMENT "Copying resources next to GameServer")
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(
|
||||
@@ -141,7 +150,8 @@ if(BUILD_TESTING)
|
||||
add_test(
|
||||
NAME GameServer.LocationState
|
||||
COMMAND GameServerStateTests)
|
||||
if(WIN32)
|
||||
# network tests (all platforms)
|
||||
if(TRUE)
|
||||
add_executable(
|
||||
GameServerNetworkProtocolTests
|
||||
tests/NetworkProtocolTests.cpp
|
||||
@@ -163,9 +173,20 @@ if(BUILD_TESTING)
|
||||
target_compile_definitions(
|
||||
GameServerWebsocketLockstepTests PRIVATE
|
||||
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(
|
||||
GameServerWebsocketLockstepTests PRIVATE game_server_core)
|
||||
if(WIN32)
|
||||
target_compile_definitions(
|
||||
GameServerWebsocketLockstepTests PRIVATE
|
||||
game_server_core ws2_32 mswsock)
|
||||
WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(
|
||||
GameServerWebsocketLockstepTests PRIVATE ws2_32 mswsock)
|
||||
endif()
|
||||
add_test(
|
||||
NAME GameServer.WebsocketLockstep
|
||||
COMMAND GameServerWebsocketLockstepTests)
|
||||
@@ -175,7 +196,7 @@ if(BUILD_TESTING)
|
||||
COMMAND GameServer --ticks 2 --no-sleep)
|
||||
add_test(
|
||||
NAME GameServer.BattleZoneSharedPlayer
|
||||
COMMAND GameServer --campaign battle_zone --location uni_exterior
|
||||
COMMAND GameServer --campaign battle_zone --location park_korea
|
||||
--ticks 2 --no-sleep)
|
||||
set_tests_properties(
|
||||
GameServer.BattleZoneSharedPlayer
|
||||
|
||||
+123
-62
@@ -1,11 +1,37 @@
|
||||
# Headless GameServer
|
||||
|
||||
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
|
||||
audio, Lua, or rendering dependencies. On Windows it exposes a deterministic
|
||||
lockstep session through a Boost.Beast WebSocket listener.
|
||||
audio, Lua, or rendering dependencies. It exposes a deterministic lockstep
|
||||
session through a Boost.Beast WebSocket listener (Windows and Linux).
|
||||
|
||||
## Requirements
|
||||
|
||||
- CMake >= 3.16, a C++17 compiler (MSVC, or GCC/Clang on Linux)
|
||||
- Ubuntu: `sudo apt install build-essential cmake git unzip curl`
|
||||
- Internet access on the first configure (see below)
|
||||
|
||||
Eigen and Boost are **downloaded automatically** by
|
||||
`cmake/FetchDependenciesServer.cmake` into `thirdparty/` the first time CMake
|
||||
configures the project (Boost zip is ~200 MB). A system Eigen3 is used if
|
||||
installed; Boost is always taken from `thirdparty/boost_1_90_0` so versions match
|
||||
the Windows build. Nothing else (SDL, zlib, libzip, ...) is needed.
|
||||
|
||||
## Build
|
||||
|
||||
Linux (recommended helper, 1 compile job to stay within low RAM):
|
||||
|
||||
```bash
|
||||
server/scripts/build.sh # JOBS=2 server/scripts/build.sh for more
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build server/build --target GameServer --parallel 1
|
||||
ctest --test-dir server/build --output-on-failure
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
@@ -14,91 +40,126 @@ cmake --build server/build --config Release --target GameServer
|
||||
ctest --test-dir server/build -C Release --output-on-failure
|
||||
```
|
||||
|
||||
Linux:
|
||||
> **Memory note:** compiling Boost.Beast code can use 1-1.5 GB per translation
|
||||
> unit. On a small VDS keep `--parallel 1`; if the compiler gets OOM-killed, add
|
||||
> temporary swap (`sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile &&
|
||||
> sudo mkswap /swapfile && sudo swapon /swapfile`). The running server itself
|
||||
> needs only a few tens of MB.
|
||||
|
||||
After every build the repository's `resources/` directory is copied next to the
|
||||
binary (`server/build/resources`, or `server/build/Release/resources` on
|
||||
Windows). Content paths are resolved from the process working directory, so the
|
||||
server must be started from the directory that contains `resources/`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build server/build --target GameServer --parallel
|
||||
ctest --test-dir server/build --output-on-failure
|
||||
server/scripts/run-server.sh
|
||||
```
|
||||
|
||||
Eigen3 must either be installed or be available at
|
||||
`thirdparty/eigen-5.0.0`. The headless target reads ordinary filesystem resources
|
||||
only; ZIP-backed campaign loading belongs to the later content-loading
|
||||
milestone.
|
||||
|
||||
Run until interrupted:
|
||||
This does `cd server/build` and runs
|
||||
|
||||
```bash
|
||||
./server/build/GameServer
|
||||
./GameServer --campaign battle_zone_multiplayer --address 127.0.0.1 --port 8081
|
||||
```
|
||||
|
||||
For a multi-configuration Windows build, the executable is normally
|
||||
`server/build/Release/GameServer.exe`.
|
||||
Environment overrides: `CAMPAIGN`, `LOCATION`, `ADDRESS`, `PORT`, `BUILD_DIR`.
|
||||
|
||||
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
|
||||
real-time delay.
|
||||
|
||||
Start the multiplayer lockstep server:
|
||||
Windows equivalent:
|
||||
|
||||
```powershell
|
||||
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
|
||||
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer
|
||||
```
|
||||
|
||||
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game
|
||||
afterward and select `battle_zone_multiplayer`; that campaign connects to
|
||||
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
|
||||
Command line options: `--campaign ID`, `--location ID`, `--campaigns PATH`
|
||||
(default `resources/config/campaigns.json`), `--address IP` (default `0.0.0.0`),
|
||||
`--port N` (default `8080`), `--ticks N` and `--no-sleep` (smoke tests).
|
||||
|
||||
The server sends one state snapshot when a player connects, then broadcasts one
|
||||
numbered command bundle every 100 ms. Bundles may be empty. Destination input is
|
||||
scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or
|
||||
client wall-clock values, define simulation time.
|
||||
|
||||
If `--location` is omitted, the campaign's configured start location is used.
|
||||
`--campaigns PATH` selects a different campaign registry. Content paths are
|
||||
resolved from the process working directory, so run from the repository root
|
||||
when using the bundled `resources/` tree.
|
||||
## Production deployment (Ubuntu, systemd + nginx)
|
||||
|
||||
Available startup options:
|
||||
The server listens on loopback only (`127.0.0.1:8081`); nginx terminates TLS and
|
||||
proxies WebSocket traffic.
|
||||
|
||||
```text
|
||||
--campaign ID
|
||||
--location ID
|
||||
--campaigns PATH
|
||||
--address IP
|
||||
--port N
|
||||
--tick-ms 100
|
||||
--ticks N
|
||||
--no-sleep
|
||||
### 1. systemd service
|
||||
|
||||
```bash
|
||||
sudo cp server/deploy/shadow-gameserver.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now shadow-gameserver
|
||||
systemctl status shadow-gameserver
|
||||
journalctl -u shadow-gameserver -f # logs
|
||||
```
|
||||
|
||||
Network mode requires a 100 ms tick; another `--tick-ms` value is rejected.
|
||||
The selected campaign's `networkPlayerSlots` lists pre-existing character IDs
|
||||
assigned to connections in order. `networkCompatibility` is a release token
|
||||
that must be changed whenever deterministic content changes; clients reject a
|
||||
different token. The bundled multiplayer Battle Zone has two slots (`player`
|
||||
and `player2`) and rejects additional clients. Disconnecting
|
||||
releases the slot. The bot's random autonomous controller is disabled in
|
||||
multiplayer.
|
||||
The unit caps memory at 512 MB and restarts on failure. After rebuilding run
|
||||
`sudo systemctl restart shadow-gameserver`. Edit `User=`/paths in the unit if the
|
||||
repository lives elsewhere. To run a second server, copy the unit with another
|
||||
`PORT` (and campaign) and add a matching `/server002/` location.
|
||||
|
||||
The graphical client executes presentation behavior after building its runtime
|
||||
`Location`; the headless content pass loads authoritative navigation, NPC,
|
||||
interactive-object, trigger, teleport, taxi, and animation metadata. Lua and
|
||||
dialogue-driven shared multiplayer behavior is not yet supported.
|
||||
### 2. nginx
|
||||
|
||||
## Tests
|
||||
```bash
|
||||
sudo cp server/deploy/nginx-api.shadowoverbishkek.com.conf \
|
||||
/etc/nginx/sites-available/api.shadowoverbishkek.com
|
||||
sudo ln -s /etc/nginx/sites-available/api.shadowoverbishkek.com \
|
||||
/etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
CTest covers:
|
||||
`location /server001/` proxies to `http://127.0.0.1:8081/` with WebSocket
|
||||
upgrade headers and a 1 hour read/send timeout (the trailing slashes strip the
|
||||
`/server001` prefix).
|
||||
|
||||
- repeated `LocationGameState::setup()` preserving mutable runtime state;
|
||||
- ticking a prepared location with zero players;
|
||||
- deterministic fixed-100 ms replay behavior;
|
||||
- protocol-v2 parsing, validation, target normalization, state hashing, and
|
||||
two-tick command ordering;
|
||||
- a WebSocket initial-state/slot/destination/tick integration test;
|
||||
- idle server startup;
|
||||
- headless loading of `battle_zone_multiplayer/uni_exterior`.
|
||||
### 3. HTTPS certificate
|
||||
|
||||
The lockstep implementation currently targets identical executable and content
|
||||
builds. Diagnostic state hashes detect divergence, but there is no automatic
|
||||
checkpoint resynchronization or reconnect input history yet.
|
||||
DNS for `api.shadowoverbishkek.com` must point at the VDS first.
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d api.shadowoverbishkek.com
|
||||
```
|
||||
|
||||
Certbot issues a Let's Encrypt certificate, adds the `443 ssl` block and an HTTP
|
||||
to HTTPS redirect, and installs auto-renewal (`systemctl list-timers | grep certbot`).
|
||||
|
||||
## Connecting
|
||||
|
||||
Public endpoint: **`wss://api.shadowoverbishkek.com/server001/`**
|
||||
|
||||
Handshake check from any machine (expects `HTTP/1.1 101 Switching Protocols`):
|
||||
|
||||
```bash
|
||||
curl -i -N --max-time 3 \
|
||||
-H "Connection: Upgrade" -H "Upgrade: websocket" \
|
||||
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
|
||||
https://api.shadowoverbishkek.com/server001/
|
||||
```
|
||||
|
||||
Or interactively: `npx wscat -c wss://api.shadowoverbishkek.com/server001/`.
|
||||
|
||||
Locally on the VDS, bypassing nginx: `ws://127.0.0.1:8081`.
|
||||
|
||||
> **Client limitation:** `src/game_server/WebsocketGameClient.cpp` currently
|
||||
> speaks plain `ws://host:port` to `/` (no TLS, no path). Until TLS and path
|
||||
> support are added to the client, the graphical game can only connect directly
|
||||
> to a non-proxied server (e.g. `ws://127.0.0.1:8080`), not to the `wss://` URL.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Animation metadata failed ... Invalid animation file`: the `.anim` files are
|
||||
Git LFS pointer stubs (`version https://git-lfs...`). Fetch the real files with
|
||||
`git lfs pull` (or copy `resources/` from a full checkout) and rebuild.
|
||||
- `GameServer.LocationState` test currently fails on every platform: it expects
|
||||
`battle_zone` and `battle_zone_multiplayer` to share a start-state file, but
|
||||
`campaigns.json` points them to different files.
|
||||
|
||||
- `Server content setup failed`: the server was not started from the directory
|
||||
containing `resources/` (use `run-server.sh`).
|
||||
- `WebSocket listener setup failed`: port already in use (`ss -ltnp | grep 8081`).
|
||||
- `502 Bad Gateway` from nginx: service not running (`systemctl status
|
||||
shadow-gameserver`).
|
||||
- Connection drops after ~60 s: check `proxy_read_timeout` in the nginx location.
|
||||
- Compiler "Killed" / `internal compiler error`: out of memory, see the memory note.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Install to /etc/nginx/sites-available/api.shadowoverbishkek.com and symlink
|
||||
# into sites-enabled. Then: sudo certbot --nginx -d api.shadowoverbishkek.com
|
||||
server {
|
||||
server_name api.shadowoverbishkek.com;
|
||||
|
||||
access_log /var/log/nginx/api.shadowoverbishkek.com-access.log;
|
||||
error_log /var/log/nginx/api.shadowoverbishkek.com-error.log;
|
||||
|
||||
# wss://api.shadowoverbishkek.com/server001/ -> 127.0.0.1:8081
|
||||
location /server001/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/api.shadowoverbishkek.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.shadowoverbishkek.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = api.shadowoverbishkek.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
server_name api.shadowoverbishkek.com;
|
||||
|
||||
listen 80;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
Executable
+9
@@ -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"
|
||||
Executable
+16
@@ -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[@]}"
|
||||
@@ -79,7 +79,7 @@ int main()
|
||||
return fail("Battle Zone modes do not share their initial state");
|
||||
}
|
||||
if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 ||
|
||||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player" ||
|
||||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player1" ||
|
||||
multiplayerBattleZoneConfig->networkPlayerSlots[1] !=
|
||||
"player2" ||
|
||||
multiplayerBattleZoneConfig->networkCompatibility.empty()) {
|
||||
@@ -140,7 +140,7 @@ int main()
|
||||
disabledPlayer->enabled = false;
|
||||
state.location->targetInteractiveObjectId = "object";
|
||||
state.location->targetInteractNpcId = "npc";
|
||||
FRG::PlayerStateMap disabledPlayers{{"player", disabledPlayer}};
|
||||
FRG::PlayerStateMap disabledPlayers{{"player1", disabledPlayer}};
|
||||
const FRG::LocationSimulationEvents disabledEvents =
|
||||
state.update(50, disabledPlayers, false);
|
||||
if (!disabledEvents.interactiveObjects.empty() ||
|
||||
@@ -165,7 +165,7 @@ int main()
|
||||
playerTwo->enabled = true;
|
||||
playerTwo->position = {2.f, 0.f, 0.f};
|
||||
FRG::PlayerStateMap combatPlayers{
|
||||
{"player", playerOne}, {"player2", playerTwo}};
|
||||
{"player1", playerOne}, {"player2", playerTwo}};
|
||||
|
||||
state.update(100, combatPlayers, false);
|
||||
if (hostileNpc.attackTargetId != "player2") {
|
||||
@@ -181,7 +181,7 @@ int main()
|
||||
|
||||
playerTwo->hp = 0.f;
|
||||
state.update(100, combatPlayers, false);
|
||||
if (hostileNpc.attackTargetId != "player") {
|
||||
if (hostileNpc.attackTargetId != "player1") {
|
||||
return fail("Hostile NPC did not acquire a replacement after target death");
|
||||
}
|
||||
|
||||
@@ -194,6 +194,59 @@ int main()
|
||||
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;
|
||||
battleZone.id = "battle_zone";
|
||||
battleZone.questsPath = "resources/config/quests_battle_zone.json";
|
||||
@@ -214,22 +267,22 @@ int main()
|
||||
FRG::CampaignConfig multiplayerBattleZone = battleZone;
|
||||
multiplayerBattleZone.id = "battle_zone_multiplayer";
|
||||
multiplayerBattleZone.networkPlayerSlots = {
|
||||
"player", "player2"};
|
||||
"player1", "player2"};
|
||||
networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
|
||||
const auto networkSnapshot = networkServer.snapshot();
|
||||
if (networkSnapshot->campaignId != "battle_zone_multiplayer")
|
||||
return fail("Network campaign ID was not retained");
|
||||
if (networkSnapshot->players.count("player") != 1)
|
||||
if (networkSnapshot->players.count("player1") != 1)
|
||||
return fail("Network campaign shared player was not created");
|
||||
if (networkSnapshot->players.count("player2") != 0)
|
||||
return fail("Network campaign unexpectedly created the test bot");
|
||||
if (networkSnapshot->players.at("player").state.enabled)
|
||||
if (networkSnapshot->players.at("player1").state.enabled)
|
||||
return fail("Network campaign player slot was enabled before connection");
|
||||
|
||||
nlohmann::json loadedNetworkState;
|
||||
networkServer.save(loadedNetworkState);
|
||||
loadedNetworkState["players"]["player2"] =
|
||||
loadedNetworkState["players"]["player"];
|
||||
loadedNetworkState["players"]["player1"];
|
||||
loadedNetworkState["players"]["player2"]["position"] =
|
||||
{4.f, 0.f, 5.f};
|
||||
networkServer.load(loadedNetworkState);
|
||||
@@ -255,7 +308,7 @@ int main()
|
||||
networkServer.submit(FRG::SetCharacterFacingAngleCommand{
|
||||
locationId, "player2", 1.5f, true});
|
||||
networkServer.submit(FRG::SetCharacterFaceTargetCommand{
|
||||
locationId, "player2", "player"});
|
||||
locationId, "player2", "player1"});
|
||||
networkServer.submit(FRG::SetCharacterEnabledCommand{
|
||||
locationId, "player2", false});
|
||||
const auto commandSnapshot = networkServer.snapshot();
|
||||
@@ -318,6 +371,7 @@ int main()
|
||||
moving->hp = 10.f;
|
||||
moving->pathWaypoints = {Eigen::Vector3f(8.f, 0.f, 0.f)};
|
||||
moving->movedLastTick = true;
|
||||
moving->attackTargetId = "stationary";
|
||||
FRG::PlayerStateMap occupancyPlayers{
|
||||
{"stationary", stationary}, {"moving", moving}};
|
||||
if (occupancyState.collectPathObstacles(
|
||||
@@ -325,7 +379,11 @@ int main()
|
||||
occupancyState.collectPathObstacles(
|
||||
"requester", occupancyPlayers, "moving").size() != 2 ||
|
||||
!occupancyState.collectPathObstacles(
|
||||
"stationary", occupancyPlayers).empty()) {
|
||||
"stationary", occupancyPlayers).empty() ||
|
||||
!occupancyState.collectPathObstacles(
|
||||
"moving", occupancyPlayers).empty() ||
|
||||
occupancyState.collectPathObstacles(
|
||||
"moving", occupancyPlayers, "stationary").size() != 1) {
|
||||
return fail("Stationary obstacle collection policy is incorrect");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ int main()
|
||||
const nlohmann::json original{
|
||||
{"schemaVersion", 2},
|
||||
{"campaignId", "battle_zone_multiplayer"},
|
||||
{"players", {{"player", {{"hp", 200.0}}}}},
|
||||
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}}
|
||||
{"players", {{"player1", {{"hp", 200.0}}}}},
|
||||
{"locations", {{"park_korea", {{"state", savedLocationState}}}}}
|
||||
};
|
||||
FRG::InitialStateMessage initial;
|
||||
initial.state = original;
|
||||
initial.assignedPlayerId = "player";
|
||||
initial.assignedPlayerId = "player1";
|
||||
initial.snapshotTick = 42;
|
||||
initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer";
|
||||
const std::string message = FRG::makeInitialStateMessage(initial);
|
||||
@@ -40,11 +40,11 @@ int main()
|
||||
require(parsed.state == original, "state payload did not round trip");
|
||||
require(parsed.snapshotTick == 42, "snapshot tick did not round trip");
|
||||
require(
|
||||
parsed.assignedPlayerId == "player",
|
||||
parsed.assignedPlayerId == "player1",
|
||||
"assigned player did not round trip");
|
||||
require(
|
||||
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
|
||||
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
|
||||
!parsed.state["locations"]["park_korea"]["state"].contains("cameraAzimuth") &&
|
||||
!parsed.state["locations"]["park_korea"]["state"].contains("cameraInclination"),
|
||||
"local camera state leaked into the network payload");
|
||||
|
||||
FRG::DestinationInputMessage input;
|
||||
@@ -87,11 +87,11 @@ int main()
|
||||
FRG::TickMessage tick;
|
||||
tick.tickId = 43;
|
||||
tick.commands.push_back(FRG::ScheduledDestinationCommand{
|
||||
1, "player", parsedInput.target});
|
||||
1, "player1", parsedInput.target});
|
||||
tick.commands.push_back(FRG::ScheduledCharacterEnabledCommand{
|
||||
2, "uni_exterior", "player2", true});
|
||||
2, "park_korea", "player2", true});
|
||||
tick.commands.push_back(FRG::ScheduledCharacterActionCommand{
|
||||
3, "player", "set_target", "hostile_npc"});
|
||||
3, "player1", "set_target", "hostile_npc"});
|
||||
tick.stateHash = "0123456789abcdef";
|
||||
FRG::TickMessage parsedTick;
|
||||
require(
|
||||
@@ -147,9 +147,9 @@ int main()
|
||||
|
||||
FRG::LockstepTimeline timeline(2);
|
||||
const std::uint64_t firstSequence =
|
||||
timeline.schedule("player", Eigen::Vector3f(1.f, 0.f, 1.f));
|
||||
timeline.schedule("player1", Eigen::Vector3f(1.f, 0.f, 1.f));
|
||||
const std::uint64_t secondSequence =
|
||||
timeline.schedule("player_2", Eigen::Vector3f(2.f, 0.f, 2.f));
|
||||
timeline.schedule("player2", Eigen::Vector3f(2.f, 0.f, 2.f));
|
||||
const FRG::TickMessage emptyTick = timeline.advance();
|
||||
require(
|
||||
emptyTick.tickId == 1 && emptyTick.commands.empty(),
|
||||
@@ -163,8 +163,8 @@ int main()
|
||||
commandTick.commands[1]).commandSequence == secondSequence,
|
||||
"scheduled commands were not finalized in stable order");
|
||||
|
||||
timeline.scheduleCharacterEnabled("uni_exterior", "player2", true);
|
||||
timeline.scheduleCharacterAction("player", "clear_attack_target");
|
||||
timeline.scheduleCharacterEnabled("park_korea", "player2", true);
|
||||
timeline.scheduleCharacterAction("player1", "clear_attack_target");
|
||||
const FRG::TickMessage presenceTick = timeline.advance();
|
||||
require(
|
||||
presenceTick.commands.size() == 1 &&
|
||||
|
||||
@@ -32,15 +32,40 @@ bool waitFor(Predicate predicate)
|
||||
|
||||
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;
|
||||
campaign.id = "test_lockstep";
|
||||
campaign.networkPlayerSlots = {"player", "player2"};
|
||||
campaign.networkPlayerSlots = {"player1", "player2"};
|
||||
|
||||
FRG::InternalGameServer authoritative;
|
||||
authoritative.startCampaign(campaign, {}, true, false);
|
||||
nlohmann::json initialState;
|
||||
authoritative.save(initialState);
|
||||
initialState["players"]["player2"] = initialState["players"]["player"];
|
||||
initialState["players"]["player2"] = initialState["players"]["player1"];
|
||||
FRG::LocationGameState testLocation;
|
||||
auto hostileNpc = std::make_shared<FRG::CharacterState>();
|
||||
hostileNpc->enabled = true;
|
||||
@@ -52,7 +77,7 @@ int main()
|
||||
initialState["locations"]["test_location"] = std::move(testLocationState);
|
||||
authoritative.load(initialState);
|
||||
FRG::WebsocketGameServer server(
|
||||
authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep",
|
||||
authoritative, {"player1", "player2"}, "lockstep-v2:test_lockstep",
|
||||
"127.0.0.1", 0);
|
||||
auto client = std::make_unique<FRG::WebsocketGameClient>(
|
||||
"127.0.0.1", std::to_string(server.port()), "/");
|
||||
@@ -66,7 +91,7 @@ int main()
|
||||
return client->initialized();
|
||||
}),
|
||||
"client did not receive initial state");
|
||||
require(client->localPlayerId() == "player", "wrong player slot assigned");
|
||||
require(client->localPlayerId() == "player1", "wrong player slot assigned");
|
||||
|
||||
auto secondClient = std::make_unique<FRG::WebsocketGameClient>(
|
||||
"127.0.0.1", std::to_string(server.port()), "/");
|
||||
@@ -95,9 +120,9 @@ int main()
|
||||
client->update(0);
|
||||
secondClient->update(0);
|
||||
return client->snapshot()->players.at("player2").state.enabled &&
|
||||
secondClient->snapshot()->players.at("player").state.enabled;
|
||||
secondClient->snapshot()->players.at("player1").state.enabled;
|
||||
}) &&
|
||||
authoritative.snapshot()->players.at("player").state.enabled &&
|
||||
authoritative.snapshot()->players.at("player1").state.enabled &&
|
||||
authoritative.snapshot()->players.at("player2").state.enabled,
|
||||
"connection presence was not broadcast to all replicas");
|
||||
|
||||
@@ -115,16 +140,16 @@ int main()
|
||||
const auto first = client->snapshot();
|
||||
const auto second = secondClient->snapshot();
|
||||
const auto host = authoritative.snapshot();
|
||||
return first->players.at("player").state.requestedWalkTarget.isApprox(
|
||||
return first->players.at("player1").state.requestedWalkTarget.isApprox(
|
||||
Eigen::Vector3f(1.23f, 0.f, 2.35f)) &&
|
||||
second->players.at("player2").state.requestedWalkTarget.isApprox(
|
||||
Eigen::Vector3f(-1.23f, 0.f, 3.46f)) &&
|
||||
first->players.at("player2").state.requestedWalkTarget.isApprox(
|
||||
second->players.at("player2").state.requestedWalkTarget) &&
|
||||
second->players.at("player").state.requestedWalkTarget.isApprox(
|
||||
first->players.at("player").state.requestedWalkTarget) &&
|
||||
host->players.at("player").state.requestedWalkTarget.isApprox(
|
||||
first->players.at("player").state.requestedWalkTarget) &&
|
||||
second->players.at("player1").state.requestedWalkTarget.isApprox(
|
||||
first->players.at("player1").state.requestedWalkTarget) &&
|
||||
host->players.at("player1").state.requestedWalkTarget.isApprox(
|
||||
first->players.at("player1").state.requestedWalkTarget) &&
|
||||
host->players.at("player2").state.requestedWalkTarget.isApprox(
|
||||
second->players.at("player2").state.requestedWalkTarget);
|
||||
});
|
||||
@@ -135,9 +160,9 @@ int main()
|
||||
std::cerr << "destination revisions " << first->revision << ", "
|
||||
<< second->revision << ", " << host->revision << "\n"
|
||||
<< "player targets "
|
||||
<< first->players.at("player").state.requestedWalkTarget.transpose()
|
||||
<< first->players.at("player1").state.requestedWalkTarget.transpose()
|
||||
<< " / "
|
||||
<< host->players.at("player").state.requestedWalkTarget.transpose()
|
||||
<< host->players.at("player1").state.requestedWalkTarget.transpose()
|
||||
<< "\nplayer2 targets "
|
||||
<< second->players.at("player2").state.requestedWalkTarget.transpose()
|
||||
<< " / "
|
||||
@@ -158,11 +183,11 @@ int main()
|
||||
waitFor([&]() {
|
||||
client->update(0);
|
||||
secondClient->update(0);
|
||||
return client->snapshot()->players.at("player").state.attackTargetId ==
|
||||
return client->snapshot()->players.at("player1").state.attackTargetId ==
|
||||
"hostile_npc" &&
|
||||
secondClient->snapshot()->players.at("player").state.attackTargetId ==
|
||||
secondClient->snapshot()->players.at("player1").state.attackTargetId ==
|
||||
"hostile_npc" &&
|
||||
authoritative.snapshot()->players.at("player").state.attackTargetId ==
|
||||
authoritative.snapshot()->players.at("player1").state.attackTargetId ==
|
||||
"hostile_npc";
|
||||
}),
|
||||
"target command was not delayed and rebroadcast");
|
||||
@@ -177,9 +202,9 @@ int main()
|
||||
waitFor([&]() {
|
||||
client->update(0);
|
||||
secondClient->update(0);
|
||||
return client->snapshot()->players.at("player").state.attackTargetId.empty() &&
|
||||
secondClient->snapshot()->players.at("player").state.attackTargetId.empty() &&
|
||||
authoritative.snapshot()->players.at("player").state.attackTargetId.empty();
|
||||
return client->snapshot()->players.at("player1").state.attackTargetId.empty() &&
|
||||
secondClient->snapshot()->players.at("player1").state.attackTargetId.empty() &&
|
||||
authoritative.snapshot()->players.at("player1").state.attackTargetId.empty();
|
||||
}),
|
||||
"clear attack target command was not delayed and rebroadcast");
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ public:
|
||||
bool showWeapon = true;
|
||||
|
||||
// --- Stable character references ---
|
||||
// Empty means no target. "player" is the campaign player; NPC IDs are
|
||||
// Empty means no target. "player1" is the campaign player; NPC IDs are
|
||||
// location-local and validated while loading location content.
|
||||
std::string attackTargetId;
|
||||
std::string faceTargetId;
|
||||
|
||||
+9
-3
@@ -558,12 +558,18 @@ namespace FRG
|
||||
logger() << "[network] Campaign '" << campaignId
|
||||
<< "' selected; creating WebSocket client" << std::endl;
|
||||
// Use "ws://127.0.0.1:8080/" for a local development server.
|
||||
static const char* const kMultiplayerServerUrl =
|
||||
"wss://api.shadowoverbishkek.com/server001/";
|
||||
|
||||
static const char* const kMultiplayerServerUrl = "wss://api.shadowoverbishkek.com/server001/";
|
||||
//static const char* const kMultiplayerServerUrl = "ws://127.0.0.1:8080/";
|
||||
|
||||
WebsocketEndpoint endpoint;
|
||||
if (!endpoint.parse(kMultiplayerServerUrl)) {
|
||||
logger() << "[network] Invalid multiplayer server URL: "
|
||||
<< kMultiplayerServerUrl << std::endl;
|
||||
remoteCampaignActive = false;
|
||||
gameServer.use(internalGameServer);
|
||||
gameServerHost.use(localGameServerHost);
|
||||
return;
|
||||
}
|
||||
websocketGameClient = std::make_unique<WebsocketGameClient>(endpoint);
|
||||
remoteGameServerHost =
|
||||
@@ -688,7 +694,7 @@ namespace FRG
|
||||
// Developer workflow: uncomment to (re)generate the campaign's start-state JSON
|
||||
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
|
||||
// Re-comment before shipping.
|
||||
saveInitialState();
|
||||
//saveInitialState();
|
||||
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -890,11 +890,11 @@ namespace FRG
|
||||
}
|
||||
|
||||
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
|
||||
// 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_HEIGHT = 1.6f;
|
||||
static constexpr float NPC_CLICK_RADIUS = 0.35f;
|
||||
static constexpr float NPC_CLICK_HEIGHT = 1.8f;
|
||||
static constexpr float NPC_PICK_EPSILON = 1e-6f;
|
||||
|
||||
Character* closestNpc = nullptr;
|
||||
|
||||
+82
-33
@@ -140,11 +140,16 @@ std::vector<PathFinder::DynamicObstacle> LocationGameState::collectPathObstacles
|
||||
std::sort(characters.begin(), characters.end(),
|
||||
[](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; });
|
||||
|
||||
const CharacterState* requester = findCharacter(requesterId, players);
|
||||
const std::string attackTargetId =
|
||||
requester ? requester->attackTargetId : std::string();
|
||||
std::vector<PathFinder::DynamicObstacle> obstacles;
|
||||
obstacles.reserve(characters.size());
|
||||
for (const auto& [id, character] : characters) {
|
||||
if (id == requesterId || !character->enabled || character->hp <= 0.f)
|
||||
continue;
|
||||
if (id == attackTargetId && id != forcedObstacleId)
|
||||
continue;
|
||||
if (character->movedLastTick && id != forcedObstacleId) continue;
|
||||
obstacles.push_back({character->position, character->collisionRadius});
|
||||
}
|
||||
@@ -173,11 +178,84 @@ bool LocationGameState::replanCharacter(
|
||||
{
|
||||
CharacterState* character = findCharacter(id, players);
|
||||
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(
|
||||
*location, id, collectPathObstacles(id, players, forcedObstacleId));
|
||||
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()
|
||||
{
|
||||
location->taxiCar.waypoints = location->taxiCarWaypoints;
|
||||
@@ -321,13 +399,6 @@ LocationSimulationEvents LocationGameState::update(
|
||||
if (player) playerIds.push_back(id);
|
||||
std::sort(playerIds.begin(), playerIds.end());
|
||||
|
||||
std::vector<std::string> eligiblePlayerIds;
|
||||
for (const std::string& id : playerIds) {
|
||||
const auto& player = players.at(id);
|
||||
if (player->enabled && player->hp > 0.f)
|
||||
eligiblePlayerIds.push_back(id);
|
||||
}
|
||||
|
||||
std::vector<std::string> npcIds;
|
||||
npcIds.reserve(npcs.size());
|
||||
for (const auto& [id, npc] : npcs)
|
||||
@@ -343,30 +414,8 @@ LocationSimulationEvents LocationGameState::update(
|
||||
continue;
|
||||
|
||||
const std::string previousTarget = npc.attackTargetId;
|
||||
const auto locked = players.find(previousTarget);
|
||||
const bool lockValid =
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::string selectedTarget =
|
||||
selectNpcTarget(npc, players, true);
|
||||
|
||||
if (selectedTarget != previousTarget) {
|
||||
npc.stopInPlace();
|
||||
@@ -474,7 +523,7 @@ LocationSimulationEvents LocationGameState::update(
|
||||
source.events.attackDirection});
|
||||
}
|
||||
|
||||
auto player = players.find("player");
|
||||
auto player = players.find("player1");
|
||||
if (!interactiveObjectsLocked &&
|
||||
!location->targetInteractiveObjectId.empty() &&
|
||||
player != players.end() && player->second && player->second->enabled) {
|
||||
@@ -502,7 +551,7 @@ LocationSimulationEvents LocationGameState::update(
|
||||
(player->second->position - npc->position).norm();
|
||||
if (distance <= NPC_TALK_DISTANCE) {
|
||||
player->second->stopInPlace();
|
||||
npc->faceTargetId = "player";
|
||||
npc->faceTargetId = "player1";
|
||||
result.npcInteractionId = location->targetInteractNpcId;
|
||||
location->targetInteractNpcId.clear();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ struct LocationGameState {
|
||||
void load(const nlohmann::json& in);
|
||||
|
||||
private:
|
||||
std::string selectNpcTarget(
|
||||
const CharacterState& npc,
|
||||
const PlayerStateMap& players,
|
||||
bool preserveValidLock) const;
|
||||
bool updateNpcCar(std::int64_t deltaMs);
|
||||
};
|
||||
|
||||
|
||||
@@ -408,6 +408,9 @@ namespace FRG {
|
||||
const std::string& loc = snapshot->currentLocationId;
|
||||
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
|
||||
if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
|
||||
// Other locations (e.g. campaigns that start directly in Darklands) have no
|
||||
// dedicated HUD; reuse the Darklands one so the health bar is shown.
|
||||
if (snapshot->isDarklands && !loc.empty()) return hudUniExtDarkRoot;
|
||||
return hudDormRoot; // dorm, or a new game before the location is set
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ namespace FRG {
|
||||
ScriptEngine::~ScriptEngine() = default;
|
||||
|
||||
void ScriptEngine::init(Location* loc, AudioPlayerAsync* audioPlayer, const std::string& scriptPath) {
|
||||
logger() << "ScriptEngine::init with script path: " << scriptPath << std::endl;
|
||||
impl = std::make_unique<Impl>();
|
||||
sol::state& lua = impl->lua;
|
||||
|
||||
@@ -172,7 +173,7 @@ namespace FRG {
|
||||
<< npcId << "'\n";
|
||||
return;
|
||||
}
|
||||
loc->faceCharacter(*npc, "player");
|
||||
loc->faceCharacter(*npc, "player1");
|
||||
});
|
||||
|
||||
api.set_function("player_walk_to",
|
||||
|
||||
@@ -293,7 +293,7 @@ LocationDefinition LocationContentLoader::load(
|
||||
for (const auto& item : root.value("npcs", json::array())) {
|
||||
NpcData data;
|
||||
data.id = item.at("id").get<std::string>();
|
||||
if (data.id.empty() || data.id == "player" ||
|
||||
if (data.id.empty() || data.id == "player1" ||
|
||||
!ids.insert(data.id).second) {
|
||||
throw std::runtime_error(
|
||||
"Missing, duplicate, or reserved NPC id in " +
|
||||
|
||||
@@ -51,7 +51,7 @@ private:
|
||||
GameSnapshot buildSnapshot() const;
|
||||
|
||||
GameState state_;
|
||||
std::string localPlayerId_ = "player";
|
||||
std::string localPlayerId_ = "player1";
|
||||
std::string resourceZipFile_;
|
||||
std::vector<std::string> networkPlayerSlots_;
|
||||
std::int64_t battleZoneTestBotElapsedMs_ = 0;
|
||||
|
||||
@@ -145,7 +145,7 @@ public:
|
||||
attributes.createOnMainThread = EM_TRUE;
|
||||
socket_ = emscripten_websocket_new(&attributes);
|
||||
if (socket_ <= 0) {
|
||||
fail("create WebSocket");
|
||||
fail("create WebSocket", socket_);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,11 +205,21 @@ private:
|
||||
}
|
||||
|
||||
static EM_BOOL onClose(
|
||||
int, const EmscriptenWebSocketCloseEvent*, void* userData)
|
||||
int, const EmscriptenWebSocketCloseEvent* event, void* userData)
|
||||
{
|
||||
auto* self = static_cast<Impl*>(userData);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -226,11 +236,13 @@ private:
|
||||
return EM_TRUE;
|
||||
}
|
||||
|
||||
void fail(const char* operation)
|
||||
void fail(const char* operation, int result = 0)
|
||||
{
|
||||
connected_ = false;
|
||||
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_;
|
||||
|
||||
+28
-13
@@ -2,6 +2,7 @@
|
||||
#include "Environment.h"
|
||||
#include "utils/Utils.h"
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
#ifdef __ANDROID__
|
||||
#include <android/log.h>
|
||||
#endif
|
||||
@@ -75,14 +76,27 @@ extern "C" void EMSCRIPTEN_KEEPALIVE onFileSystemLoaded() {
|
||||
}
|
||||
|
||||
|
||||
// Single definition of the "logical" viewport size: the *visible* area of the page
|
||||
// (visualViewport), not 100vh, which on mobile includes the area under the URL bar.
|
||||
static void getViewportCssSize(int& w, int& h) {
|
||||
w = EM_ASM_INT({
|
||||
var vv = window.visualViewport;
|
||||
return Math.round((vv && vv.width) || window.innerWidth);
|
||||
});
|
||||
h = EM_ASM_INT({
|
||||
var vv = window.visualViewport;
|
||||
return Math.round((vv && vv.height) || window.innerHeight);
|
||||
});
|
||||
}
|
||||
|
||||
static void applyResize(int logicalW, int logicalH) {
|
||||
// Получаем коэффициент плотности пикселей (например, 2.625 на Pixel или 3.0 на Samsung)
|
||||
double dpr = emscripten_get_device_pixel_ratio();
|
||||
//double dpr = 1; // low quality
|
||||
|
||||
// Вычисляем реальные физические пиксели
|
||||
int physicalW = static_cast<int>(logicalW * dpr);
|
||||
int physicalH = static_cast<int>(logicalH * dpr);
|
||||
int physicalW = static_cast<int>(std::lround(logicalW * dpr));
|
||||
int physicalH = static_cast<int>(std::lround(logicalH * dpr));
|
||||
|
||||
// Устанавливаем размер внутреннего буфера канваса
|
||||
emscripten_set_canvas_element_size("#canvas", physicalW, physicalH);
|
||||
@@ -94,6 +108,11 @@ static void applyResize(int logicalW, int logicalH) {
|
||||
SDL_SetWindowSize(FRG::Environment::window, physicalW, physicalH);
|
||||
}
|
||||
|
||||
// SDL may have written an inline CSS size derived from the physical pixels, which
|
||||
// overrides the stylesheet. Re-assert the CSS box so its aspect always matches the
|
||||
// drawing buffer (otherwise the image is stretched / cropped on mobile).
|
||||
emscripten_set_element_css_size("#canvas", logicalW, logicalH);
|
||||
|
||||
// Пушим событие, чтобы движок пересчитал матрицы проекции
|
||||
SDL_Event e = {};
|
||||
e.type = SDL_WINDOWEVENT;
|
||||
@@ -118,8 +137,8 @@ void MainLoop() {
|
||||
|
||||
// Теперь DOM перестроен, можно забирать актуальные размеры.
|
||||
// Если canvas должен занимать все окно браузера:
|
||||
int canvasW = EM_ASM_INT({ return window.innerWidth; });
|
||||
int canvasH = EM_ASM_INT({ return window.innerHeight; });
|
||||
int canvasW, canvasH;
|
||||
getViewportCssSize(canvasW, canvasH);
|
||||
|
||||
applyResize(canvasW, canvasH);
|
||||
}
|
||||
@@ -141,14 +160,10 @@ EM_BOOL onWebGLContextRestored(int /*eventType*/, const void* /*reserved*/, void
|
||||
|
||||
|
||||
EM_BOOL onWindowResized(int /*eventType*/, const EmscriptenUiEvent* e, void* /*userData*/) {
|
||||
double w, h;
|
||||
emscripten_get_element_css_size("window", &w, &h);
|
||||
int w, h;
|
||||
getViewportCssSize(w, h);
|
||||
|
||||
// Если "window" не поддерживается вашей версией SDK, используем данные из e:
|
||||
// int logicalW = e->windowInnerWidth;
|
||||
// int logicalH = e->windowInnerHeight;
|
||||
|
||||
applyResize(static_cast<int>(w), static_cast<int>(h));
|
||||
applyResize(w, h);
|
||||
return EM_FALSE;
|
||||
}
|
||||
|
||||
@@ -181,8 +196,8 @@ int main(int argc, char* argv[]) {
|
||||
emscripten_set_resize_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, nullptr, EM_FALSE, onWindowResized);
|
||||
emscripten_set_fullscreenchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, EM_TRUE, onFullscreenChanged);
|
||||
|
||||
int canvasW = EM_ASM_INT({ return window.innerWidth; });
|
||||
int canvasH = EM_ASM_INT({ return window.innerHeight; });
|
||||
int canvasW, canvasH;
|
||||
getViewportCssSize(canvasW, canvasH);
|
||||
applyResize(canvasW, canvasH);
|
||||
|
||||
SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0");
|
||||
|
||||
Reference in new issue
Block a user