Compare commits

..

4 Commits

Author SHA1 Message Date
Martin Slachta 2e95c941ba #2 - Docker building for older glibc 2026-08-05 10:00:31 +02:00
Martin Slachta 2f0662e2bb #2 - Static linking & dependency reduction 2026-08-05 09:59:25 +02:00
Martin Slachta a097f4d4b0 #1 - ClientWorldController refactoring 2026-08-03 23:30:18 +02:00
Martin Slachta f4174eb0c7 #1 - quicr module 2026-08-01 22:43:09 +02:00
92 changed files with 2559 additions and 2039 deletions
+1 -4
View File
@@ -12,8 +12,5 @@ cmake-build-debug/
compile_commands.json
*.md
# cloned by FetchContent at configure time
external/glm/
external/entt/
external/jolt/
# cloned by FetchContent into the build tree at configure time
external/tracy/
+1
View File
@@ -2,6 +2,7 @@ build/
Debug/
Release/
Testing/
dist/
.cache
imgui.ini
+2 -2
View File
@@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.26)
project(towards)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(cmake/CompilerOptions.cmake)
include(cmake/Dependencies.cmake)
@@ -50,8 +52,6 @@ target_link_libraries(${PROJECT_NAME}
Jolt
tl::expected
Tracy::TracyClient
pqxx
pq
)
include(cmake/Modules.cmake)
+14
View File
@@ -41,3 +41,17 @@ Server is at `build/modules/client/mmo_server`
Client is at `build/modules/client/mmo_client`
First run the server, so that client can connect. The server will try to use port 8080, but if it is already occupied, it will try use the next free higher one.
## Docker build
It also possible to build the project using Docker. Advantage is that you do not have to set up the environment. The current Dockerfile also uses older linux distribution to be backwards compatible with older systems. To build everything, build the Dockerfile at `docker/Dockerfile` with until target `builder`:
```
$ docker build -f docker/Dockerfile --target builder -t tw_builder:jammy .
```
To easily export the built executables, run the target `export` of the same Dockerfile.
```
$ docker build -f docker/Dockerfile --target export --output type=local,dest=dist .
```
+3 -2
View File
@@ -4,8 +4,9 @@ set(LFT_ENABLE_EXAMPLES OFF CACHE BOOL "Disable loft examples" FORCE)
set(FASTNOISE2_NOISETOOL OFF CACHE BOOL "Disable FastNoise2 graph tool" FORCE)
find_package(Vulkan REQUIRED)
find_package(SDL2 REQUIRED)
find_package(Protobuf CONFIG REQUIRED)
find_package(SDL3 CONFIG REQUIRED)
find_library(LIBDECOR_LIB decor-0)
find_package(Protobuf CONFIG REQUIRED VERSION 3.75)
include(FetchContent)
set(CMAKE_CONFIGURATION_TYPES "Debug;Release;Distribution")
+29 -5
View File
@@ -24,13 +24,37 @@ services:
depends_on:
- timescaledb
zone-server:
build: .
# The zone server is not containerised yet: it needs libpqxx >= 7.7 for
# pqxx::params and jammy carries 6.4.
# ── e2e harness ────────────────────────────────────────────────────────────
# Opt in with `--profile e2e`, so a plain `docker compose up` still brings up
# the database and dashboards on their own.
#
# docker compose --profile e2e up --build \
# --abort-on-container-exit --exit-code-from chat-mock-client
chat-server:
profiles: [e2e]
build:
context: .
dockerfile: docker/Dockerfile
target: chat-server
ports:
- "8101:8101/udp"
- "8102:8102/udp"
- "8099:8099/udp"
chat-mock-client:
profiles: [e2e]
build:
context: .
dockerfile: docker/Dockerfile
target: chat-mock-client
# Address.hpp parses peers with inet_addr, which takes literal IPv4 only and
# never consults DNS, so a service name is not addressable. Sharing the
# server's network namespace makes its default 127.0.0.1 land on the server.
network_mode: "service:chat-server"
depends_on:
- timescaledb
- chat-server
command: ["--channel", "1"]
volumes:
timescaledb_data:
+17 -3
View File
@@ -1,14 +1,28 @@
[requires]
protobuf/7.35.0
sdl/2.32.10
sdl/3.4.8
[tool_requires]
protobuf/7.35.0
[options]
sdl/*:pulse=False
libffi/*:shared=True
protobuf/*:shared=False
sdl/*:shared=False
sdl/*:camera=False
sdl/*:dialog=False
sdl/*:tray=False
sdl/*:sensor=False
sdl/*:alsa=True
sdl/*:pulseaudio=False
sdl/*:sndio=False
sdl/*:opengl=False
sdl/*:opengles=False
sdl/*:vulkan=True
sdl/*:joystick=False
sdl/*:haptic=False
sdl/*:hidapi=False
[generators]
CMakeDeps
CMakeToolchain
+147
View File
@@ -0,0 +1,147 @@
# syntax=docker/dockerfile:1.7
#
# Builds against Ubuntu 22.04 (glibc 2.35) so the binaries also run on
# distributions older than the developer machine. Two things make that work:
#
# * GCC 14 from the toolchain PPA — jammy's own gcc-11 has no <print>, which
# eight translation units use.
# * -static-libstdc++ -static-libgcc — the target then needs nothing newer
# than the glibc of this base image.
#
# docker build -f docker/Dockerfile --target mock-client -t tw_mock_client .
# docker build -f docker/Dockerfile --target test .
# docker build -f docker/Dockerfile --target export --output type=local,dest=dist .
ARG UBUNTU_VERSION=22.04
# ── toolchain stage ──────────────────────────────────────────────────────────
FROM ubuntu:${UBUNTU_VERSION} AS toolchain
ENV DEBIAN_FRONTEND=noninteractive
# jammy's Vulkan headers are 1.3.204, which predates the VkBufferUsageFlags2
# constants the renderer uses, and its glslang predates the -gVS the shader
# target passes. LunarG publishes current headers, loader and glslang for jammy,
# and outranks the distro packages on version.
ARG VULKAN_SDK_VERSION=1.4.313
# The apt lists are kept: conan installs the xorg and egl system packages itself
# while resolving SDL.
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates gnupg wget \
&& add-apt-repository -y ppa:ubuntu-toolchain-r/test \
&& wget -qO /etc/apt/trusted.gpg.d/lunarg.asc \
https://packages.lunarg.com/lunarg-signing-key-pub.asc \
&& wget -qO /etc/apt/sources.list.d/lunarg-vulkan.list \
"https://packages.lunarg.com/vulkan/${VULKAN_SDK_VERSION}/lunarg-vulkan-${VULKAN_SDK_VERSION}-jammy.list" \
&& apt-get update && apt-get install -y --no-install-recommends \
gcc-14 g++-14 \
git make ninja-build ccache pkg-config \
python3-pip \
vulkan-headers libvulkan-dev glslang-tools \
libdecor-0-dev
# cmake comes from pip because jammy ships 3.22 and the project asks for 3.26.
ARG CMAKE_VERSION=4.4.0
ARG CONAN_VERSION=2.31.1
RUN pip3 install --no-cache-dir "cmake==${CMAKE_VERSION}" "conan==${CONAN_VERSION}"
# Some autotools dependencies (flex, by way of SDL) run sub-configures that look
# for an unsuffixed gcc/cc and ignore $CC, so 14 has to answer to the plain names.
RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 100 \
--slave /usr/bin/g++ g++ /usr/bin/g++-14 \
--slave /usr/bin/gcov gcov /usr/bin/gcov-14 \
&& update-alternatives --install /usr/bin/cc cc /usr/bin/gcc-14 100 \
&& update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-14 100
ENV CC=gcc
ENV CXX=g++
COPY docker/conan/jammy /etc/conan/jammy
# ── dependency stage ─────────────────────────────────────────────────────────
# Kept apart from the build so that editing sources does not rebuild SDL and
# protobuf. The conan cache lives in the image layer rather than a cache mount,
# so the generators in /deps can never outlive the packages they point at.
FROM toolchain AS deps
COPY conanfile.txt /src/conanfile.txt
RUN conan install /src/conanfile.txt \
--profile:all=/etc/conan/jammy \
--build=missing \
--output-folder=/deps
# ── build stage ──────────────────────────────────────────────────────────────
# The build tree is a cache mount, so an edit rebuilds only what it touched.
# That is also why glm, entt and Jolt have to travel in the build context: their
# FetchContent SOURCE_DIRs point into external/, while the stamps that record
# them as populated live in the cached build tree. Dropping them from the
# context would leave the stamps pointing at empty directories.
FROM deps AS builder
WORKDIR /src
COPY . .
# tw_server is absent on purpose: it needs libpqxx >= 7.7 for pqxx::params and
# jammy carries 6.4.
RUN --mount=type=cache,target=/build \
--mount=type=cache,target=/root/.ccache \
cmake -S /src -B /build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=/deps/conan_toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release \
-DUSE_MOLD_LINKER=OFF \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_EXE_LINKER_FLAGS="-static-libstdc++ -static-libgcc" \
&& cmake --build /build --target \
tw_client \
tw_mock_client \
tw_chat_server_exe \
tw_chat_mock_client \
tw_message_protocol_tests \
tw_metrics_tests \
tw_network_tests \
tw_peer_to_peer_tests \
&& mkdir -p /out/bin /out/shaders \
&& cp /build/modules/client/tw_client \
/build/modules/mock_client/tw_mock_client \
/build/modules/chat_service/chat_server_exe/tw_chat_server_exe \
/build/modules/chat_service/chat_service/tests/chat_mock_client/tw_chat_mock_client \
/out/bin/ \
&& cp /build/modules/client/shaders/*.spirv /out/shaders/
# ── unit test stage ──────────────────────────────────────────────────────────
# Catch2 suites only. The process harnesses below need peers and a network, so
# they run as compose services instead.
FROM builder AS test
RUN --mount=type=cache,target=/build \
ctest --test-dir /build --output-on-failure
# ── client export stage ──────────────────────────────────────────────────────
# Not runnable as a container; the client needs a GPU and a display. Extract it:
# docker build -f docker/Dockerfile --target export --output type=local,dest=dist .
FROM scratch AS export
COPY --from=builder /out/bin/tw_client /tw_client
COPY --from=builder /out/shaders/ /shaders/
# ── runtime stages ───────────────────────────────────────────────────────────
FROM gcr.io/distroless/base-debian12:nonroot AS mock-client
COPY --from=builder /out/bin/tw_mock_client /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/tw_mock_client"]
FROM gcr.io/distroless/base-debian12:nonroot AS chat-server
COPY --from=builder /out/bin/tw_chat_server_exe /usr/local/bin/
EXPOSE 8101/udp
ENTRYPOINT ["/usr/local/bin/tw_chat_server_exe"]
FROM gcr.io/distroless/base-debian12:nonroot AS chat-mock-client
COPY --from=builder /out/bin/tw_chat_mock_client /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/tw_chat_mock_client"]
+14
View File
@@ -0,0 +1,14 @@
[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.version=14
compiler.cppstd=gnu23
compiler.libcxx=libstdc++11
os=Linux
[conf]
# xorg/system and egl/system resolve to apt packages; the build runs as root so
# no sudo is available or needed.
tools.system.package_manager:mode=install
tools.system.package_manager:sudo=False
+2 -4
View File
@@ -3,10 +3,9 @@ project(imgui)
file(GLOB FILES
./*.cpp
./backends/imgui_impl_vulkan.cpp
./backends/imgui_impl_sdl2.cpp
./backends/imgui_impl_sdl3.cpp
)
find_package(SDL2 QUIET)
find_package(Vulkan REQUIRED)
add_library(${PROJECT_NAME} OBJECT ${FILES})
@@ -21,11 +20,10 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PUBLIC
volk::volk
${SDL2_LIBRARIES}
sdl::sdl
)
target_include_directories(${PROJECT_NAME}
PRIVATE
${volk_INCLUDE_DIRS}
${SDL2_INCLUDE_DIRS}
)
+1 -1
View File
@@ -25,7 +25,7 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PUBLIC
volk::volk
${SDL2_LIBRARIES}
sdl::sdl
imgui::imgui
)
#
+2 -3
View File
@@ -5,13 +5,13 @@ option(LFT_ENABLE_EXAMPLES "Enable building examples" ON)
if (WIN32)
set(VOLK_STATIC_DEFINES VK_USE_PLATFORM_WIN32_KHR)
elseif(UNIX)
set(VOLK_STATIC_DEFINES VK_USE_PLATFORM_XCB_KHR)
set(VOLK_STATIC_DEFINES VK_USE_PLATFORM_XCB_KHR VK_USE_PLATFORM_WAYLAND_KHR)
endif()
# Please set default paths for vulkan and SDL2
set(VULKAN_PATH "$ENV{VULKAN_SDK}" CACHE STRING "Path to vulkan directory")
set(SDL2_PATH "$ENV{VULKAN_SDK}/cmake" CACHE STRING "Path to SDL2 directory")
# set(SDL2_PATH "$ENV{VULKAN_SDK}/cmake" CACHE STRING "Path to SDL2 directory")
list(APPEND CMAKE_PREFIX_PATH "$ENV{VULKAN_SDK}/cmake")
@@ -51,7 +51,6 @@ add_definitions(
)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_BUILD_TYPE Debug)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
-140
View File
@@ -1,140 +0,0 @@
# CMAKE generated file: DO NOT EDIT!
# Generated by "Unix Makefiles" Generator, CMake Version 3.30
# Default target executed when no arguments are given to make.
default_target: all
.PHONY : default_target
# Allow only one "make -f Makefile2" at a time, but pass parallelism.
.NOTPARALLEL:
#=============================================================================
# Special targets provided by cmake.
# Disable implicit rules so canonical targets will work.
.SUFFIXES:
# Disable VCS-based implicit rules.
% : %,v
# Disable VCS-based implicit rules.
% : RCS/%
# Disable VCS-based implicit rules.
% : RCS/%,v
# Disable VCS-based implicit rules.
% : SCCS/s.%
# Disable VCS-based implicit rules.
% : s.%
.SUFFIXES: .hpux_make_needs_suffix_list
# Command-line flag to silence nested $(MAKE).
$(VERBOSE)MAKESILENT = -s
#Suppress display of executed commands.
$(VERBOSE).SILENT:
# A target that is always out of date.
cmake_force:
.PHONY : cmake_force
#=============================================================================
# Set environment variables for the build.
# The shell in which to execute make rules.
SHELL = /bin/sh
# The CMake executable.
CMAKE_COMMAND = /usr/bin/cmake
# The command to remove a file.
RM = /usr/bin/cmake -E rm -f
# Escaping for special characters.
EQUALS = =
# The top-level source directory on which CMake was run.
CMAKE_SOURCE_DIR = /home/martin/projects/mmo
# The top-level build directory on which CMake was run.
CMAKE_BINARY_DIR = /home/martin/projects/mmo
#=============================================================================
# Targets provided globally by CMake.
# Special rule for the target edit_cache
edit_cache:
@$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake cache editor..."
/usr/bin/ccmake -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR)
.PHONY : edit_cache
# Special rule for the target edit_cache
edit_cache/fast: edit_cache
.PHONY : edit_cache/fast
# Special rule for the target rebuild_cache
rebuild_cache:
@$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake to regenerate build system..."
/usr/bin/cmake --regenerate-during-build -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR)
.PHONY : rebuild_cache
# Special rule for the target rebuild_cache
rebuild_cache/fast: rebuild_cache
.PHONY : rebuild_cache/fast
# The main all target
all: cmake_check_build_system
cd /home/martin/projects/mmo && $(CMAKE_COMMAND) -E cmake_progress_start /home/martin/projects/mmo/CMakeFiles /home/martin/projects/mmo/external/loft//CMakeFiles/progress.marks
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/all
$(CMAKE_COMMAND) -E cmake_progress_start /home/martin/projects/mmo/CMakeFiles 0
.PHONY : all
# The main clean target
clean:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/clean
.PHONY : clean
# The main clean target
clean/fast: clean
.PHONY : clean/fast
# Prepare targets for installation.
preinstall: all
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/preinstall
.PHONY : preinstall
# Prepare targets for installation.
preinstall/fast:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/preinstall
.PHONY : preinstall/fast
# clear depends
depend:
cd /home/martin/projects/mmo && $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 1
.PHONY : depend
# Help Target
help:
@echo "The following are some of the valid targets for this Makefile:"
@echo "... all (the default if no target is provided)"
@echo "... clean"
@echo "... depend"
@echo "... edit_cache"
@echo "... rebuild_cache"
.PHONY : help
#=============================================================================
# Special targets to cleanup operation of make.
# Special rule to run CMake to check the build system integrity.
# No rule that depends on this can have commands that come from listfiles
# because they might be regenerated.
cmake_check_build_system:
cd /home/martin/projects/mmo && $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 0
.PHONY : cmake_check_build_system
+1 -1
View File
@@ -111,7 +111,7 @@ target_link_libraries(
target_link_libraries(
${PROJECT_NAME} PRIVATE
${SDL2_LIBRARIES}
sdl::sdl
)
get_property(dirs DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY INCLUDE_DIRECTORIES)
@@ -17,7 +17,8 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PUBLIC
volk::volk
${SDL2_LIBRARIES})
sdl::sdl
)
target_include_directories(${PROJECT_NAME}
PUBLIC
-560
View File
@@ -1,560 +0,0 @@
# CMAKE generated file: DO NOT EDIT!
# Generated by "Unix Makefiles" Generator, CMake Version 3.30
# Default target executed when no arguments are given to make.
default_target: all
.PHONY : default_target
# Allow only one "make -f Makefile2" at a time, but pass parallelism.
.NOTPARALLEL:
#=============================================================================
# Special targets provided by cmake.
# Disable implicit rules so canonical targets will work.
.SUFFIXES:
# Disable VCS-based implicit rules.
% : %,v
# Disable VCS-based implicit rules.
% : RCS/%
# Disable VCS-based implicit rules.
% : RCS/%,v
# Disable VCS-based implicit rules.
% : SCCS/s.%
# Disable VCS-based implicit rules.
% : s.%
.SUFFIXES: .hpux_make_needs_suffix_list
# Command-line flag to silence nested $(MAKE).
$(VERBOSE)MAKESILENT = -s
#Suppress display of executed commands.
$(VERBOSE).SILENT:
# A target that is always out of date.
cmake_force:
.PHONY : cmake_force
#=============================================================================
# Set environment variables for the build.
# The shell in which to execute make rules.
SHELL = /bin/sh
# The CMake executable.
CMAKE_COMMAND = /usr/bin/cmake
# The command to remove a file.
RM = /usr/bin/cmake -E rm -f
# Escaping for special characters.
EQUALS = =
# The top-level source directory on which CMake was run.
CMAKE_SOURCE_DIR = /home/martin/projects/mmo
# The top-level build directory on which CMake was run.
CMAKE_BINARY_DIR = /home/martin/projects/mmo
#=============================================================================
# Targets provided globally by CMake.
# Special rule for the target edit_cache
edit_cache:
@$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake cache editor..."
/usr/bin/ccmake -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR)
.PHONY : edit_cache
# Special rule for the target edit_cache
edit_cache/fast: edit_cache
.PHONY : edit_cache/fast
# Special rule for the target rebuild_cache
rebuild_cache:
@$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake to regenerate build system..."
/usr/bin/cmake --regenerate-during-build -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR)
.PHONY : rebuild_cache
# Special rule for the target rebuild_cache
rebuild_cache/fast: rebuild_cache
.PHONY : rebuild_cache/fast
# The main all target
all: cmake_check_build_system
cd /home/martin/projects/mmo && $(CMAKE_COMMAND) -E cmake_progress_start /home/martin/projects/mmo/CMakeFiles /home/martin/projects/mmo/external/loft/modules/base//CMakeFiles/progress.marks
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/modules/base/all
$(CMAKE_COMMAND) -E cmake_progress_start /home/martin/projects/mmo/CMakeFiles 0
.PHONY : all
# The main clean target
clean:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/modules/base/clean
.PHONY : clean
# The main clean target
clean/fast: clean
.PHONY : clean/fast
# Prepare targets for installation.
preinstall: all
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/modules/base/preinstall
.PHONY : preinstall
# Prepare targets for installation.
preinstall/fast:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/modules/base/preinstall
.PHONY : preinstall/fast
# clear depends
depend:
cd /home/martin/projects/mmo && $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 1
.PHONY : depend
# Convenience name for target.
external/loft/modules/base/CMakeFiles/loft_base.dir/rule:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f CMakeFiles/Makefile2 external/loft/modules/base/CMakeFiles/loft_base.dir/rule
.PHONY : external/loft/modules/base/CMakeFiles/loft_base.dir/rule
# Convenience name for target.
loft_base: external/loft/modules/base/CMakeFiles/loft_base.dir/rule
.PHONY : loft_base
# fast build rule for target.
loft_base/fast:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/build
.PHONY : loft_base/fast
src/FramebufferBuilder.o: src/FramebufferBuilder.cpp.o
.PHONY : src/FramebufferBuilder.o
# target to build an object file
src/FramebufferBuilder.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/FramebufferBuilder.cpp.o
.PHONY : src/FramebufferBuilder.cpp.o
src/FramebufferBuilder.i: src/FramebufferBuilder.cpp.i
.PHONY : src/FramebufferBuilder.i
# target to preprocess a source file
src/FramebufferBuilder.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/FramebufferBuilder.cpp.i
.PHONY : src/FramebufferBuilder.cpp.i
src/FramebufferBuilder.s: src/FramebufferBuilder.cpp.s
.PHONY : src/FramebufferBuilder.s
# target to generate assembly for a file
src/FramebufferBuilder.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/FramebufferBuilder.cpp.s
.PHONY : src/FramebufferBuilder.cpp.s
src/Gpu.o: src/Gpu.cpp.o
.PHONY : src/Gpu.o
# target to build an object file
src/Gpu.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/Gpu.cpp.o
.PHONY : src/Gpu.cpp.o
src/Gpu.i: src/Gpu.cpp.i
.PHONY : src/Gpu.i
# target to preprocess a source file
src/Gpu.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/Gpu.cpp.i
.PHONY : src/Gpu.cpp.i
src/Gpu.s: src/Gpu.cpp.s
.PHONY : src/Gpu.s
# target to generate assembly for a file
src/Gpu.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/Gpu.cpp.s
.PHONY : src/Gpu.cpp.s
src/Instance.o: src/Instance.cpp.o
.PHONY : src/Instance.o
# target to build an object file
src/Instance.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/Instance.cpp.o
.PHONY : src/Instance.cpp.o
src/Instance.i: src/Instance.cpp.i
.PHONY : src/Instance.i
# target to preprocess a source file
src/Instance.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/Instance.cpp.i
.PHONY : src/Instance.cpp.i
src/Instance.s: src/Instance.cpp.s
.PHONY : src/Instance.s
# target to generate assembly for a file
src/Instance.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/Instance.cpp.s
.PHONY : src/Instance.cpp.s
src/io/file.o: src/io/file.cpp.o
.PHONY : src/io/file.o
# target to build an object file
src/io/file.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/io/file.cpp.o
.PHONY : src/io/file.cpp.o
src/io/file.i: src/io/file.cpp.i
.PHONY : src/io/file.i
# target to preprocess a source file
src/io/file.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/io/file.cpp.i
.PHONY : src/io/file.cpp.i
src/io/file.s: src/io/file.cpp.s
.PHONY : src/io/file.s
# target to generate assembly for a file
src/io/file.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/io/file.cpp.s
.PHONY : src/io/file.cpp.s
src/resources/Buffer.o: src/resources/Buffer.cpp.o
.PHONY : src/resources/Buffer.o
# target to build an object file
src/resources/Buffer.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/Buffer.cpp.o
.PHONY : src/resources/Buffer.cpp.o
src/resources/Buffer.i: src/resources/Buffer.cpp.i
.PHONY : src/resources/Buffer.i
# target to preprocess a source file
src/resources/Buffer.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/Buffer.cpp.i
.PHONY : src/resources/Buffer.cpp.i
src/resources/Buffer.s: src/resources/Buffer.cpp.s
.PHONY : src/resources/Buffer.s
# target to generate assembly for a file
src/resources/Buffer.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/Buffer.cpp.s
.PHONY : src/resources/Buffer.cpp.s
src/resources/BufferBusWriter.o: src/resources/BufferBusWriter.cpp.o
.PHONY : src/resources/BufferBusWriter.o
# target to build an object file
src/resources/BufferBusWriter.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/BufferBusWriter.cpp.o
.PHONY : src/resources/BufferBusWriter.cpp.o
src/resources/BufferBusWriter.i: src/resources/BufferBusWriter.cpp.i
.PHONY : src/resources/BufferBusWriter.i
# target to preprocess a source file
src/resources/BufferBusWriter.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/BufferBusWriter.cpp.i
.PHONY : src/resources/BufferBusWriter.cpp.i
src/resources/BufferBusWriter.s: src/resources/BufferBusWriter.cpp.s
.PHONY : src/resources/BufferBusWriter.s
# target to generate assembly for a file
src/resources/BufferBusWriter.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/BufferBusWriter.cpp.s
.PHONY : src/resources/BufferBusWriter.cpp.s
src/resources/DefaultAllocator.o: src/resources/DefaultAllocator.cpp.o
.PHONY : src/resources/DefaultAllocator.o
# target to build an object file
src/resources/DefaultAllocator.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/DefaultAllocator.cpp.o
.PHONY : src/resources/DefaultAllocator.cpp.o
src/resources/DefaultAllocator.i: src/resources/DefaultAllocator.cpp.i
.PHONY : src/resources/DefaultAllocator.i
# target to preprocess a source file
src/resources/DefaultAllocator.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/DefaultAllocator.cpp.i
.PHONY : src/resources/DefaultAllocator.cpp.i
src/resources/DefaultAllocator.s: src/resources/DefaultAllocator.cpp.s
.PHONY : src/resources/DefaultAllocator.s
# target to generate assembly for a file
src/resources/DefaultAllocator.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/DefaultAllocator.cpp.s
.PHONY : src/resources/DefaultAllocator.cpp.s
src/resources/Image.o: src/resources/Image.cpp.o
.PHONY : src/resources/Image.o
# target to build an object file
src/resources/Image.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/Image.cpp.o
.PHONY : src/resources/Image.cpp.o
src/resources/Image.i: src/resources/Image.cpp.i
.PHONY : src/resources/Image.i
# target to preprocess a source file
src/resources/Image.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/Image.cpp.i
.PHONY : src/resources/Image.cpp.i
src/resources/Image.s: src/resources/Image.cpp.s
.PHONY : src/resources/Image.s
# target to generate assembly for a file
src/resources/Image.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/Image.cpp.s
.PHONY : src/resources/Image.cpp.s
src/resources/ImageBusWriter.o: src/resources/ImageBusWriter.cpp.o
.PHONY : src/resources/ImageBusWriter.o
# target to build an object file
src/resources/ImageBusWriter.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/ImageBusWriter.cpp.o
.PHONY : src/resources/ImageBusWriter.cpp.o
src/resources/ImageBusWriter.i: src/resources/ImageBusWriter.cpp.i
.PHONY : src/resources/ImageBusWriter.i
# target to preprocess a source file
src/resources/ImageBusWriter.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/ImageBusWriter.cpp.i
.PHONY : src/resources/ImageBusWriter.cpp.i
src/resources/ImageBusWriter.s: src/resources/ImageBusWriter.cpp.s
.PHONY : src/resources/ImageBusWriter.s
# target to generate assembly for a file
src/resources/ImageBusWriter.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/ImageBusWriter.cpp.s
.PHONY : src/resources/ImageBusWriter.cpp.s
src/resources/ImageView.o: src/resources/ImageView.cpp.o
.PHONY : src/resources/ImageView.o
# target to build an object file
src/resources/ImageView.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/ImageView.cpp.o
.PHONY : src/resources/ImageView.cpp.o
src/resources/ImageView.i: src/resources/ImageView.cpp.i
.PHONY : src/resources/ImageView.i
# target to preprocess a source file
src/resources/ImageView.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/ImageView.cpp.i
.PHONY : src/resources/ImageView.cpp.i
src/resources/ImageView.s: src/resources/ImageView.cpp.s
.PHONY : src/resources/ImageView.s
# target to generate assembly for a file
src/resources/ImageView.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/ImageView.cpp.s
.PHONY : src/resources/ImageView.cpp.s
src/resources/MipmapGenerator.o: src/resources/MipmapGenerator.cpp.o
.PHONY : src/resources/MipmapGenerator.o
# target to build an object file
src/resources/MipmapGenerator.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/MipmapGenerator.cpp.o
.PHONY : src/resources/MipmapGenerator.cpp.o
src/resources/MipmapGenerator.i: src/resources/MipmapGenerator.cpp.i
.PHONY : src/resources/MipmapGenerator.i
# target to preprocess a source file
src/resources/MipmapGenerator.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/MipmapGenerator.cpp.i
.PHONY : src/resources/MipmapGenerator.cpp.i
src/resources/MipmapGenerator.s: src/resources/MipmapGenerator.cpp.s
.PHONY : src/resources/MipmapGenerator.s
# target to generate assembly for a file
src/resources/MipmapGenerator.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/resources/MipmapGenerator.cpp.s
.PHONY : src/resources/MipmapGenerator.cpp.s
src/shaders/GlslShaderBuilder.o: src/shaders/GlslShaderBuilder.cpp.o
.PHONY : src/shaders/GlslShaderBuilder.o
# target to build an object file
src/shaders/GlslShaderBuilder.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/GlslShaderBuilder.cpp.o
.PHONY : src/shaders/GlslShaderBuilder.cpp.o
src/shaders/GlslShaderBuilder.i: src/shaders/GlslShaderBuilder.cpp.i
.PHONY : src/shaders/GlslShaderBuilder.i
# target to preprocess a source file
src/shaders/GlslShaderBuilder.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/GlslShaderBuilder.cpp.i
.PHONY : src/shaders/GlslShaderBuilder.cpp.i
src/shaders/GlslShaderBuilder.s: src/shaders/GlslShaderBuilder.cpp.s
.PHONY : src/shaders/GlslShaderBuilder.s
# target to generate assembly for a file
src/shaders/GlslShaderBuilder.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/GlslShaderBuilder.cpp.s
.PHONY : src/shaders/GlslShaderBuilder.cpp.s
src/shaders/Pipeline.o: src/shaders/Pipeline.cpp.o
.PHONY : src/shaders/Pipeline.o
# target to build an object file
src/shaders/Pipeline.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/Pipeline.cpp.o
.PHONY : src/shaders/Pipeline.cpp.o
src/shaders/Pipeline.i: src/shaders/Pipeline.cpp.i
.PHONY : src/shaders/Pipeline.i
# target to preprocess a source file
src/shaders/Pipeline.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/Pipeline.cpp.i
.PHONY : src/shaders/Pipeline.cpp.i
src/shaders/Pipeline.s: src/shaders/Pipeline.cpp.s
.PHONY : src/shaders/Pipeline.s
# target to generate assembly for a file
src/shaders/Pipeline.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/Pipeline.cpp.s
.PHONY : src/shaders/Pipeline.cpp.s
src/shaders/PipelineBuilder.o: src/shaders/PipelineBuilder.cpp.o
.PHONY : src/shaders/PipelineBuilder.o
# target to build an object file
src/shaders/PipelineBuilder.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/PipelineBuilder.cpp.o
.PHONY : src/shaders/PipelineBuilder.cpp.o
src/shaders/PipelineBuilder.i: src/shaders/PipelineBuilder.cpp.i
.PHONY : src/shaders/PipelineBuilder.i
# target to preprocess a source file
src/shaders/PipelineBuilder.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/PipelineBuilder.cpp.i
.PHONY : src/shaders/PipelineBuilder.cpp.i
src/shaders/PipelineBuilder.s: src/shaders/PipelineBuilder.cpp.s
.PHONY : src/shaders/PipelineBuilder.s
# target to generate assembly for a file
src/shaders/PipelineBuilder.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/PipelineBuilder.cpp.s
.PHONY : src/shaders/PipelineBuilder.cpp.s
src/shaders/SpirvShaderBuilder.o: src/shaders/SpirvShaderBuilder.cpp.o
.PHONY : src/shaders/SpirvShaderBuilder.o
# target to build an object file
src/shaders/SpirvShaderBuilder.cpp.o:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/SpirvShaderBuilder.cpp.o
.PHONY : src/shaders/SpirvShaderBuilder.cpp.o
src/shaders/SpirvShaderBuilder.i: src/shaders/SpirvShaderBuilder.cpp.i
.PHONY : src/shaders/SpirvShaderBuilder.i
# target to preprocess a source file
src/shaders/SpirvShaderBuilder.cpp.i:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/SpirvShaderBuilder.cpp.i
.PHONY : src/shaders/SpirvShaderBuilder.cpp.i
src/shaders/SpirvShaderBuilder.s: src/shaders/SpirvShaderBuilder.cpp.s
.PHONY : src/shaders/SpirvShaderBuilder.s
# target to generate assembly for a file
src/shaders/SpirvShaderBuilder.cpp.s:
cd /home/martin/projects/mmo && $(MAKE) $(MAKESILENT) -f external/loft/modules/base/CMakeFiles/loft_base.dir/build.make external/loft/modules/base/CMakeFiles/loft_base.dir/src/shaders/SpirvShaderBuilder.cpp.s
.PHONY : src/shaders/SpirvShaderBuilder.cpp.s
# Help Target
help:
@echo "The following are some of the valid targets for this Makefile:"
@echo "... all (the default if no target is provided)"
@echo "... clean"
@echo "... depend"
@echo "... edit_cache"
@echo "... rebuild_cache"
@echo "... loft_base"
@echo "... src/FramebufferBuilder.o"
@echo "... src/FramebufferBuilder.i"
@echo "... src/FramebufferBuilder.s"
@echo "... src/Gpu.o"
@echo "... src/Gpu.i"
@echo "... src/Gpu.s"
@echo "... src/Instance.o"
@echo "... src/Instance.i"
@echo "... src/Instance.s"
@echo "... src/io/file.o"
@echo "... src/io/file.i"
@echo "... src/io/file.s"
@echo "... src/resources/Buffer.o"
@echo "... src/resources/Buffer.i"
@echo "... src/resources/Buffer.s"
@echo "... src/resources/BufferBusWriter.o"
@echo "... src/resources/BufferBusWriter.i"
@echo "... src/resources/BufferBusWriter.s"
@echo "... src/resources/DefaultAllocator.o"
@echo "... src/resources/DefaultAllocator.i"
@echo "... src/resources/DefaultAllocator.s"
@echo "... src/resources/Image.o"
@echo "... src/resources/Image.i"
@echo "... src/resources/Image.s"
@echo "... src/resources/ImageBusWriter.o"
@echo "... src/resources/ImageBusWriter.i"
@echo "... src/resources/ImageBusWriter.s"
@echo "... src/resources/ImageView.o"
@echo "... src/resources/ImageView.i"
@echo "... src/resources/ImageView.s"
@echo "... src/resources/MipmapGenerator.o"
@echo "... src/resources/MipmapGenerator.i"
@echo "... src/resources/MipmapGenerator.s"
@echo "... src/shaders/GlslShaderBuilder.o"
@echo "... src/shaders/GlslShaderBuilder.i"
@echo "... src/shaders/GlslShaderBuilder.s"
@echo "... src/shaders/Pipeline.o"
@echo "... src/shaders/Pipeline.i"
@echo "... src/shaders/Pipeline.s"
@echo "... src/shaders/PipelineBuilder.o"
@echo "... src/shaders/PipelineBuilder.i"
@echo "... src/shaders/PipelineBuilder.s"
@echo "... src/shaders/SpirvShaderBuilder.o"
@echo "... src/shaders/SpirvShaderBuilder.i"
@echo "... src/shaders/SpirvShaderBuilder.s"
.PHONY : help
#=============================================================================
# Special targets to cleanup operation of make.
# Special rule to run CMake to check the build system integrity.
# No rule that depends on this can have commands that come from listfiles
# because they might be regenerated.
cmake_check_build_system:
cd /home/martin/projects/mmo && $(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 0
.PHONY : cmake_check_build_system
@@ -9,7 +9,6 @@ set(LIBS
loft_base
loft_common
loft_window
${SDL2_LIBRARIES}
volk)
set(FILES
+3 -2
View File
@@ -4,7 +4,7 @@ file(GLOB FILES src/*.c src/*.cpp)
add_library(${PROJECT_NAME} ${FILES})
find_package(SDL2 REQUIRED)
find_package(SDL3 REQUIRED)
set_target_properties(${PROJECT_NAME} PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${PROJECT_SOURCE_DIR}/include/"
@@ -13,7 +13,8 @@ set_target_properties(${PROJECT_NAME} PROPERTIES
target_link_libraries(${PROJECT_NAME}
PUBLIC
loft_base
${SDL2_LIBRARIES}
sdl::sdl
${LIBDECOR_LIB}
)
target_include_directories(${PROJECT_NAME}
+1 -1
View File
@@ -6,7 +6,7 @@
#define LOFT_SDLWINDOW_H
#include "Window.hpp"
#include <SDL2/SDL.h>
#include <SDL3/SDL.h>
#include <utility>
#include <volk.h>
#include <string>
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once
#include <volk.h>
#include <SDL2/SDL_events.h>
#include <SDL3/SDL_events.h>
#include <vector>
#include <string>
+23 -19
View File
@@ -1,10 +1,12 @@
#include <SDL2/SDL_error.h>
#include <SDL_video.h>
#include "SDLWindow.h"
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_video.h>
#include <volk.h>
#include <stdexcept>
#include "SDLWindow.h"
#include "LoftException.hpp"
#include <SDL2/SDL_vulkan.h>
#include <SDL3/SDL_vulkan.h>
#include <vector>
#include <vulkan/vulkan_core.h>
@@ -27,16 +29,16 @@ VkExtent2D SDLWindow::get_size() const {
SDLWindow::SDLWindow(std::string name, VkRect2D rect) :
m_extent{rect.extent}
{
if(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVERYTHING) < 0) {
if(!SDL_Init(SDL_INIT_VIDEO)) {
throw std::runtime_error("Failed to initialize sdl");
}
m_pWindow = SDL_CreateWindow(name.c_str(),
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
// SDL_WINDOWPOS_CENTERED,
// SDL_WINDOWPOS_CENTERED,
rect.extent.width,
rect.extent.height,
SDL_WINDOW_VULKAN | SDL_WINDOW_ALLOW_HIGHDPI |
SDL_WINDOW_VULKAN | SDL_WINDOW_HIGH_PIXEL_DENSITY |
SDL_WINDOW_RESIZABLE);
if(m_pWindow == nullptr) {
@@ -46,7 +48,7 @@ m_extent{rect.extent}
Surface SDLWindow::create_surface(const Instance* instance) const {
VkSurfaceKHR surface = VK_NULL_HANDLE;
SDL_Vulkan_CreateSurface(m_pWindow, instance->instance(), &surface);
SDL_Vulkan_CreateSurface(m_pWindow, instance->instance(), NULL, &surface);
return Surface(instance, surface);
}
@@ -60,19 +62,21 @@ int32_t SDLWindow::poll_event(SDL_Event *pOutEvent) const {
std::vector<std::string> SDLWindow::get_required_extensions() const {
uint32_t count = 0;
SDL_Vulkan_GetInstanceExtensions(m_pWindow, &count, nullptr);
const char* const *instance_extensions = SDL_Vulkan_GetInstanceExtensions(&count);
std::vector<const char*> extensions(count);
SDL_Vulkan_GetInstanceExtensions(m_pWindow, &count, extensions.data());
std::vector<std::string> extensions(count);
for(int i = 0; i < count; i++) {
extensions[i] = std::string(instance_extensions[i]);
}
std::vector<std::string> extension_strings(count);
std::transform(extensions.begin(), extensions.end(),
extension_strings.begin(),
[](const char* str) {
return std::string(str);
});
// std::vector<std::string> extension_strings(count);
// std::transform(extensions.begin(), extensions.end(),
// extension_strings.begin(),
// [](const char* str) {
// return std::string(str);
// });
return extension_strings;
return extensions;
}
}
@@ -53,7 +53,7 @@ int main(int argc, char* argv[]) {
}
if (!channel_set) {
std::println(stderr, "Usage: {} [--host <ip>] [--port <port>] --channel <id>", argv[0]);
// std::println(stderr, "Usage: {} [--host <ip>] [--port <port>] --channel <id>", argv[0]);
return 1;
}
@@ -87,7 +87,7 @@ int main(int argc, char* argv[]) {
messages.set_handler<mmo::chat::ChatMessageBroadcastRequest>(
[](tw::msg::PeerId, const mmo::chat::ChatMessageBroadcastRequest& bcast) {
std::println("[ch:{}] <{}> {}", bcast.channel_id(), bcast.sender_id(), bcast.message());
// std::println("[ch:{}] <{}> {}", bcast.channel_id(), bcast.sender_id(), bcast.message());
});
mmo::chat::JoinChannelRequest join;
@@ -98,10 +98,10 @@ int main(int argc, char* argv[]) {
[channel_id](std::span<const std::byte> data) {
mmo::chat::JoinChannelResponse resp;
resp.ParseFromArray(data.data(), static_cast<int>(data.size()));
std::println("Joined channel {} successfully.", channel_id);
// std::println("Joined channel {} successfully.", channel_id);
});
std::println("Joined channel {}. Type a message and press Enter. Ctrl+C to quit.", channel_id);
// std::println("Joined channel {}. Type a message and press Enter. Ctrl+C to quit.", channel_id);
while (!g_quit.load()) {
fd_set fds;
@@ -121,7 +121,7 @@ int main(int argc, char* argv[]) {
[channel_id](std::span<const std::byte> data) {
mmo::chat::SendChatMessageResponse resp;
resp.ParseFromArray(data.data(), static_cast<int>(data.size()));
std::println("Message sent to channel {}.", channel_id);
// std::println("Message sent to channel {}.", channel_id);
});
}
}
+5 -2
View File
@@ -4,8 +4,10 @@ add_subdirectory(shaders)
file(GLOB FILES
src/*.cpp
src/app/*.cpp
src/network/*.cpp
src/world/*.cpp
src/world/controllers/*.cpp
src/io/*.cpp
src/draw/*.cpp
src/draw/RenderPasses/*.cpp
@@ -25,6 +27,7 @@ target_link_libraries(${PROJECT_NAME}
tw::quicr
loft::common
loft::base
${LIBDECOR_LIB}
loft_window
loft::render_graph
tw::protocol
@@ -38,8 +41,8 @@ target_link_libraries(${PROJECT_NAME}
Jolt
Tracy::TracyClient
TracyClient
pqxx
pq
# pqxx
# pq
)
target_include_directories(${PROJECT_NAME}
+119
View File
@@ -0,0 +1,119 @@
#include "ClientArgs.hpp"
#include <arpa/inet.h>
#include <charconv>
#include <algorithm>
#include <cctype>
namespace tw::app {
namespace {
std::string_view trim(std::string_view str) {
// Skip leading whitespace
size_t start = 0;
while(start < str.length() && std::isspace(static_cast<unsigned char>(str[start]))) {
++start;
}
// Skip trailing whitespace
size_t end = str.length();
while(end > start && std::isspace(static_cast<unsigned char>(str[end - 1]))) {
--end;
}
return str.substr(start, end - start);
}
bool is_valid_ipv4(std::string_view ip_str) {
// Use inet_pton to validate IPv4 format
struct in_addr addr;
return inet_pton(AF_INET, std::string(ip_str).c_str(), &addr) == 1;
}
tl::expected<int, std::string> parse_port(std::string_view port_str) {
if(port_str.empty()) {
return 8080; // Default port
}
int port = 0;
const char* end = port_str.data() + port_str.length();
auto result = std::from_chars(port_str.data(), end, port);
// from_chars stops at the first character it cannot use, so a partially
// numeric port like "80x" would otherwise be accepted as 80.
if(result.ec != std::errc() || result.ptr != end) {
return tl::make_unexpected("port must be numeric");
}
if(port < 1 || port > 65535) {
return tl::make_unexpected("port must be between 1 and 65535");
}
return port;
}
} // anonymous namespace
tl::expected<net::Address, std::string> parse_address(std::string_view text) {
// Trim whitespace
text = trim(text);
if(text.empty()) {
return tl::make_unexpected("address cannot be empty");
}
// Find the colon to split host and port
size_t colon_pos = text.rfind(':');
std::string_view host;
std::string_view port_str;
if(colon_pos == std::string_view::npos) {
// No colon found: treat entire string as port or host
// If it's all digits, treat as port; otherwise as host (will fail validation)
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
[](unsigned char c) { return std::isdigit(c); });
if(all_digits) {
host = "127.0.0.1";
port_str = text;
} else {
// Treat as host with no port
host = text;
port_str = "";
}
} else {
host = text.substr(0, colon_pos);
port_str = text.substr(colon_pos + 1);
}
// Validate host
if(host.empty()) {
return tl::make_unexpected("host cannot be empty");
}
if(!is_valid_ipv4(host)) {
return tl::make_unexpected("not a valid IPv4 address");
}
// Parse port
auto port_result = parse_port(port_str);
if(!port_result) {
return tl::make_unexpected(port_result.error());
}
int port = port_result.value();
return net::Address(std::optional<std::string>(std::string(host)), port);
}
std::optional<std::string> server_arg(int argc, char** argv) {
for(int i = 1; i < argc - 1; ++i) {
if(std::string_view(argv[i]) == "--server") {
return std::string(argv[i + 1]);
}
}
return std::nullopt;
}
} // namespace tw::app
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <string_view>
#include <string>
#include <optional>
#include <tl/expected.hpp>
#include "Address.hpp"
namespace tw::app {
/**
* Parse an address string into a network address.
*
* Accepts "host:port" or a bare port number. Bare port uses 127.0.0.1.
* Missing port defaults to 8080. Trims surrounding whitespace.
* Validates the host with inet_pton and returns an error string
* for non-IPv4 addresses or invalid ports.
*/
tl::expected<net::Address, std::string> parse_address(std::string_view text);
/**
* Extract the --server argument value from the command line.
*
* Scans argv for --server and returns the following argument,
* or nothing if the flag is absent or has no value.
*/
std::optional<std::string> server_arg(int argc, char** argv);
} // namespace tw::app
@@ -0,0 +1,31 @@
#include "FavouriteServers.hpp"
#include <algorithm>
namespace tw::app {
FavouriteServers::FavouriteServers()
: FileAddressList("favourite_servers.txt") {
}
const char* FavouriteServers::name() const {
return "Favourites";
}
void FavouriteServers::add(const std::string& entry) {
// Check if already present
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
return; // Already present, do nothing
}
// Append at the end
m_entries.push_back(entry);
// Cap at MAX_ENTRIES
if(m_entries.size() > MAX_ENTRIES) {
m_entries.resize(MAX_ENTRIES);
}
}
} // namespace tw::app
@@ -0,0 +1,33 @@
#pragma once
#include "FileAddressList.hpp"
namespace tw::app {
/**
* Manages a list of favourite server addresses.
*
* Persists addresses to a text file, one "ip:port" per line.
* Appended in the order they are added, de-duplicated,
* capped at 32 entries. File path is
* $XDG_CONFIG_HOME/towards/favourite_servers.txt, falling back to
* $HOME/.config/towards/favourite_servers.txt. If both variables are
* unset, keeps the list in memory only.
*/
class FavouriteServers : public FileAddressList {
static constexpr size_t MAX_ENTRIES = 32;
public:
FavouriteServers();
const char* name() const override;
/**
* Add an address to the favourites list.
* Appended at the end if not already present, capped at 32.
* Does not persist to disk; call save() after modifying.
*/
void add(const std::string& entry) override;
};
} // namespace tw::app
+110
View File
@@ -0,0 +1,110 @@
#include "FileAddressList.hpp"
#include <spdlog/spdlog.h>
#include <filesystem>
#include <fstream>
#include <cstdlib>
#include <algorithm>
namespace tw::app {
namespace {
std::string get_config_dir() {
// Try XDG_CONFIG_HOME first
const char* xdg_config_home = std::getenv("XDG_CONFIG_HOME");
if(xdg_config_home && xdg_config_home[0] != '\0') {
return std::string(xdg_config_home) + "/towards";
}
// Fall back to $HOME/.config/towards
const char* home = std::getenv("HOME");
if(home && home[0] != '\0') {
return std::string(home) + "/.config/towards";
}
// Both unset
return "";
}
} // anonymous namespace
FileAddressList::FileAddressList(const std::string& file_name) {
std::string config_dir = get_config_dir();
if(config_dir.empty()) {
spdlog::debug("XDG_CONFIG_HOME and HOME not set; address lists will not be persisted");
m_can_save = false;
return;
}
m_path = config_dir + "/" + file_name;
m_can_save = true;
}
void FileAddressList::load() {
if(m_path.empty()) {
return; // No config path available
}
std::ifstream file(m_path);
if(!file.is_open()) {
// File doesn't exist or can't be read; this is not an error
return;
}
m_entries.clear();
std::string line;
while(std::getline(file, line)) {
// Trim whitespace from the line
size_t start = line.find_first_not_of(" \t\r\n");
size_t end = line.find_last_not_of(" \t\r\n");
if(start != std::string::npos) {
line = line.substr(start, end - start + 1);
if(!line.empty()) {
m_entries.push_back(line);
}
}
}
}
void FileAddressList::save() const {
if(!m_can_save || m_path.empty()) {
return; // Cannot save without config path
}
// Create the directory if needed
std::filesystem::path config_path(m_path);
std::filesystem::path config_dir = config_path.parent_path();
try {
std::filesystem::create_directories(config_dir);
} catch(const std::filesystem::filesystem_error&) {
// If we can't create the directory, silently fail to save
return;
}
// Write entries to file
std::ofstream file(m_path);
if(!file.is_open()) {
return; // Can't open file for writing; silently fail
}
for(const auto& entry : m_entries) {
file << entry << "\n";
}
}
void FileAddressList::remove(const std::string& entry) {
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
m_entries.erase(it);
}
}
const std::vector<std::string>& FileAddressList::entries() const {
return m_entries;
}
} // namespace tw::app
@@ -0,0 +1,32 @@
#pragma once
#include "ServerAddressProvider.hpp"
#include <vector>
#include <string>
namespace tw::app {
/**
* Shared behaviour of address lists backed by a file.
*
* One "ip:port" per line. Resolves file path to
* $XDG_CONFIG_HOME/towards/<file_name>, falling back to
* $HOME/.config/towards/<file_name>.
* add() stays pure virtual: subclasses define their own order.
*/
class FileAddressList : public ServerAddressProvider {
protected:
std::vector<std::string> m_entries;
std::string m_path;
bool m_can_save = false;
explicit FileAddressList(const std::string& file_name);
public:
void load() override;
void save() const override;
void remove(const std::string& entry) override;
const std::vector<std::string>& entries() const override;
};
} // namespace tw::app
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "debug/DebugWindowRegistry.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "draw/WorldRenderer.hpp"
#include "io/InputState.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
namespace tw::app {
/**
* Bundle of runtime-owned subsystems that the game state needs.
*/
struct GameContext {
tw::World* world;
tw::JoltPhysicsWorld* physics_world;
tw::drw::WorldRenderer* renderer;
tw::io::InputManager* input_manager;
tw::dbg::NetworkMetrics* metrics;
tw::dbg::DebugWindowRegistry* debug_windows;
};
} // namespace tw::app
+34
View File
@@ -0,0 +1,34 @@
#include "GameState.hpp"
namespace tw::app {
GameState::GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection)
: m_context(context),
m_connection(std::move(connection)),
m_entity_gui(context.world),
m_network_gui(*context.metrics)
{
m_controller = std::make_unique<tw::ClientWorldController>(
m_context.input_manager,
m_context.world,
m_context.physics_world,
m_context.renderer,
m_connection.get(),
m_context.metrics
);
m_context.debug_windows->add(&m_entity_gui);
m_context.debug_windows->add(&m_network_gui);
}
GameState::~GameState() {
m_context.debug_windows->remove(&m_entity_gui);
m_context.debug_windows->remove(&m_network_gui);
}
void GameState::update(double delta_time) {
m_controller->update(delta_time);
m_context.world->step(delta_time);
}
} // namespace tw::app
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <memory>
#include "GameContext.hpp"
#include "debug/tools/EntityManagerGui.hpp"
#include "debug/tools/NetworkStatsGui.hpp"
#include "network/ServerConnection.hpp"
#include "world/controllers/ClientWorldController.hpp"
namespace tw::app {
/**
* The game world state.
* Responsible for updating the game simulation and rendering debug information.
*/
class GameState {
GameContext m_context;
std::unique_ptr<tw::net::ServerConnection> m_connection;
std::unique_ptr<tw::ClientWorldController> m_controller;
tw::dbg::tools::EntityManagerGui m_entity_gui;
tw::dbg::tools::NetworkStatsGui m_network_gui;
public:
/**
* Constructs the game state with the given context and connection.
* The connection must be established before creating the game state.
*/
GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection);
/**
* Takes the debug panels back out of the menu.
*/
~GameState();
/**
* Updates the game state: draws debug GUIs, updates the controller,
* and steps the world physics.
*/
void update(double delta_time);
};
} // namespace tw::app
+182
View File
@@ -0,0 +1,182 @@
#include "LobbyState.hpp"
#include "ClientArgs.hpp"
#include "imgui.h"
#include <cstdio>
namespace tw::app {
namespace {
const ImVec4 FAVOURITES_COLOUR{1.0f, 0.8f, 0.2f, 1.0f};
const ImVec4 RECENT_COLOUR{0.7f, 0.7f, 0.7f, 1.0f};
}
LobbyState::LobbyState(std::optional<tw::net::Address> auto_connect) {
m_recent.load();
m_favourites.load();
set_address_input(m_recent.entries().empty()
? "127.0.0.1:8080"
: m_recent.entries().front());
if(auto_connect) {
begin_connect(*auto_connect);
}
}
void LobbyState::set_address_input(const std::string& address) {
std::snprintf(m_address_input, sizeof(m_address_input), "%s", address.c_str());
}
void LobbyState::begin_connect(tw::net::Address address) {
m_error.clear();
m_connection = std::make_unique<tw::net::ServerConnection>(address);
auto started = m_connection->start();
if(!started) {
m_error = started.error().message();
m_connection.reset();
}
}
void LobbyState::draw_form() {
const float input_width = 200.0f;
ImGui::SetNextItemWidth(input_width);
bool submitted = ImGui::InputText("##address", m_address_input, sizeof(m_address_input),
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
submitted |= ImGui::Button("Connect");
if(!submitted) {
return;
}
auto parsed = parse_address(m_address_input);
if(parsed) {
begin_connect(*parsed);
} else {
m_error = parsed.error();
}
}
void LobbyState::draw_favourite_toggle(const std::string& entry) {
const char* label = m_favourites.contains(entry) ? "[*]" : "[ ]";
// The label alone would collide between the rows drawn in one frame, so the
// entry it acts on is what identifies the button.
std::string button_id = std::string(label) + "##fav_" + entry;
if(ImGui::SmallButton(button_id.c_str())) {
toggle_favourite(entry);
}
}
void LobbyState::toggle_favourite(const std::string& entry) {
if(m_favourites.contains(entry)) {
m_favourites.remove(entry);
} else {
m_favourites.add(entry);
}
m_favourites.save();
}
void LobbyState::draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour) {
ImGui::PushStyleColor(ImGuiCol_Text, header_colour);
ImGui::SeparatorText(provider.name());
ImGui::PopStyleColor();
// BeginChild is one of the two calls whose End must run even when it
// returns false, so the result only decides whether rows are submitted.
if(ImGui::BeginChild(provider.name(), ImVec2(0, 120), ImGuiChildFlags_Borders)) {
for(const auto& entry : provider.entries()) {
draw_favourite_toggle(entry);
ImGui::SameLine();
if(ImGui::Selectable(entry.c_str(), false)) {
set_address_input(entry);
}
if(ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
auto parsed = parse_address(entry);
if(parsed) {
begin_connect(*parsed);
}
}
}
}
ImGui::EndChild();
}
void LobbyState::draw_status() {
if(m_connection) {
switch(m_connection->status()) {
case tw::net::ConnectionStatus::Idle:
break;
case tw::net::ConnectionStatus::Connecting: {
ImGui::TextUnformatted("Connecting...");
ImGui::SameLine();
draw_favourite_toggle(m_connection->address().to_string());
ImGui::SameLine();
if(ImGui::Button("Cancel")) {
m_connection.reset();
}
break;
}
case tw::net::ConnectionStatus::Connected:
ImGui::TextColored(ImVec4(0, 1, 0, 1), "Connected!");
m_recent.add(m_connection->address().to_string());
m_recent.save();
m_result = LobbyResult{std::move(m_connection)};
break;
case tw::net::ConnectionStatus::Failed: {
std::string failed_msg = "Connection failed: " + m_connection->error();
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", failed_msg.c_str());
if(ImGui::Button("Dismiss")) {
m_connection.reset();
}
break;
}
}
} else if(!m_error.empty()) {
std::string error_msg = "Error: " + m_error;
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", error_msg.c_str());
}
}
void LobbyState::update(double delta_time) {
if(m_connection) {
m_connection->update();
}
// Center the window
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver,
ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Lobby", nullptr, ImGuiWindowFlags_NoMove);
draw_form();
draw_provider(m_favourites, FAVOURITES_COLOUR);
draw_provider(m_recent, RECENT_COLOUR);
ImGui::Separator();
draw_status();
ImGui::End();
}
std::optional<LobbyResult> LobbyState::take_result() {
// Moving out of an optional leaves it engaged, which would hand the caller
// a second, empty result on the next frame.
auto result = std::move(m_result);
m_result.reset();
return result;
}
} // namespace tw::app
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <memory>
#include <string>
#include <optional>
#include "RecentServers.hpp"
#include "FavouriteServers.hpp"
#include "ServerAddressProvider.hpp"
#include "network/ServerConnection.hpp"
struct ImVec4;
namespace tw::app {
/**
* The edge out of the lobby: a connection that finished its handshake.
*/
struct LobbyResult {
std::unique_ptr<tw::net::ServerConnection> connection;
};
/**
* The lobby screen state. Renders an address input field, recent server list,
* and manages a connection attempt in flight.
*/
class LobbyState {
static constexpr size_t ADDRESS_INPUT_SIZE = 64;
tw::app::RecentServers m_recent;
tw::app::FavouriteServers m_favourites;
/**
* Edited in place by the input field, so it has to outlive the frame that
* draws it rather than being rebuilt from a string every time.
*/
char m_address_input[ADDRESS_INPUT_SIZE];
std::string m_error;
std::unique_ptr<tw::net::ServerConnection> m_connection;
std::optional<LobbyResult> m_result;
void begin_connect(tw::net::Address address);
void set_address_input(const std::string& address);
void draw_form();
/**
* Renders one address list. The colour is what tells the lists apart on
* screen, so it belongs to the lobby rather than to the list itself.
*/
void draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour);
void draw_favourite_toggle(const std::string& entry);
void toggle_favourite(const std::string& entry);
void draw_status();
public:
/**
* Constructs the lobby state, optionally starting an auto-connect if a
* server address is provided.
*/
explicit LobbyState(std::optional<tw::net::Address> auto_connect);
/**
* Updates the lobby: draws the UI, pumps the connection attempt if one
* is in flight.
*/
void update(double delta_time);
/**
* Returns the transition result if the lobby is done. Moves the result
* out, leaving none behind.
*/
std::optional<LobbyResult> take_result();
};
} // namespace tw::app
+31
View File
@@ -0,0 +1,31 @@
#include "RecentServers.hpp"
#include <algorithm>
namespace tw::app {
RecentServers::RecentServers()
: FileAddressList("recent_servers.txt") {
}
const char* RecentServers::name() const {
return "Recent";
}
void RecentServers::add(const std::string& entry) {
// Remove if already in list (de-duplicate)
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
m_entries.erase(it);
}
// Add to front (most recent first)
m_entries.insert(m_entries.begin(), entry);
// Cap at MAX_ENTRIES
if(m_entries.size() > MAX_ENTRIES) {
m_entries.resize(MAX_ENTRIES);
}
}
} // namespace tw::app
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "FileAddressList.hpp"
namespace tw::app {
/**
* Manages a list of recently used server addresses.
*
* Persists addresses to a text file, one "ip:port" per line.
* Most recent first, capped at 8 entries. File path is
* $XDG_CONFIG_HOME/towards/recent_servers.txt, falling back to
* $HOME/.config/towards/recent_servers.txt. If both variables are
* unset, keeps the list in memory only.
*/
class RecentServers : public FileAddressList {
static constexpr size_t MAX_ENTRIES = 8;
public:
RecentServers();
const char* name() const override;
/**
* Add an address to the recents list.
* Most recent first, de-duplicated, capped at 8.
* Does not persist to disk; call save() after connecting.
*/
void add(const std::string& entry) override;
};
} // namespace tw::app
@@ -0,0 +1,42 @@
#pragma once
#include <vector>
#include <string>
namespace tw::app {
/**
* Abstract interface for server address lists.
*
* Implementations manage a list of "ip:port" entries, load/save them,
* and provide a human-readable name for the UI.
*/
class ServerAddressProvider {
public:
virtual ~ServerAddressProvider() = default;
/** Human-readable list name, shown as the section header in the lobby. */
virtual const char* name() const = 0;
virtual const std::vector<std::string>& entries() const = 0;
virtual void load() = 0;
virtual void save() const = 0;
virtual void add(const std::string& entry) = 0;
virtual void remove(const std::string& entry) = 0;
/**
* Check whether an entry exists in the list.
* Implemented over entries() — subclasses need not override.
*/
bool contains(const std::string& entry) const {
const auto& vec = entries();
for(const auto& e : vec) {
if(e == entry) {
return true;
}
}
return false;
}
};
} // namespace tw::app
+44
View File
@@ -0,0 +1,44 @@
#include "DebugUI.hpp"
#include <imgui.h>
namespace tw::dbg {
void DebugUI::draw_dockspace() {
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->WorkPos);
ImGui::SetNextWindowSize(viewport->WorkSize);
ImGui::SetNextWindowViewport(viewport->ID);
const ImGuiWindowFlags flags =
ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking |
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoBackground;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
ImGui::Begin("##debug_dockspace_host", nullptr, flags);
ImGui::PopStyleVar(3);
// A pass through centre leaves the middle empty until something is docked
// there, which is where the world is drawn.
ImGui::DockSpace(ImGui::GetID("debug_dockspace"), ImVec2(0.0f, 0.0f),
ImGuiDockNodeFlags_PassthruCentralNode);
if(ImGui::BeginMenuBar()) {
m_windows.draw_menu();
ImGui::EndMenuBar();
}
ImGui::End();
}
void DebugUI::draw_windows() {
m_windows.draw_windows();
}
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "debug/DebugWindowRegistry.hpp"
namespace tw::dbg {
/**
* The full screen host the debug panels live in: a menu bar to toggle them and
* a dock space to arrange them in. Draws no background of its own, so the world
* stays visible underneath.
*/
class DebugUI {
DebugWindowRegistry m_windows;
public:
DebugWindowRegistry& windows() {
return m_windows;
}
/**
* Opens the host for this frame. Has to run before anything that should be
* dockable is drawn, since the dock space has to exist by then.
*/
void draw_dockspace();
void draw_windows();
};
}
+26
View File
@@ -0,0 +1,26 @@
#include "DebugWindow.hpp"
#include <imgui.h>
#include <utility>
namespace tw::dbg {
DebugWindow::DebugWindow(std::string id, std::string title, std::string category)
: m_id(std::move(id)),
m_title(std::move(title)),
m_category(std::move(category)),
m_label(m_title + "##" + m_id)
{
}
void DebugWindow::draw() {
if(!m_open) {
return;
}
if(ImGui::Begin(m_label.c_str(), &m_open)) {
draw_contents();
}
ImGui::End();
}
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <string>
namespace tw::dbg {
/**
* A debug panel that can be toggled from the menu bar.
*
* The frame around a panel is drawn here so every one of them gets the same
* close button and docking behaviour; subclasses only fill in the contents.
*/
class DebugWindow {
std::string m_id;
std::string m_title;
std::string m_category;
/**
* The label handed to the ui, "title##id". Saved positions are keyed by the
* whole label, so the visible half can change without losing the layout.
*/
std::string m_label;
bool m_open = false;
protected:
/**
* Fills the panel. Called only while it is open, between begin and end.
*/
virtual void draw_contents() = 0;
public:
DebugWindow(std::string id, std::string title, std::string category);
virtual ~DebugWindow() = default;
DebugWindow(const DebugWindow&) = delete;
DebugWindow& operator=(const DebugWindow&) = delete;
const std::string& id() const { return m_id; }
const std::string& title() const { return m_title; }
const std::string& category() const { return m_category; }
bool is_open() const { return m_open; }
void set_open(bool open) { m_open = open; }
/**
* The flag the menu item toggles, and the one the close button clears.
*/
bool* open_flag() { return &m_open; }
void draw();
};
}
@@ -0,0 +1,59 @@
#include "DebugWindowRegistry.hpp"
#include "DebugWindow.hpp"
#include <imgui.h>
#include <algorithm>
namespace tw::dbg {
void DebugWindowRegistry::add(DebugWindow* window) {
auto remembered = m_open_state.find(window->id());
if(remembered != m_open_state.end()) {
window->set_open(remembered->second);
}
m_windows.push_back(window);
}
void DebugWindowRegistry::remove(DebugWindow* window) {
m_open_state[window->id()] = window->is_open();
std::erase(m_windows, window);
}
void DebugWindowRegistry::draw_menu() {
if(!ImGui::BeginMenu("Windows")) {
return;
}
// Ordered by the first panel that asked for the category, so the menu does
// not reshuffle as panels come and go.
std::vector<std::string> categories;
for(auto* window : m_windows) {
if(std::find(categories.begin(), categories.end(), window->category()) == categories.end()) {
categories.push_back(window->category());
}
}
for(const auto& category : categories) {
if(!ImGui::BeginMenu(category.c_str())) {
continue;
}
for(auto* window : m_windows) {
if(window->category() == category) {
ImGui::MenuItem(window->title().c_str(), nullptr, window->open_flag());
}
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
void DebugWindowRegistry::draw_windows() {
for(auto* window : m_windows) {
window->draw();
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
namespace tw::dbg {
class DebugWindow;
/**
* The debug panels that exist right now.
*
* Panels are listed by whatever owns them, for as long as it lives, so the menu
* follows what the client is currently doing. Whether a panel was open is kept
* here rather than on the panel, since the owner is built again on every
* reconnect and the panel would come back closed.
*/
class DebugWindowRegistry {
std::vector<DebugWindow*> m_windows;
/**
* Keyed by panel id, remembered across the panels themselves.
*/
std::unordered_map<std::string, bool> m_open_state;
public:
/**
* Lists a panel, restoring whether it was open last time one with the same
* id was listed. Ownership stays with the caller, which has to remove it
* again before the panel dies.
*/
void add(DebugWindow* window);
void remove(DebugWindow* window);
/**
* One submenu per category, listing every panel in it. Expects to be called
* inside a menu bar.
*/
void draw_menu();
void draw_windows();
};
}
@@ -1,92 +1,126 @@
#pragma once
#include <imgui.h>
#include <implot.h>
#include <algorithm>
#include <chrono>
#include <format>
#include <ratio>
#include <cstddef>
#include <vector>
#include "debug/ComponentGui.hpp"
#include <implot.h>
#include <implot_internal.h>
#include "network/EntityInterpolation.hpp"
#include "world/Transform.hpp"
template<>
class tw::dbg::ComponentGui<tw::net::EntityPositionInterpolation> {
class tw::dbg::ComponentGui<tw::Transform> {
private:
using Clock = std::chrono::high_resolution_clock;
/** Ten seconds of frames at sixty a second. */
static constexpr size_t CAPACITY = 600;
/** How much of the trace the plot shows. */
static constexpr float WINDOW_IN_SECONDS = 10.0f;
inline static const Transform* m_subject = nullptr;
inline static int m_last_frame = -1;
inline static Clock::time_point m_started_at {};
inline static size_t m_head = 0;
inline static size_t m_count = 0;
inline static std::vector<float> m_times;
inline static std::vector<float> m_positions;
void restart(const Transform* instance) {
m_subject = instance;
m_last_frame = -1;
m_started_at = Clock::now();
m_head = 0;
m_count = 0;
m_times.resize(CAPACITY);
m_positions.resize(CAPACITY);
}
/** Keeps one sample per frame, so drawing twice does not double up. */
void sample(float time, float position) {
const int frame = ImGui::GetFrameCount();
if(m_last_frame == frame) {
return;
}
m_last_frame = frame;
m_times[m_head] = time;
m_positions[m_head] = position;
m_head = (m_head + 1) % CAPACITY;
m_count = std::min(m_count + 1, CAPACITY);
}
public:
void draw(net::EntityPositionInterpolation* instance) {
ImGui::SeparatorText("Entity Interpolation");
void draw(Transform* instance) {
ImGui::SeparatorText("Drawn Position");
bool m_is_scrolling = true;
static float m_metric_history = 10.0f;
ImGui::Checkbox("Is Scrolling", &m_is_scrolling);
if(m_is_scrolling) {
ImGui::SliderFloat("History", &m_metric_history,1,30,"%.1f s");
static int axis = 0;
ImGui::Combo("Axis", &axis, "X\0Y\0Z\0");
if(m_subject != instance) {
restart(instance);
}
static int32_t time_buffer_len = 1000;
ImGui::SliderInt("Buffer length (ms)", &time_buffer_len, 0, 1000);
const float time = std::chrono::duration<float>(Clock::now() - m_started_at).count();
const float position = instance->position()[axis];
auto now = std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(time_buffer_len);
auto [from, to, mix] = instance->get_values_around(now);
auto mixed_value = glm::mix(from, to, mix);
sample(time, position);
ImGui::Text("Mixed value: (%f, %f)", mixed_value.x, mixed_value.y);
ImGui::Text("%.3f", position);
if(ImPlot::BeginPlot("Interpolation")) {
auto plot_x_from = instance->times().begin()->time_since_epoch().count();
std::vector<float> times(instance->times().max_size());
for(auto it = instance->times().begin(); it != instance->times().end(); ++it) {
times.push_back(it->time_since_epoch().count() - plot_x_from);
}
if(ImPlot::BeginPlot("Position", ImVec2(-1.0f, 180.0f))) {
ImPlot::SetupAxes("seconds", "position", ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxisLimits(ImAxis_X1,
time - WINDOW_IN_SECONDS, time, ImGuiCond_Always);
float interpolated_time = now.time_since_epoch().count() - plot_x_from;
std::vector<float> values(instance->values().max_size());
for(auto it = instance->values().begin(); it != instance->values().end(); ++it) {
values.push_back(it->x);
}
values.push_back(mixed_value.x);
// The trace wraps around, so it is read from wherever the oldest
// sample ended up.
const int offset = m_count == CAPACITY ? (int)m_head : 0;
ImPlot::PlotLine("Drawn", m_times.data(), m_positions.data(),
(int)m_count, ImPlotLineFlags_None, offset);
ImPlot::PlotScatter("Server", times.data(), values.data(), times.size() - 1);
ImPlot::PushStyleVar(ImPlotStyleVar_FillAlpha, 0.25f);
ImPlot::SetNextMarkerStyle(ImPlotMarker_Square, 6, ImPlot::GetColormapColor(1), IMPLOT_AUTO, ImPlot::GetColormapColor(1));
ImPlot::PlotScatter("Client", &interpolated_time, &mixed_value.x, 1);
ImPlot::PopStyleVar();
ImPlot::EndPlot();
}
if(ImGui::BeginTable("entityInterpolation", 2)) {
for(int i = 0; i < instance->values().size(); i++) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
auto value = instance->values()[i];
auto now = std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(time_buffer_len);
if(instance->times()[i] > now && (i == instance->values().size() - 1 || instance->times()[i + 1] <= now)) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImVec4(0.7f, 0.3f, 0.3f, 0.65f)));
ImGui::Text("%f %f %f", mixed_value.x, mixed_value.y, mixed_value.z);
ImGui::TableNextColumn();
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImVec4(0.0f, 0.0f, 0.0f, 0.0f)));
}
ImGui::Text("%f %f %f", value.x, value.y, value.z);
auto point = instance->times()[i].time_since_epoch();
auto hours = std::chrono::duration_cast<std::chrono::hours>(point);
point -= hours;
hours %= 24;
auto minutes = std::chrono::duration_cast<std::chrono::minutes>(point);
point -= minutes;
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(point);
point -= seconds;
auto milliseconds = std::chrono::duration_cast<std::chrono::milliseconds>(point);
ImGui::TableNextColumn();
auto fmt = std::format("{}:{}:{}.{}", hours, minutes, seconds, milliseconds);
ImGui::Text(fmt.c_str());
}
ImGui::EndTable();
}
}
};
/**
* Says whether the position above stands still because nothing new arrives for
* the entity, or because what arrives is the same position over and over.
*/
template<>
class tw::dbg::ComponentGui<tw::net::EntityPositionInterpolation> {
private:
using Clock = std::chrono::high_resolution_clock;
static int64_t millis_since(Clock::time_point point) {
return std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - point).count();
}
public:
void draw(net::EntityPositionInterpolation* instance) {
ImGui::SeparatorText("Received Positions");
const size_t newest = instance->values().size() - 1;
ImGui::Text("Newest %.3f %.3f %.3f, %ld ms ago",
instance->values()[newest].x,
instance->values()[newest].y,
instance->values()[newest].z,
millis_since(instance->times()[newest]));
ImGui::Text("Oldest %.3f %.3f %.3f, %ld ms ago",
instance->values()[0].x,
instance->values()[0].y,
instance->values()[0].z,
millis_since(instance->times()[0]));
}
};
@@ -19,6 +19,7 @@
namespace tw::dbg::tools {
EntityManagerGui::EntityManagerGui(World* world) :
DebugWindow("entity_manager", "Entities", "World"),
m_world(world) {
}
@@ -49,7 +50,8 @@ void EntityManagerGui::draw_entity_components() {
ImGui::Text("Vertex offset: %i", mesh->vertex_offset());
}
draw_debug_components<tw::CharacterBody, tw::CharacterController, tw::net::EntityPositionInterpolation>();
draw_debug_components<tw::Transform, tw::net::EntityPositionInterpolation,
tw::CharacterBody, tw::CharacterController>();
// auto character = m_world->registry().try_get<tw::CharacterBody>(m_selected);
// if(character != nullptr) {
@@ -64,9 +66,7 @@ void EntityManagerGui::draw_entity_components() {
ImGui::EndChild();
}
void EntityManagerGui::draw() {
ImGui::Begin("Transforms");
void EntityManagerGui::draw_contents() {
ImGui::BeginChild("Entities", ImVec2(0, 260), ImGuiChildFlags_Border);
ImGui::SeparatorText("Entities");
@@ -87,8 +87,6 @@ void EntityManagerGui::draw() {
if(m_selected_entity.has_value()) {
draw_entity_components();
}
ImGui::End();
}
}
@@ -1,6 +1,7 @@
#pragma once
#include "debug/ComponentGui.hpp"
#include "debug/DebugWindow.hpp"
#include "entt/entity/fwd.hpp"
#include "world/World.hpp"
@@ -8,7 +9,7 @@
namespace tw::dbg::tools {
class EntityManagerGui {
class EntityManagerGui : public tw::dbg::DebugWindow {
private:
World* m_world;
@@ -29,14 +30,15 @@ private:
...);
}
protected:
void draw_contents() override;
public:
entt::entity& selected() {
return m_selected;
}
EntityManagerGui(World* world);
void draw();
};
}
@@ -1,5 +1,6 @@
#pragma once
#include "debug/DebugWindow.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "debug/tools/MetricWidget.hpp"
@@ -14,7 +15,7 @@ namespace tw::dbg::tools {
* connection actually carried. Durations are shown as the average of each
* second, with the range behind them.
*/
class NetworkStatsGui {
class NetworkStatsGui : public tw::dbg::DebugWindow {
MetricWidget m_response;
MetricWidget m_update;
MetricWidget m_bytes_in;
@@ -29,23 +30,8 @@ class NetworkStatsGui {
int m_history_in_seconds = 30;
public:
explicit NetworkStatsGui(const NetworkMetrics& metrics) :
m_response("Response", "ms", metrics.response_ms(), metrics::MetricField::Avg),
m_update("Network update", "ms", metrics.update_ms(), metrics::MetricField::Avg),
m_bytes_in("Bytes in", "B/s", metrics.bytes_in(), metrics::MetricField::Sum),
m_bytes_out("Bytes out", "B/s", metrics.bytes_out(), metrics::MetricField::Sum),
m_messages_in("Messages in", "1/s", metrics.messages_in(), metrics::MetricField::Sum),
m_messages_out("Messages out", "1/s", metrics.messages_out(), metrics::MetricField::Sum),
m_rollbacks("Rollbacks", "1/s", metrics.rollbacks(), metrics::MetricField::Sum),
m_correction_distance("Correction distance", "m", metrics.correction_distance(), metrics::MetricField::Avg),
m_ack_lag_frames("Ack lag", "frames", metrics.ack_lag_frames(), metrics::MetricField::Avg),
m_replayed_frames("Replayed frames", "frames", metrics.replayed_frames(), metrics::MetricField::Avg)
{ }
void draw() {
ImGui::Begin("Network Stats");
protected:
void draw_contents() override {
ImGui::SliderInt("History", &m_history_in_seconds, 5, 300, "%d s");
const size_t history = (size_t)m_history_in_seconds;
@@ -63,9 +49,22 @@ public:
m_correction_distance.draw(history);
m_ack_lag_frames.draw(history);
m_replayed_frames.draw(history);
ImGui::End();
}
public:
explicit NetworkStatsGui(const NetworkMetrics& metrics) :
DebugWindow("network_stats", "Network Stats", "Network"),
m_response("Response", "ms", metrics.response_ms(), metrics::MetricField::Avg),
m_update("Network update", "ms", metrics.update_ms(), metrics::MetricField::Avg),
m_bytes_in("Bytes in", "B/s", metrics.bytes_in(), metrics::MetricField::Sum),
m_bytes_out("Bytes out", "B/s", metrics.bytes_out(), metrics::MetricField::Sum),
m_messages_in("Messages in", "1/s", metrics.messages_in(), metrics::MetricField::Sum),
m_messages_out("Messages out", "1/s", metrics.messages_out(), metrics::MetricField::Sum),
m_rollbacks("Rollbacks", "1/s", metrics.rollbacks(), metrics::MetricField::Sum),
m_correction_distance("Correction distance", "m", metrics.correction_distance(), metrics::MetricField::Avg),
m_ack_lag_frames("Ack lag", "frames", metrics.ack_lag_frames(), metrics::MetricField::Avg),
m_replayed_frames("Replayed frames", "frames", metrics.replayed_frames(), metrics::MetricField::Avg)
{ }
};
}
@@ -7,14 +7,14 @@
namespace tw::dbg::tools {
PerformanceStatsGui::PerformanceStatsGui(LockStep& lock_step) :
DebugWindow("performance_stats", "Performance", "General"),
m_lockstep(lock_step),
fps_history(1000),
frame_idxs(1000)
{
}
void PerformanceStatsGui::draw() {
ImGui::Begin("Stats");
void PerformanceStatsGui::draw_contents() {
ImGui::Text("FPS: %ld", m_lockstep.fps());
fps_history[fps_history_idx] = m_lockstep.fps();
@@ -34,8 +34,6 @@ void PerformanceStatsGui::draw() {
ImPlot::PlotLine("FPS", frame_idxs.data(), fps_history.data(), is_plot_filled ? (int)fps_history.size() : (int)fps_history_idx - 1);
ImPlot::EndPlot();
}
ImGui::End();
}
}
@@ -1,10 +1,11 @@
#pragma once
#include "debug/DebugWindow.hpp"
#include "runtime/LockStep.hpp"
namespace tw::dbg::tools {
class PerformanceStatsGui {
class PerformanceStatsGui : public tw::dbg::DebugWindow {
private:
LockStep& m_lockstep;
@@ -15,10 +16,11 @@ private:
uint32_t frame_idx = 0;
bool is_plot_filled = false;
protected:
void draw_contents() override;
public:
PerformanceStatsGui(LockStep& lock_step);
void draw();
};
}
@@ -49,7 +49,9 @@ Instance create_instance(const std::string& name, const lft::win::Window* window
// required_extensions.push_back(VK_EXT_ROBUSTNESS_2_EXTENSION_NAME);
std::vector<std::string> required_layers = {
#if DEBUG
"VK_LAYER_KHRONOS_validation"
#endif
};
/**
@@ -1,8 +1,8 @@
#pragma once
#include <SDL_keycode.h>
#include <SDL_mouse.h>
#include <imgui_impl_sdl2.h>
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_keycode.h>
#include <imgui_impl_sdl3.h>
#include <Window.hpp>
@@ -69,30 +69,30 @@ public:
m_motion_y = 0.0f;
while(m_window->poll_event(&event)) {
ImGui_ImplSDL2_ProcessEvent(&event);
ImGui_ImplSDL3_ProcessEvent(&event);
switch(event.type) {
case SDL_QUIT:
case SDL_EVENT_QUIT:
m_is_quit = true;
break;
case SDL_KEYDOWN:
case SDL_KEYUP: {
switch(event.key.keysym.sym) {
case SDLK_w:
m_is_pressed[0] = event.key.state;
case SDL_EVENT_KEY_DOWN:
case SDL_EVENT_KEY_UP: {
switch(event.key.key) {
case SDLK_W:
m_is_pressed[0] = event.key.down;
break;
case SDLK_s:
m_is_pressed[1] = event.key.state;
case SDLK_S:
m_is_pressed[1] = event.key.down;
break;
case SDLK_a:
m_is_pressed[2] = event.key.state;
case SDLK_A:
m_is_pressed[2] = event.key.down;
break;
case SDLK_d:
m_is_pressed[3] = event.key.state;
case SDLK_D:
m_is_pressed[3] = event.key.down;
break;
}
} break;
case SDL_MOUSEMOTION:
case SDL_EVENT_MOUSE_MOTION:
m_motion_x = event.motion.xrel;
m_motion_y = event.motion.yrel;
break;
@@ -1,10 +0,0 @@
#pragma once
/**
* Writes entity derivation message
*/
template<typename TMesg>
class EntityDerivationWriter {
};
@@ -0,0 +1,32 @@
#pragma once
#include "entt/entt.hpp"
#include <cstdint>
#include <map>
#include <optional>
namespace tw::net {
class EntityIdMap {
public:
const bool is_server_entity_registered(uint32_t server_id) const {
return m_mappings.find(server_id) != m_mappings.end();
}
const std::optional<entt::entity> get_local(uint32_t server_id) const {
if(is_server_entity_registered(server_id)) {
return m_mappings.find(server_id)->second;
}
return {};
}
void register_server_to_local(uint32_t server_id, entt::entity local) {
m_mappings[server_id] = local;
}
private:
std::map<uint32_t, entt::entity> m_mappings;
};
}
@@ -9,19 +9,16 @@
namespace tw::net {
EntityPositionInterpolator::EntityPositionInterpolator(
entt::registry* registry, entt::entity player_entity, size_t bufferingIntervalInMillis
entt::registry* registry, size_t bufferingIntervalInMillis
) : m_registry(registry),
m_player_entity(player_entity),
m_bufferingIntervalInMillis(bufferingIntervalInMillis)
{
}
{ }
void EntityPositionInterpolator::register_entity(entt::entity entity) {
m_registry->emplace<EntityPositionInterpolation>(entity, glm::vec3());
}
void EntityPositionInterpolator::add_position_for_entity(entt::entity entity, glm::vec3 position) {
void EntityPositionInterpolator::set_position(Clock::time_point time_point, entt::entity entity, glm::vec3 position) {
auto* interpolation = m_registry->try_get<EntityPositionInterpolation>(entity);
if(interpolation == nullptr) {
spdlog::warn("Attempt to add position for non-registered entity {}", (uint32_t)entity);
@@ -31,7 +28,7 @@ void EntityPositionInterpolator::add_position_for_entity(entt::entity entity, gl
interpolation->push(Clock::now(), position);
}
glm::vec3 EntityPositionInterpolator::get_position_for_entity(entt::entity entity) {
glm::vec3 EntityPositionInterpolator::get_position(Clock::time_point time_point, entt::entity entity) {
auto* interpolation = m_registry->try_get<EntityPositionInterpolation>(entity);
if(interpolation == nullptr) {
spdlog::warn("Attempt to get position for non-registered entity {}", (uint32_t)entity);
@@ -44,12 +41,10 @@ glm::vec3 EntityPositionInterpolator::get_position_for_entity(entt::entity entit
return glm::mix(from, to, value);
}
void EntityPositionInterpolator::update() {
void EntityPositionInterpolator::interpolate_smoothed_entities(Clock::time_point time_point) {
m_registry->view<EntityPositionInterpolation, Transform>()
.each([&](const auto entity, const EntityPositionInterpolation& interpolation, Transform& ts) {
auto now = Clock::now() - std::chrono::milliseconds(m_bufferingIntervalInMillis);
auto [from, to, value] = interpolation.get_values_around(now);
auto [from, to, value] = interpolation.get_values_around(time_point);
ts.set_position(glm::mix(from, to, value));
});
}
@@ -1,3 +1,4 @@
#pragma once
#include <chrono>
#include <entt/entt.hpp>
@@ -12,23 +13,20 @@ class EntityPositionInterpolator {
entt::registry* m_registry;
entt::entity m_player_entity;
size_t m_bufferingIntervalInMillis;
glm::vec3 get_position(Clock::time_point time_point, entt::entity entity);
public:
EntityPositionInterpolator(
entt::registry* registry,
entt::entity player_entity,
size_t bufferingIntervalInMillis);
void register_entity(entt::entity entity);
void add_position_for_entity(entt::entity entity, glm::vec3 position);
void set_position(Clock::time_point time_point, entt::entity entity, glm::vec3 position);
glm::vec3 get_position_for_entity(entt::entity entity);
void update();
void interpolate_smoothed_entities(Clock::time_point time_point);
};
}
@@ -1,4 +1,4 @@
#include "PlayerReconciler.hpp"
#include "PlayerRollback.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/CharacterBody.hpp"
@@ -9,7 +9,7 @@
namespace tw::net {
PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics)
PlayerRollback::PlayerRollback(JoltPhysicsWorld* physics)
: m_physics(physics), m_last_reconciled_ack(0), m_rollback_count(0),
m_last_correction_distance(0.0f), m_last_replayed_frames(0), m_last_ack_frame(0)
{
@@ -21,21 +21,21 @@ PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics)
}
}
void PlayerReconciler::record_input(uint32_t frame, glm::vec3 input) {
void PlayerRollback::set_input(uint32_t frame, glm::vec3 input) {
size_t idx = frame % RING_SIZE;
m_records[idx].frame = frame;
m_records[idx].valid = true;
m_records[idx].input = input;
}
void PlayerReconciler::record_prediction(uint32_t frame, glm::vec3 position) {
void PlayerRollback::record_prediction(uint32_t frame, glm::vec3 position) {
size_t idx = frame % RING_SIZE;
if (m_records[idx].frame == frame && m_records[idx].valid) {
m_records[idx].predicted_position = position;
}
}
bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
bool PlayerRollback::apply_correction(uint32_t ack_frame, glm::vec3 authoritative_position,
entt::entity player, entt::registry* registry,
uint32_t current_frame)
{
@@ -77,14 +77,16 @@ bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_pos
return true;
}
void PlayerReconciler::place_character(entt::entity player, entt::registry* registry,
void PlayerRollback::place_character(entt::entity player, entt::registry* registry,
glm::vec3 position, bool clear_velocity) {
CharacterBody* body = registry->try_get<CharacterBody>(player);
Transform* transform = registry->try_get<Transform>(player);
if (!body) {
return;
}
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
transform->set_position(position);
if (clear_velocity) {
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
@@ -92,7 +94,7 @@ void PlayerReconciler::place_character(entt::entity player, entt::registry* regi
}
}
void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player,
void PlayerRollback::replay_from(uint32_t from_frame, entt::entity player,
entt::registry* registry, uint32_t current_frame) {
for (uint32_t f = from_frame + 1; f < current_frame; ++f) {
m_physics->step(f, tw::JoltPhysicsWorld::FIXED_DELTA_TIME, true);
@@ -111,7 +113,7 @@ void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player,
}
}
void PlayerReconciler::reset_at(uint32_t frame) {
void PlayerRollback::reset_at(uint32_t frame) {
m_last_reconciled_ack = frame;
for (auto& record : m_records) {
@@ -11,7 +11,7 @@ class JoltPhysicsWorld;
namespace tw::net {
class PlayerReconciler {
class PlayerRollback {
private:
struct Record {
uint32_t frame;
@@ -33,13 +33,14 @@ private:
uint32_t m_last_ack_frame = 0;
public:
PlayerReconciler(tw::JoltPhysicsWorld* physics);
PlayerRollback(tw::JoltPhysicsWorld* physics);
void record_input(uint32_t frame, glm::vec3 input);
void set_input(uint32_t frame, glm::vec3 input);
void record_prediction(uint32_t frame, glm::vec3 position);
bool reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
entt::entity player, entt::registry* registry, uint32_t current_frame);
bool apply_correction(
uint32_t ack_frame, glm::vec3 authoritative_position,
entt::entity player, entt::registry* registry, uint32_t current_frame);
private:
/**
@@ -56,11 +57,6 @@ private:
public:
/**
* Drops every stored frame and treats `frame` as already answered. Used when
* the player is placed outright, where nothing recorded before the placement
* describes where it now is.
*/
void reset_at(uint32_t frame);
uint64_t rollback_count() const { return m_rollback_count; }
@@ -0,0 +1,187 @@
#include "ReplicatorClient.hpp"
#include "PlayerMove.pb.h"
#include "debug/metrics/NetworkMetrics.hpp"
#include "network/EntityIdMap.hpp"
#include "world/CharacterBody.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/Transform.hpp"
#include <chrono>
namespace tw::net {
ReplicatorClient::ReplicatorClient(
ProtobufMessages* messages,
EntityIdMap* entity_id_map,
dbg::NetworkMetrics *network_metrics,
JoltPhysicsWorld *physics_world,
World *world,
net::ServerConnection* server_connection,
EntityPositionInterpolator* entity_writer
) :
m_messages(messages),
m_entity_id_map(entity_id_map),
m_network_metrics(network_metrics),
m_rollback(physics_world),
m_world(world),
m_server_connection(server_connection),
m_input_send_times(INPUT_SEND_TIME_COUNT),
m_entity_interpolator(entity_writer)
{
}
int64_t ReplicatorClient::now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()).count();
}
void ReplicatorClient::set_player_entity(entt::entity entity) {
m_player_entity = entity;
}
void ReplicatorClient::measure_response_time(uint32_t current_frame_idx, uint32_t snapshot_frame_idx) {
// Snapshots carry frame zero until the server has an input to answer, and
// repeat the same frame whenever no newer one arrived in between.
if(snapshot_frame_idx == 0 || snapshot_frame_idx <= m_last_measured_frame) {
return;
}
// Anything the send times no longer cover, including a frame we never sent,
// which underflows into a large distance.
if(current_frame_idx - snapshot_frame_idx >= INPUT_SEND_TIME_COUNT) {
return;
}
m_last_measured_frame = snapshot_frame_idx;
auto sent_at = m_input_send_times[snapshot_frame_idx % INPUT_SEND_TIME_COUNT];
m_network_metrics->record_response_time(Clock::now() - sent_at);
}
void ReplicatorClient::set_input(uint32_t frame_idx, glm::vec3 input) {
m_rollback.set_input(frame_idx, input);
mmo::PlayerMoveMessage player_move_message = {};
player_move_message.set_frame_idx(frame_idx);
mmo::PlayerInput* player_input = new mmo::PlayerInput();
player_input->set_x(input.x);
player_input->set_y(input.y);
player_input->set_z(input.z);
player_move_message.set_allocated_input(player_input);
auto send_result = m_messages->send(m_server_connection->server(), player_move_message, false);
if(!send_result) {
spdlog::error("Failed to send message: {}", send_result.error().message());
}
m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now();
}
void ReplicatorClient::record_prediction(uint32_t frame_idx) {
if(!m_player_entity.has_value()) {
return;
}
Transform* transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(!transform) {
return;
}
glm::vec3 position = transform->position();
m_rollback.record_prediction(frame_idx, position);
}
void ReplicatorClient::update() {
if(!m_player_entity.has_value()) {
return;
}
// m_entity_interpolator.interpolate_smoothed_entities(std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(100));
// Transform* transform = m_world->registry().try_get<Transform>(m_player_entity.value());
// if(!transform) {
// return;
// }
//
// transform->set_position(m_render_position);
}
void ReplicatorClient::snap_player_to(uint32_t frame_idx, entt::entity entity, glm::vec3 position) {
CharacterBody* body = m_world->registry().try_get<CharacterBody>(entity);
if(body) {
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
body->m_desired_velocity = JPH::Vec3::sZero();
}
Transform* transform = m_world->registry().try_get<Transform>(entity);
if(transform) {
transform->set_position(position);
}
m_render_position = position;
// m_rollback.reset_at(frame_idx);
}
void ReplicatorClient::handle_snapshot_entity(
uint32_t current_frame_idx,
uint32_t record_frame_idx,
serial::EntityRecord& record
) {
std::optional<entt::entity> entity = m_entity_id_map->get_local(record.id);
if(!entity.has_value()) {
spdlog::warn("Received position update for unknown entity {}", record.id);
return;
}
glm::vec3 p = {record.position.x, record.position.y, record.position.z};
if(m_player_entity.has_value() &&
entity.value() == m_player_entity.value()
) {
if(!m_player_position_initialized) {
m_player_position_initialized = true;
// snap_player_to(current_frame_idx, entity.value(), p);
// return;
}
//snap_player_to(current_frame_idx, entity.value(), p);
// bool reconcile_happened = m_reconciler.reconcile(record_frame_idx, p, entity.value(), &m_world->registry(), current_frame_idx);
//
// if(reconcile_happened) {
// m_network_metrics->record_rollback();
// m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance());
// m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames());
// }
// if(record_frame_idx != 0) {
// uint32_t ack_lag = 0;
// if(current_frame_idx >= record_frame_idx) {
// ack_lag = current_frame_idx - record_frame_idx;
// }
// m_network_metrics->record_ack_lag(ack_lag);
// }
}
m_entity_interpolator->set_position(std::chrono::high_resolution_clock::now(), entity.value(), p);
}
void ReplicatorClient::handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader) {
auto header = reader.read_header();
measure_response_time(current_frame_idx, header.frame_idx);
while(reader.has_entity()) {
auto entity_r = reader.read_entity();
handle_snapshot_entity(current_frame_idx, header.frame_idx, entity_r);
}
}
}
@@ -0,0 +1,114 @@
#pragma once
#include "ProtobufMessages.hpp"
#include "WorldStateWriter.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "network/EntityIdMap.hpp"
#include "network/PlayerRollback.hpp"
#include "network/ServerConnection.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "network/EntityPositionInterpolator.hpp"
#include "world/World.hpp"
namespace tw::net {
/**
* Handles replication messages from server.
*/
class ReplicatorClient {
public:
ReplicatorClient(
ProtobufMessages* messages,
EntityIdMap* entity_id_map,
dbg::NetworkMetrics *network_metrics,
JoltPhysicsWorld *physics_world,
World *world,
net::ServerConnection* server_connection,
EntityPositionInterpolator* entity_writer
);
/**
* Sets player controlled entity. The behaviour for this entity will be
* little different.
*/
void set_player_entity(entt::entity entity);
/**
* Takes the input driving `frame_idx`, keeping it for the answer to that
* frame and putting it on the wire.
*/
void set_input(uint32_t frame_idx, glm::vec3 input);
/**
* Takes where the player ended up on `frame_idx`, which should represent
* the prediction taken.
*/
void record_prediction(uint32_t frame_idx);
void handle_snapshot(uint32_t current_frame_idx, serial::WorldStateReader& reader);
private:
/**
* Records how long the answer to `frame_idx` took to arrive, ignoring
* frames that were already measured or are too old to still have a send
* time.
*/
void measure_response_time(uint32_t current_frame_idx, uint32_t snapshot_frame_idx);
/**
* Places the player at an authoritative position outright, clearing the
* predicted state that led there. Used for the first position the server
* sends, which the local simulation has no history to reconcile against.
*/
void snap_player_to(uint32_t frame_idx, entt::entity entity, glm::vec3 position);
void handle_snapshot_entity(
uint32_t current_frame_idx,
uint32_t record_frame_idx,
serial::EntityRecord& record
);
void update();
glm::vec3 render_position() const {
return m_render_position;
}
private:
using Clock = std::chrono::steady_clock;
static int64_t now_ms();
ProtobufMessages *m_messages;
EntityIdMap* m_entity_id_map;
dbg::NetworkMetrics *m_network_metrics;
net::PlayerRollback m_rollback;
World *m_world;
net::ServerConnection* m_server_connection;
std::optional<entt::entity> m_player_entity;
net::EntityPositionInterpolator* m_entity_interpolator;
/**
* Whether the server has placed the player at least once. Entities are
* created before their position arrives, so the body starts at the origin
* and has to be moved once the first position shows up.
*/
bool m_player_position_initialized = false;
glm::vec3 m_render_position{0.0f};
/**
* When each input was sent, indexed by its frame. Holds the most recent
* INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes
* unmeasured.
*/
static constexpr size_t INPUT_SEND_TIME_COUNT = 256;
std::vector<Clock::time_point> m_input_send_times;
uint32_t m_last_measured_frame = 0;
};
}
@@ -0,0 +1,61 @@
#include "ServerConnection.hpp"
#include <spdlog/spdlog.h>
namespace tw::net {
ServerConnection::ServerConnection(Address address) :
m_address(address),
m_status(ConnectionStatus::Idle),
m_started_at(Clock::now()) {
}
tl::expected<void, msg::MessageError> ServerConnection::start() {
auto endpoint_r = msg::MessageEndpoint::create();
if(!endpoint_r) {
m_status = ConnectionStatus::Failed;
m_error = endpoint_r.error().message();
return tl::make_unexpected(endpoint_r.error());
}
m_endpoint = std::move(endpoint_r.value());
auto server_r = m_endpoint->connect(m_address.ip_string(), m_address.port());
if(!server_r) {
m_status = ConnectionStatus::Failed;
m_error = server_r.error().message();
return tl::make_unexpected(server_r.error());
}
m_server = server_r.value();
m_status = ConnectionStatus::Connecting;
m_started_at = Clock::now();
spdlog::info("Attempting to connect to server at {}", m_address.to_string());
return {};
}
void ServerConnection::update() {
if(!m_endpoint) {
return;
}
m_endpoint->update();
if(m_status == ConnectionStatus::Connecting && m_server) {
if(m_server->is_established()) {
m_status = ConnectionStatus::Connected;
spdlog::info("Connected to server at {}", m_address.to_string());
} else {
auto elapsed = Clock::now() - m_started_at;
if(elapsed >= CONNECT_TIMEOUT) {
m_status = ConnectionStatus::Failed;
m_error = "No response from " + m_address.to_string();
spdlog::error("Connection timeout to {}", m_address.to_string());
}
}
}
}
}
@@ -0,0 +1,54 @@
#pragma once
#include <memory>
#include <string>
#include <chrono>
#include "Address.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include "message_protocol/MessageConnection.hpp"
#include "message_protocol/MessageError.hpp"
#include <tl/expected.hpp>
namespace tw::net {
/**
* Status of a server connection attempt or established connection.
*/
enum class ConnectionStatus { Idle, Connecting, Connected, Failed };
/**
* Encapsulates a connection to a game server.
*/
class ServerConnection {
using Clock = std::chrono::steady_clock;
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
msg::MessageConnection* m_server = nullptr;
Address m_address;
ConnectionStatus m_status = ConnectionStatus::Idle;
std::string m_error;
Clock::time_point m_started_at;
static constexpr std::chrono::seconds CONNECT_TIMEOUT{5};
public:
explicit ServerConnection(Address address);
tl::expected<void, msg::MessageError> start();
void update();
ConnectionStatus status() const { return m_status; }
const std::string& error() const { return m_error; }
const Address& address() const { return m_address; }
msg::MessageEndpoint* endpoint() const { return m_endpoint.get(); }
msg::MessageConnection* server() const { return m_server; }
};
}
+56 -87
View File
@@ -1,23 +1,23 @@
#include "runtime.hpp"
#include "app/ClientArgs.hpp"
#include "Address.hpp"
#include "SDLWindow.h"
#include "debug/tools/NetworkStatsGui.hpp"
#include "debug/tools/PerformanceStatsGui.hpp"
#include "entt/entity/fwd.hpp"
#include "io/InputState.hpp"
#include "debug/tools/EntityManagerGui.hpp"
#include "debug/tools/PerformanceStatsGui.hpp"
#include "draw/MeshData.hpp"
#include "imgui.h"
#include "imgui_impl_vulkan.h"
#include "imgui_impl_sdl2.h"
#include "imgui_impl_sdl3.h"
#include "implot.h"
#include "implot_internal.h"
#include "world/Transform.hpp"
#include <SDL_events.h>
#include "spdlog/spdlog.h"
#include <glm/glm.hpp>
#include <tracy/Tracy.hpp>
#include <memory>
@@ -31,17 +31,23 @@ std::unique_ptr<lft::win::Window> create_window(const std::string& name, VkExten
});
}
int get_port_from_args(int argc, char** argv) {
try {
if(argc > 1) {
return atoi(argv[1]);
} else {
return 8080;
}
} catch(std::exception& e) {
std::println("Could not parse port from arguments, using default.");
return 8080;
/**
* The address to connect to without asking, if one was given on the command
* line. A malformed one is reported and dropped, leaving the user in the lobby.
*/
static std::optional<net::Address> auto_connect_address(int argc, char** argv) {
auto argument = app::server_arg(argc, argv);
if(!argument) {
return {};
}
auto parsed = app::parse_address(*argument);
if(!parsed) {
spdlog::error("Failed to parse server address: {}", parsed.error());
return {};
}
return *parsed;
}
Runtime::Runtime(int argc, char** argv) :
@@ -52,51 +58,43 @@ Runtime::Runtime(int argc, char** argv) :
m_world_renderer("towards", m_window.get(), &m_world, &m_files),
m_input_manager(m_window.get()),
m_network_metrics(),
m_world_controller(&m_input_manager, &m_world, &m_physics_world, &m_world_renderer, { "127.0.0.1", get_port_from_args(argc, argv) }, &m_network_metrics),
m_lockstep(60)
m_lockstep(60),
m_debug_ui(),
m_perf_stats(m_lockstep),
m_state(std::in_place_type<app::LobbyState>, auto_connect_address(argc, argv))
{
m_debug_ui.windows().add(&m_perf_stats);
}
// bool Runtime::world_state_packet_handler(uint32_t* p_frame_idx, WorldSnapshotMessage* mesg) {
// uint32_t frame_idx = *p_frame_idx;
Runtime::~Runtime() {
m_debug_ui.windows().remove(&m_perf_stats);
}
// if(mesg->frame_idx < frame_idx) {
// return false;
// }
app::GameContext Runtime::context() {
return app::GameContext{
&m_world,
&m_physics_world,
&m_world_renderer,
&m_input_manager,
&m_network_metrics,
&m_debug_ui.windows(),
};
}
void Runtime::update_state(double delta_time) {
if(auto* lobby = std::get_if<app::LobbyState>(&m_state)) {
lobby->update(delta_time);
// for(int i = 0; i < mesg->player_states.size(); i++) {
// if(!m_players.contains(mesg->player_states[i].id)) {
// auto mesh = m_world_renderer.add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
// const auto entity = m_world.registry().create();
// m_players.insert({mesg->player_states[i].id, entity});
if(auto result = lobby->take_result()) {
m_state.emplace<app::GameState>(context(), std::move(result->connection));
}
return;
}
// m_world.registry().emplace<Transform>(entity, Transform(mesg->player_states[i].position));
// m_world.registry().emplace<PlayerInfoComponent>(entity,
// PlayerInfoComponent(
// mesg->player_states[i].id,
// mesg->player_states[i].name));
// m_world.registry().emplace<drw::Mesh>(entity, mesh);
// } else {
// auto entity = (entt::entity)mesg->player_states[i].id;
// auto entity_ts = m_world.registry()
// .try_get<Transform>(entity);
// if(entity_ts) {
// entity_ts->transform = glm::translate(glm::mat4(1.0f), mesg->player_states[i].position);
// }
// }
// }
// return true;
// }
std::get<app::GameState>(m_state).update(delta_time);
}
void Runtime::run() {
dbg::tools::EntityManagerGui entity_manager(&m_world);
dbg::tools::PerformanceStatsGui perf_stats(m_lockstep);
dbg::tools::NetworkStatsGui network_stats(m_network_metrics);
ImPlot::CreateContext();
m_is_running = true;
@@ -106,50 +104,21 @@ void Runtime::run() {
}
ImGui_ImplVulkan_NewFrame();
ImGui_ImplSDL2_NewFrame();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
// ImGui::DockSpaceOverViewport();
entity_manager.draw();
ImGui::Begin("History");
if(ImGui::BeginTable("historyTable", 2)) {
// for(auto key : m_world_controller.position_history().keys()) {
// ImGui::TableNextRow();
//
// // auto v = *m_world_controller.player_history().get(key).value();
// // auto input = std::format("{} {} {}", v.x, v.y, v.z);
// auto p = *m_world_controller.position_history().get(key).value();
// auto position = std::format("{} {} {}", p.x, p.y, p.z);
//
// ImGui::TableNextColumn();
// ImGui::Text("%u", key);
// // ImGui::TableNextColumn();
// // ImGui::Text(input.c_str());
// ImGui::TableNextColumn();
// ImGui::Text(position.c_str());
// }
ImGui::EndTable();
}
ImGui::End();
bool change_imgui = false;
m_input_manager.update();
if(m_input_manager.is_quit()) {
m_is_running = false;
}
m_world_controller.update(m_lockstep.delta_time());
perf_stats.draw();
network_stats.draw();
tw::dbg::ComponentGui<tw::io::InputManager>().draw(&m_input_manager);
// Anything that docks needs the dock space to already be there, so the
// host goes up before the state draws.
m_debug_ui.draw_dockspace();
m_world.step(m_lockstep.delta_time());
update_state(m_lockstep.delta_time());
m_debug_ui.draw_windows();
m_world_renderer.render();
FrameMark;
+20 -11
View File
@@ -1,10 +1,16 @@
#pragma once
#include <variant>
#include "app/GameContext.hpp"
#include "app/GameState.hpp"
#include "app/LobbyState.hpp"
#include "debug/DebugUI.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "debug/tools/PerformanceStatsGui.hpp"
#include "draw/WorldRenderer.hpp"
#include "io/InputState.hpp"
#include "runtime/LockStep.hpp"
#include "world/ClientWorldController.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
@@ -18,35 +24,38 @@ namespace tw {
class Runtime {
private:
io::Files m_files;
std::unique_ptr<lft::win::Window> m_window;
tw::World m_world;
JoltPhysicsWorld m_physics_world;
tw::drw::WorldRenderer m_world_renderer;
tw::io::InputManager m_input_manager;
tw::dbg::NetworkMetrics m_network_metrics;
tw::ClientWorldController m_world_controller;
tw::LockStep m_lockstep;
bool m_is_running;
/**
* Declared before the state so panels the state owns can still be taken out
* of the menu while the state is being torn down.
*/
tw::dbg::DebugUI m_debug_ui;
tw::dbg::tools::PerformanceStatsGui m_perf_stats;
std::variant<tw::app::LobbyState, tw::app::GameState> m_state;
bool m_is_running;
std::unordered_map<uint32_t, entt::entity> m_players;
void send_player_positions();
app::GameContext context();
void update_state(double delta_time);
public:
const bool is_running() const {
return m_is_running;
}
Runtime(int argc, char** argv);
~Runtime();
void run();
};
@@ -1,474 +0,0 @@
#include "ClientWorldController.hpp"
#include <glm/glm.hpp>
#include <chrono>
#include <spdlog/spdlog.h>
#include "Address.hpp"
#include "Entity.pb.h"
#include "Login.pb.h"
#include "WorldState.pb.h"
#include "PlayerMove.pb.h"
#include "entt/entity/entity.hpp"
#include "entt/entity/fwd.hpp"
#include "messages/PlayerMoveMessage.hpp"
#include "metrics/HistoryBuffer.hpp"
#include "world/CharacterBody.hpp"
#include "world/CharacterController.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/WorldEntity.hpp"
#include "tw/serial/WorldStateWriter.hpp"
#include "network/EntityInterpolation.hpp"
namespace tw {
typedef HistoryBuffer<long, glm::vec3> EntityPositionHistory;
entt::entity create_player_entity(World* world, JoltPhysicsWorld* physics_world, drw::WorldRenderer* renderer) {
// entt::entity entity = world->registry().create();
// drw::Mesh mesh = renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
// world->registry()
// .emplace<Transform>(entity,
// Transform(glm::vec3(0.0f, 10.0f, 0.0f)));
// world->registry()
// .emplace<EntityPositionHistory>(entity, 0, glm::vec3(0.0f, 10.0f, 0.0f), 1000);
// world->registry()
// .emplace<tw::WorldEntity>(entity,
// tw::WorldEntity(std::string("player"), (uint32_t)entity));
// world->registry()
// .emplace<tw::drw::Mesh>(entity, mesh);
// world->registry()
// .emplace<tw::CharacterController>(entity, 20.0f);
// auto* body = &world->registry()
// .emplace<CharacterBody>(entity, physics_world->create_character(
// new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
// glm::vec3(0.0f, 10.0f, 0.0f)));
// return entity;
return entt::entity(0);
}
entt::entity
ClientWorldController::create_entity(const std::string& name, glm::vec3 position) {
const auto entity = m_world->registry().create();
if(!m_mesh.has_value()) {
m_mesh = m_world_renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
}
spdlog::info("Creating entity {}", name);
m_world->registry()
.emplace<Transform>(entity,
Transform(position));
m_world->registry()
.emplace<tw::WorldEntity>(entity,
tw::WorldEntity(name, (uint32_t)entity));
m_world->registry()
.emplace<tw::drw::Mesh>(entity,
m_mesh.value());
return entity;
}
static std::unique_ptr<msg::MessageEndpoint> create_endpoint() {
auto endpoint_r = msg::MessageEndpoint::create();
if(!endpoint_r) {
spdlog::error("Failed to create the endpoint: {}", endpoint_r.error().message());
throw std::runtime_error("Failed to create the endpoint");
}
return std::move(endpoint_r.value());
}
static msg::MessageConnection* connect_to_server(msg::MessageEndpoint* endpoint, net::Address address) {
auto server_r = endpoint->connect(address.ip_string(), address.port());
if(!server_r) {
spdlog::error("Failed to connect to server: {}", server_r.error().message());
throw std::runtime_error("Failed to connect to server");
}
spdlog::info("Connected to server at {}", address.to_string());
return server_r.value();
}
std::optional<entt::entity> ClientWorldController::map_from_server_entity(int id) {
if(m_entity_mapping.contains(id)) {
return m_entity_mapping[id];
}
return {};
}
void ClientWorldController::map_server_entity(int server_id, entt::entity local_id) {
m_entity_mapping[server_id] = local_id;
}
void ClientWorldController::apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count) {
for(int i = 0; i < count; i++) {
const mmo::EntityPosition* const position = positions[i];
std::optional<entt::entity> entity = map_from_server_entity(position->id());
if(!entity.has_value()) {
spdlog::warn("Received position update for unknown entity {}", position->id());
continue;
}
glm::vec3 p = {position->x(), position->y(), position->z()};
m_entity_interpolator.add_position_for_entity(entity.value(), p);
EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
if(history != nullptr) {
auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
history->set(millis, p);
}
}
}
ClientWorldController::ClientWorldController(
const io::InputManager* inputs,
World* world,
JoltPhysicsWorld* physics_world,
drw::WorldRenderer* world_renderer,
tw::net::Address address,
dbg::NetworkMetrics* network_metrics
) :
m_input_manager(inputs),
m_world(world),
m_physics_world(physics_world),
m_world_renderer(world_renderer),
// m_player_entity(/* create_player_entity(world, physics_world, world_renderer) */),
m_player_controller(&world_renderer->camera(), glm::vec3()),
m_endpoint(create_endpoint()),
m_server(connect_to_server(m_endpoint.get(), address)),
m_messages(m_endpoint.get()),
m_network_metrics(network_metrics),
m_tick_step(20),
m_is_connected(false),
m_input_send_times(INPUT_SEND_TIME_COUNT),
m_position_history_exporter("/home/martin/output.csv"),
m_entity_interpolator(&m_world->registry(), (entt::entity)0, 300),
m_reconciler(physics_world)
{
m_messages.set_handler<mmo::LoginResponse>(
[this](msg::PeerId, const mmo::LoginResponse& mesg) {
if(!m_is_connected) {
spdlog::info("Logged in!");
}
});
m_messages.set_handler<mmo::SetControlledEntity>(
[this](msg::PeerId, const mmo::SetControlledEntity& mesg) {
spdlog::info("Setting controlled entity from server id {}", mesg.entity_id());
m_controlled_server_id = mesg.entity_id();
try_bind_player_entity();
});
m_endpoint->set_handler(Message<mmo::WorldStateMessage>::value,
[this](msg::PeerId, std::span<const std::byte> data) {
serial::WorldStateReader reader(data);
auto header = reader.read_header();
measure_response_time(header.frame_idx);
while(reader.has_spawn()) {
auto spawn = reader.read_spawn();
auto entity = create_entity("test", glm::vec3());
spdlog::info("Spawning entity {}", spawn);
map_server_entity(spawn, entity);
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == spawn) {
try_bind_player_entity();
} else {
m_entity_interpolator.register_entity(entity);
}
}
while(reader.has_entity()) {
auto entity_r = reader.read_entity();
std::optional<entt::entity> entity = map_from_server_entity(entity_r.id);
if(!entity.has_value()) {
spdlog::warn("Received position update for unknown entity {}", entity_r.id);
continue;
}
glm::vec3 p = {entity_r.position.x, entity_r.position.y, entity_r.position.z};
if(m_player_entity.has_value() && entity.value() == m_player_entity.value()) {
// The entity was created before its position was known, so the
// body sits at the origin until the server places it. There is
// no predicted history to reconcile against yet.
if(!m_player_position_initialized) {
m_player_position_initialized = true;
snap_player_to(entity.value(), p);
continue;
}
glm::vec3 position_before = glm::vec3(0.0f);
Transform* player_transform = m_world->registry().try_get<Transform>(entity.value());
if(player_transform) {
position_before = player_transform->position();
}
bool reconcile_happened = m_reconciler.reconcile(header.frame_idx, p, entity.value(), &m_world->registry(), m_frame_idx);
if(reconcile_happened) {
// Where the replay actually ended up, which is ahead of the
// acked position by the frames that were re-simulated.
glm::vec3 position_after = player_transform
? player_transform->position()
: p;
glm::vec3 correction_delta = position_before - position_after;
float correction_magnitude = glm::length(correction_delta);
if(correction_magnitude > 5.0f) {
correction_delta = glm::normalize(correction_delta) * 5.0f;
}
m_visual_error += correction_delta;
m_render_curr_position = position_after;
m_render_prev_position = position_after;
m_tick_accumulator = 0.0;
m_network_metrics->record_rollback();
m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance());
m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames());
}
if(header.frame_idx != 0) {
uint32_t ack_lag = 0;
if(m_frame_idx >= header.frame_idx) {
ack_lag = m_frame_idx - header.frame_idx;
}
m_network_metrics->record_ack_lag(ack_lag);
}
} else {
m_entity_interpolator.add_position_for_entity(entity.value(), p);
}
EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
if(history != nullptr) {
auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
history->set(millis, p);
}
}
// apply_entity_positions();
});
m_messages.set_handler<mmo::EntitySpawnMessage>(
[this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) {
auto entity = create_entity(mesg.name(), glm::vec3());
map_server_entity(mesg.entity_id(), entity);
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == mesg.entity_id()) {
try_bind_player_entity();
} else {
m_entity_interpolator.register_entity(entity);
}
});
}
ClientWorldController::~ClientWorldController() {
}
void ClientWorldController::try_bind_player_entity() {
if(!m_controlled_server_id.has_value()) {
return;
}
auto local_entity = map_from_server_entity(m_controlled_server_id.value());
if(!local_entity.has_value()) {
return;
}
if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) {
return;
}
entt::entity entity = local_entity.value();
spdlog::info("Binding player entity");
m_player_entity = entity;
Transform* transform = m_world->registry().try_get<Transform>(entity);
glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f);
m_world->registry().emplace<CharacterController>(entity, 20.0f);
m_world->registry().emplace<CharacterBody>(entity, m_physics_world->create_character(
new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
position
));
if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
m_world->registry().remove<net::EntityPositionInterpolation>(entity);
}
}
void ClientWorldController::snap_player_to(entt::entity entity, glm::vec3 position) {
CharacterBody* body = m_world->registry().try_get<CharacterBody>(entity);
if(body) {
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
body->m_desired_velocity = JPH::Vec3::sZero();
}
Transform* transform = m_world->registry().try_get<Transform>(entity);
if(transform) {
transform->set_position(position);
}
m_render_prev_position = position;
m_render_curr_position = position;
m_tick_accumulator = 0.0;
m_visual_error = glm::vec3(0.0f);
// Frames simulated before the player was placed describe a position it never
// actually had, so answers to them must not be reconciled against.
m_reconciler.reset_at(m_frame_idx);
}
void ClientWorldController::export_entity_history() {
}
void ClientWorldController::measure_response_time(uint32_t frame_idx) {
// Snapshots carry frame zero until the server has an input to answer, and
// repeat the same frame whenever no newer one arrived in between.
if(frame_idx == 0 || frame_idx <= m_last_measured_frame) {
return;
}
// Anything the send times no longer cover, including a frame we never sent,
// which underflows into a large distance.
if(m_frame_idx - frame_idx >= INPUT_SEND_TIME_COUNT) {
return;
}
m_last_measured_frame = frame_idx;
auto sent_at = m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT];
m_network_metrics->record_response_time(Clock::now() - sent_at);
}
void ClientWorldController::update(double delta_time) {
m_player_controller.update(m_input_manager, delta_time);
ImGui::Begin("Player Controller");
if(m_player_entity.has_value()) {
ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity.value());
}
ImGui::Text("Player count: %ld", m_entity_mapping.size());
for(auto mapping : m_entity_mapping) {
ImGui::Text("%d -> %d", (uint32_t)mapping.first, mapping.second);
}
ImGui::End();
if(m_tick_step.update()) {
auto network_start = Clock::now();
m_endpoint->update();
m_network_metrics->record_update_time(Clock::now() - network_start);
m_network_metrics->sample({
.bytes_sent = m_endpoint->bytes_sent(),
.bytes_received = m_endpoint->bytes_received(),
.messages_sent = m_endpoint->messages_sent(),
.messages_received = m_endpoint->messages_received()
});
if(!m_is_connected && false) {
return;
} else {
glm::vec3 input = m_player_controller.input();
if(m_player_entity.has_value()) {
CharacterController* controller = m_world->registry().try_get<CharacterController>(m_player_entity.value());
if(controller) {
controller->set_input(m_frame_idx, input);
m_reconciler.record_input(m_frame_idx, input);
}
}
m_physics_world->step(m_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
if(m_player_entity.has_value()) {
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(player_transform) {
glm::vec3 true_position = player_transform->position();
m_reconciler.record_prediction(m_frame_idx, true_position);
m_render_prev_position = m_render_curr_position;
m_render_curr_position = true_position;
m_tick_accumulator = 0.0;
}
}
mmo::PlayerMoveMessage player_move_message = {};
player_move_message.set_frame_idx(m_frame_idx);
mmo::PlayerInput* player_input = new mmo::PlayerInput();
player_input->set_x(input.x);
player_input->set_y(input.y);
player_input->set_z(input.z);
player_move_message.set_allocated_input(player_input);
auto r = m_messages.send(m_server, player_move_message, false);
m_input_send_times[m_frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now();
export_entity_history();
m_frame_idx++;
m_world->registry().view<Transform>()
.each([&](const auto e, Transform& t) {
m_position_history_exporter.write((uint32_t)e,
std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count(), t.position());
});
}
}
m_entity_interpolator.update();
if(m_player_entity.has_value()) {
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(player_transform) {
m_visual_error *= std::exp(-delta_time * kVisualErrorDecayRate);
if(glm::length(m_visual_error) < 0.001f) {
m_visual_error = glm::vec3(0.0f);
}
m_tick_accumulator += delta_time;
float alpha = glm::clamp(
static_cast<float>(m_tick_accumulator / JoltPhysicsWorld::FIXED_DELTA_TIME),
0.0f, 1.0f
);
glm::vec3 smoothed_position = glm::mix(m_render_prev_position, m_render_curr_position, alpha) + m_visual_error;
player_transform->set_position(smoothed_position);
m_player_controller.set_target(smoothed_position);
}
}
}
}
@@ -1,152 +0,0 @@
#pragma once
#include <entt/entt.hpp>
#include <glm/gtx/io.hpp>
#include "Address.hpp"
#include "ProtobufMessages.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include "entt/entity/fwd.hpp"
#include "io/InputState.hpp"
#include "metrics/HistoryBufferExporter.hpp"
#include "runtime/LockStep.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
#include "draw/WorldRenderer.hpp"
#include "world/ThirdPersonPlayerController.hpp"
#include "network/EntityPositionInterpolator.hpp"
#include "network/PlayerReconciler.hpp"
namespace tw {
/**
* Multiplayer player controller. Requires address of a server to work.
*/
class ClientWorldController {
/**
* Inputs
*/
const io::InputManager* m_input_manager;
/**
* Pointers to the world
*/
World* m_world;
drw::WorldRenderer* m_world_renderer;
JoltPhysicsWorld* m_physics_world;
std::optional<entt::entity> m_player_entity;
std::optional<uint32_t> m_controlled_server_id;
ThirdPersonPlayerController m_player_controller;
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
msg::MessageConnection* m_server;
ProtobufMessages m_messages;
dbg::NetworkMetrics* m_network_metrics;
LockStep m_tick_step;
uint32_t m_frame_idx = 1;
entt::entity m_entity_id;
glm::vec3 m_input;
bool m_is_connected;
std::optional<drw::Mesh> m_mesh;
using Clock = std::chrono::steady_clock;
void try_bind_player_entity();
/**
* Places the player at an authoritative position outright, clearing the
* predicted state that led there. Used for the first position the server
* sends, which the local simulation has no history to reconcile against.
*/
void snap_player_to(entt::entity entity, glm::vec3 position);
/**
* Whether the server has placed the player at least once. Entities are
* created before their position arrives, so the body starts at the origin
* and has to be moved once the first position shows up.
*/
bool m_player_position_initialized = false;
/**
* When each input was sent, indexed by its frame. Holds the most recent
* INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes
* unmeasured.
*/
static constexpr size_t INPUT_SEND_TIME_COUNT = 256;
std::vector<Clock::time_point> m_input_send_times;
uint32_t m_last_measured_frame = 0;
/**
* Records how long the answer to `frame_idx` took to arrive, ignoring
* frames that were already measured or are too old to still have a send
* time.
*/
void measure_response_time(uint32_t frame_idx);
HistoryBufferExporter<long, glm::vec3> m_position_history_exporter;
net::EntityPositionInterpolator m_entity_interpolator;
net::PlayerReconciler m_reconciler;
/**
* Visual smoothing for render-rate interpolation between 20 Hz ticks.
*/
glm::vec3 m_render_prev_position{0.0f};
glm::vec3 m_render_curr_position{0.0f};
double m_tick_accumulator = 0.0;
/**
* Visual error from reconciliation corrections, decays over time.
*/
glm::vec3 m_visual_error{0.0f};
static constexpr double kVisualErrorDecayRate = 12.0;
/**
* Mapping from the server entity_id to local entity_id
* Server might have the same entity under different name
* TODO: Figure out if entt supports custUntitledom IDs to sync them
*/
std::unordered_map<uint32_t, entt::entity> m_entity_mapping;
entt::entity create_entity(const std::string& name, glm::vec3 position);
std::optional<entt::entity> map_from_server_entity(int id);
void map_server_entity(int server_id, entt::entity local_id);
void apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count);
/**
* Exports entity history to CSV file for analysis
*/
void export_entity_history();
public:
ClientWorldController(
const io::InputManager* inputs,
World* world,
JoltPhysicsWorld* physics_world,
drw::WorldRenderer* world_renderer,
tw::net::Address address,
dbg::NetworkMetrics* network_metrics
);
~ClientWorldController();
void update(double delta_time);
};
}
@@ -0,0 +1,242 @@
#include "ClientWorldController.hpp"
#include <glm/glm.hpp>
#include <chrono>
#include <spdlog/spdlog.h>
#include <span>
#include "network/ServerConnection.hpp"
#include "Entity.pb.h"
#include "Login.pb.h"
#include "WorldState.pb.h"
#include "PlayerMove.pb.h"
#include "entt/entity/entity.hpp"
#include "entt/entity/fwd.hpp"
#include "messages/PlayerMoveMessage.hpp"
#include "metrics/HistoryBuffer.hpp"
#include "world/CharacterBody.hpp"
#include "world/CharacterController.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/WorldEntity.hpp"
#include "tw/serial/WorldStateWriter.hpp"
#include "network/EntityInterpolation.hpp"
namespace tw {
typedef HistoryBuffer<long, glm::vec3> EntityPositionHistory;
entt::entity
ClientWorldController::create_entity(const std::string& name, glm::vec3 position) {
const auto entity = m_world->registry().create();
if(!m_mesh.has_value()) {
m_mesh = m_world_renderer->add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
}
spdlog::info("Creating entity {}", name);
m_world->registry()
.emplace<Transform>(entity,
Transform(position));
m_world->registry()
.emplace<tw::WorldEntity>(entity,
tw::WorldEntity(name, (uint32_t)entity));
m_world->registry()
.emplace<tw::drw::Mesh>(entity,
m_mesh.value());
return entity;
}
// void ClientWorldController::apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count) {
// for(int i = 0; i < count; i++) {
// const mmo::EntityPosition* const position = positions[i];
// std::optional<entt::entity> entity = m_entity_id_map.get_local(position->id());
//
// if(!entity.has_value()) {
// spdlog::warn("Received position update for unknown entity {}", position->id());
// continue;
// }
//
// glm::vec3 p = {position->x(), position->y(), position->z()};
// m_entity_interpolator.add_position_for_entity(entity.value(), p);
//
// EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
//
// if(history != nullptr) {
// auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
// history->set(millis, p);
// }
// }
// }
void ClientWorldController::spawn_entity(const std::string& name, uint32_t server_id) {
auto entity = create_entity(name, glm::vec3());
spdlog::info("Spawning entity {}", server_id);
m_entity_id_map.register_server_to_local(server_id, entity);
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == server_id) {
try_bind_player_entity();
} else {
}
m_interpolator.register_entity(entity);
}
ClientWorldController::ClientWorldController(
const io::InputManager* inputs,
World* world,
JoltPhysicsWorld* physics_world,
drw::WorldRenderer* world_renderer,
net::ServerConnection* connection,
dbg::NetworkMetrics* network_metrics
) :
m_input_manager(inputs),
m_world(world),
m_physics_world(physics_world),
m_world_renderer(world_renderer),
m_player_controller(&world_renderer->camera(), glm::vec3()),
m_connection(connection),
m_messages(connection->endpoint()),
m_network_metrics(network_metrics),
m_input_update_tick_step(20),
m_interpolator(&m_world->registry(), 300),
m_replicator_client(&m_messages, &m_entity_id_map, m_network_metrics, m_physics_world, m_world, m_connection, &m_interpolator)
{
m_messages.set_handler<mmo::LoginResponse>(
[this](msg::PeerId, const mmo::LoginResponse& mesg) {
spdlog::info("Logged in!");
});
m_messages.set_handler<mmo::SetControlledEntity>(
[this](msg::PeerId, const mmo::SetControlledEntity& mesg) {
spdlog::info("Setting controlled entity from server id {}", mesg.entity_id());
m_controlled_server_id = mesg.entity_id();
try_bind_player_entity();
});
m_connection->endpoint()->set_handler(Message<mmo::WorldStateMessage>::value,
[this](msg::PeerId, std::span<const std::byte> data) {
serial::WorldStateReader reader(data);
m_replicator_client.handle_snapshot(m_network_frame_idx, reader);
});
m_messages.set_handler<mmo::EntitySpawnMessage>(
[this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) {
for(auto& spawn : mesg.spawns()) {
spawn_entity(spawn.name(), spawn.entity_id());
}
});
}
ClientWorldController::~ClientWorldController() { }
void ClientWorldController::try_bind_player_entity() {
if(!m_controlled_server_id.has_value()) {
return;
}
auto local_entity = m_entity_id_map.get_local(m_controlled_server_id.value());
if(!local_entity.has_value()) {
return;
}
if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) {
return;
}
entt::entity entity = local_entity.value();
spdlog::info("Binding player entity");
m_player_entity = entity;
m_replicator_client.set_player_entity(entity);
Transform* transform = m_world->registry().try_get<Transform>(entity);
glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f);
m_world->registry().emplace<CharacterController>(entity, 20.0f);
m_world->registry().emplace<CharacterBody>(entity, m_physics_world->create_character(
new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
position
));
// if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
// m_world->registry().remove<net::EntityPositionInterpolation>(entity);
// }
}
void ClientWorldController::update_network() {
auto network_start = Clock::now();
// collects incoming messages
m_connection->update();
m_network_metrics->record_update_time(Clock::now() - network_start);
m_network_metrics->sample({
.bytes_sent = m_connection->endpoint()->bytes_sent(),
.bytes_received = m_connection->endpoint()->bytes_received(),
.messages_sent = m_connection->endpoint()->messages_sent(),
.messages_received = m_connection->endpoint()->messages_received()
});
m_replicator_client.record_prediction(m_frame_idx);
// m_world->registry().view<Transform>()
// .each([&](const auto e, Transform& t) {
// m_position_history_exporter.write((uint32_t)e,
// std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count(), t.position());
// });
}
void ClientWorldController::update(double delta_time) {
// collects messages from network for processing
update_network();
m_player_controller.update(m_input_manager, delta_time);
// collect input every tick of the input_update_tick_step
if(m_input_update_tick_step.has_ticked()) {
// TODO: might be more like `calc_velocity_vector`.
// 1. it is not exactly *input* but input mapped to normalized velocity vector
// 2. I hate calling methods `update`
glm::vec3 input = m_player_controller.input();
if(m_player_entity.has_value()) {
CharacterController* controller = m_world->registry().try_get<CharacterController>(m_player_entity.value());
if(controller) {
// controller->set_input(m_network_frame_idx, input);
m_replicator_client.set_input(m_network_frame_idx, input);
}
}
// m_physics_world->step(m_network_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
m_network_frame_idx++;
}
m_interpolator.interpolate_smoothed_entities(std::chrono::high_resolution_clock::now() - std::chrono::milliseconds(500));
if(m_player_entity.has_value()) {
// after interpolation happen, follow the target
// TODO: The third person controller could pull
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(player_transform) {
m_player_controller.set_target(player_transform->position());
}
}
m_frame_idx++;
// m_replicator_client.update();
}
}
@@ -0,0 +1,107 @@
#pragma once
#include <entt/entt.hpp>
#include <glm/gtx/io.hpp>
#include "ProtobufMessages.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "entt/entity/fwd.hpp"
#include "io/InputState.hpp"
#include "metrics/HistoryBufferExporter.hpp"
#include "network/EntityIdMap.hpp"
#include "network/ReplicatorClient.hpp"
#include "runtime/LockStep.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
#include "draw/WorldRenderer.hpp"
#include "world/ThirdPersonPlayerController.hpp"
#include "network/EntityPositionInterpolator.hpp"
namespace tw::net {
class ServerConnection;
}
namespace tw {
/**
* Multiplayer player controller. Requires address of a server to work.
*/
class ClientWorldController {
/**
* Inputs
*/
const io::InputManager* m_input_manager;
/**
* Pointers to the world
*/
World* m_world;
drw::WorldRenderer* m_world_renderer;
JoltPhysicsWorld* m_physics_world;
std::optional<entt::entity> m_player_entity;
std::optional<uint32_t> m_controlled_server_id;
ThirdPersonPlayerController m_player_controller;
net::ServerConnection* m_connection;
ProtobufMessages m_messages;
dbg::NetworkMetrics* m_network_metrics;
LockStep m_input_update_tick_step;
uint32_t m_frame_idx = 1;
uint32_t m_network_frame_idx = 1;
entt::entity m_entity_id;
glm::vec3 m_input;
std::optional<drw::Mesh> m_mesh;
net::EntityPositionInterpolator m_interpolator;
net::ReplicatorClient m_replicator_client;
net::EntityIdMap m_entity_id_map;
using Clock = std::chrono::steady_clock;
void try_bind_player_entity();
entt::entity create_entity(const std::string& name, glm::vec3 position);
void apply_entity_positions(const mmo::EntityPosition* const* positions, size_t count);
void update_network();
public:
ClientWorldController(
const io::InputManager* inputs,
World* world,
JoltPhysicsWorld* physics_world,
drw::WorldRenderer* world_renderer,
net::ServerConnection* connection,
dbg::NetworkMetrics* network_metrics
);
~ClientWorldController();
constexpr uint32_t frame_idx() const {
return m_frame_idx;
}
constexpr std::optional<entt::entity> player_entity() const {
if(!m_controlled_server_id.has_value()) {
return {};
}
return m_entity_id_map.get_local(m_controlled_server_id.value());
}
void spawn_entity(const std::string& name, uint32_t server_id);
void update(double delta_time);
};
}
+2 -2
View File
@@ -6,7 +6,7 @@
#include "imgui.h"
#include "backends/imgui_impl_vulkan.h"
#include "backends/imgui_impl_sdl2.h"
#include "backends/imgui_impl_sdl3.h"
namespace tw::drw::gui {
@@ -73,7 +73,7 @@ lft::rg::RenderTaskBuilder ImGuiRenderPass::create_render_task() {
const std::string font_path = "/home/martin/projects/mmo/external/imgui/misc/fonts/ProggyClean.ttf";
io.Fonts->AddFontFromFileTTF(font_path.c_str(), 13.0f);
auto init_info = create_imgui_init_info(info.gpu(), info.renderpass());
ImGui_ImplSDL2_InitForVulkan(context->m_window->get_handle());
ImGui_ImplSDL3_InitForVulkan(context->m_window->get_handle());
ImGui_ImplVulkan_Init(&init_info);
context->is_initialized = true;
+6 -1
View File
@@ -1,4 +1,5 @@
#include "Address.hpp"
#include "Entity.pb.h"
#include "Login.pb.h"
#include "PlayerMove.pb.h"
#include "ProtobufMessages.hpp"
@@ -63,6 +64,9 @@ public:
m_messages.set_handler<mmo::WorldStateMessage>(
[this](tw::msg::PeerId, const mmo::WorldStateMessage& mesg) { });
m_messages.set_handler<mmo::EntitySpawnMessage>(
[this](tw::msg::PeerId, const mmo::EntitySpawnMessage& mesg) { });
m_messages.set_handler<mmo::LoginResponse>(
[this](tw::msg::PeerId, const mmo::LoginResponse& mesg) {
if(!m_is_connected) {
@@ -103,12 +107,13 @@ public:
player_input->set_z(m_velocity.z);
player_move_mesg.set_allocated_input(player_input);
auto r = m_messages.send(m_server, player_move_mesg, false);
m_frame_idx++;
}
}
};
int main() {
const int NUM_CLIENTS = 600;
const int NUM_CLIENTS = 10;
tw::net::Address address = {"127.0.0.1", 8101};
std::vector<std::thread> threads;
+1 -1
View File
@@ -2,7 +2,7 @@
#include <cstring>
#include <functional>
#include <fmt/format.h>
#include <spdlog/fmt/fmt.h>
namespace tw::net {
+71 -71
View File
@@ -1,71 +1,71 @@
#include <barrier>
#include <catch2/catch_test_macros.hpp>
#include <span>
#include <iostream>
#include "UdpStream.hpp"
#include "Address.hpp"
#include "quicr/QuicrConnection.hpp"
#include "quicr/QuicrConnectionListener.hpp"
#include "quicr/QuicrEndpoint.hpp"
TEST_CASE("Start two sockets and send message", "[udp]") {
std::barrier create_sync_point(2);
std::barrier send_sync_point(2);
const std::string message = "Hello, server!";
std::thread server_thread([&]() {
auto r = tw::net::UdpStream::bind(tw::net::Address {"127.0.0.1", 6969});
if(!r.has_value()) {
std::cout << ("Failed to bind UDP stream: {}") << r.error().message() << std::endl;
}
auto flag_result = r->set_non_blocking();
auto server = std::move(r.value());
create_sync_point.arrive_and_wait();
send_sync_point.arrive_and_wait();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::vector<std::byte> buffer(1024);
tw::net::Address from {{}, 0};
auto read_result = server.read_into(std::span{buffer.data(), buffer.size()}, &from);
if(!read_result.has_value()) {
spdlog::error("Failed to read from UDP stream: {}", read_result.error().message());
}
// convert the buffer to a string
std::string mesg(buffer.data(), buffer.data() + read_result.value());
REQUIRE(mesg == message);
});
std::thread client_thread([&]() {
create_sync_point.arrive_and_wait();
auto connect_result = tw::net::UdpStream::to(tw::net::Address{"127.0.0.1", 6969});
if(!connect_result.has_value()) {
spdlog::error("Failed to bind UDP stream: {}", connect_result.error().mesg());
}
auto client = std::move(connect_result.value());
std::string mesg = message;
auto write_result = client.write(std::as_writable_bytes(std::span(mesg.begin(), mesg.end())));
if(!write_result.has_value() && write_result.value() == mesg.length()) {
spdlog::error("Failed to write to UDP stream: {}", write_result.error().message());
}
send_sync_point.arrive_and_wait();
});
server_thread.join();
client_thread.join();
}
using namespace tw::net;
using namespace tw::net::quicr;
// #include <barrier>
// #include <catch2/catch_test_macros.hpp>
// #include <span>
// #include <iostream>
//
// #include "UdpStream.hpp"
// #include "Address.hpp"
// #include "quicr/QuicrConnection.hpp"
// #include "quicr/QuicrConnectionListener.hpp"
// #include "quicr/QuicrEndpoint.hpp"
//
// TEST_CASE("Start two sockets and send message", "[udp]") {
// std::barrier create_sync_point(2);
// std::barrier send_sync_point(2);
//
// const std::string message = "Hello, server!";
//
// std::thread server_thread([&]() {
// auto r = tw::net::UdpStream::bind(tw::net::Address {"127.0.0.1", 6969});
// if(!r.has_value()) {
// std::cout << ("Failed to bind UDP stream: {}") << r.error().message() << std::endl;
// }
//
// auto flag_result = r->set_non_blocking();
//
// auto server = std::move(r.value());
//
// create_sync_point.arrive_and_wait();
// send_sync_point.arrive_and_wait();
//
// std::this_thread::sleep_for(std::chrono::milliseconds(100));
//
// std::vector<std::byte> buffer(1024);
// tw::net::Address from {{}, 0};
// auto read_result = server.read_into(std::span{buffer.data(), buffer.size()}, &from);
//
// if(!read_result.has_value()) {
// spdlog::error("Failed to read from UDP stream: {}", read_result.error().message());
// }
//
// // convert the buffer to a string
// std::string mesg(buffer.data(), buffer.data() + read_result.value());
// REQUIRE(mesg == message);
// });
//
// std::thread client_thread([&]() {
// create_sync_point.arrive_and_wait();
//
// auto connect_result = tw::net::UdpStream::to(tw::net::Address{"127.0.0.1", 6969});
// if(!connect_result.has_value()) {
// spdlog::error("Failed to bind UDP stream: {}", connect_result.error().mesg());
// }
//
// auto client = std::move(connect_result.value());
//
// std::string mesg = message;
// auto write_result = client.write(std::as_writable_bytes(std::span(mesg.begin(), mesg.end())));
//
// if(!write_result.has_value() && write_result.value() == mesg.length()) {
// spdlog::error("Failed to write to UDP stream: {}", write_result.error().message());
// }
//
// send_sync_point.arrive_and_wait();
// });
//
// server_thread.join();
// client_thread.join();
// }
//
// using namespace tw::net;
// using namespace tw::net::quicr;
@@ -36,8 +36,6 @@ PeerWorldController::PeerWorldController(uint32_t self_id, uint16_t port,
});
}
// ── registry & connection ─────────────────────────────────────────────────────
void PeerWorldController::register_self() {
PeerRegistry reg(m_registry_path);
reg.register_self(m_self_id, "127.0.0.1:" + std::to_string(m_port));
@@ -86,8 +84,6 @@ void PeerWorldController::wait_for_connections(uint32_t expected_count,
m_self_id, m_peer_ids.size(), expected_count);
}
// ── tick ──────────────────────────────────────────────────────────────────────
bool PeerWorldController::tick() {
m_link->poll();
@@ -122,8 +118,6 @@ bool PeerWorldController::tick() {
return has_advanced;
}
// ── helpers ───────────────────────────────────────────────────────────────────
mmo::peer::PeerAction PeerWorldController::build_local_action(uint32_t frame) const {
float t = static_cast<float>(frame) *
static_cast<float>(WorldStepProcessor::FIXED_DELTA_S);
@@ -56,6 +56,9 @@ public:
const std::vector<uint32_t>& peer_ids() const { return m_peer_ids; }
uint32_t get_peer_latest_snapshot_frame(uint32_t peer_id) {
}
private:
uint32_t m_self_id;
uint16_t m_port;
+5 -1
View File
@@ -2,12 +2,16 @@ syntax = "proto3";
package mmo;
message EntitySpawnMessage {
message EntitySpawn {
uint32 entity_id = 1;
bool is_player = 2;
string name = 3;
}
message EntitySpawnMessage {
repeated EntitySpawn spawns = 1;
}
message EntityDespawnMessage {
uint32 entity_id = 1;
}
@@ -1,6 +1,5 @@
#pragma once
#include <nlohmann/json.hpp>
#include "Serialization.hpp"
#include "Serializers.hpp"
@@ -1,59 +1,5 @@
#pragma once
/**
* WorldStateWriter / WorldStateReader
* =====================================
* Project-specific serialisation for the per-frame world-state snapshot
* sent from the server to each connected client.
*
* Wire format (all values little-endian):
*
* ┌──────────────────────────────────────────────────────────┐
* │ Header (16 bytes) │
* │ message_type : uint32 (supplied by the caller) │
* │ sequence : uint32 (always 0, no reply expected) │
* │ frame_idx : uint32 │
* │ entity_count : uint32 (number of position records) │
* ├──────────────────────────────────────────────────────────┤
* │ Spawns section │
* │ spawn_count : uint32 │
* │ spawn[i].id : uint32 × spawn_count │
* ├──────────────────────────────────────────────────────────┤
* │ Despawns section │
* │ despawn_count : uint32 │
* │ despawn[i].id : uint32 × despawn_count │
* ├──────────────────────────────────────────────────────────┤
* │ Entity positions (hot path — tightly packed) │
* │ [ id:uint32, x:float, y:float, z:float ] × entity_count│
* └──────────────────────────────────────────────────────────┘
*
* Total minimum size : 20 bytes (header + empty spawns + empty despawns)
* Per entity : 16 bytes
* 300 entities : 20 + 300×16 = 4820 bytes (well under MTU for segmented)
*
* Usage (server side, called once per client per frame):
*
* tw::serial::WorldStateWriter w(buffer); // buffer is a BinaryBuffer
* w.begin(frame_idx);
* w.write_spawns(interest.spawns());
* w.write_despawns(interest.despawns());
* w.begin_entities(num_entities); // writes entity_count slot
* for each entity in interest.entities():
* w.write_entity(entity_id, position);
* w.end(); // patches entity_count
* // buffer.view() is ready to send
*
* Usage (client side):
*
* tw::serial::WorldStateReader r(payload_span);
* auto header = r.read_header(); // frame_idx + counts
* for (auto id : r.read_spawns()) { ... }
* for (auto id : r.read_despawns()) { ... }
* while (r.has_entity()) {
* auto [id, pos] = r.read_entity();
* ...
* }
*/
#include "Codec.hpp"
#include "GlmCodec.hpp"
@@ -67,10 +13,6 @@
namespace tw::serial {
// ──────────────────────────────────────────────────────────────────────────
// WorldStateWriter
// ──────────────────────────────────────────────────────────────────────────
class WorldStateWriter {
BinaryWriter m_w;
@@ -81,13 +23,6 @@ class WorldStateWriter {
public:
explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {}
/**
* Write the header. Call once per frame, before everything else.
* entity_count is patched in end().
*
* message_type is supplied by the caller so that this module stays free of
* the address the message is delivered to.
*/
void begin(uint32_t frame_idx, uint32_t message_type) noexcept {
m_entity_count = 0;
@@ -104,11 +39,6 @@ public:
m_entity_count_offset = m_w.reserve_u32();
}
// ── Spawns ────────────────────────────────────────────────────────────
/**
* Write the spawn list. Pass any range of entt::entity.
*/
template<typename Range>
void write_spawns(const Range& spawns) noexcept {
auto count = static_cast<uint32_t>(std::size(spawns));
@@ -118,8 +48,6 @@ public:
}
}
// ── Despawns ──────────────────────────────────────────────────────────
template<typename Range>
void write_despawns(const Range& despawns) noexcept {
auto count = static_cast<uint32_t>(std::size(despawns));
@@ -129,16 +57,6 @@ public:
}
}
// ── Entity positions (the hot path) ───────────────────────────────────
/**
* Write a single entity position record.
* id : raw uint32 of the entity handle
* pos : world-space position (x, y, z)
*
* This is the innermost loop of the replicator — every byte matters.
* The compiler will inline both calls down to two contiguous memcpys.
*/
void write_entity(uint32_t id, const glm::vec3& pos) noexcept {
m_w.write(id);
// Write x, y, z as 3 contiguous floats
@@ -146,20 +64,14 @@ public:
++m_entity_count;
}
// Convenience overload accepting an entt::entity handle directly
void write_entity(entt::entity entity, const glm::vec3& pos) noexcept {
write_entity(static_cast<uint32_t>(entity), pos);
}
/**
* Patch the entity_count field written in begin() and finalise the
* buffer. Must be called exactly once after all write_entity() calls.
*/
void end() noexcept {
m_w.patch_u32(m_entity_count_offset, m_entity_count);
}
/** Expose the underlying buffer view (e.g. to pass to send_message). */
std::span<const std::byte> view() noexcept {
return m_w.buffer().view();
}
@@ -170,10 +82,6 @@ public:
}
};
// ──────────────────────────────────────────────────────────────────────────
// WorldStateReader (client-side / test use)
// ──────────────────────────────────────────────────────────────────────────
struct WorldStateHeader {
uint32_t packet_type;
uint32_t frame_idx;
+1
View File
@@ -35,6 +35,7 @@ target_link_libraries(tw_server_lib
Jolt
protobuf::libprotobuf
${Boost_LIBRARIES}
${LIBDECOR_LIB}
Tracy::TracyClient
pqxx
pq
-58
View File
@@ -1,58 +0,0 @@
# Build the toolchain stage on its own and pass it back in to skip the apt step:
# docker build -f modules/server/Dockerfile --target toolchain -t tw_toolchain .
# docker build -f modules/server/Dockerfile --build-arg TOOLCHAIN_IMAGE=tw_toolchain .
ARG TOOLCHAIN_IMAGE=toolchain
# ── toolchain stage ──────────────────────────────────────────────────────────
FROM ubuntu:24.04 AS toolchain
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
clang \
libstdc++-14-dev \
cmake \
ninja-build \
mold \
git \
ca-certificates \
pkg-config \
glslang-tools \
libvulkan-dev \
libsdl2-dev \
libprotobuf-dev protobuf-compiler \
libpq-dev \
libpqxx-dev \
&& rm -rf /var/lib/apt/lists/*
ENV CC=clang
ENV CXX=clang++
# ── build stage ──────────────────────────────────────────────────────────────
FROM ${TOOLCHAIN_IMAGE} AS builder
WORKDIR /src
COPY . .
# glm, entt, Jolt, spdlog, expected, Catch2 and tracy are cloned at configure time
RUN cmake -B /build -G Ninja -DCMAKE_BUILD_TYPE=Release \
&& cmake --build /build --target tw_server
# Stage the shared libraries the binary was linked against; the runtime image has
# no package manager. glibc and its loader stay behind, they come with that image.
RUN mkdir -p /rootfs/usr/lib/x86_64-linux-gnu \
&& ldd /build/modules/server/tw_server \
| awk '/=> \//{ print $3 }' \
| grep -vE '/(libc|libm|libdl|libpthread|librt|libresolv|libanl)\.so' \
| xargs -I{} cp -L {} /rootfs/usr/lib/x86_64-linux-gnu/
# ── runtime stage ─────────────────────────────────────────────────────────────
FROM gcr.io/distroless/cc-debian13:nonroot
COPY --from=builder /rootfs/ /
COPY --from=builder /build/modules/server/tw_server /usr/local/bin/tw_server
# player connections, zone-server peering
EXPOSE 8101/udp 8102/udp
ENTRYPOINT ["/usr/local/bin/tw_server"]
+5 -2
View File
@@ -8,19 +8,22 @@
namespace tw::net {
/**
* Session with the player's client.
*/
struct PlayerSession {
public:
SessionId session_id;
msg::MessageConnection* connection;
uint32_t last_frame;
uint32_t last_received_frame;
uint32_t acked_frame;
PlayerSession(SessionId session_id, msg::MessageConnection* connection) :
session_id(session_id),
connection(connection),
last_frame(0),
last_received_frame(0),
acked_frame(0)
{ }
};
-8
View File
@@ -12,11 +12,6 @@
namespace tw::net {
// Listens on a dedicated port for incoming zone-server peer connections and
// opens outgoing connections to known peers.
//
// Incoming and outgoing peers are held together so that broadcast messages
// (ZoneHello, ZoneBye) reach every peer uniformly.
class ZoneClusterLink {
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
ProtobufMessages m_messages;
@@ -24,13 +19,10 @@ class ZoneClusterLink {
public:
explicit ZoneClusterLink(int port);
// Poll for messages and accept any pending peer connections.
void update();
// Connect to a remote zone peer and register it in the peer list.
msg::MessageConnection* connect_to_peer(const std::string& host, int port);
// Send a protobuf message to a single peer.
template<typename T>
void send_mesg(msg::MessageConnection* peer, const T& msg) {
auto send_r = m_messages.send(peer, msg, false);
+1 -23
View File
@@ -19,8 +19,6 @@ ZoneManager::ZoneManager(im::AreaBounds zone_bounds)
))
{}
// ── ZoneProxy interface ───────────────────────────────────────────────────────
im::AreaBounds ZoneManager::area() const {
return {
{ (float)m_spatial_backend->world_min_x(), (float)m_spatial_backend->world_min_z() },
@@ -33,8 +31,6 @@ void ZoneManager::transfer_entity(EntityInfo&& info) {
spawn_entity(std::move(info));
}
// ── Zone management ───────────────────────────────────────────────────────────
entt::entity ZoneManager::spawn_entity(EntityInfo&& info) {
entt::entity entity = m_world->registry().create();
@@ -104,8 +100,6 @@ uint32_t ZoneManager::acked_input_frame(im::InterestId interest_id) const {
return it != m_session_acked_frame.end() ? it->second : 0;
}
// ── Private helpers ───────────────────────────────────────────────────────────
void ZoneManager::check_neighbor_transfers() {
ZoneScopedN("ZoneManager::check_neighbor_transfers");
@@ -161,9 +155,7 @@ void ZoneManager::check_neighbor_transfers() {
}
}
// ── Tick ──────────────────────────────────────────────────────────────────────
void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
void ZoneManager::update(uint32_t frame_idx, float delta_time) {
ZoneScopedN("ZoneManager::tick");
FrameMarkStart("Interest System");
@@ -176,21 +168,7 @@ void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
m_world->step(delta_time);
FrameMarkEnd("World step");
// Re-stamp each client entity's newest input onto the server's frame index.
// The input ring is keyed by client frame numbers (independent counter),
// but physics lookup uses the server's frame counter. By re-stamping onto
// the server frame, input(server_frame_idx) resolves by exact match rather
// than fallback, ensuring deterministic and correct input consumption.
for (const auto& [interest_id, entity] : m_client_entities) {
auto* controller = m_world->registry().try_get<CharacterController>(entity);
if (!controller) continue;
controller->set_input(frame_idx, controller->input());
}
FrameMarkStart("Physics step");
// Fixed step, not the measured interval: the simulation has to advance by the
// same amount every frame for a replay of the same inputs to land in the same
// place.
m_physics_world.step(frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME);
FrameMarkEnd("Physics step");
+7 -25
View File
@@ -18,30 +18,28 @@
namespace tw::net {
// 500-unit cells, 500-unit view radius → 3×3 neighbourhood, no per-entity distance checks.
// World bounds are supplied to the constructor at runtime.
using SpatialBackendType = im::FixedGrid<500, 500>;
// How far into this zone a neighbour's interest area is expanded.
// Entities within this margin are tracked by the interest system before transfer.
static constexpr float kNeighborBorderOverlap = 100.0f;
/**
* Manages one zone: contains state of all entities inside and does logic for them.
*/
class ZoneManager : public ZoneProxy {
struct NeighborEntry {
ZoneProxy* proxy;
im::InterestId interest_id;
};
// Declaration order == initialisation order.
std::unique_ptr<World> m_world;
JoltPhysicsWorld m_physics_world;
std::unique_ptr<SpatialBackendType> m_spatial_backend;
std::unique_ptr<im::InterestSystem<SpatialBackendType>> m_interest_system;
std::unordered_map<im::InterestId, entt::entity> m_client_entities;
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions; // reverse map
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame; // most recent input frame received
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame; // input frame consumed by last tick
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions;
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame;
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame;
std::vector<NeighborEntry> m_neighbors;
uint32_t m_next_neighbor_id = 0x80000000u;
@@ -57,39 +55,23 @@ public:
ZoneManager(im::AreaBounds zone_bounds);
// ── ZoneProxy interface ───────────────────────────────────────────────────
// Returns the geographic area this zone owns, derived from the spatial backend bounds.
im::AreaBounds area() const override;
// Receive an entity transferred from a neighbouring zone.
// Spawns the entity in this zone's world; wires up input routing if session_id is set.
// NOTE: ZoneServer must also update its session→zone routing table after this call.
void transfer_entity(EntityInfo&& info) override;
// ── Zone management ───────────────────────────────────────────────────────
entt::entity spawn_entity(EntityInfo&& info);
// Registers an interest for the given entity and begins tracking entities near their position.
// Uses session_id as the InterestId; the interest system is unaware of this mapping.
// Can fetch the interest using `get_interest(interest_id)`.
void add_client(im::InterestId interest_id, entt::entity entity);
void on_player_move(SessionId session_id, mmo::PlayerMoveMessage&& message);
// Registers a neighbouring zone. Its geographic area is added to the interest
// manager and checked each tick; entities that cross into it are transferred.
void register_neighbor_zone(ZoneProxy* neighbor);
// Returns the current interest state for any registered id, or nullptr.
const im::Interest* get_interest(im::InterestId id) const;
// Returns the input frame that this session's entity was last simulated with, or 0 if unknown.
uint32_t acked_input_frame(im::InterestId interest_id) const;
// Runs one tick: interest queries, neighbour transfer checks, world step, physics step.
void tick(uint32_t frame_idx, float delta_time);
void update(uint32_t frame_idx, float delta_time);
};
} // namespace tw::net
+2 -7
View File
@@ -61,10 +61,8 @@ void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMess
auto it = m_session_zone.find(session_id);
if (it == m_session_zone.end()) return;
// Echoed back in the next snapshot, so the client can tell how long its
// input took to come back.
if (auto* session = m_player_session_registry->session(session_id)) {
session->last_frame = message.frame_idx();
session->last_received_frame = message.frame_idx();
}
it->second->on_player_move(session_id, std::move(message));
@@ -134,11 +132,8 @@ void ZoneServer::run() {
FrameMarkEnd("Update clients");
for (auto& zone : m_zones) {
zone->tick(frame_idx, lock_step.delta_time());
zone->update(frame_idx, lock_step.delta_time());
// Copy acked frames from zone to sessions before replication.
// This ensures snapshots carry the input frame actually consumed by this tick,
// not frames that arrive in the trailing network update.
for (const auto& [session_id, session_zone] : m_session_zone) {
if (session_zone != zone.get()) continue;
if (auto* session = m_player_session_registry->session(session_id)) {
@@ -7,28 +7,6 @@
namespace tw::net::im {
/**
* Bounded-world spatial grid backend.
*
* World area bounds are supplied at construction time so the same grid type
* can be reused for differently-sized zones without recompiling.
* CELL_SIZE and VIEW_RADIUS remain template parameters so kNeighborRadius
* can be computed at compile time, keeping the hot-path loop bounds constant.
*
* Template parameters:
* CELL_SIZE: size of each cell (meters). Recommended: CELL_SIZE == VIEW_RADIUS
* for maximum efficiency (no distance checks needed).
* VIEW_RADIUS: the subscription radius around a player (meters).
*
* Constructor parameters:
* min_x, max_x, min_z, max_z — world area bounds (meters).
*
* Complexity:
* begin_frame(): O(cols × rows) clearing
* insert(): O(1)
* query_neighbors(): O(kNeighborRadius²) cells × avg entities per cell
* query_area(): O(cells overlapping AABB) × avg entities per cell
*/
template<uint32_t CELL_SIZE, uint32_t VIEW_RADIUS>
class FixedGrid {
public:
@@ -45,9 +23,6 @@ private:
int32_t m_world_min_z, m_world_max_z;
uint32_t m_cols, m_rows;
// Flat 2D vector of cell vectors indexed as [cz * m_cols + cx].
// Outer vector is allocated once at construction; inner vectors retain
// capacity across frames so no heap allocations occur in steady state.
std::vector<std::vector<entt::entity>> m_cells;
[[nodiscard]] inline std::pair<uint32_t, uint32_t> world_to_cell(
@@ -1,6 +1,8 @@
#pragma once
#include "Entity.pb.h"
#include "MessageRegistry.hpp"
#include "WorldState.pb.h"
#include "network/NetworkReceiver.hpp"
#include "network/PlayerSessionRegistry.hpp"
#include "systems/Interest.hpp"
@@ -18,14 +20,6 @@ namespace tw::net {
/**
* Decides what to replicate and to whom. Does not mutate world state.
*
* Per-frame flow:
* 1. For each session: reset its BinaryBuffer and write the header,
* spawns and despawns from the InterestSystem.
* 2. For each session: iterate its interest set and write entity positions
* 3. Patch entity_count and send each raw buffer
*
* Memory: one BinaryBuffer per session, pre-allocated and reused every frame.
*/
template<im::SpatialBackend Backend>
class StateReplicator {
@@ -51,7 +45,10 @@ public:
/**
* Replicates the current world state for one zone to its connected clients.
*/
void replicate(const entt::registry& registry, const im::InterestSystem<Backend>* interest_manager) {
void replicate(
const entt::registry& registry,
const im::InterestSystem<Backend>* interest_manager
) {
ZoneScopedN("Replicator");
const auto& sessions = m_client_registry->sessions();
@@ -59,16 +56,12 @@ public:
if (session_count == 0) return;
// ── Grow buffer pool if needed (only on new connections) ──────────
if (m_frames.size() < session_count) {
m_frames.resize(session_count);
for (auto& buf : m_frames)
buf.reserve(kInitialCapacity);
}
// Build one WorldStateWriter per client referencing the pre-allocated
// buffer. reserve() is called before this loop so no reallocation
// occurs and the buffer references inside the writers stay valid.
std::vector<tw::serial::WorldStateWriter> writers;
writers.reserve(session_count);
for (std::size_t i = 0; i < session_count; ++i)
@@ -76,7 +69,6 @@ public:
std::vector<const im::Interest*> client_states(session_count);
// ── 1. Write headers / spawns / despawns ──────────────────────────
{
ZoneScopedN("Preparing messages");
@@ -96,26 +88,37 @@ public:
m_frames[i].reserve(needed);
writers[i].reset();
writers[i].begin(session->acked_frame, Message<mmo::WorldStateMessage>::value);
writers[i].begin(session->last_received_frame, Message<mmo::WorldStateMessage>::value);
writers[i].write_spawns(state->spawn());
writers[i].write_despawns(state->despawn());
mmo::EntitySpawnMessage spawn_message = {};
for(auto& spawn : state->spawn()) {
auto spawn_item = spawn_message.add_spawns();
spawn_item->set_entity_id((uint32_t)spawn);
spawn_item->set_is_player(false);
spawn_item->set_name("Test");
}
m_network->send_mesg(sessions[i]->session_id, spawn_message);
}
}
// ── 2. Write entity positions (hot path, O(entities × sessions)) ──
{
ZoneScopedN("Putting transforms into messages");
auto view = registry.view<Transform>();
view.each([&](entt::entity e, const Transform& t) {
for (std::size_t i = 0; i < session_count; ++i) {
if (client_states[i] && client_states[i]->is_interested_in_entity(e))
if (client_states[i] && client_states[i]->is_interested_in_entity(e)) {
writers[i].write_entity(e, t.position());
// spdlog::info("Entity {} is at {} {} {}", (uint32_t)e, t.position().x, t.position().y, t.position().z);
}
}
});
}
// ── 3. Finalise and dispatch ───────────────────────────────────────
{
ZoneScopedN("Sending messages");
+1 -27
View File
@@ -12,28 +12,10 @@
namespace tw::net::im {
/**
* Spatial interest management: maintains per-subscription spawn/despawn deltas.
*
* Each subscription is identified by a caller-chosen InterestId (uint32_t).
* Two kinds of subscription:
* set_entity_interest(id, entity) — tracks entities in the neighbourhood of
* a world entity's position (e.g. a player).
* set_area_interest(id, bounds) — tracks entities inside an XZ quad
* (e.g. a zone border region).
*
* The caller decides how ids map to sessions, zones, or anything else.
* InterestSystem has no knowledge of that mapping.
*
* Two-phase update per frame:
* Phase 1 — Spatial rebuild (O(E)): insert all Transform entities into the backend.
* Phase 2 — Per-subscription deltas (O(N × K)): for each subscription query the
* backend, sort results, compute spawn/despawn via set_difference.
*/
template<SpatialBackend Backend>
class InterestSystem {
const World* m_world;
Backend* m_backend; // non-owning; caller manages lifetime
Backend* m_backend;
std::unordered_map<InterestId, Interest> m_interests;
public:
@@ -42,33 +24,26 @@ public:
m_backend(backend)
{}
// Registers or replaces a subscription tracking the neighbourhood of an entity.
void set_entity_interest(InterestId id, entt::entity entity) {
m_interests.insert_or_assign(id, Interest(entity));
}
// Registers or replaces a subscription tracking entities inside an XZ quad.
void set_area_interest(InterestId id, AreaBounds bounds) {
m_interests.insert_or_assign(id, Interest(bounds));
}
// Removes a subscription. No-op if id is not registered.
void remove_interest(InterestId id) {
m_interests.erase(id);
}
// Returns the current interest state for the given id, or nullptr.
const Interest* get_interest(InterestId id) const {
auto it = m_interests.find(id);
return it != m_interests.end() ? &it->second : nullptr;
}
// Updates all subscriptions for the current frame.
// Must be called once per frame after world positions have been updated.
void update() {
ZoneScopedN("InterestSystem::update");
// ── Phase 1: Rebuild spatial backend ─────────────────────────────────
{
ZoneScopedN("InterestSystem::Phase1_GridRebuild");
m_backend->begin_frame();
@@ -79,7 +54,6 @@ public:
});
}
// ── Phase 2: Per-subscription deltas ─────────────────────────────────
{
ZoneScopedN("InterestSystem::Phase2_Deltas");
+4 -4
View File
@@ -39,16 +39,16 @@ int main() {
tw::Transform* transformA = zoneA.registry().try_get<tw::Transform>(entityA);
transformA->set_position(glm::vec3(970.0f, 0.0f, 0.0f));
zoneA.tick(0, 0.1f);
zoneB.tick(0, 0.1f);
zoneA.update(0, 0.1f);
zoneB.update(0, 0.1f);
interest = zoneA.get_interest(1);
transformA = zoneA.registry().try_get<tw::Transform>(entityA);
transformA->set_position(glm::vec3(1020.0f, 0.0f, 0.0f));
zoneA.tick(1, 0.1f);
zoneB.tick(1, 0.1f);
zoneA.update(1, 0.1f);
zoneB.update(1, 0.1f);
interest = zoneA.get_interest(1);
+1 -1
View File
@@ -51,7 +51,7 @@ public:
return m_last_interval_ms / (double)IN_SECOND;
}
bool update() {
bool has_ticked() {
auto now = Clock::now();
m_last_interval_ms = duration_cast<Millis>(now - m_last_point).count();
+16 -15
View File
@@ -1,7 +1,7 @@
#pragma once
#include <array>
#include <chrono>
#include <vector>
#include <glm/glm.hpp>
#include "metrics/HistoryBuffer.hpp"
@@ -20,16 +20,15 @@ private:
HistoryBuffer<Clock::time_point, glm::vec3> m_history;
HistoryBuffer<Clock::time_point, glm::vec3> m_position_history;
// Frame-indexed input ring, capacity 64
struct InputSlot {
uint32_t frame;
glm::vec3 input;
bool valid;
};
std::array<InputSlot, 64> m_input_ring;
// buffers inputs with history frame_idx stamp. Can be then read with get_input(frame_idx)
std::vector<InputSlot> m_input_ring;
uint32_t m_last_input_frame;
glm::vec3 m_last_input;
uint32_t m_frame_idx;
@@ -42,11 +41,10 @@ public:
m_speed(speed),
m_history(Clock::now(), glm::vec3(), 10 * 20),
m_position_history(Clock::now(), glm::vec3(), 10 * 20),
m_input_ring(64),
m_last_input_frame(0),
m_last_input(0.0f),
m_frame_idx(0)
{
// Initialize input ring
for(auto& slot : m_input_ring) {
slot.frame = 0;
slot.input = glm::vec3(0.0f);
@@ -61,31 +59,34 @@ public:
m_input_ring[idx].valid = true;
m_last_input_frame = frame_idx;
m_last_input = input;
}
void set_frame_idx(uint32_t idx) {
m_frame_idx = idx;
}
glm::vec3 input() const {
return m_last_input;
/**
* Returns the most recently received input, or zero vector if none was ever set.
* This is what the simulation consumes: frame stamps come from the client's own
* counter, so they cannot be looked up by a server-side frame number.
*/
glm::vec3 latest_input() const {
const auto& slot = m_input_ring[m_last_input_frame % m_input_ring.size()];
return slot.valid ? slot.input : glm::vec3(0.0f);
}
// Returns the input for the specified frame, or falls back to the most recently set input
// if the frame slot has been overwritten or never written
/**
* Returns input for specific frame or zero vector if not yet set
*/
glm::vec3 input(uint32_t frame_idx) const {
size_t idx = frame_idx % m_input_ring.size();
const auto& slot = m_input_ring[idx];
// If slot contains the exact frame we're looking for, return it
if(slot.valid && slot.frame == frame_idx) {
return slot.input;
}
// Otherwise, fall back to the most recent input
// This handles dropped packets (slot never written) or wraparound (slot overwritten)
return m_last_input;
return glm::vec3(0.0f);
}
};
+1 -1
View File
@@ -133,7 +133,7 @@ void JoltPhysicsWorld::update(uint32_t frame_idx, double delta_time) {
bool mAllowSliding = true;
if (player_controls_horizontal_velocity) {
glm::vec3 input = controller.input(frame_idx);
glm::vec3 input = controller.latest_input();
// Smooth the player input
JPH::Vec3 vel = JPH::Vec3(input.x, input.y, input.z) * controller.speed();
float inertia_factor = 1.0f - std::exp(-delta_time * 10.0f); // 10.0 = responsiveness tuning knob