mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-07-22 16:19:19 +02:00
network improvements
This commit is contained in:
72
EvLaunch.cpp
72
EvLaunch.cpp
@@ -31,22 +31,23 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
// encodes expected key in a short, where low byte represents the actual key,
|
// encodes expected key in a short, where low byte represents the actual key,
|
||||||
// and the high byte holds modifiers: 0x1 = shift, 0x2 = ctrl, 0x4 = alt
|
// and the high byte holds modifiers: 0x1 = shift, 0x2 = ctrl, 0x4 = alt
|
||||||
int vk_to_glfw_key( int const Keycode ) {
|
int vk_to_glfw_key( int const Keycode ) {
|
||||||
|
char modifier = 0;
|
||||||
|
char key = 0;
|
||||||
|
|
||||||
#ifdef _WIN32
|
if (Keycode < 'A') {
|
||||||
auto const code = VkKeyScan( Keycode );
|
key = Keycode;
|
||||||
#else
|
} else if (Keycode <= 'Z') {
|
||||||
auto const code = (short int)Keycode;
|
key = Keycode;
|
||||||
#endif
|
modifier = GLFW_MOD_SHIFT;
|
||||||
char key = code & 0xff;
|
} else if (Keycode < 'a') {
|
||||||
char shiftstate = ( code & 0xff00 ) >> 8;
|
key = Keycode;
|
||||||
|
} else if (Keycode <= 'z') {
|
||||||
|
key = Keycode - 32;
|
||||||
|
} else {
|
||||||
|
ErrorLog("unknown key: " + std::to_string(Keycode));
|
||||||
|
}
|
||||||
|
|
||||||
if( (key >= 'A') && (key <= 'Z') ) {
|
return ((int)modifier << 8) | key;
|
||||||
key = GLFW_KEY_A + key - 'A';
|
|
||||||
}
|
|
||||||
else if( ( key >= '0' ) && ( key <= '9' ) ) {
|
|
||||||
key = GLFW_KEY_0 + key - '0';
|
|
||||||
}
|
|
||||||
return key + ( shiftstate << 8 );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool TEventLauncher::Load(cParser *parser)
|
bool TEventLauncher::Load(cParser *parser)
|
||||||
@@ -137,23 +138,25 @@ bool TEventLauncher::Load(cParser *parser)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool TEventLauncher::check_activation_key() {
|
||||||
|
|
||||||
|
char key = iKey & 0xff;
|
||||||
|
|
||||||
|
bool result = Console::Pressed(key);
|
||||||
|
|
||||||
|
char modifier = iKey >> 8;
|
||||||
|
if (modifier & GLFW_MOD_SHIFT)
|
||||||
|
result |= Global.shiftState;
|
||||||
|
if (modifier & GLFW_MOD_CONTROL)
|
||||||
|
result |= Global.ctrlState;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
bool TEventLauncher::check_activation() {
|
bool TEventLauncher::check_activation() {
|
||||||
|
|
||||||
auto bCond { false };
|
auto bCond { false };
|
||||||
|
|
||||||
if( iKey != 0 ) {
|
|
||||||
if( iKey > 255 ) {
|
|
||||||
// key and modifier
|
|
||||||
auto const modifier = ( iKey & 0xff00 ) >> 8;
|
|
||||||
bCond = ( Console::Pressed( iKey & 0xff ) )
|
|
||||||
&& ( ( modifier & 1 ) ? Global.shiftState : true )
|
|
||||||
&& ( ( modifier & 2 ) ? Global.ctrlState : true );
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// just key
|
|
||||||
bCond = ( Console::Pressed( iKey & 0xff ) ); // czy klawisz wciśnięty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if( DeltaTime > 0 ) {
|
if( DeltaTime > 0 ) {
|
||||||
if( UpdatedTime > DeltaTime ) {
|
if( UpdatedTime > DeltaTime ) {
|
||||||
UpdatedTime = 0; // naliczanie od nowa
|
UpdatedTime = 0; // naliczanie od nowa
|
||||||
@@ -240,14 +243,13 @@ TEventLauncher::export_as_text_( std::ostream &Output ) const {
|
|||||||
<< ( dRadius > 0 ? std::sqrt( dRadius ) : dRadius ) << ' ';
|
<< ( dRadius > 0 ? std::sqrt( dRadius ) : dRadius ) << ' ';
|
||||||
// activation key
|
// activation key
|
||||||
if( iKey != 0 ) {
|
if( iKey != 0 ) {
|
||||||
auto const key { iKey & 0xff };
|
auto key { iKey & 0xff };
|
||||||
auto const modifier { ( iKey & 0xff00 ) >> 8 };
|
auto const modifier { iKey >> 8 };
|
||||||
if( ( key >= GLFW_KEY_A ) && ( key <= GLFW_KEY_Z ) ) {
|
|
||||||
Output << static_cast<char>(( 'A' + key - GLFW_KEY_A + ( ( modifier & 1 ) == 0 ? 32 : 0 ) )) << ' ';
|
if (key >= 'A' && key <= 'Z' && !(modifier & GLFW_MOD_SHIFT))
|
||||||
}
|
key += 32;
|
||||||
else if( ( key >= GLFW_KEY_0 ) && ( key <= GLFW_KEY_9 ) ) {
|
|
||||||
Output << static_cast<char>(( '0' + key - GLFW_KEY_0 )) << ' ';
|
Output << (char)key;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Output << "none ";
|
Output << "none ";
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public:
|
|||||||
|
|
||||||
// methods
|
// methods
|
||||||
bool Load( cParser *parser );
|
bool Load( cParser *parser );
|
||||||
|
bool check_activation_key();
|
||||||
bool check_activation();
|
bool check_activation();
|
||||||
// checks conditions associated with the event. returns: true if the conditions are met
|
// checks conditions associated with the event. returns: true if the conditions are met
|
||||||
bool check_conditions();
|
bool check_conditions();
|
||||||
|
|||||||
19
Event.cpp
19
Event.cpp
@@ -1967,14 +1967,19 @@ event_manager::update() {
|
|||||||
CheckQuery();
|
CheckQuery();
|
||||||
// test list of global events for possible new additions to the queue
|
// test list of global events for possible new additions to the queue
|
||||||
for( auto *launcher : m_launcherqueue ) {
|
for( auto *launcher : m_launcherqueue ) {
|
||||||
|
if (launcher->check_conditions() && launcher->Event1) {
|
||||||
|
// NOTE: we're presuming global events aren't going to use event2
|
||||||
|
|
||||||
if( true == ( launcher->check_activation() && launcher->check_conditions() ) ) {
|
if (launcher->check_activation()) {
|
||||||
// NOTE: we're presuming global events aren't going to use event2
|
WriteLog( "Eventlauncher: " + launcher->name() );
|
||||||
WriteLog( "Eventlauncher " + launcher->name() );
|
AddToQuery( launcher->Event1, nullptr );
|
||||||
if( launcher->Event1 ) {
|
}
|
||||||
AddToQuery( launcher->Event1, nullptr );
|
|
||||||
}
|
if (launcher->check_activation_key()) {
|
||||||
}
|
WriteLog( "Eventlauncher: " + launcher->name() );
|
||||||
|
m_relay.post(user_command::queueevent, (double)simulation::Events.GetEventId(launcher->Event1), 0.0, GLFW_PRESS, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
Event.h
2
Event.h
@@ -15,6 +15,7 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
#include "EvLaunch.h"
|
#include "EvLaunch.h"
|
||||||
#include "Logs.h"
|
#include "Logs.h"
|
||||||
#include "lua.h"
|
#include "lua.h"
|
||||||
|
#include "command.h"
|
||||||
|
|
||||||
// common event interface
|
// common event interface
|
||||||
class basic_event {
|
class basic_event {
|
||||||
@@ -632,6 +633,7 @@ private:
|
|||||||
event_map m_eventmap;
|
event_map m_eventmap;
|
||||||
basic_table<TEventLauncher> m_launchers;
|
basic_table<TEventLauncher> m_launchers;
|
||||||
eventlauncher_sequence m_launcherqueue;
|
eventlauncher_sequence m_launcherqueue;
|
||||||
|
command_relay m_relay;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3
Names.h
3
Names.h
@@ -19,7 +19,8 @@ public:
|
|||||||
// destructor
|
// destructor
|
||||||
~basic_table() {
|
~basic_table() {
|
||||||
for( auto *item : m_items ) {
|
for( auto *item : m_items ) {
|
||||||
delete item; } }
|
if (item)
|
||||||
|
delete item; } }
|
||||||
// methods
|
// methods
|
||||||
// adds provided item to the collection. returns: true if there's no duplicate with the same name, false otherwise
|
// adds provided item to the collection. returns: true if there's no duplicate with the same name, false otherwise
|
||||||
bool
|
bool
|
||||||
|
|||||||
90
Train.cpp
90
Train.cpp
@@ -6918,51 +6918,90 @@ TTrain::MoveToVehicle(TDynamicObject *target) {
|
|||||||
ErrorLog("MoveToVehicle");
|
ErrorLog("MoveToVehicle");
|
||||||
// > Ra: to nie może być tak robione, to zbytnia proteza jest
|
// > Ra: to nie może być tak robione, to zbytnia proteza jest
|
||||||
// indeed, too much hacks...
|
// indeed, too much hacks...
|
||||||
|
// TODO: cleanup
|
||||||
|
|
||||||
simulation::Trains.detach(Dynamic()->name());
|
TTrain *target_train = simulation::Trains.find(target->name());
|
||||||
|
if (target_train) {
|
||||||
|
// let's try to destroy this TTrain and move to already existing one
|
||||||
|
|
||||||
if( Dynamic()->Mechanik ) {
|
if (!Dynamic()->Mechanik || !Dynamic()->Mechanik->AIControllFlag) {
|
||||||
// AI może sobie samo pójść
|
|
||||||
if( false == Dynamic()->Mechanik->AIControllFlag ) {
|
|
||||||
// tylko jeśli ręcznie prowadzony
|
// tylko jeśli ręcznie prowadzony
|
||||||
// jeśli prowadzi AI, to mu nie robimy dywersji!
|
// jeśli prowadzi AI, to mu nie robimy dywersji!
|
||||||
|
|
||||||
Occupied()->CabDeactivisation();
|
Occupied()->CabDeactivisation();
|
||||||
Occupied()->ActiveCab = 0;
|
Occupied()->ActiveCab = 0;
|
||||||
Occupied()->BrakeLevelSet( Occupied()->Handle->GetPos( bh_NP ) ); //rozwala sterowanie hamulcem GF 04-2016
|
Occupied()->BrakeLevelSet(Occupied()->Handle->GetPos(bh_NP)); //rozwala sterowanie hamulcem GF 04-2016
|
||||||
Dynamic()->MechInside = false;
|
Dynamic()->MechInside = false;
|
||||||
Dynamic()->Controller = AIdriver;
|
Dynamic()->Controller = AIdriver;
|
||||||
|
|
||||||
|
Dynamic()->bDisplayCab = false;
|
||||||
|
Dynamic()->ABuSetModelShake( {} );
|
||||||
|
|
||||||
|
Dynamic()->Mechanik->MoveTo(target);
|
||||||
|
|
||||||
|
target_train->Occupied()->LimPipePress = target_train->Occupied()->PipePress;
|
||||||
|
target_train->Occupied()->CabActivisation(); // załączenie rozrządu (wirtualne kabiny)
|
||||||
|
target_train->Dynamic()->MechInside = true;
|
||||||
|
target_train->Dynamic()->Controller = Humandriver;
|
||||||
|
} else {
|
||||||
|
target_train->Dynamic()->bDisplayCab = false;
|
||||||
|
target_train->Dynamic()->ABuSetModelShake( {} );
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Dynamic()->bDisplayCab = false;
|
target_train->Dynamic()->bDisplayCab = true;
|
||||||
Dynamic()->ABuSetModelShake( {} );
|
target_train->Dynamic()->ABuSetModelShake( {} ); // zerowanie przesunięcia przed powrotem?
|
||||||
|
|
||||||
if( Dynamic()->Mechanik ) // AI może sobie samo pójść
|
// potentially move player
|
||||||
if( false == Dynamic()->Mechanik->AIControllFlag ) {
|
if (simulation::Train == this) {
|
||||||
|
simulation::Train = target_train;
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete this TTrain
|
||||||
|
pending_delete = true;
|
||||||
|
} else {
|
||||||
|
// move this TTrain to other dynamic
|
||||||
|
|
||||||
|
// remove TTrain from global list, we're going to change dynamic anyway
|
||||||
|
simulation::Trains.detach(Dynamic()->name());
|
||||||
|
|
||||||
|
if (!Dynamic()->Mechanik || !Dynamic()->Mechanik->AIControllFlag) {
|
||||||
// tylko jeśli ręcznie prowadzony
|
// tylko jeśli ręcznie prowadzony
|
||||||
// przsunięcie obiektu zarządzającego
|
// jeśli prowadzi AI, to mu nie robimy dywersji!
|
||||||
Dynamic()->Mechanik->MoveTo( target );
|
|
||||||
}
|
|
||||||
|
|
||||||
DynamicSet( target );
|
Occupied()->CabDeactivisation();
|
||||||
|
Occupied()->ActiveCab = 0;
|
||||||
|
Occupied()->BrakeLevelSet(Occupied()->Handle->GetPos(bh_NP)); //rozwala sterowanie hamulcem GF 04-2016
|
||||||
|
Dynamic()->MechInside = false;
|
||||||
|
Dynamic()->Controller = AIdriver;
|
||||||
|
|
||||||
|
Dynamic()->bDisplayCab = false;
|
||||||
|
Dynamic()->ABuSetModelShake( {} );
|
||||||
|
|
||||||
|
Dynamic()->Mechanik->MoveTo(target);
|
||||||
|
|
||||||
|
DynamicSet(target);
|
||||||
|
|
||||||
if( Dynamic()->Mechanik ) // AI może sobie samo pójść
|
|
||||||
if( false == Dynamic()->Mechanik->AIControllFlag ) // tylko jeśli ręcznie prowadzony
|
|
||||||
{
|
|
||||||
Occupied()->LimPipePress = Occupied()->PipePress;
|
Occupied()->LimPipePress = Occupied()->PipePress;
|
||||||
Occupied()->CabActivisation(); // załączenie rozrządu (wirtualne kabiny)
|
Occupied()->CabActivisation(); // załączenie rozrządu (wirtualne kabiny)
|
||||||
Dynamic()->MechInside = true;
|
Dynamic()->MechInside = true;
|
||||||
Dynamic()->Controller = Humandriver;
|
Dynamic()->Controller = Humandriver;
|
||||||
|
} else {
|
||||||
|
Dynamic()->bDisplayCab = false;
|
||||||
|
Dynamic()->ABuSetModelShake( {} );
|
||||||
|
|
||||||
|
DynamicSet(target);
|
||||||
}
|
}
|
||||||
InitializeCab(
|
|
||||||
Occupied()->CabNo,
|
InitializeCab(
|
||||||
Dynamic()->asBaseDir + Occupied()->TypeName + ".mmd" );
|
Occupied()->CabNo,
|
||||||
if( false == FreeFlyModeFlag ) {
|
Dynamic()->asBaseDir + Occupied()->TypeName + ".mmd" );
|
||||||
|
|
||||||
Dynamic()->bDisplayCab = true;
|
Dynamic()->bDisplayCab = true;
|
||||||
Dynamic()->ABuSetModelShake( {} ); // zerowanie przesunięcia przed powrotem?
|
Dynamic()->ABuSetModelShake( {} ); // zerowanie przesunięcia przed powrotem?
|
||||||
}
|
|
||||||
|
|
||||||
simulation::Trains.insert(this, Dynamic()->name());
|
// add it back with updated dynamic name
|
||||||
|
simulation::Trains.insert(this, Dynamic()->name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checks whether specified point is within boundaries of the active cab
|
// checks whether specified point is within boundaries of the active cab
|
||||||
@@ -8140,5 +8179,10 @@ void train_table::update(double dt)
|
|||||||
if (!train)
|
if (!train)
|
||||||
continue;
|
continue;
|
||||||
train->Update(dt);
|
train->Update(dt);
|
||||||
|
if (train->pending_delete) {
|
||||||
|
purge(train->Dynamic()->name());
|
||||||
|
if (simulation::Train == train)
|
||||||
|
simulation::Train = nullptr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
Train.h
1
Train.h
@@ -690,6 +690,7 @@ private:
|
|||||||
void set_localbrake(float);
|
void set_localbrake(float);
|
||||||
|
|
||||||
uint16_t id();
|
uint16_t id();
|
||||||
|
bool pending_delete = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
class train_table : public basic_table<TTrain> {
|
class train_table : public basic_table<TTrain> {
|
||||||
|
|||||||
@@ -716,17 +716,18 @@ eu07_application::init_modes() {
|
|||||||
bool eu07_application::init_network() {
|
bool eu07_application::init_network() {
|
||||||
if (Global.network_conf.is_server || Global.network_conf.is_client) {
|
if (Global.network_conf.is_server || Global.network_conf.is_client) {
|
||||||
m_network.emplace();
|
m_network.emplace();
|
||||||
if (Global.network_conf.is_server) {
|
}
|
||||||
m_network->create_server(Global.network_conf.server_host, Global.network_conf.server_port);
|
|
||||||
}
|
if (Global.network_conf.is_server) {
|
||||||
if (Global.network_conf.is_client) {
|
m_network->create_server(Global.network_conf.server_host, Global.network_conf.server_port);
|
||||||
m_network->connect(Global.network_conf.client_host, Global.network_conf.client_port);
|
}
|
||||||
}
|
if (Global.network_conf.is_client) {
|
||||||
else {
|
m_network->connect(Global.network_conf.client_host, Global.network_conf.client_port);
|
||||||
Global.random_seed = std::random_device{}();
|
}
|
||||||
Global.random_engine.seed(Global.random_seed);
|
else {
|
||||||
Global.ready_to_load = true;
|
Global.random_seed = std::random_device{}();
|
||||||
}
|
Global.random_engine.seed(Global.random_seed);
|
||||||
|
Global.ready_to_load = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
36
scene.cpp
36
scene.cpp
@@ -35,7 +35,7 @@ basic_cell::on_click( TAnimModel const *Instance ) {
|
|||||||
if( ( launcher->name() == Instance->name() )
|
if( ( launcher->name() == Instance->name() )
|
||||||
&& ( glm::length2( launcher->location() - Instance->location() ) < launcher->dRadius )
|
&& ( glm::length2( launcher->location() - Instance->location() ) < launcher->dRadius )
|
||||||
&& ( true == launcher->check_conditions() ) ) {
|
&& ( true == launcher->check_conditions() ) ) {
|
||||||
launch_event( launcher );
|
launch_event( launcher, true );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,11 +123,13 @@ basic_cell::update_events() {
|
|||||||
|
|
||||||
// event launchers
|
// event launchers
|
||||||
for( auto *launcher : m_eventlaunchers ) {
|
for( auto *launcher : m_eventlaunchers ) {
|
||||||
if( ( true == ( launcher->check_activation() && launcher->check_conditions() ) )
|
if (launcher->check_conditions()
|
||||||
&& ( SquareMagnitude( launcher->location() - Global.pCamera.Pos ) < launcher->dRadius ) ) {
|
&& SquareMagnitude( launcher->location() - Global.pCamera.Pos ) < launcher->dRadius) {
|
||||||
|
if (launcher->check_activation())
|
||||||
launch_event( launcher );
|
launch_event(launcher, false);
|
||||||
}
|
if (launcher->check_activation_key())
|
||||||
|
launch_event(launcher, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,16 +618,18 @@ void basic_cell::create_map_geometry(std::vector<gfx::basic_vertex> &Bank)
|
|||||||
|
|
||||||
// executes event assigned to specified launcher
|
// executes event assigned to specified launcher
|
||||||
void
|
void
|
||||||
basic_cell::launch_event( TEventLauncher *Launcher ) {
|
basic_cell::launch_event( TEventLauncher *Launcher, bool local_only ) {
|
||||||
|
WriteLog( "Eventlauncher: " + Launcher->name() );
|
||||||
WriteLog( "Eventlauncher " + Launcher->name() );
|
if (!local_only) {
|
||||||
if( ( true == Global.shiftState )
|
if( Launcher->Event1 ) {
|
||||||
&& ( Launcher->Event2 != nullptr ) ) {
|
simulation::Events.AddToQuery( Launcher->Event1, nullptr );
|
||||||
simulation::Events.AddToQuery( Launcher->Event2, nullptr );
|
}
|
||||||
}
|
} else {
|
||||||
else if( Launcher->Event1 ) {
|
if (Global.shiftState && Launcher->Event2 != nullptr)
|
||||||
simulation::Events.AddToQuery( Launcher->Event1, nullptr );
|
m_relay.post(user_command::queueevent, (double)simulation::Events.GetEventId(Launcher->Event2), 0.0, GLFW_PRESS, 0);
|
||||||
}
|
else if (Launcher->Event1)
|
||||||
|
m_relay.post(user_command::queueevent, (double)simulation::Events.GetEventId(Launcher->Event1), 0.0, GLFW_PRESS, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjusts cell bounding area to enclose specified node
|
// adjusts cell bounding area to enclose specified node
|
||||||
|
|||||||
4
scene.h
4
scene.h
@@ -21,6 +21,7 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
#include "Track.h"
|
#include "Track.h"
|
||||||
#include "Traction.h"
|
#include "Traction.h"
|
||||||
#include "sound.h"
|
#include "sound.h"
|
||||||
|
#include "command.h"
|
||||||
|
|
||||||
class opengl_renderer;
|
class opengl_renderer;
|
||||||
namespace scene {
|
namespace scene {
|
||||||
@@ -169,7 +170,7 @@ private:
|
|||||||
using memorycell_sequence = std::vector<TMemCell *>;
|
using memorycell_sequence = std::vector<TMemCell *>;
|
||||||
// methods
|
// methods
|
||||||
void
|
void
|
||||||
launch_event( TEventLauncher *Launcher );
|
launch_event(TEventLauncher *Launcher , bool local_only);
|
||||||
void
|
void
|
||||||
enclose_area( scene::basic_node *Node );
|
enclose_area( scene::basic_node *Node );
|
||||||
// members
|
// members
|
||||||
@@ -194,6 +195,7 @@ private:
|
|||||||
bool m_geometrycreated { false };
|
bool m_geometrycreated { false };
|
||||||
unsigned int m_framestamp { 0 }; // id of last rendered gfx frame
|
unsigned int m_framestamp { 0 }; // id of last rendered gfx frame
|
||||||
TTrack *tTrackAnim = nullptr; // obiekty do przeliczenia animacji
|
TTrack *tTrackAnim = nullptr; // obiekty do przeliczenia animacji
|
||||||
|
command_relay m_relay;
|
||||||
};
|
};
|
||||||
|
|
||||||
// basic scene partitioning structure, holds terrain geometry and collection of cells
|
// basic scene partitioning structure, holds terrain geometry and collection of cells
|
||||||
|
|||||||
Reference in New Issue
Block a user