2024-03-13 15:08:37 +00:00
|
|
|
#include "game_root.hpp"
|
2024-03-16 12:02:36 +00:00
|
|
|
#include "game_mode.hpp"
|
2024-05-29 22:49:37 +00:00
|
|
|
#include "godot_macros.hpp"
|
2024-03-16 12:02:36 +00:00
|
|
|
#include "level.hpp"
|
|
|
|
#include "player.hpp"
|
|
|
|
#include "player_input.hpp"
|
2024-05-28 14:28:36 +00:00
|
|
|
#include "player.hpp"
|
2024-03-16 12:02:36 +00:00
|
|
|
#include "spawn_point.hpp"
|
2024-05-29 15:36:17 +00:00
|
|
|
#include "utils/game_state.hpp"
|
2024-03-13 15:08:37 +00:00
|
|
|
#include <cstdint>
|
|
|
|
#include <godot_cpp/classes/global_constants.hpp>
|
2024-03-16 12:02:36 +00:00
|
|
|
#include <godot_cpp/classes/input.hpp>
|
2024-03-13 15:08:37 +00:00
|
|
|
#include <godot_cpp/classes/packed_scene.hpp>
|
|
|
|
#include <godot_cpp/classes/scene_state.hpp>
|
2024-03-16 12:02:36 +00:00
|
|
|
#include <godot_cpp/core/class_db.hpp>
|
|
|
|
#include <godot_cpp/templates/pair.hpp>
|
2024-03-13 15:08:37 +00:00
|
|
|
#include <godot_cpp/variant/string_name.hpp>
|
|
|
|
#include <godot_cpp/variant/utility_functions.hpp>
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
namespace utils {
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::_bind_methods() {
|
|
|
|
#define CLASSNAME GameRoot3D
|
2024-03-13 15:08:37 +00:00
|
|
|
GDFUNCTION(reset_game_mode);
|
2024-05-28 14:28:36 +00:00
|
|
|
GDPROPERTY_HINTED(first_boot_level, gd::Variant::OBJECT, gd::PROPERTY_HINT_RESOURCE_TYPE, "PackedScene");
|
2024-05-29 15:36:17 +00:00
|
|
|
GDPROPERTY_HINTED(game_state_prototype, gd::Variant::OBJECT, gd::PROPERTY_HINT_RESOURCE_TYPE, "GameState");
|
2024-05-28 14:28:36 +00:00
|
|
|
GDSIGNAL("player_connected", gd::PropertyInfo(gd::Variant::OBJECT, "player_input", gd::PROPERTY_HINT_NODE_TYPE, "PlayerInput"));
|
|
|
|
GDSIGNAL("player_disconnected", gd::PropertyInfo(gd::Variant::OBJECT, "player_input", gd::PROPERTY_HINT_NODE_TYPE, "PlayerInput"));
|
|
|
|
GDSIGNAL("player_spawned", gd::PropertyInfo(gd::Variant::OBJECT, "player_info", gd::PROPERTY_HINT_NODE_TYPE, "Node"));
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
GameRoot3D *GameRoot3D::get_singleton() {
|
|
|
|
return GameRoot3D::singleton_instance;
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
bool GameRoot3D::has_singleton() {
|
|
|
|
return GameRoot3D::singleton_instance != nullptr;
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::_enter_tree() { GDGAMEONLY();
|
2024-03-13 15:08:37 +00:00
|
|
|
// TODO: Replace this with detecting input devices
|
2024-05-28 15:06:32 +00:00
|
|
|
this->player_input_connected();
|
2024-03-13 15:08:37 +00:00
|
|
|
this->grab_singleton();
|
2024-10-05 20:16:51 +00:00
|
|
|
this->rng = godot::Ref<godot::RandomNumberGenerator>(memnew(godot::RandomNumberGenerator));
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::_ready() { GDGAMEONLY();
|
|
|
|
this->load_level(this->first_boot_level);
|
2024-05-30 13:42:53 +00:00
|
|
|
// TODO: try load save data from file.
|
|
|
|
this->game_state = this->game_state_prototype->duplicate(true);
|
2024-05-06 09:03:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
void GameRoot3D::_exit_tree() { GDGAMEONLY();
|
2024-03-13 15:08:37 +00:00
|
|
|
this->release_singleton();
|
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::player_input_connected() {
|
2024-03-13 15:08:37 +00:00
|
|
|
PlayerInput *input = memnew(PlayerInput);
|
|
|
|
this->add_child(input);
|
|
|
|
this->players.insert(this->next_player_id++, {input, nullptr});
|
2024-05-28 14:28:36 +00:00
|
|
|
this->emit_signal(gd::StringName("player_connected"), input);
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::remove_player(uint32_t player_id) {
|
2024-03-16 19:56:26 +00:00
|
|
|
if(!this->players.has(player_id))
|
|
|
|
return;
|
2024-03-17 13:49:35 +00:00
|
|
|
// convert player object to node
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::Node *node = this->players.get(player_id).second->to_node();
|
2024-03-17 13:49:35 +00:00
|
|
|
if(node == nullptr) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("IPlayer::to_node failed for player with id '", player_id, "'");
|
2024-03-17 13:49:35 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
node->queue_free();
|
2024-03-16 19:56:26 +00:00
|
|
|
this->players.get(player_id).second = nullptr;
|
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::remove_all_players() {
|
2024-03-17 13:49:35 +00:00
|
|
|
// free all player instances in use
|
2024-05-28 14:28:36 +00:00
|
|
|
for(gd::KeyValue<uint32_t, gd::Pair<PlayerInput*, IPlayer*>> &pair : this->players) {
|
2024-03-17 13:49:35 +00:00
|
|
|
// skip unused player slots
|
|
|
|
if(pair.value.second == nullptr)
|
|
|
|
continue;
|
|
|
|
else
|
|
|
|
this->remove_player(pair.key);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
bool GameRoot3D::initialize_player(IPlayer *player, uint32_t id) {
|
2024-03-16 21:12:52 +00:00
|
|
|
if(!this->players.has(id))
|
|
|
|
return false;
|
2024-03-17 13:49:35 +00:00
|
|
|
// register the player
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::Pair<PlayerInput*, IPlayer*> &found{this->players.get(id)};
|
2024-03-16 12:02:36 +00:00
|
|
|
found.second = player;
|
2024-03-17 13:49:35 +00:00
|
|
|
// set player id
|
|
|
|
player->player_id = id;
|
2024-03-21 23:14:29 +00:00
|
|
|
this->emit_signal("player_spawned", player->to_node());
|
2024-03-17 13:49:35 +00:00
|
|
|
this->add_child(player->to_node());
|
2024-03-16 12:02:36 +00:00
|
|
|
player->setup_player_input(found.first);
|
2024-03-13 15:08:37 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::reset_game_mode() {
|
2024-05-30 13:07:17 +00:00
|
|
|
this->set_game_mode(nullptr);
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
Level3D *GameRoot3D::load_level(gd::Ref<gd::PackedScene> level) {
|
|
|
|
return this->load_level_at(level, gd::Transform3D());
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
Level3D *GameRoot3D::load_level_at(gd::Ref<gd::PackedScene> level, gd::Transform3D at) {
|
2024-03-13 15:08:37 +00:00
|
|
|
if(!GameRoot3D::is_valid_level(level)) {
|
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
Level3D *instance = Object::cast_to<Level3D>(level->instantiate());
|
|
|
|
if(instance == nullptr) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Unexpected failure to instantiate level scene '", level->get_path(), "'.");
|
2024-03-13 15:08:37 +00:00
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
this->levels.insert(level->get_path(), instance);
|
2024-05-21 13:57:55 +00:00
|
|
|
instance->connect("tree_exited", callable_mp(this, &GameRoot3D::level_unloaded).bind(level->get_path()));
|
2024-03-21 23:14:29 +00:00
|
|
|
// store and add to tree at desired transform
|
2024-03-13 15:08:37 +00:00
|
|
|
// if this is the first level containing a game mode currently active use it's gamemode as a prototype
|
2024-05-30 13:07:17 +00:00
|
|
|
gd::Ref<gd::PackedScene> game_mode_prototype{instance->get_game_mode_prototype()};
|
2024-05-31 19:25:58 +00:00
|
|
|
bool const switch_game_mode{!this->game_mode || this->game_mode->get_scene_file_path() != game_mode_prototype->get_path()};
|
2024-04-16 09:45:46 +00:00
|
|
|
if(switch_game_mode) {
|
2024-03-16 12:02:36 +00:00
|
|
|
this->set_game_mode(instance->get_game_mode_prototype());
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
2024-03-21 23:14:29 +00:00
|
|
|
this->add_child(instance);
|
|
|
|
instance->set_global_transform(at);
|
2024-05-30 13:07:17 +00:00
|
|
|
// set initial player positions if new player were spawned due to game mode switch
|
|
|
|
if(switch_game_mode && this->game_mode != nullptr) {
|
2024-05-28 14:28:36 +00:00
|
|
|
for(gd::KeyValue<uint32_t, gd::Pair<PlayerInput *, IPlayer *>> const &kvp : this->players) {
|
2024-04-16 09:45:46 +00:00
|
|
|
this->place_player_at_spawnpoint(kvp.value.second);
|
|
|
|
}
|
|
|
|
}
|
2024-03-13 15:08:37 +00:00
|
|
|
return instance;
|
|
|
|
}
|
|
|
|
|
2024-05-21 13:57:55 +00:00
|
|
|
void GameRoot3D::unload_all_levels() {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::HashMap<gd::StringName, Level3D*> levels = this->get_levels();
|
|
|
|
for(gd::KeyValue<gd::StringName, Level3D*> &kvp : levels)
|
2024-05-21 13:57:55 +00:00
|
|
|
kvp.value->call_deferred("queue_free");
|
|
|
|
this->get_levels().clear();
|
|
|
|
this->reset_game_mode();
|
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
void GameRoot3D::replace_levels(gd::Ref<gd::PackedScene> scene) {
|
2024-05-21 13:57:55 +00:00
|
|
|
this->unload_all_levels();
|
|
|
|
this->load_level(scene);
|
|
|
|
}
|
|
|
|
|
2024-03-13 15:08:37 +00:00
|
|
|
void GameRoot3D::register_spawn_point(SpawnPoint3D *spawn_point) {
|
|
|
|
if(this->spawn_points.has(spawn_point)) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Duplicate attempt to register spawnpoint '", spawn_point->get_path(), "'");
|
2024-03-13 15:08:37 +00:00
|
|
|
return;
|
|
|
|
}
|
2024-05-23 13:55:47 +00:00
|
|
|
if(!this->spawn_points.has(spawn_point))
|
|
|
|
this->spawn_points.push_back(spawn_point);
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
void GameRoot3D::unregister_spawn_point(SpawnPoint3D *spawn_point) {
|
|
|
|
if(!this->spawn_points.has(spawn_point)) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Attempt to unregister spawnpoint '", spawn_point->get_path(), "', which is not registered.");
|
2024-03-13 15:08:37 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
this->spawn_points.erase(spawn_point);
|
|
|
|
}
|
|
|
|
|
2024-04-16 09:45:46 +00:00
|
|
|
void GameRoot3D::place_player_at_spawnpoint(IPlayer *player) {
|
|
|
|
if(this->spawn_points.is_empty()) return;
|
2024-06-11 07:09:11 +00:00
|
|
|
SpawnPoint3D *spawn_point = this->spawn_points[rng->randi() % this->spawn_points.size()];
|
2024-04-16 09:45:46 +00:00
|
|
|
player->spawn_at_position(spawn_point->get_global_transform());
|
|
|
|
}
|
|
|
|
|
2024-05-21 09:56:41 +00:00
|
|
|
void GameRoot3D::player_despawned(uint32_t id) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::Pair<PlayerInput*, IPlayer*> &pair = this->players.get(id);
|
2024-05-21 09:56:41 +00:00
|
|
|
pair.second = nullptr;
|
|
|
|
pair.first->clear_listeners();
|
|
|
|
}
|
|
|
|
|
2024-05-30 13:07:17 +00:00
|
|
|
void GameRoot3D::set_game_mode(gd::Ref<gd::PackedScene> prototype) {
|
2024-05-06 09:03:00 +00:00
|
|
|
this->remove_all_players();
|
2024-05-30 13:07:17 +00:00
|
|
|
if(this->game_mode != nullptr)
|
|
|
|
this->game_mode->queue_free();
|
|
|
|
if(prototype.is_null() || !prototype.is_valid())
|
|
|
|
return; // allow "unsetting" the gamemode by passing an invalid gamemode
|
|
|
|
// Detect passing of valid scene that is an invalid game mode
|
|
|
|
if(!gd::ClassDB::is_parent_class(prototype->get_state()->get_node_type(0), "GameMode")) {
|
|
|
|
gd::UtilityFunctions::push_error("Attempted to load non-gamemode scene as gamemode");
|
2024-05-06 09:03:00 +00:00
|
|
|
return;
|
|
|
|
}
|
2024-05-30 13:07:17 +00:00
|
|
|
// instantiate the game mode as a child
|
|
|
|
this->game_mode = Object::cast_to<GameMode>(prototype->instantiate());
|
|
|
|
this->add_child(game_mode);
|
|
|
|
// instantiate players
|
2024-05-21 09:57:21 +00:00
|
|
|
if(this->game_mode->get_player_scene().is_valid()) {
|
|
|
|
uint32_t new_player_id = this->find_empty_player_slot();
|
|
|
|
do {
|
|
|
|
IPlayer *player = this->spawn_player(new_player_id);
|
|
|
|
if(player != nullptr)
|
|
|
|
this->initialize_player(player, new_player_id);
|
|
|
|
new_player_id = this->find_empty_player_slot();
|
|
|
|
} while(new_player_id != 0);
|
|
|
|
}
|
2024-05-06 09:03:00 +00:00
|
|
|
}
|
|
|
|
|
2024-05-29 15:36:17 +00:00
|
|
|
GameMode *GameRoot3D::get_game_mode() const {
|
2024-05-30 13:07:17 +00:00
|
|
|
return this->game_mode;
|
2024-05-06 09:03:00 +00:00
|
|
|
}
|
|
|
|
|
2024-05-29 15:36:17 +00:00
|
|
|
GameState *GameRoot3D::get_game_state() const {
|
|
|
|
return this->game_state.ptr();
|
|
|
|
}
|
|
|
|
|
|
|
|
gd::HashMap<gd::StringName, Level3D *> &GameRoot3D::get_levels() {
|
|
|
|
return this->levels;
|
|
|
|
}
|
|
|
|
|
|
|
|
IPlayer *GameRoot3D::get_player(uint32_t id) {
|
|
|
|
return this->players[id].second;
|
|
|
|
}
|
|
|
|
|
|
|
|
gd::Vector<IPlayer*> GameRoot3D::get_players() {
|
|
|
|
gd::Vector<IPlayer*> players{};
|
|
|
|
for(gd::KeyValue<uint32_t, gd::Pair<PlayerInput*, IPlayer*>> pair : this->players) {
|
|
|
|
players.push_back(pair.value.second);
|
|
|
|
}
|
|
|
|
return players;
|
2024-05-06 09:03:00 +00:00
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
void GameRoot3D::set_first_boot_level(gd::Ref<gd::PackedScene> level) {
|
2024-03-13 15:08:37 +00:00
|
|
|
if(level.is_null() || !level.is_valid()) {
|
|
|
|
this->first_boot_level.unref();
|
|
|
|
return;
|
|
|
|
}
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::StringName const root_type = level->get_state()->get_node_type(0);
|
|
|
|
if(!gd::ClassDB::is_parent_class(root_type, "Level3D")) {
|
|
|
|
gd::UtilityFunctions::push_error("First boot level cannot be of type '", root_type, "'. First boot level has to inherit from Level3D");
|
2024-03-13 15:08:37 +00:00
|
|
|
this->first_boot_level.unref();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this->first_boot_level = level;
|
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::Ref<gd::PackedScene> GameRoot3D::get_first_boot_level() const {
|
2024-03-13 15:08:37 +00:00
|
|
|
return this->first_boot_level;
|
|
|
|
}
|
|
|
|
|
2024-05-29 15:36:17 +00:00
|
|
|
void GameRoot3D::set_game_state_prototype(gd::Ref<GameState> game_state) {
|
|
|
|
this->game_state_prototype = game_state;
|
2024-05-23 13:55:47 +00:00
|
|
|
}
|
|
|
|
|
2024-05-29 15:36:17 +00:00
|
|
|
gd::Ref<GameState> GameRoot3D::get_game_state_prototype() const {
|
|
|
|
return this->game_state_prototype;
|
2024-05-23 13:55:47 +00:00
|
|
|
}
|
|
|
|
|
2024-05-30 13:42:53 +00:00
|
|
|
gd::RandomNumberGenerator &GameRoot3D::get_rng() {
|
2024-06-11 07:09:11 +00:00
|
|
|
return *this->rng.ptr();
|
2024-05-30 13:42:53 +00:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:03:00 +00:00
|
|
|
void GameRoot3D::grab_singleton() {
|
|
|
|
if(GameRoot3D::has_singleton()) {
|
|
|
|
this->set_process_mode(PROCESS_MODE_DISABLED);
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("More than one GameRoot instance active");
|
2024-05-06 09:03:00 +00:00
|
|
|
} else {
|
|
|
|
GameRoot3D::singleton_instance = this;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void GameRoot3D::release_singleton() {
|
|
|
|
if(GameRoot3D::singleton_instance == this) {
|
|
|
|
GameRoot3D::singleton_instance = nullptr;
|
|
|
|
} else {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("GameRoot instance attempted to release singleton while it is not the singleton instance");
|
2024-05-06 09:03:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
uint32_t GameRoot3D::find_empty_player_slot() const {
|
2024-05-28 14:28:36 +00:00
|
|
|
for(gd::KeyValue<uint32_t, gd::Pair<PlayerInput*, IPlayer*>> const &kvp : this->players) {
|
2024-05-06 09:03:00 +00:00
|
|
|
if(kvp.value.second == nullptr) {
|
|
|
|
return kvp.key;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
2024-03-16 12:02:36 +00:00
|
|
|
IPlayer *GameRoot3D::spawn_player(uint32_t id) {
|
|
|
|
if(id == 0) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Failed to find any valid player slot when spawning player");
|
2024-03-16 12:02:36 +00:00
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
Node *player_node = this->game_mode->get_player_scene()->instantiate();
|
|
|
|
if(player_node == nullptr) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Failed to instantiate player scene '", this->game_mode->get_player_scene()->get_path(), "'");
|
2024-03-16 12:02:36 +00:00
|
|
|
return nullptr;
|
|
|
|
}
|
|
|
|
IPlayer *player = dynamic_cast<IPlayer*>(player_node);
|
|
|
|
if(player == nullptr) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Player scene does not implement required IPlayer interface");
|
2024-03-16 12:02:36 +00:00
|
|
|
player_node->queue_free();
|
|
|
|
return nullptr;
|
|
|
|
}
|
2024-05-21 09:56:41 +00:00
|
|
|
player_node->connect("tree_exited", callable_mp(this, &GameRoot3D::player_despawned).bind(id));
|
2024-03-16 12:02:36 +00:00
|
|
|
return player;
|
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
void GameRoot3D::level_unloaded(gd::StringName scene_path) {
|
2024-05-21 13:57:55 +00:00
|
|
|
this->levels.erase(scene_path);
|
|
|
|
}
|
|
|
|
|
2024-05-28 14:28:36 +00:00
|
|
|
bool GameRoot3D::is_valid_level(gd::Ref<gd::PackedScene> &level) {
|
2024-03-13 15:08:37 +00:00
|
|
|
if(level.is_null() || !level.is_valid() || !level->can_instantiate()) {
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::UtilityFunctions::push_error("Can't load level from invalid packed scene");
|
2024-03-13 15:08:37 +00:00
|
|
|
return false;
|
|
|
|
}
|
2024-05-28 14:28:36 +00:00
|
|
|
gd::StringName const root_type = level->get_state()->get_node_type(0);
|
|
|
|
if(!gd::ClassDB::is_parent_class(root_type, "Level3D")) {
|
|
|
|
gd::UtilityFunctions::push_error("Can't load level with root type '", root_type, "'. Root node has to be of type Level3D");
|
2024-03-13 15:08:37 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
2024-05-06 09:03:00 +00:00
|
|
|
|
|
|
|
GameRoot3D *GameRoot3D::singleton_instance{nullptr};
|
2024-03-13 15:08:37 +00:00
|
|
|
}
|