From 14eaa8dda685757f3b02d8e9fcf859b2a4dc1fe4 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Mon, 9 Feb 2026 10:22:13 -0800 Subject: [PATCH] Docs edits rebased from master --- .../server-cpp/spacetimedb/.gitignore | 21 + .../server-cpp/spacetimedb/CMakeLists.txt | 63 ++ .../server-cpp/spacetimedb/src/lib.cpp | 732 ++++++++++++++++++ .../00300-unity-tutorial/00300-part-2.md | 151 ++++ .../00300-unity-tutorial/00400-part-3.md | 280 +++++++ .../00300-unity-tutorial/00500-part-4.md | 251 ++++++ .../00400-unreal-tutorial/00300-part-2.md | 153 +++- .../00400-unreal-tutorial/00400-part-3.md | 280 +++++++ .../00400-unreal-tutorial/00500-part-4.md | 251 ++++++ 9 files changed, 2181 insertions(+), 1 deletion(-) create mode 100644 demo/Blackholio/server-cpp/spacetimedb/.gitignore create mode 100644 demo/Blackholio/server-cpp/spacetimedb/CMakeLists.txt create mode 100644 demo/Blackholio/server-cpp/spacetimedb/src/lib.cpp diff --git a/demo/Blackholio/server-cpp/spacetimedb/.gitignore b/demo/Blackholio/server-cpp/spacetimedb/.gitignore new file mode 100644 index 00000000000..68eb119c532 --- /dev/null +++ b/demo/Blackholio/server-cpp/spacetimedb/.gitignore @@ -0,0 +1,21 @@ +# Build directories +/build/ +/build_*/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS generated files +.DS_Store +Thumbs.db + +# CMake cache +CMakeCache.txt +CMakeFiles/ + +# Compiled WASM files +*.wasm +*.opt.wasm \ No newline at end of file diff --git a/demo/Blackholio/server-cpp/spacetimedb/CMakeLists.txt b/demo/Blackholio/server-cpp/spacetimedb/CMakeLists.txt new file mode 100644 index 00000000000..dee60ae7793 --- /dev/null +++ b/demo/Blackholio/server-cpp/spacetimedb/CMakeLists.txt @@ -0,0 +1,63 @@ +cmake_minimum_required(VERSION 3.20) +project(spacetime_cpp_module LANGUAGES C CXX) + +# Set C++ standard +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Set the module source file +set(MODULE_SOURCE "src/lib.cpp" CACHE STRING "Source file for the SpacetimeDB module") + +# Export compile commands for better IDE support +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Set optimization level for Release builds +if(CMAKE_BUILD_TYPE STREQUAL "Release") + set(CMAKE_CXX_FLAGS_RELEASE "-O2") +endif() + +# ------------------------------------------------------------------------------ +# SpacetimeDB C++ SDK - Use local SDK from repository +# ------------------------------------------------------------------------------ + +set(SPACETIMEDB_CPP_SDK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../crates/bindings-cpp") +message(STATUS "Using local SpacetimeDB C++ SDK: ${SPACETIMEDB_CPP_SDK_DIR}") +add_subdirectory("${SPACETIMEDB_CPP_SDK_DIR}" "${CMAKE_BINARY_DIR}/_deps/spacetime_cpp_sdk-build") + +# Create the main module executable +add_executable(lib ${MODULE_SOURCE}) + +# Link against the SDK target (preferred alias) +target_link_libraries(lib PRIVATE spacetimedb_cpp_library) + +# Configure Emscripten-specific settings for WASM output +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(EXPORTED_FUNCS "['_malloc','_free','___describe_module__','___call_reducer__','___call_view__','___call_view_anon__','___call_procedure__']") + + # Compile options/defines for WASM + # (apply to both the module and the SDK, so everything sees the same feature flags) + target_compile_options(lib PRIVATE -fno-exceptions) + target_compile_options(spacetimedb_cpp_library PRIVATE -fno-exceptions) + + # Uncomment the following to allow unstable features of SpacetimeDB + #target_compile_definitions(lib PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) + #target_compile_definitions(spacetimedb_cpp_library PUBLIC SPACETIMEDB_UNSTABLE_FEATURES) + + target_link_options(lib PRIVATE + "SHELL:-sSTANDALONE_WASM=1" + "SHELL:-sWASM=1" + "SHELL:--no-entry" + "SHELL:-sEXPORTED_FUNCTIONS=${EXPORTED_FUNCS}" + "SHELL:-sERROR_ON_UNDEFINED_SYMBOLS=1" + "SHELL:-sFILESYSTEM=0" + "SHELL:-sDISABLE_EXCEPTION_CATCHING=1" + "SHELL:-sALLOW_MEMORY_GROWTH=0" + "SHELL:-sINITIAL_MEMORY=16MB" + "SHELL:-sSUPPORT_LONGJMP=0" + "SHELL:-sSUPPORT_ERRNO=0" + "SHELL:-std=c++20" + "SHELL:-O2" + ) + + set_target_properties(lib PROPERTIES OUTPUT_NAME "lib" SUFFIX ".wasm") +endif() diff --git a/demo/Blackholio/server-cpp/spacetimedb/src/lib.cpp b/demo/Blackholio/server-cpp/spacetimedb/src/lib.cpp new file mode 100644 index 00000000000..d7297bbb074 --- /dev/null +++ b/demo/Blackholio/server-cpp/spacetimedb/src/lib.cpp @@ -0,0 +1,732 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace SpacetimeDB; +using SpacetimeDB::Private; +using SpacetimeDB::Public; + +constexpr int32_t START_PLAYER_MASS = 15; +constexpr int32_t START_PLAYER_SPEED = 10; +constexpr int32_t FOOD_MASS_MIN = 2; +constexpr int32_t FOOD_MASS_MAX = 4; +constexpr size_t TARGET_FOOD_COUNT = 600; +constexpr float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +constexpr int32_t MIN_MASS_TO_SPLIT = START_PLAYER_MASS * 2; +constexpr int32_t MAX_CIRCLES_PER_PLAYER = 16; +constexpr float SPLIT_RECOMBINE_DELAY_SEC = 5.0f; +constexpr float SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC = 2.0f; +constexpr float ALLOWED_SPLIT_CIRCLE_OVERLAP_PCT = 0.9f; +constexpr float SELF_COLLISION_SPEED = 0.05f; // 1 == instantly separate circles. less means separation takes time + +struct DbVector2 { + float x; + float y; + + DbVector2() : x(0.0f), y(0.0f) {} + DbVector2(float x_in, float y_in) : x(x_in), y(y_in) {} + + float sqr_magnitude() const { + return x * x + y * y; + } + + float magnitude() const { + return std::sqrt(sqr_magnitude()); + } + + DbVector2 normalized() const { + float mag = magnitude(); + if (mag == 0.0f) { + return DbVector2(0.0f, 0.0f); + } + return DbVector2(x / mag, y / mag); + } +}; +SPACETIMEDB_STRUCT(DbVector2, x, y) + +inline DbVector2 operator+(const DbVector2& a, const DbVector2& b) { + return DbVector2(a.x + b.x, a.y + b.y); +} + +inline DbVector2 operator-(const DbVector2& a, const DbVector2& b) { + return DbVector2(a.x - b.x, a.y - b.y); +} + +inline DbVector2& operator+=(DbVector2& a, const DbVector2& b) { + a.x += b.x; + a.y += b.y; + return a; +} + +inline DbVector2& operator-=(DbVector2& a, const DbVector2& b) { + a.x -= b.x; + a.y -= b.y; + return a; +} + +inline DbVector2 operator*(const DbVector2& v, float scalar) { + return DbVector2(v.x * scalar, v.y * scalar); +} + +inline DbVector2 operator*(float scalar, const DbVector2& v) { + return DbVector2(v.x * scalar, v.y * scalar); +} + +inline DbVector2 operator/(const DbVector2& v, float scalar) { + if (scalar == 0.0f) { + return DbVector2(0.0f, 0.0f); + } + return DbVector2(v.x / scalar, v.y / scalar); +} + +struct Config { + int32_t id; + int64_t world_size; +}; +SPACETIMEDB_STRUCT(Config, id, world_size) +SPACETIMEDB_TABLE(Config, config, Public) +FIELD_PrimaryKey(config, id) + +struct Entity { + int32_t entity_id; + DbVector2 position; + int32_t mass; +}; +SPACETIMEDB_STRUCT(Entity, entity_id, position, mass) +SPACETIMEDB_TABLE(Entity, entity, Public) +FIELD_PrimaryKeyAutoInc(entity, entity_id) +SPACETIMEDB_TABLE(Entity, logged_out_entity, Private) +FIELD_PrimaryKeyAutoInc(logged_out_entity, entity_id) + +struct Circle { + int32_t entity_id; + int32_t player_id; + DbVector2 direction; + float speed; + Timestamp last_split_time; +}; +SPACETIMEDB_STRUCT(Circle, entity_id, player_id, direction, speed, last_split_time) +SPACETIMEDB_TABLE(Circle, circle, Public) +FIELD_PrimaryKey(circle, entity_id) +FIELD_Index(circle, player_id) +SPACETIMEDB_TABLE(Circle, logged_out_circle, Private) +FIELD_PrimaryKey(logged_out_circle, entity_id) +FIELD_Index(logged_out_circle, player_id) + +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name) +SPACETIMEDB_TABLE(Player, player, Public) +FIELD_PrimaryKey(player, identity) +FIELD_UniqueAutoInc(player, player_id) +SPACETIMEDB_TABLE(Player, logged_out_player, Private) +FIELD_PrimaryKey(logged_out_player, identity) +FIELD_UniqueAutoInc(logged_out_player, player_id) + +struct Food { + int32_t entity_id; +}; +SPACETIMEDB_STRUCT(Food, entity_id) +SPACETIMEDB_TABLE(Food, food, Public) +FIELD_PrimaryKey(food, entity_id) + +struct MoveAllPlayersTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(MoveAllPlayersTimer, scheduled_id, scheduled_at) +SPACETIMEDB_TABLE(MoveAllPlayersTimer, move_all_players_timer, Private) +FIELD_PrimaryKeyAutoInc(move_all_players_timer, scheduled_id) +SPACETIMEDB_SCHEDULE(move_all_players_timer, 1, move_all_players) + +struct SpawnFoodTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(SpawnFoodTimer, scheduled_id, scheduled_at) +SPACETIMEDB_TABLE(SpawnFoodTimer, spawn_food_timer, Private) +FIELD_PrimaryKeyAutoInc(spawn_food_timer, scheduled_id) +SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food) + +struct CircleDecayTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(CircleDecayTimer, scheduled_id, scheduled_at) +SPACETIMEDB_TABLE(CircleDecayTimer, circle_decay_timer, Private) +FIELD_PrimaryKeyAutoInc(circle_decay_timer, scheduled_id) +SPACETIMEDB_SCHEDULE(circle_decay_timer, 1, circle_decay) + +struct CircleRecombineTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; + int32_t player_id; +}; +SPACETIMEDB_STRUCT(CircleRecombineTimer, scheduled_id, scheduled_at, player_id) +SPACETIMEDB_TABLE(CircleRecombineTimer, circle_recombine_timer, Private) +FIELD_PrimaryKeyAutoInc(circle_recombine_timer, scheduled_id) +SPACETIMEDB_SCHEDULE(circle_recombine_timer, 1, circle_recombine) + +struct ConsumeEntityTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; + int32_t consumed_entity_id; + int32_t consumer_entity_id; +}; +SPACETIMEDB_STRUCT(ConsumeEntityTimer, scheduled_id, scheduled_at, consumed_entity_id, consumer_entity_id) +SPACETIMEDB_TABLE(ConsumeEntityTimer, consume_entity_timer, Private) +FIELD_PrimaryKeyAutoInc(consume_entity_timer, scheduled_id) +SPACETIMEDB_SCHEDULE(consume_entity_timer, 1, consume_entity) + +static float mass_to_radius(int32_t mass) { + return std::sqrt(static_cast(mass)); +} + +static float mass_to_max_move_speed(int32_t mass) { + return 2.0f * static_cast(START_PLAYER_SPEED) + / (1.0f + std::sqrt(static_cast(mass) / static_cast(START_PLAYER_MASS))); +} + +static bool is_overlapping(const Entity& a, const Entity& b) { + float dx = a.position.x - b.position.x; + float dy = a.position.y - b.position.y; + float distance_sq = dx * dx + dy * dy; + + float radius_a = mass_to_radius(a.mass); + float radius_b = mass_to_radius(b.mass); + float max_radius = std::max(radius_a, radius_b); + + return distance_sq <= max_radius * max_radius; +} + +static Outcome spawn_circle_at( + ReducerContext& ctx, + int32_t player_id, + int32_t mass, + const DbVector2& position, + const Timestamp& timestamp +) { + Entity new_entity{0, position, mass}; + Entity inserted_entity = ctx.db[entity].insert(new_entity); + Circle new_circle{ + inserted_entity.entity_id, + player_id, + DbVector2(0.0f, 1.0f), + 0.0f, + timestamp + }; + ctx.db[circle].insert(new_circle); + + return Ok(inserted_entity); +} + +static Outcome spawn_player_initial_circle(ReducerContext& ctx, int32_t player_id) { + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + + auto& rng = ctx.rng(); + float world_size = static_cast(config_opt->world_size); + float player_start_radius = mass_to_radius(START_PLAYER_MASS); + float x = rng.gen_range(player_start_radius, world_size - player_start_radius); + float y = rng.gen_range(player_start_radius, world_size - player_start_radius); + return spawn_circle_at(ctx, player_id, START_PLAYER_MASS, DbVector2(x, y), ctx.timestamp); +} + +static void schedule_consume_entity(ReducerContext& ctx, int32_t consumer_id, int32_t consumed_id) { + ConsumeEntityTimer timer{ + 0, + ScheduleAt::time(ctx.timestamp), + consumed_id, + consumer_id + }; + ctx.db[consume_entity_timer].insert(timer); +} + +static Outcome destroy_entity(ReducerContext& ctx, int32_t entity_id) { + (void)ctx.db[food_entity_id].delete_by_key(entity_id); + (void)ctx.db[circle_entity_id].delete_by_key(entity_id); + (void)ctx.db[entity_entity_id].delete_by_key(entity_id); + return Ok(); +} + +static DbVector2 calculate_center_of_mass(const std::vector& entities) { + int32_t total_mass = 0; + DbVector2 center_of_mass(0.0f, 0.0f); + for (const auto& entity_row : entities) { + total_mass += entity_row.mass; + center_of_mass += entity_row.position * static_cast(entity_row.mass); + } + if (total_mass == 0) { + return DbVector2(0.0f, 0.0f); + } + return center_of_mass / static_cast(total_mass); +} + +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{0, 1000}); + + ctx.db[circle_decay_timer].insert( + CircleDecayTimer{0, ScheduleAt::interval(TimeDuration::from_seconds(5))} + ); + + ctx.db[spawn_food_timer].insert( + SpawnFoodTimer{0, ScheduleAt::interval(TimeDuration::from_millis(500))} + ); + + ctx.db[move_all_players_timer].insert( + MoveAllPlayersTimer{0, ScheduleAt::interval(TimeDuration::from_millis(50))} + ); + + return Ok(); +} + +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + auto logged_out = ctx.db[logged_out_player_identity].find(ctx.sender); + if (logged_out.has_value()) { + ctx.db[player].insert(logged_out.value()); + (void)ctx.db[logged_out_player_identity].delete_by_key(logged_out->identity); + + auto logged_out_circles = ctx.db[logged_out_circle_player_id].filter(logged_out->player_id); + for (const auto& circle_row : logged_out_circles) { + auto entity_opt = ctx.db[logged_out_entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + return Err("Could not find logged out entity"); + } + + ctx.db[entity].insert(entity_opt.value()); + (void)ctx.db[logged_out_entity_entity_id].delete_by_key(entity_opt->entity_id); + + ctx.db[circle].insert(circle_row); + (void)ctx.db[logged_out_circle_entity_id].delete_by_key(circle_row.entity_id); + } + } else { + Player new_player{ctx.sender, 0, std::string()}; + ctx.db[player].insert(new_player); + } + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + Player player_row = player_opt.value(); + int32_t player_id = player_row.player_id; + + ctx.db[logged_out_player].insert(player_row); + (void)ctx.db[player_identity].delete_by_key(player_row.identity); + + auto circles = ctx.db[circle_player_id].filter(player_id); + for (const auto& circle_row : circles) { + auto entity_opt = ctx.db[entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + return Err("Could not find circle entity"); + } + + ctx.db[logged_out_entity].insert(entity_opt.value()); + (void)ctx.db[entity_entity_id].delete_by_key(entity_opt->entity_id); + + ctx.db[logged_out_circle].insert(circle_row); + (void)ctx.db[circle_entity_id].delete_by_key(circle_row.entity_id); + } + + return Ok(); +} + +SPACETIMEDB_REDUCER(enter_game, ReducerContext ctx, std::string name) { + LOG_INFO("Creating player with name " + name); + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player_row = player_opt.value(); + player_row.name = std::move(name); + (void)ctx.db[player_identity].update(player_row); + + auto spawn_result = spawn_player_initial_circle(ctx, player_row.player_id); + if (spawn_result.is_err()) { + return Err(spawn_result.error()); + } + + return Ok(); +} + +SPACETIMEDB_REDUCER(respawn, ReducerContext ctx) { + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("No such player found"); + } + + auto spawn_result = spawn_player_initial_circle(ctx, player_opt->player_id); + if (spawn_result.is_err()) { + return Err(spawn_result.error()); + } + + return Ok(); +} + +SPACETIMEDB_REDUCER(suicide, ReducerContext ctx) { + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("No such player found"); + } + + auto circles = ctx.db[circle_player_id].filter(player_opt->player_id); + for (const auto& circle_row : circles) { + auto destroy_result = destroy_entity(ctx, circle_row.entity_id); + if (destroy_result.is_err()) { + return Err(destroy_result.error()); + } + } + return Ok(); +} + +SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction) { + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + auto circles = ctx.db[circle_player_id].filter(player_opt->player_id); + for (auto circle_row : circles) { + circle_row.direction = direction.normalized(); + circle_row.speed = std::clamp(direction.magnitude(), 0.0f, 1.0f); + (void)ctx.db[circle_entity_id].update(circle_row); + } + return Ok(); +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer) { + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + float world_size = static_cast(config_opt->world_size); + + std::unordered_map circle_directions; + circle_directions.reserve(static_cast(ctx.db[circle].count())); + for (const auto& circle_row : ctx.db[circle]) { + circle_directions.emplace(circle_row.entity_id, circle_row.direction * circle_row.speed); + } + + for (const auto& player_row : ctx.db[player]) { + auto circles_range = ctx.db[circle_player_id].filter(player_row.player_id); + std::vector circles; + circles.reserve(circles_range.size()); + for (const auto& circle_row : circles_range) { + circles.push_back(circle_row); + } + + std::vector player_entities; + player_entities.reserve(circles.size()); + for (const auto& circle_row : circles) { + auto entity_opt = ctx.db[entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + return Err("Circle has no entity"); + } + player_entities.push_back(entity_opt.value()); + } + + if (player_entities.size() <= 1) { + continue; + } + float count = static_cast(player_entities.size()); + + for (size_t i = 0; i < player_entities.size(); ++i) { + const Circle& circle_i = circles[i]; + float time_since_split = + static_cast(ctx.timestamp.duration_since(circle_i.last_split_time).micros()) + / 1000000.0f; + float time_before_recombining = + std::max(SPLIT_RECOMBINE_DELAY_SEC - time_since_split, 0.0f); + if (time_before_recombining > SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC) { + continue; + } + + const Entity& entity_i = player_entities[i]; + for (size_t j = 0; j < player_entities.size(); ++j) { + if (j == i) { + continue; + } + const Entity& entity_j = player_entities[j]; + DbVector2 diff = entity_i.position - entity_j.position; + float distance_sqr = diff.sqr_magnitude(); + if (distance_sqr <= 0.0001f) { + diff = DbVector2(1.0f, 0.0f); + distance_sqr = 1.0f; + } + float radius_sum = mass_to_radius(entity_i.mass) + mass_to_radius(entity_j.mass); + if (distance_sqr > radius_sum * radius_sum) { + float gravity_multiplier = + 1.0f - time_before_recombining / SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC; + DbVector2 vec = diff.normalized() + * (radius_sum - std::sqrt(distance_sqr)) + * gravity_multiplier + * 0.05f + / count; + auto it_i = circle_directions.find(entity_i.entity_id); + auto it_j = circle_directions.find(entity_j.entity_id); + if (it_i != circle_directions.end()) { + it_i->second += vec / 2.0f; + } + if (it_j != circle_directions.end()) { + it_j->second -= vec / 2.0f; + } + } + } + } + + for (size_t i = 0; i < player_entities.size(); ++i) { + const Entity& entity_i = player_entities[i]; + for (size_t j = i + 1; j < player_entities.size(); ++j) { + const Entity& entity_j = player_entities[j]; + DbVector2 diff = entity_i.position - entity_j.position; + float distance_sqr = diff.sqr_magnitude(); + if (distance_sqr <= 0.0001f) { + diff = DbVector2(1.0f, 0.0f); + distance_sqr = 1.0f; + } + float radius_sum = mass_to_radius(entity_i.mass) + mass_to_radius(entity_j.mass); + float radius_sum_multiplied = radius_sum * ALLOWED_SPLIT_CIRCLE_OVERLAP_PCT; + if (distance_sqr < radius_sum_multiplied * radius_sum_multiplied) { + DbVector2 vec = diff.normalized() + * (radius_sum - std::sqrt(distance_sqr)) + * SELF_COLLISION_SPEED; + auto it_i = circle_directions.find(entity_i.entity_id); + auto it_j = circle_directions.find(entity_j.entity_id); + if (it_i != circle_directions.end()) { + it_i->second += vec / 2.0f; + } + if (it_j != circle_directions.end()) { + it_j->second -= vec / 2.0f; + } + } + } + } + } + + for (const auto& circle_row : ctx.db[circle]) { + auto entity_opt = ctx.db[entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + continue; + } + Entity circle_entity = entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + + DbVector2 direction(0.0f, 0.0f); + auto direction_it = circle_directions.find(circle_row.entity_id); + if (direction_it != circle_directions.end()) { + direction = direction_it->second; + } + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + + float min = circle_radius; + float max = world_size - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min, max); + circle_entity.position.y = std::clamp(new_pos.y, min, max); + (void)ctx.db[entity_entity_id].update(circle_entity); + } + + std::unordered_map entities; + entities.reserve(static_cast(ctx.db[entity].count())); + for (const auto& entity_row : ctx.db[entity]) { + entities.emplace(entity_row.entity_id, entity_row); + } + + for (const auto& circle_row : ctx.db[circle]) { + auto entity_it = entities.find(circle_row.entity_id); + if (entity_it == entities.end()) { + continue; + } + const Entity& circle_entity = entity_it->second; + + for (const auto& other_pair : entities) { + const Entity& other_entity = other_pair.second; + if (other_entity.entity_id == circle_entity.entity_id) { + continue; + } + + if (is_overlapping(circle_entity, other_entity)) { + auto other_circle = ctx.db[circle_entity_id].find(other_entity.entity_id); + if (other_circle.has_value()) { + if (other_circle->player_id != circle_row.player_id) { + float mass_ratio = + static_cast(other_entity.mass) / static_cast(circle_entity.mass); + if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) { + schedule_consume_entity(ctx, circle_entity.entity_id, other_entity.entity_id); + } + } + } else { + schedule_consume_entity(ctx, circle_entity.entity_id, other_entity.entity_id); + } + } + } + } + + return Ok(); +} + +SPACETIMEDB_REDUCER(consume_entity, ReducerContext ctx, ConsumeEntityTimer request) { + auto consumed_opt = ctx.db[entity_entity_id].find(request.consumed_entity_id); + if (!consumed_opt.has_value()) { + return Err("Consumed entity doesn't exist"); + } + auto consumer_opt = ctx.db[entity_entity_id].find(request.consumer_entity_id); + if (!consumer_opt.has_value()) { + return Err("Consumer entity doesn't exist"); + } + + Entity consumed_entity = consumed_opt.value(); + Entity consumer_entity = consumer_opt.value(); + consumer_entity.mass += consumed_entity.mass; + + auto destroy_result = destroy_entity(ctx, consumed_entity.entity_id); + if (destroy_result.is_err()) { + return Err(destroy_result.error()); + } + (void)ctx.db[entity_entity_id].update(consumer_entity); + + return Ok(); +} + +SPACETIMEDB_REDUCER(player_split, ReducerContext ctx) { + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Sender has no player"); + } + + auto circles = ctx.db[circle_player_id].filter(player_opt->player_id); + int32_t circle_count = static_cast(ctx.db[circle_player_id].filter(player_opt->player_id).size()); + if (circle_count >= MAX_CIRCLES_PER_PLAYER) { + LOG_WARN("Player has max circles already"); + return Ok(); + } + + for (auto circle_row : circles) { + auto entity_opt = ctx.db[entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + return Err("Circle has no entity"); + } + Entity circle_entity = entity_opt.value(); + if (circle_entity.mass >= MIN_MASS_TO_SPLIT * 2) { + int32_t half_mass = circle_entity.mass / 2; + auto spawn_result = spawn_circle_at( + ctx, + circle_row.player_id, + half_mass, + circle_entity.position + circle_row.direction, + ctx.timestamp + ); + if (spawn_result.is_err()) { + return Err(spawn_result.error()); + } + + circle_entity.mass -= half_mass; + circle_row.last_split_time = ctx.timestamp; + (void)ctx.db[circle_entity_id].update(circle_row); + (void)ctx.db[entity_entity_id].update(circle_entity); + + circle_count += 1; + if (circle_count >= MAX_CIRCLES_PER_PLAYER) { + break; + } + } + } + + int64_t recombine_micros = static_cast(SPLIT_RECOMBINE_DELAY_SEC * 1000000.0f); + Timestamp trigger_at = ctx.timestamp + TimeDuration::from_micros(recombine_micros); + CircleRecombineTimer timer{0, ScheduleAt::time(trigger_at), player_opt->player_id}; + ctx.db[circle_recombine_timer].insert(timer); + + LOG_WARN("Player split!"); + return Ok(); +} + +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx, SpawnFoodTimer) { + if (ctx.db[player].count() == 0) { + return Ok(); + } + + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + float world_size = static_cast(config_opt->world_size); + + auto& rng = ctx.rng(); + uint64_t food_count = ctx.db[food].count(); + while (food_count < static_cast(TARGET_FOOD_COUNT)) { + int32_t food_mass = rng.gen_range(FOOD_MASS_MIN, FOOD_MASS_MAX - 1); + float food_radius = mass_to_radius(food_mass); + float x = rng.gen_range(food_radius, world_size - food_radius); + float y = rng.gen_range(food_radius, world_size - food_radius); + + Entity food_entity{0, DbVector2(x, y), food_mass}; + Entity inserted_entity = ctx.db[entity].insert(food_entity); + ctx.db[food].insert(Food{inserted_entity.entity_id}); + + food_count += 1; + LOG_INFO("Spawned food! " + std::to_string(inserted_entity.entity_id)); + } + + return Ok(); +} + +SPACETIMEDB_REDUCER(circle_decay, ReducerContext ctx, CircleDecayTimer) { + for (const auto& circle_row : ctx.db[circle]) { + auto entity_opt = ctx.db[entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + return Err("Entity not found"); + } + Entity circle_entity = entity_opt.value(); + if (circle_entity.mass <= START_PLAYER_MASS) { + continue; + } + circle_entity.mass = static_cast(static_cast(circle_entity.mass) * 0.99f); + (void)ctx.db[entity_entity_id].update(circle_entity); + } + return Ok(); +} + +SPACETIMEDB_REDUCER(circle_recombine, ReducerContext ctx, CircleRecombineTimer timer) { + auto circles = ctx.db[circle_player_id].filter(timer.player_id); + std::vector recombining_entities; + for (const auto& circle_row : circles) { + float time_since_split = + static_cast(ctx.timestamp.duration_since(circle_row.last_split_time).micros()) + / 1000000.0f; + if (time_since_split >= SPLIT_RECOMBINE_DELAY_SEC) { + auto entity_opt = ctx.db[entity_entity_id].find(circle_row.entity_id); + if (!entity_opt.has_value()) { + return Err("Circle has no entity"); + } + recombining_entities.push_back(entity_opt.value()); + } + } + + if (recombining_entities.size() <= 1) { + return Ok(); + } + + int32_t base_entity_id = recombining_entities[0].entity_id; + for (size_t i = 1; i < recombining_entities.size(); ++i) { + schedule_consume_entity(ctx, base_entity_id, recombining_entities[i].entity_id); + } + + return Ok(); +} diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md index b6766e12672..b6de02b529c 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md @@ -58,6 +58,15 @@ spacetime init --lang rust --server-only blackholio This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. + + +Run the following command to initialize the SpacetimeDB server module project with C++ as the language: + +```bash +spacetime init --lang cpp --server-only blackholio +``` + +This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with C++ as the programming language. @@ -75,6 +84,11 @@ In this section we'll be making some edits to the file `blackholio/spacetimedb/s **Important: Open the `blackholio/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** + + +In this section we'll be making some edits to the file `blackholio/spacetimedb/src/lib.cpp`. We recommend you open up this file in an IDE like VSCode or Rider. + +**Important: Open the `blackholio/spacetimedb/src/lib.cpp` file and delete its contents. We will be writing it from scratch here.** @@ -102,6 +116,16 @@ use std::time::Duration; use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp}; ``` + + +**Copy and paste into lib.cpp:** + +```cpp +#include "spacetimedb.h" + +using namespace SpacetimeDB; +``` + @@ -156,6 +180,29 @@ The `spacetimedb::table` macro takes two parameters, a `name` which is the name The `#[primary_key]` attribute, specifies that the `id` field should be used as the primary key of the table. + + +Each row in a SpacetimeDB table is associated with a `struct` type in C++. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.cpp`. + +```cpp +// We're using this table as a singleton, so in this table +// there will only be one element where the `id` is 0. +struct Config { + int32_t id; + int64_t world_size; +}; +SPACETIMEDB_STRUCT(Config, id, world_size); +SPACETIMEDB_TABLE(Config, config, Public); +FIELD_PrimaryKey(config, id); +``` + +Let's break down this code. This defines a normal C++ `struct` with two fields: `id` and `world_size`. We use the `SPACETIMEDB_STRUCT` macro to register the struct for serialization, listing all field names. Then `SPACETIMEDB_TABLE` registers it as a table with the name `config` and `Public` visibility. + +The `SPACETIMEDB_TABLE` macro takes three parameters: the struct type, the table name (which you'll use to access the table in code), and a visibility modifier (`Public` or `Private`) which determines whether rows are synced to clients. + +The `FIELD_PrimaryKey` macro specifies that the `id` field should be used as the primary key of the table. @@ -267,6 +314,55 @@ pub struct Food { } ``` + + +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a regular struct registered with `SPACETIMEDB_STRUCT` and one registered with `SPACETIMEDB_TABLE` is that tables actually store data, whereas registering a struct alone just allows you to use it as a column type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of lib.cpp:** + +```cpp +// This allows us to store 2D points in tables. +struct DbVector2 { + float x; + float y; +}; +SPACETIMEDB_STRUCT(DbVector2, x, y); +``` + +Let's create a few tables to represent entities in our game. + +```cpp +struct Entity { + // The `FIELD_PrimaryKeyAutoInc` constraint indicates to SpacetimeDB that + // this value should be determined by SpacetimeDB on insert. + int32_t entity_id; + DbVector2 position; + int32_t mass; +}; +SPACETIMEDB_STRUCT(Entity, entity_id, position, mass); +SPACETIMEDB_TABLE(Entity, entity, Public); +FIELD_PrimaryKeyAutoInc(entity, entity_id); + +struct Circle { + int32_t entity_id; + int32_t player_id; + DbVector2 direction; + float speed; + Timestamp last_split_time; +}; +SPACETIMEDB_STRUCT(Circle, entity_id, player_id, direction, speed, last_split_time); +SPACETIMEDB_TABLE(Circle, circle, Public); +FIELD_PrimaryKey(circle, entity_id); +FIELD_Index(circle, player_id); + +struct Food { + int32_t entity_id; +}; +SPACETIMEDB_STRUCT(Food, entity_id); +SPACETIMEDB_TABLE(Food, food, Public); +FIELD_PrimaryKey(food, entity_id); +``` + @@ -317,6 +413,22 @@ pub struct Player { There's a few new concepts we should touch on. First of all, we are using the `#[unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. + + + +```cpp +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name); +SPACETIMEDB_TABLE(Player, player, Public); +FIELD_PrimaryKey(player, identity); +FIELD_UniqueAutoInc(player, player_id); +``` + +There's a few new concepts we should touch on. First of all, we are using the `FIELD_UniqueAutoInc` macro on the `player_id` field. This macro combines two constraints: it ensures that only one row in the player table has a particular `player_id`, and it indicates that this field should get automatically assigned an auto-incremented value. @@ -350,6 +462,16 @@ pub fn debug(ctx: &ReducerContext) -> Result<(), String> { } ``` + + + +```cpp +SPACETIMEDB_REDUCER(debug, ReducerContext ctx) { + LOG_INFO("This reducer was called by " + ctx.sender.to_string()); + return Ok(); +} +``` + @@ -413,6 +535,13 @@ Next, use the `spacetime` command to call our newly defined `debug` reducer: spacetime call --server local blackholio debug ``` + + + +```sh +spacetime call --server local blackholio debug +``` + @@ -476,6 +605,25 @@ The `client_connected` argument to the `spacetimedb::reducer` macro indicates to > - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. + + +Next let's connect our client to our database. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and change `SPACETIMEDB_REDUCER` to `SPACETIMEDB_CLIENT_CONNECTED`. The end result should look like this: + +```cpp +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + LOG_INFO(ctx.sender.to_string() + " just connected."); + return Ok(); +} +``` + +The `SPACETIMEDB_CLIENT_CONNECTED` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `SPACETIMEDB_INIT` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. +> - `SPACETIMEDB_CLIENT_CONNECTED` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `SPACETIMEDB_CLIENT_DISCONNECTED` - Called when a user disconnects from the SpacetimeDB database. + @@ -498,6 +646,9 @@ The `spacetime` CLI has built in functionality to let us generate C# types that Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: + +Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: + ```sh diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md index ab95e71294d..2159031affd 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md @@ -156,6 +156,81 @@ pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + + +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `SPACETIMEDB_INIT` reducer. SpacetimeDB calls the `SPACETIMEDB_INIT` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. + +Add this new reducer above our `connect` reducer. + +```cpp +// Note the SPACETIMEDB_INIT macro. +// This indicates to SpacetimeDB that it should be called +// once upon database creation. +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{0, 1000}); + return Ok(); +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `insert` function. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the file. + +```cpp +const int32_t FOOD_MASS_MIN = 2; +const int32_t FOOD_MASS_MAX = 4; +const size_t TARGET_FOOD_COUNT = 600; + +float mass_to_radius(int32_t mass) { + return std::sqrt(static_cast(mass)); +} + +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx) { + // Check if there are any players logged in + bool has_players = false; + for (const auto& _ : ctx.db[player]) { + has_players = true; + break; + } + if (!has_players) { + // Are there no logged in players? Skip food spawn. + return Ok(); + } + + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + auto& rng = ctx.rng(); + + // Count current food + uint64_t food_count = 0; + for (const auto& _ : ctx.db[food]) { + food_count++; + } + + while (food_count < TARGET_FOOD_COUNT) { + int32_t food_mass = rng.gen_range(FOOD_MASS_MIN, FOOD_MASS_MAX); + float food_radius = mass_to_radius(food_mass); + float x = rng.gen_range(food_radius, static_cast(world_size) - food_radius); + float y = rng.gen_range(food_radius, static_cast(world_size) - food_radius); + + auto inserted_entity = ctx.db[entity].insert(Entity{0, {x, y}, food_mass}); + ctx.db[food].insert(Food{inserted_entity.entity_id}); + + food_count += 1; + LOG_INFO("Spawned food! " + std::to_string(inserted_entity.entity_id)); + } + + return Ok(); +} +``` + +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + @@ -195,6 +270,22 @@ pub struct SpawnFoodTimer { Note the `scheduled(spawn_food)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + + +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below the Player table. + +```cpp +struct SpawnFoodTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(SpawnFoodTimer, scheduled_id, scheduled_at); +SPACETIMEDB_TABLE(SpawnFoodTimer, spawn_food_timer, Private); +FIELD_PrimaryKeyAutoInc(spawn_food_timer, scheduled_id); +SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food); +``` + +Note the `SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food)` call. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. The second parameter `1` is the 0-based column index of the `scheduled_at` field. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. @@ -223,6 +314,15 @@ pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), St } ``` + + + +```cpp +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx, SpawnFoodTimer _timer) { + // ... +} +``` + @@ -277,6 +377,29 @@ You can use `ScheduleAt::Interval` to schedule a reducer call at an interval lik ::: + + +Let's modify our `SPACETIMEDB_INIT` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. + +```cpp +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{ + 0, + 1000, + }); + ctx.db[spawn_food_timer].insert(SpawnFoodTimer{ + 0, + ScheduleAt(TimeDuration::from_millis(500)), + }); + return Ok(); +} +``` + +:::note +You can use `ScheduleAt(TimeDuration::from_millis(...))` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt(Timestamp::from_millis_since_epoch(...))` to specify a specific time at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: + @@ -300,6 +423,13 @@ Let's add a second table to our `Player` struct. Modify the `Player` struct by a #[spacetimedb::table(name = logged_out_player)] ``` + + + +```cpp +SPACETIMEDB_TABLE(Player, logged_out_player, Private); +``` + @@ -338,6 +468,28 @@ pub struct Player { } ``` + + + +```cpp +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name); +SPACETIMEDB_TABLE(Player, player, Public); +SPACETIMEDB_TABLE(Player, logged_out_player, Private); +FIELD_PrimaryKey(player, identity); +FIELD_UniqueAutoInc(player, player_id); +FIELD_PrimaryKey(logged_out_player, identity); +FIELD_UniqueAutoInc(logged_out_player, player_id); +``` + +:::note +In C++, since we're creating two separate tables from the same struct, we need to apply the field constraints (`FIELD_PrimaryKey` and `FIELD_UniqueAutoInc`) to both `player` and `logged_out_player` independently. Each table maintains its own indexes and constraints. +::: + @@ -423,6 +575,42 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { } ``` + + +Next, modify your `connect` reducer and add a new `disconnect` reducer below it: + +```cpp +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + // Check if this player was previously logged out + auto logged_out_player_opt = ctx.db[logged_out_player_identity].find(ctx.sender); + if (logged_out_player_opt.has_value()) { + // Move player from logged_out_player to player table + ctx.db[player].insert(logged_out_player_opt.value()); + ctx.db[logged_out_player_identity].delete_by_key(logged_out_player_opt.value().identity); + } else { + // New player - create and insert into player table + ctx.db[player].insert(Player{ctx.sender, 0, ""}); + } + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + // Find the player in the player table + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player = player_opt.value(); + + // Move player from player to logged_out_player table + ctx.db[logged_out_player].insert(player); + ctx.db[player_identity].delete_by_key(player.identity); + + return Ok(); +} +``` + @@ -567,6 +755,68 @@ fn spawn_circle_at( The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. + + +Add the following to the bottom of your file. + +```cpp +const int32_t START_PLAYER_MASS = 15; + +// Helper function to spawn a circle at a specific location +Entity spawn_circle_at(ReducerContext& ctx, int32_t player_id, int32_t mass, DbVector2 position, Timestamp timestamp) { + auto inserted_entity = ctx.db[entity].insert(Entity{0, position, mass}); + + ctx.db[circle].insert(Circle{ + inserted_entity.entity_id, + player_id, + DbVector2{0.0f, 1.0f}, // direction + 0.0f, // speed + timestamp // last_split_time + }); + + return inserted_entity; +} + +// Helper function to spawn a player's initial circle +Entity spawn_player_initial_circle(ReducerContext& ctx, int32_t player_id) { + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + // This shouldn't happen, but handle it gracefully + return Entity{0, {0.0f, 0.0f}, 0}; + } + int64_t world_size = config_opt.value().world_size; + + auto& rng = ctx.rng(); + float player_start_radius = mass_to_radius(START_PLAYER_MASS); + float x = rng.gen_range(player_start_radius, static_cast(world_size) - player_start_radius); + float y = rng.gen_range(player_start_radius, static_cast(world_size) - player_start_radius); + + return spawn_circle_at(ctx, player_id, START_PLAYER_MASS, DbVector2{x, y}, ctx.timestamp); +} + +SPACETIMEDB_REDUCER(enter_game, ReducerContext ctx, std::string name) { + LOG_INFO("Creating player with name " + name); + + // Find the player + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + // Update the player's name + Player updated_player = player_opt.value(); + int32_t player_id = updated_player.player_id; + updated_player.name = name; + ctx.db[player_identity].update(updated_player); + + // Spawn initial circle for the player + spawn_player_initial_circle(ctx, player_id); + + return Ok(); +} +``` + +The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. @@ -618,6 +868,36 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { } ``` + + + +```cpp +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + // Find the player in the player table + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player = player_opt.value(); + int32_t player_id = player.player_id; + + // Move player from player to logged_out_player table + ctx.db[logged_out_player].insert(player); + ctx.db[player_identity].delete_by_key(player.identity); + + // Remove any circles from the arena + for (const Circle& circle : ctx.db[circle_player_id]) { + if (circle.player_id == player_id) { + ctx.db[entity_entity_id].delete_by_key(circle.entity_id); + ctx.db[circle_entity_id].delete_by_key(circle.entity_id); + } + } + + return Ok(); +} +``` + diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md index 647efe78bcb..d5a7a3ca3e9 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md @@ -220,6 +220,108 @@ pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. + + + +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.h` file in the `blackholio/spacetimedb/src` directory and add the following contents. We'll also move the `DbVector2` type from `lib.cpp` into this file. + +```cpp +#pragma once + +#include "spacetimedb.h" +#include + +using namespace SpacetimeDB; + +// This allows us to store 2D points in tables. +struct DbVector2 { + float x; + float y; + + // Helper methods + float sqr_magnitude() const { + return x * x + y * y; + } + + float magnitude() const { + return std::sqrt(x * x + y * y); + } + + DbVector2 normalized() const { + float mag = magnitude(); + if (mag != 0.0f) { + return DbVector2{x / mag, y / mag}; + } + return DbVector2{0.0f, 0.0f}; + } + + // Operator overloads + DbVector2 operator+(const DbVector2& other) const { + return DbVector2{x + other.x, y + other.y}; + } + + DbVector2& operator+=(const DbVector2& other) { + x += other.x; + y += other.y; + return *this; + } + + DbVector2 operator-(const DbVector2& other) const { + return DbVector2{x - other.x, y - other.y}; + } + + DbVector2& operator-=(const DbVector2& other) { + x -= other.x; + y -= other.y; + return *this; + } + + DbVector2 operator*(float scalar) const { + return DbVector2{x * scalar, y * scalar}; + } + + DbVector2 operator/(float scalar) const { + if (scalar != 0.0f) { + return DbVector2{x / scalar, y / scalar}; + } + return DbVector2{0.0f, 0.0f}; + } +}; +SPACETIMEDB_STRUCT(DbVector2, x, y); +``` + +At the very top of `lib.cpp` add the following line to include the `DbVector2` from the `math.h` header, and remove the `DbVector2` struct definition from `lib.cpp`: + +```cpp +#include "spacetimedb.h" +#include "math.h" +// ... +``` + +Next, add the following reducer to the end of your `lib.cpp` file. + +```cpp +SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction) { + // Find the player + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + int32_t player_id = player_opt.value().player_id; + + // Update all circles owned by this player + for (Circle circle : ctx.db[circle_player_id].filter(player_id)) { + circle.direction = direction.normalized(); + circle.speed = std::clamp(direction.magnitude(), 0.0f, 1.0f); + ctx.db[circle_entity_id].update(circle); + } + + return Ok(); +} +``` + +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender` value is not set by the client. Instead `ctx.sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. @@ -319,6 +421,55 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re } ``` + + + +```cpp +struct MoveAllPlayersTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(MoveAllPlayersTimer, scheduled_id, scheduled_at); +SPACETIMEDB_TABLE(MoveAllPlayersTimer, move_all_players_timer, Private); +FIELD_PrimaryKeyAutoInc(move_all_players_timer, scheduled_id); +SPACETIMEDB_SCHEDULE(move_all_players_timer, 1, move_all_players); + +const int32_t START_PLAYER_SPEED = 10; + +float mass_to_max_move_speed(int32_t mass) { + return 2.0f * START_PLAYER_SPEED / (1.0f + std::sqrt(static_cast(mass) / static_cast(START_PLAYER_MASS))); +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) { + // Get world size from config + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + // Handle player input + for (const Circle& circle : ctx.db[circle]) { + auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id); + if (!circle_entity_opt.has_value()) { + // This can happen if a circle is eaten by another circle + continue; + } + Entity circle_entity = circle_entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + DbVector2 direction = circle.direction * circle.speed; + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + float min_bound = circle_radius; + float max_bound = static_cast(world_size) - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound); + circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound); + ctx.db[entity_entity_id].update(circle_entity); + } + + return Ok(); +} +``` + @@ -350,6 +501,17 @@ ctx.db })?; ``` + + +Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. + +```cpp +ctx.db[move_all_players_timer].insert(MoveAllPlayersTimer{ + 0, + ScheduleAt(TimeDuration::from_millis(50)), +}); +``` + @@ -584,6 +746,92 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re } ``` + + +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. + +Sometimes simple is best! Add the following code to your `lib.cpp` file and make sure to replace the existing `move_all_players` reducer. + +```cpp +const float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +bool is_overlapping(const Entity& a, const Entity& b) { + float dx = a.position.x - b.position.x; + float dy = a.position.y - b.position.y; + float distance_sq = dx * dx + dy * dy; + + float radius_a = mass_to_radius(a.mass); + float radius_b = mass_to_radius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + float max_radius = std::max(radius_a, radius_b); + return distance_sq <= max_radius * max_radius; +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) { + // Get world size from config + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + // Handle player input + for (const Circle& circle : ctx.db[circle]) { + auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id); + if (!circle_entity_opt.has_value()) { + // This can happen if a circle is eaten by another circle + continue; + } + Entity circle_entity = circle_entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + DbVector2 direction = circle.direction * circle.speed; + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + float min_bound = circle_radius; + float max_bound = static_cast(world_size) - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound); + circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound); + + // Check collisions + for (const Entity& entity : ctx.db[entity]) { + if (entity.entity_id == circle_entity.entity_id) { + continue; + } + if (is_overlapping(circle_entity, entity)) { + // Check to see if we're overlapping with food + auto food_opt = ctx.db[food_entity_id].find(entity.entity_id); + if (food_opt.has_value()) { + ctx.db[entity_entity_id].delete_by_key(entity.entity_id); + ctx.db[food_entity_id].delete_by_key(entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + auto other_circle_opt = ctx.db[circle_entity_id].find(entity.entity_id); + if (other_circle_opt.has_value()) { + const Circle& other_circle = other_circle_opt.value(); + if (other_circle.player_id != circle.player_id) { + float mass_ratio = static_cast(entity.mass) / static_cast(circle_entity.mass); + if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) { + ctx.db[entity_entity_id].delete_by_key(entity.entity_id); + ctx.db[circle_entity_id].delete_by_key(entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + } + + ctx.db[entity_entity_id].update(circle_entity); + } + + return Ok(); +} +``` + @@ -648,6 +896,9 @@ To delete your Maincloud database, you can run: `spacetime delete --server mainc learned how we can used scheduled reducers to implement a physics simulation right within your module. + +So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `SPACETIMEDB_CLIENT_CONNECTED` and `SPACETIMEDB_INIT` and how to create scheduled reducers. You learned how we can use scheduled reducers to implement a physics simulation right within your module. + You've also learned how view module logs and connect your client to your database server, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md index bde4659ba26..ba5dffca554 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md @@ -59,6 +59,15 @@ Run the following command to initialize the SpacetimeDB server module project wi spacetime init --lang rust --server-only blackholio ``` +This command creates a new folder named `blackholio` inside of your Unreal project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. + + +Run the following command to initialize the SpacetimeDB server module project with C++ as the language: + +```bash +spacetime init --lang cpp --server-only blackholio +``` + This command creates a new folder named `blackholio` inside of your Unreal project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. @@ -76,6 +85,11 @@ In this section we'll be making some edits to the file `blackholio/spacetimedb/s **Important: Open the `blackholio/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** + +In this section we'll be making some edits to the file `blackholio/spacetimedb/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or Rider. + +**Important: Open the `blackholio/spacetimedb/src/lib.cpp` file and delete its contents. We will be writing it from scratch here.** + First we need to add some imports at the top of the file. Some will remain unused for now. @@ -102,6 +116,16 @@ use std::time::Duration; use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp}; ``` + + +**Copy and paste into lib.cpp:** + +```cpp +#include "spacetimedb.h" + +using namespace SpacetimeDB; +``` + @@ -140,7 +164,7 @@ Let's start by defining the `Config` table. This is a simple table which will st ```rust // We're using this table as a singleton, so in this table -// there only be one element where the `id` is 0. +// there will only be one element where the `id` is 0. #[spacetimedb::table(name = config, public)] pub struct Config { #[primary_key] @@ -155,6 +179,29 @@ The `spacetimedb::table` macro takes two parameters, a `name` which is the name The `#[primary_key]` attribute, specifies that the `id` field should be used as the primary key of the table. + +Each row in a SpacetimeDB table is associated with a `struct` type in C++. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.cpp`. + +```cpp +// We're using this table as a singleton, so in this table +// there will only be one element where the `id` is 0. +struct Config { + int32_t id; + int64_t world_size; +}; +SPACETIMEDB_STRUCT(Config, id, world_size); +SPACETIMEDB_TABLE(Config, config, Public); +FIELD_PrimaryKey(config, id); +``` + +Let's break down this code. This defines a normal C++ `struct` with two fields: `id` and `world_size`. We use the `SPACETIMEDB_STRUCT` macro to register the struct for serialization, listing all field names. Then `SPACETIMEDB_TABLE` registers it as a table with the name `config` and `Public` visibility. + +The `SPACETIMEDB_TABLE` macro takes three parameters: the struct type, the table name (which you'll use to access the table in code), and a visibility modifier (`Public` or `Private`) which determines whether rows are synced to clients. + +The `FIELD_PrimaryKey` macro specifies that the `id` field should be used as the primary key of the table. + :::note @@ -267,6 +314,55 @@ pub struct Food { } ``` + + +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a regular struct registered with `SPACETIMEDB_STRUCT` and one registered with `SPACETIMEDB_TABLE` is that tables actually store data, whereas registering a struct alone just allows you to use it as a column type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of lib.cpp:** + +```cpp +// This allows us to store 2D points in tables. +struct DbVector2 { + float x; + float y; +}; +SPACETIMEDB_STRUCT(DbVector2, x, y); +``` + +Let's create a few tables to represent entities in our game. + +```cpp +struct Entity { + // The `FIELD_PrimaryKeyAutoInc` constraint indicates to SpacetimeDB that + // this value should be determined by SpacetimeDB on insert. + int32_t entity_id; + DbVector2 position; + int32_t mass; +}; +SPACETIMEDB_STRUCT(Entity, entity_id, position, mass); +SPACETIMEDB_TABLE(Entity, entity, Public); +FIELD_PrimaryKeyAutoInc(entity, entity_id); + +struct Circle { + int32_t entity_id; + int32_t player_id; + DbVector2 direction; + float speed; + Timestamp last_split_time; +}; +SPACETIMEDB_STRUCT(Circle, entity_id, player_id, direction, speed, last_split_time); +SPACETIMEDB_TABLE(Circle, circle, Public); +FIELD_PrimaryKey(circle, entity_id); +FIELD_Index(circle, player_id); + +struct Food { + int32_t entity_id; +}; +SPACETIMEDB_STRUCT(Food, entity_id); +SPACETIMEDB_TABLE(Food, food, Public); +FIELD_PrimaryKey(food, entity_id); +``` + @@ -316,6 +412,22 @@ pub struct Player { There's a few new concepts we should touch on. First of all, we are using the `#[unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. + + +```cpp +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name); +SPACETIMEDB_TABLE(Player, player, Public); +FIELD_PrimaryKey(player, identity); +FIELD_UniqueAutoInc(player, player_id); +``` + +There's a few new concepts we should touch on. First of all, we are using the `FIELD_UniqueAutoInc` macro on the `player_id` field. This macro combines two constraints: it ensures that only one row in the player table has a particular `player_id`, and it indicates that this field should get automatically assigned an auto-incremented value. + We also have an `identity` field which uses the `Identity` type. The `Identity` type is an identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. @@ -348,6 +460,16 @@ pub fn debug(ctx: &ReducerContext) -> Result<(), String> { } ``` + + + +```cpp +SPACETIMEDB_REDUCER(debug, ReducerContext ctx) { + LOG_INFO("This reducer was called by " + ctx.sender.to_string()); + return Ok(); +} +``` + @@ -409,6 +531,13 @@ spacetime call --server local blackholio Debug spacetime call --server local blackholio debug ``` + + + +```sh +spacetime call --server local blackholio debug +``` + @@ -472,6 +601,25 @@ The `client_connected` argument to the `spacetimedb::reducer` macro indicates to > - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. + + +Next let's connect our client to our database. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and change `SPACETIMEDB_REDUCER` to `SPACETIMEDB_CLIENT_CONNECTED`. The end result should look like this: + +```cpp +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + LOG_INFO(ctx.sender.to_string() + " just connected."); + return Ok(); +} +``` + +The `SPACETIMEDB_CLIENT_CONNECTED` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `SPACETIMEDB_INIT` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. +> - `SPACETIMEDB_CLIENT_CONNECTED` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `SPACETIMEDB_CLIENT_DISCONNECTED` - Called when a user disconnects from the SpacetimeDB database. + @@ -492,6 +640,9 @@ Let's generate our types for our module. In the `blackholio/spacetimedb` directo Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: + +Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: + ```sh diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md index 192c7d3ebc8..a6658a41559 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md @@ -156,6 +156,81 @@ pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + + +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `SPACETIMEDB_INIT` reducer. SpacetimeDB calls the `SPACETIMEDB_INIT` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. + +Add this new reducer above our `connect` reducer. + +```cpp +// Note the SPACETIMEDB_INIT macro. +// This indicates to SpacetimeDB that it should be called +// once upon database creation. +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{0, 1000}); + return Ok(); +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `insert` function. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the file. + +```cpp +const int32_t FOOD_MASS_MIN = 2; +const int32_t FOOD_MASS_MAX = 4; +const size_t TARGET_FOOD_COUNT = 600; + +float mass_to_radius(int32_t mass) { + return std::sqrt(static_cast(mass)); +} + +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx) { + // Check if there are any players logged in + bool has_players = false; + for (const auto& _ : ctx.db[player]) { + has_players = true; + break; + } + if (!has_players) { + // Are there no logged in players? Skip food spawn. + return Ok(); + } + + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + auto& rng = ctx.rng(); + + // Count current food + uint64_t food_count = 0; + for (const auto& _ : ctx.db[food]) { + food_count++; + } + + while (food_count < TARGET_FOOD_COUNT) { + int32_t food_mass = rng.gen_range(FOOD_MASS_MIN, FOOD_MASS_MAX); + float food_radius = mass_to_radius(food_mass); + float x = rng.gen_range(food_radius, static_cast(world_size) - food_radius); + float y = rng.gen_range(food_radius, static_cast(world_size) - food_radius); + + auto inserted_entity = ctx.db[entity].insert(Entity{0, {x, y}, food_mass}); + ctx.db[food].insert(Food{inserted_entity.entity_id}); + + food_count += 1; + LOG_INFO("Spawned food! " + std::to_string(inserted_entity.entity_id)); + } + + return Ok(); +} +``` + +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + @@ -194,6 +269,22 @@ pub struct SpawnFoodTimer { Note the `scheduled(spawn_food)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below the Player table. + +```cpp +struct SpawnFoodTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(SpawnFoodTimer, scheduled_id, scheduled_at); +SPACETIMEDB_TABLE(SpawnFoodTimer, spawn_food_timer, Private); +FIELD_PrimaryKeyAutoInc(spawn_food_timer, scheduled_id); +SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food); +``` + +Note the `SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food)` call. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. The second parameter `1` is the 0-based column index of the `scheduled_at` field. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + You can create, delete, or change a schedule by inserting, deleting, or updating rows in this table. @@ -221,6 +312,15 @@ pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), St } ``` + + + +```cpp +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx, SpawnFoodTimer _timer) { + // ... +} +``` + @@ -271,6 +371,29 @@ pub fn init(ctx: &ReducerContext) -> Result<(), String> { You can use `ScheduleAt::Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt::Time()` to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. ::: + + +Let's modify our `SPACETIMEDB_INIT` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. + +```cpp +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{ + 0, + 1000, + }); + ctx.db[spawn_food_timer].insert(SpawnFoodTimer{ + 0, + ScheduleAt(TimeDuration::from_millis(500)), + }); + return Ok(); +} +``` + +:::note +You can use `ScheduleAt(TimeDuration::from_millis(...))` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt(Timestamp::from_millis_since_epoch(...))` to specify a specific time at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: + @@ -294,6 +417,13 @@ Let's add a second table to our `Player` struct. Modify the `Player` struct by a #[spacetimedb::table(name = logged_out_player)] ``` + + + +```cpp +SPACETIMEDB_TABLE(Player, logged_out_player, Private); +``` + @@ -332,6 +462,28 @@ pub struct Player { } ``` + + + +```cpp +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name); +SPACETIMEDB_TABLE(Player, player, Public); +SPACETIMEDB_TABLE(Player, logged_out_player, Private); +FIELD_PrimaryKey(player, identity); +FIELD_UniqueAutoInc(player, player_id); +FIELD_PrimaryKey(logged_out_player, identity); +FIELD_UniqueAutoInc(logged_out_player, player_id); +``` + +:::note +In C++, since we're creating two separate tables from the same struct, we need to apply the field constraints (`FIELD_PrimaryKey` and `FIELD_UniqueAutoInc`) to both `player` and `logged_out_player` independently. Each table maintains its own indexes and constraints. +::: + @@ -416,6 +568,42 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { } ``` + + +Next, modify your `connect` reducer and add a new `disconnect` reducer below it: + +```cpp +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + // Check if this player was previously logged out + auto logged_out_player_opt = ctx.db[logged_out_player_identity].find(ctx.sender); + if (logged_out_player_opt.has_value()) { + // Move player from logged_out_player to player table + ctx.db[player].insert(logged_out_player_opt.value()); + ctx.db[logged_out_player_identity].delete_by_key(logged_out_player_opt.value().identity); + } else { + // New player - create and insert into player table + ctx.db[player].insert(Player{ctx.sender, 0, ""}); + } + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + // Find the player in the player table + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player = player_opt.value(); + + // Move player from player to logged_out_player table + ctx.db[logged_out_player].insert(player); + ctx.db[player_identity].delete_by_key(player.identity); + + return Ok(); +} +``` + @@ -553,6 +741,68 @@ fn spawn_circle_at( } ``` +The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. + + +Add the following to the bottom of your file. + +```cpp +const int32_t START_PLAYER_MASS = 15; + +// Helper function to spawn a circle at a specific location +Entity spawn_circle_at(ReducerContext& ctx, int32_t player_id, int32_t mass, DbVector2 position, Timestamp timestamp) { + auto inserted_entity = ctx.db[entity].insert(Entity{0, position, mass}); + + ctx.db[circle].insert(Circle{ + inserted_entity.entity_id, + player_id, + DbVector2{0.0f, 1.0f}, // direction + 0.0f, // speed + timestamp // last_split_time + }); + + return inserted_entity; +} + +// Helper function to spawn a player's initial circle +Entity spawn_player_initial_circle(ReducerContext& ctx, int32_t player_id) { + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + // This shouldn't happen, but handle it gracefully + return Entity{0, {0.0f, 0.0f}, 0}; + } + int64_t world_size = config_opt.value().world_size; + + auto& rng = ctx.rng(); + float player_start_radius = mass_to_radius(START_PLAYER_MASS); + float x = rng.gen_range(player_start_radius, static_cast(world_size) - player_start_radius); + float y = rng.gen_range(player_start_radius, static_cast(world_size) - player_start_radius); + + return spawn_circle_at(ctx, player_id, START_PLAYER_MASS, DbVector2{x, y}, ctx.timestamp); +} + +SPACETIMEDB_REDUCER(enter_game, ReducerContext ctx, std::string name) { + LOG_INFO("Creating player with name " + name); + + // Find the player + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + // Update the player's name + Player updated_player = player_opt.value(); + int32_t player_id = updated_player.player_id; + updated_player.name = name; + ctx.db[player_identity].update(updated_player); + + // Spawn initial circle for the player + spawn_player_initial_circle(ctx, player_id); + + return Ok(); +} +``` + The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. @@ -605,6 +855,36 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { } ``` + + + +```cpp +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + // Find the player in the player table + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player = player_opt.value(); + int32_t player_id = player.player_id; + + // Move player from player to logged_out_player table + ctx.db[logged_out_player].insert(player); + ctx.db[player_identity].delete_by_key(player.identity); + + // Remove any circles from the arena + for (const Circle& circle : ctx.db[circle_player_id]) { + if (circle.player_id == player_id) { + ctx.db[entity_entity_id].delete_by_key(circle.entity_id); + ctx.db[circle_entity_id].delete_by_key(circle.entity_id); + } + } + + return Ok(); +} +``` + diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md index ae8eb620633..8287ea2b07f 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md @@ -220,6 +220,108 @@ pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. + + +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.h` file in the `blackholio/spacetimedb/src` directory and add the following contents. We'll also move the `DbVector2` type from `lib.cpp` into this file. + +```cpp +#pragma once + +#include "spacetimedb.h" +#include + +using namespace SpacetimeDB; + +// This allows us to store 2D points in tables. +struct DbVector2 { + float x; + float y; + + // Helper methods + float sqr_magnitude() const { + return x * x + y * y; + } + + float magnitude() const { + return std::sqrt(x * x + y * y); + } + + DbVector2 normalized() const { + float mag = magnitude(); + if (mag != 0.0f) { + return DbVector2{x / mag, y / mag}; + } + return DbVector2{0.0f, 0.0f}; + } + + // Operator overloads + DbVector2 operator+(const DbVector2& other) const { + return DbVector2{x + other.x, y + other.y}; + } + + DbVector2& operator+=(const DbVector2& other) { + x += other.x; + y += other.y; + return *this; + } + + DbVector2 operator-(const DbVector2& other) const { + return DbVector2{x - other.x, y - other.y}; + } + + DbVector2& operator-=(const DbVector2& other) { + x -= other.x; + y -= other.y; + return *this; + } + + DbVector2 operator*(float scalar) const { + return DbVector2{x * scalar, y * scalar}; + } + + DbVector2 operator/(float scalar) const { + if (scalar != 0.0f) { + return DbVector2{x / scalar, y / scalar}; + } + return DbVector2{0.0f, 0.0f}; + } +}; +SPACETIMEDB_STRUCT(DbVector2, x, y); +``` + +At the very top of `lib.cpp` add the following line to include the `DbVector2` from the `math.h` header, and remove the `DbVector2` struct definition from `lib.cpp`: + +```cpp +#include "spacetimedb.h" +#include "math.h" +// ... +``` + +Next, add the following reducer to the end of your `lib.cpp` file. + +```cpp +SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction) { + // Find the player + auto player_opt = ctx.db[player_identity].find(ctx.sender); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + int32_t player_id = player_opt.value().player_id; + + // Update all circles owned by this player + for (Circle circle : ctx.db[circle_player_id].filter(player_id)) { + circle.direction = direction.normalized(); + circle.speed = std::clamp(direction.magnitude(), 0.0f, 1.0f); + ctx.db[circle_entity_id].update(circle); + } + + return Ok(); +} +``` + +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender` value is not set by the client. Instead `ctx.sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. + Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input. @@ -318,6 +420,55 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re } ``` + + + +```cpp +struct MoveAllPlayersTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(MoveAllPlayersTimer, scheduled_id, scheduled_at); +SPACETIMEDB_TABLE(MoveAllPlayersTimer, move_all_players_timer, Private); +FIELD_PrimaryKeyAutoInc(move_all_players_timer, scheduled_id); +SPACETIMEDB_SCHEDULE(move_all_players_timer, 1, move_all_players); + +const int32_t START_PLAYER_SPEED = 10; + +float mass_to_max_move_speed(int32_t mass) { + return 2.0f * START_PLAYER_SPEED / (1.0f + std::sqrt(static_cast(mass) / static_cast(START_PLAYER_MASS))); +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) { + // Get world size from config + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + // Handle player input + for (const Circle& circle : ctx.db[circle]) { + auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id); + if (!circle_entity_opt.has_value()) { + // This can happen if a circle is eaten by another circle + continue; + } + Entity circle_entity = circle_entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + DbVector2 direction = circle.direction * circle.speed; + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + float min_bound = circle_radius; + float max_bound = static_cast(world_size) - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound); + circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound); + ctx.db[entity_entity_id].update(circle_entity); + } + + return Ok(); +} +``` + @@ -349,6 +500,17 @@ ctx.db })?; ``` + + +Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. + +```cpp +ctx.db[move_all_players_timer].insert(MoveAllPlayersTimer{ + 0, + ScheduleAt(TimeDuration::from_millis(50)), +}); +``` + @@ -647,6 +809,92 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re } ``` + + +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. + +Sometimes simple is best! Add the following code to your `lib.cpp` file and make sure to replace the existing `move_all_players` reducer. + +```cpp +const float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +bool is_overlapping(const Entity& a, const Entity& b) { + float dx = a.position.x - b.position.x; + float dy = a.position.y - b.position.y; + float distance_sq = dx * dx + dy * dy; + + float radius_a = mass_to_radius(a.mass); + float radius_b = mass_to_radius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + float max_radius = std::max(radius_a, radius_b); + return distance_sq <= max_radius * max_radius; +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) { + // Get world size from config + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + // Handle player input + for (const Circle& circle : ctx.db[circle]) { + auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id); + if (!circle_entity_opt.has_value()) { + // This can happen if a circle is eaten by another circle + continue; + } + Entity circle_entity = circle_entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + DbVector2 direction = circle.direction * circle.speed; + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + float min_bound = circle_radius; + float max_bound = static_cast(world_size) - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound); + circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound); + + // Check collisions + for (const Entity& entity : ctx.db[entity]) { + if (entity.entity_id == circle_entity.entity_id) { + continue; + } + if (is_overlapping(circle_entity, entity)) { + // Check to see if we're overlapping with food + auto food_opt = ctx.db[food_entity_id].find(entity.entity_id); + if (food_opt.has_value()) { + ctx.db[entity_entity_id].delete_by_key(entity.entity_id); + ctx.db[food_entity_id].delete_by_key(entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + auto other_circle_opt = ctx.db[circle_entity_id].find(entity.entity_id); + if (other_circle_opt.has_value()) { + const Circle& other_circle = other_circle_opt.value(); + if (other_circle.player_id != circle.player_id) { + float mass_ratio = static_cast(entity.mass) / static_cast(circle_entity.mass); + if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) { + ctx.db[entity_entity_id].delete_by_key(entity.entity_id); + ctx.db[circle_entity_id].delete_by_key(entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + } + + ctx.db[entity_entity_id].update(circle_entity); + } + + return Ok(); +} +``` + @@ -685,6 +933,9 @@ So far you've learned how to configure a new Unreal project to work with Spaceti So far you've learned how to configure a new Unreal project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `client_connected` and `init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. + +So far you've learned how to configure a new Unreal project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `SPACETIMEDB_CLIENT_CONNECTED` and `SPACETIMEDB_INIT` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. + You've also learned how view module logs and connect your client to your database server, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game!