diff --git a/Animator.h b/Animator.h index 86a1c40..0f2d1b5 100644 --- a/Animator.h +++ b/Animator.h @@ -2,6 +2,7 @@ #define ANIMATOR_CLASS #include +#include #include #include #include @@ -9,6 +10,9 @@ #include #include "Rig.h" +// Scale of the "Head" bone (and what hangs off it), around the neck. 1 = as authored. +inline float HeadScale = 0.25f; + struct AssimpNodeData { glm::mat4 transformation; @@ -199,6 +203,8 @@ private: } glm::mat4 globalTransform = parentTransform * nodeTransform; + if (nodeName == "Head") + globalTransform = glm::scale(globalTransform, glm::vec3(HeadScale)); auto it = m_BoneInfoMap.find(nodeName); if (it != m_BoneInfoMap.end()) { diff --git a/Camera.h b/Camera.h index 40b3e99..2791b6a 100644 --- a/Camera.h +++ b/Camera.h @@ -16,6 +16,10 @@ const glm::vec3 worldUp = { 0.0f, 1.0f, 0.0f }; +// Mouse sensitivity asked at startup (see main.cpp). 0 = keep the camera's built-in default. +inline float StartupCmPer360 = 0.0f; +inline float StartupMouseDpi = 800.0f; + class Camera { public: bool lockedcursor = true; @@ -28,7 +32,15 @@ public: float FOV = 110.0f; float screenshake = 0.0f; - Camera() {} + Camera() { + if (StartupCmPer360 > 0.0f) SetSensitivityCm(StartupCmPer360, StartupMouseDpi); + } + + // Turn sensitivity into "this many cm of mouse travel per full 360 degree turn" for a mouse + // of the given DPI (mouse counts per inch). + void SetSensitivityCm(float cmPer360, float dpi) { + sensitivity = 6.2831853f / (cmPer360 / 2.54f * dpi); + } void AddScreenshake(float amount) { screenshake += amount; diff --git a/Engine.cpp b/Engine.cpp index 7eef487..5e9f538 100644 --- a/Engine.cpp +++ b/Engine.cpp @@ -145,7 +145,7 @@ void Engine::Initiate() Character* testcharacter = new Character(); testcharacter->name = "character"; testcharacter->castshadow = true; - testcharacter->body->t.ScaleBy({ 0.6f, 2.1f, 0.6f }); + testcharacter->body->t.ScaleBy({ 0.7f, 2.5f, 0.7f }); testcharacter->body->t.TranslateTo({ 0.0f, 10.0f, 0.0f }); testcharacter->body->SetRig(testrig); @@ -172,8 +172,8 @@ void Engine::Initiate() Character* npc = new Character(); npc->name = "npc"; npc->castshadow = true; - npc->body->t.ScaleBy({ 0.6f, 2.1f, 0.6f }); - npc->body->t.TranslateTo({ 3.0f, 1.05f, 3.0f }); + npc->body->t.ScaleBy({ 0.7f, 2.5f, 0.7f }); + npc->body->t.TranslateTo({ 3.0f, 1.25f, 3.0f }); npc->body->SetRig(testrig); npc->body->SetAnimation(testanimation); npc->BindToRenderSystem(); @@ -197,7 +197,7 @@ void Engine::Initiate() Character* c = new Character(); c->name = "remote"; c->castshadow = true; - c->body->t.ScaleBy({ 0.6f, 2.1f, 0.6f }); + c->body->t.ScaleBy({ 0.7f, 2.5f, 0.7f }); c->body->SetRig(testrig); c->body->SetAnimation(testanimation); c->BindToRenderSystem(); @@ -393,13 +393,13 @@ void Engine::Initiate() // Map is a rectangle: x in [-60, 60], z in [-30, 30] (authored at 1x, scaled by mapScale) // Centre dome - AddTiers("dome", { 0.0f, 0.0f }, 18.0f, 1.0f, 5); + AddTiers("dome", { 0.0f, 0.0f }, 18.0f, 2.0f, 5); // Cones, opposite corners of the dome AddTiers("cone", { -12.0f, -13.0f }, 6.0f, 1.0f, 5); AddTiers("cone", { 12.0f, 13.0f }, 6.0f, 1.0f, 5); // Big cubes either side of the dome - AddBlock("cube", { -24.0f, 0.0f, 0.0f }, { 7.0f, 7.0f, 7.0f }); - AddBlock("cube", { 24.0f, 0.0f, 0.0f }, { 7.0f, 7.0f, 7.0f }); + AddBlock("cube", { -24.0f, 0.0f, 0.0f }, { 7.0f, 8.4f, 7.0f }); + AddBlock("cube", { 24.0f, 0.0f, 0.0f }, { 7.0f, 8.4f, 7.0f }); // Stairs climbing to a tall back wall (the ramp), back right AddBlock("stair", { 29.0f, 0.0f, -12.0f }, { 2.5f, 1.0f, 10.0f }); AddBlock("stair", { 31.5f, 0.0f, -12.0f }, { 2.5f, 2.0f, 10.0f }); @@ -437,7 +437,10 @@ void Engine::Initiate() // body = 50 damage (two shots). Respawns the target when it dies. // Movement tuning const float airControl = 0.5f; // fraction of ground acceleration available in the air - const float grappleRange = 12.0f; // metres from the player + const float shockedAirControl = 0.1f; // air control after being hit by another player's shockwave + const float shockedDuration = 2.0f; // seconds the reduced air control lasts + float shockedTime = 0.0f; + const float grappleRange = 30.0f; // metres from the player const float grappleSpeed = 28.0f; // launch speed toward the hit point const float grappleCooldownTime = 2.5f; // seconds float grappleCooldown = 0.0f; @@ -448,7 +451,7 @@ void Engine::Initiate() const float slideDuration = 0.8f; const float slideFalloffTime = 0.7f; const float slideDropY = 0.65f; // standing centre height (0.9) minus lying (0.25) - const float standEyeOffset = 0.85f; // eyes 0.85 above body centre (body centre 1.05, height 2.1) + const float standEyeOffset = 1.05f; // eyes 1.05 above body centre (body centre 1.25, height 2.5) const float slideEyeOffset = 0.25f; // lying down, eyes just above the floor float eyeOffset = standEyeOffset; bool sliding = false; @@ -459,6 +462,33 @@ void Engine::Initiate() float dashCooldown = 0.0f; const float shootCooldownTime = 0.5f; // seconds between shots float shootCooldown = 0.0f; + + // Cubes (E). Whoever hosts (or an offline player) owns and simulates them and decides when they + // despawn; LAN clients only ask the host for one and draw the poses it streams back. + const float cubeCooldownTime = 0.5f; // seconds between cubes per player + const double cubeLifetime = 30.0; // seconds before a cube despawns + float cubeCooldown = 0.0f; + // Cubes also rain from the sky on their own (host / offline only; clients see them via the stream) + const float cubeRainInterval = 0.5f; // seconds between rain cubes + const float cubeRainHeight = 45.0f; + const size_t cubeRainMax = 200; // stop raining while this many cubes exist + float cubeRainTimer = 0.0f; + struct HostCube { uint32_t id; Mesh* mesh; double spawnTime; }; + std::vector hostCubes; + uint32_t nextCubeId = 0; + std::map lastCubeSpawn; // host: when each client last spawned one (rate limit) + struct PuppetCube { Mesh* mesh; double lastSeen; }; + std::map puppetCubes; // client: the host's cubes, keyed by host id + auto SpawnCube = [&](const glm::vec3& at) { + Mesh* nc = CreateCubeMesh(); + nc->name = "cube!"; + nc->t.ScaleTo(1.0f); + nc->t.TranslateTo(at); + nc->BindToRenderSystem(); + mainf->AddChild(nc); + physicsengine->AddObject(new p(nc)); + hostCubes.push_back({ ++nextCubeId, nc, glfwGetTime() }); + }; glm::vec3 slideDir(0.0f); // Shockwave (key Q): projectile that explodes on the first surface it touches. The blast @@ -508,6 +538,9 @@ void Engine::Initiate() body->linearvelocity += dir * power * falloff; body->WakeUp(); + // Another player's blast (not our own) takes away most of our air control for a while + if (body == characterBody && fromNet) shockedTime = shockedDuration; + if (body == characterBody && sliding) { // The slide overrides horizontal velocity every frame, so end it and stand back up sliding = false; @@ -673,15 +706,12 @@ void Engine::Initiate() // Input � keyboard events // -------------------------------------------------------------------- while (Keyboard::Event buffer = keyboard->ReadKey()) { - if (buffer.GetCode() == 'E' && buffer.IsPress()) { - Mesh* nc = CreateCubeMesh(); - nc->name = "cube!"; - nc->t.ScaleTo(1.0f); - nc->t.TranslateTo(camera->t.GetTranslation()); - nc->t.RotateByEulerAngles({ 0.0f, 0.0f, 0.0f }); - nc->BindToRenderSystem(); - mainf->AddChild(nc); - physicsengine->AddObject(new p(nc)); + if (buffer.GetCode() == 'E' && buffer.IsPress() && cubeCooldown <= 0.0f) { + cubeCooldown = cubeCooldownTime; + if (lan && lan->IsActive() && lan->GetMode() == Lan::Mode::Client) + lan->SendCubeSpawn(camera->t.GetTranslation()); // the host spawns it + else + SpawnCube(camera->t.GetTranslation()); } if (buffer.GetCode() == 'F' && buffer.IsPress()) { const glm::vec3 playerPos = testcharacter->body->t.GetTranslation(); @@ -808,6 +838,7 @@ void Engine::Initiate() slideCooldown = std::max(slideCooldown - deltatime, 0.0f); dashCooldown = std::max(dashCooldown - deltatime, 0.0f); shootCooldown = std::max(shootCooldown - deltatime, 0.0f); + cubeCooldown = std::max(cubeCooldown - deltatime, 0.0f); const float accel = 40.0f; const float damp = 0.75f; const float groundTopSpeed = movespeed * accel * refDt * damp / (1.0f - damp); @@ -842,7 +873,7 @@ void Engine::Initiate() const float current = glm::dot(glm::vec3(characterBody->linearvelocity.x, 0.0f, characterBody->linearvelocity.z), wishDir); const float add = glm::clamp(groundTopSpeed - current, 0.0f, - accel * airControl * movespeed * deltatime); + accel * (shockedTime > 0.0f ? shockedAirControl : airControl) * movespeed * deltatime); characterBody->linearvelocity.x += wishDir.x * add; characterBody->linearvelocity.z += wishDir.z * add; } @@ -860,6 +891,7 @@ void Engine::Initiate() // Shockwave projectile: arc forward, explode on the first collider along this frame's path shockCooldown = std::max(shockCooldown - deltatime, 0.0f); + shockedTime = std::max(shockedTime - deltatime, 0.0f); if (shockActive) { shockVel.y -= shockGravity * deltatime; shockAge += deltatime; @@ -983,6 +1015,36 @@ void Engine::Initiate() // -------------------------------------------------------------------- // LAN: send our transform, apply the other player's // -------------------------------------------------------------------- + // Rain cubes from the sky at random spots over the map (host / offline) + if (!lan || !lan->IsActive() || lan->GetMode() == Lan::Mode::Host) { + cubeRainTimer -= deltatime; + if (cubeRainTimer <= 0.0f) { + cubeRainTimer += cubeRainInterval; + if (hostCubes.size() < cubeRainMax) { + glm::vec3 at = RandomSpawn(); + at.y = cubeRainHeight; + SpawnCube(at); + std::uniform_real_distribution spin(0.0f, 6.28318f); + hostCubes.back().mesh->t.RotateToEulerAngles({ spin(respawnRng), spin(respawnRng), spin(respawnRng) }); + } + } + } + + // Despawn cubes that have lived long enough (host / offline) + { + const double cubeNow = glfwGetTime(); + for (size_t i = 0; i < hostCubes.size();) { + if (cubeNow - hostCubes[i].spawnTime > cubeLifetime) { + Mesh* m = hostCubes[i].mesh; + rendersystem->RemoveRenderable(m); + physicsengine->RemoveObject(m); + m->Delete(); + hostCubes.erase(hostCubes.begin() + i); + } + else ++i; + } + } + if (lan && lan->IsActive()) { NetState mine; mine.position = testcharacter->body->t.GetTranslation(); @@ -994,7 +1056,53 @@ void Engine::Initiate() std::vector updates; std::vector blasts; std::vector hits; - lan->Receive(updates, &blasts, &hits); + std::vector cubeRequests; + std::vector cubeStates; + lan->Receive(updates, &blasts, &hits, &cubeRequests, &cubeStates); + + // Host: spawn the cubes clients asked for (rate limited per client), then stream every cube's pose + for (const NetCubeRequest& rq : cubeRequests) { + double& last = lastCubeSpawn[rq.sender]; + if (now - last < cubeCooldownTime * 0.9) continue; + last = now; + SpawnCube(glm::clamp(rq.position, glm::vec3(-200.0f), glm::vec3(200.0f))); + } + if (lan->GetMode() == Lan::Mode::Host) { + std::vector snapshot; + snapshot.reserve(hostCubes.size()); + for (const HostCube& hc : hostCubes) { + NetCube nc; + nc.id = hc.id; + nc.position = hc.mesh->t.GetTranslation(); + nc.rotation = hc.mesh->t.GetRotationQuaternion(); + snapshot.push_back(nc); + } + lan->SendCubes(snapshot); + } + + // Client: show the host's cubes, and drop the ones it stopped sending (despawned) + for (const NetCube& nc : cubeStates) { + auto it = puppetCubes.find(nc.id); + if (it == puppetCubes.end()) { + Mesh* m = CreateCubeMesh(); + m->name = "cube!"; + m->t.ScaleTo(1.0f); + m->BindToRenderSystem(); + mainf->AddChild(m); + it = puppetCubes.emplace(nc.id, PuppetCube{ m, now }).first; + } + it->second.mesh->t.TranslateTo(nc.position); + it->second.mesh->t.RotateToQuaternion(nc.rotation); + it->second.lastSeen = now; + } + for (auto it = puppetCubes.begin(); it != puppetCubes.end();) { + if (now - it->second.lastSeen > 1.5) { + rendersystem->RemoveRenderable(it->second.mesh); + it->second.mesh->Delete(); + it = puppetCubes.erase(it); + } + else ++it; + } for (const NetBlast& b : blasts) Explode(b.center, true, b.radius, b.power); // Damage from other players (respawn handles dying, see the player respawn check) diff --git a/Net.cpp b/Net.cpp index fc5f879..769e098 100644 --- a/Net.cpp +++ b/Net.cpp @@ -16,7 +16,7 @@ namespace { constexpr uint32_t NET_MAGIC = 0x45494946; // "EIIF" - constexpr uint16_t NET_VERSION = 2; + constexpr uint16_t NET_VERSION = 3; constexpr unsigned short NET_PORT = 27015; constexpr double NET_TIMEOUT = 2.0; // seconds of silence before a player is dropped @@ -67,6 +67,30 @@ namespace { }; #pragma pack(pop) + constexpr uint32_t NET_MAGIC_SPAWN = 0x45494953; // "EIIS" + constexpr uint32_t NET_MAGIC_CUBES = 0x45494943; // "EIIC" + constexpr int CUBES_PER_PACKET = 36; + +#pragma pack(push, 1) + struct SpawnPacket { + uint32_t magic; + uint16_t version; + uint8_t sender; + uint32_t id; + float pos[3]; + }; + struct CubesHeader { + uint32_t magic; + uint16_t version; + uint8_t count; + }; + struct CubeEntry { + uint32_t id; + float pos[3]; + float rot[4]; // x, y, z, w + }; +#pragma pack(pop) + constexpr int MAX_ENTRIES = Lan::MaxPlayers; constexpr int MAX_DATAGRAM = (int)sizeof(Header) + MAX_ENTRIES * (int)sizeof(Entry); @@ -127,6 +151,11 @@ struct Lan::Impl { uint32_t blastSeq = 0; std::vector seenBlasts; // (type, sender, id) of recent blasts/hits, for de-duplication + struct PendingSpawn { SpawnPacket pkt; int copiesLeft; }; + std::vector pendingSpawns; + uint32_t spawnSeq = 0; + size_t cubeCursor = 0; // host: where the next slice of cubes starts + struct PendingHit { HitPacket pkt; int copiesLeft; }; std::vector pendingHits; uint32_t hitSeq = 0; @@ -232,6 +261,7 @@ void Lan::Stop() { impl->peers.clear(); impl->pendingBlasts.clear(); impl->pendingHits.clear(); + impl->pendingSpawns.clear(); impl->seenBlasts.clear(); impl->anyRecv = false; active = false; @@ -266,9 +296,60 @@ void Lan::SendHit(uint8_t target, const NetHit& h) { impl->pendingHits.push_back(ph); } +void Lan::SendCubeSpawn(const glm::vec3& position) { + if (!active || mode != Mode::Client) return; + Impl::PendingSpawn ps; + ps.pkt.magic = NET_MAGIC_SPAWN; + ps.pkt.version = NET_VERSION; + ps.pkt.sender = 0; // the host stamps the real id + ps.pkt.id = ++impl->spawnSeq; + ps.pkt.pos[0] = position.x; ps.pkt.pos[1] = position.y; ps.pkt.pos[2] = position.z; + ps.copiesLeft = BLAST_COPIES; + impl->pendingSpawns.push_back(ps); +} + +void Lan::SendCubes(const std::vector& cubes) { + if (!active || mode != Mode::Host || cubes.empty() || impl->peers.empty()) return; + + const size_t n = cubes.size(); + const size_t count = std::min(n, (size_t)CUBES_PER_PACKET); + if (impl->cubeCursor >= n) impl->cubeCursor = 0; + + char buf[sizeof(CubesHeader) + CUBES_PER_PACKET * sizeof(CubeEntry)]; + CubesHeader h; + h.magic = NET_MAGIC_CUBES; + h.version = NET_VERSION; + h.count = (uint8_t)count; + std::memcpy(buf, &h, sizeof(h)); + for (size_t i = 0; i < count; i++) { + const NetCube& c = cubes[(impl->cubeCursor + i) % n]; + CubeEntry e; + e.id = c.id; + e.pos[0] = c.position.x; e.pos[1] = c.position.y; e.pos[2] = c.position.z; + e.rot[0] = c.rotation.x; e.rot[1] = c.rotation.y; e.rot[2] = c.rotation.z; e.rot[3] = c.rotation.w; + std::memcpy(buf + sizeof(h) + i * sizeof(e), &e, sizeof(e)); + } + impl->cubeCursor = (impl->cubeCursor + count) % n; + + const int len = (int)(sizeof(h) + count * sizeof(CubeEntry)); + for (const Impl::Peer& to : impl->peers) + sendto(impl->sock, buf, len, 0, (const sockaddr*)&to.addr, (int)sizeof(to.addr)); +} + void Lan::Send(const NetState& s) { if (!active) return; + // One more copy of every pending cube spawn request (client -> host) + for (Impl::PendingSpawn& ps : impl->pendingSpawns) { + sendto(impl->sock, (const char*)&ps.pkt, (int)sizeof(ps.pkt), 0, + (const sockaddr*)&impl->hostAddr, (int)sizeof(impl->hostAddr)); + ps.copiesLeft--; + } + impl->pendingSpawns.erase( + std::remove_if(impl->pendingSpawns.begin(), impl->pendingSpawns.end(), + [](const Impl::PendingSpawn& ps) { return ps.copiesLeft <= 0; }), + impl->pendingSpawns.end()); + // One more copy of every pending hit: clients send to the host, the host to the target only for (Impl::PendingHit& ph : impl->pendingHits) { if (mode == Mode::Client) impl->SendHitPacket(impl->hostAddr, ph.pkt); @@ -309,10 +390,11 @@ void Lan::Send(const NetState& s) { } } -void Lan::Receive(std::vector& out, std::vector* blasts, std::vector* hits) { +void Lan::Receive(std::vector& out, std::vector* blasts, std::vector* hits, + std::vector* spawns, std::vector* cubes) { if (!active) return; - char buf[MAX_DATAGRAM + 64]; + char buf[1500]; for (int i = 0; i < 512; i++) { // bounded so a flood can't stall the frame sockaddr_in from{}; int fromLen = (int)sizeof(from); @@ -355,6 +437,50 @@ void Lan::Receive(std::vector& out, std::vector* blasts, st continue; } + if (magic == NET_MAGIC_SPAWN) { + if (mode != Mode::Host || r != (int)sizeof(SpawnPacket)) continue; + SpawnPacket sp; + std::memcpy(&sp, buf, sizeof(sp)); + if (sp.version != NET_VERSION) continue; + + // Only known players may spawn cubes + const Impl::Peer* sender = nullptr; + for (const Impl::Peer& pe : impl->peers) + if (SameAddress(pe.addr, from)) { sender = &pe; break; } + if (!sender) continue; + + if (spawns && impl->FirstSighting(2, sender->id, sp.id)) { + NetCubeRequest req; + req.sender = sender->id; + req.id = sp.id; + req.position = glm::vec3(sp.pos[0], sp.pos[1], sp.pos[2]); + spawns->push_back(req); + } + continue; + } + + if (magic == NET_MAGIC_CUBES) { + if (mode != Mode::Client || !SameAddress(from, impl->hostAddr)) continue; + CubesHeader ch; + if (r < (int)sizeof(ch)) continue; + std::memcpy(&ch, buf, sizeof(ch)); + if (ch.version != NET_VERSION || ch.count > CUBES_PER_PACKET) continue; + if (r != (int)(sizeof(ch) + ch.count * sizeof(CubeEntry))) continue; + + if (cubes) { + for (int k = 0; k < ch.count; k++) { + CubeEntry e; + std::memcpy(&e, buf + sizeof(ch) + k * sizeof(e), sizeof(e)); + NetCube c; + c.id = e.id; + c.position = glm::vec3(e.pos[0], e.pos[1], e.pos[2]); + c.rotation = glm::quat(e.rot[3], e.rot[0], e.rot[1], e.rot[2]); + cubes->push_back(c); + } + } + continue; + } + if (magic == NET_MAGIC_HIT) { if (r != (int)sizeof(HitPacket)) continue; HitPacket hp; diff --git a/Net.h b/Net.h index d466033..e8d96cb 100644 --- a/Net.h +++ b/Net.h @@ -43,6 +43,21 @@ struct NetHit { glm::vec3 knockback = glm::vec3(0.0f); // added to the victim's velocity }; +// A physics cube owned by the host. The host simulates every cube and streams their poses to the +// clients, which only draw them. The host also decides when a cube despawns. +struct NetCube { + uint32_t id = 0; + glm::vec3 position = glm::vec3(0.0f); + glm::quat rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); +}; + +// A client asking the host to spawn a cube. Re-sent a few times (UDP) and de-duplicated by the host. +struct NetCubeRequest { + uint8_t sender = 0; // filled in by the host + uint32_t id = 0; + glm::vec3 position = glm::vec3(0.0f); +}; + class Lan { public: enum class Mode { Off, Host, Client }; @@ -65,8 +80,16 @@ public: // Drains all waiting packets and appends the other players' newest states to 'out'. // If 'blasts' is given, new (de-duplicated) shockwave blasts from other players are appended to it. // 'hits' gets damage aimed at us (de-duplicated). + // 'spawns' (host only) gets cube spawn requests from clients; 'cubes' (client only) gets the + // host's cube poses. void Receive(std::vector& out, std::vector* blasts = nullptr, - std::vector* hits = nullptr); + std::vector* hits = nullptr, std::vector* spawns = nullptr, + std::vector* cubes = nullptr); + // Client: ask the host to spawn a cube at 'position'. Sent over the next few Send() calls. + void SendCubeSpawn(const glm::vec3& position); + // Host: stream cube poses to every client. Call once per frame; large sets go out in rotating + // slices, so a cube is refreshed every few frames. + void SendCubes(const std::vector& cubes); // Damage another player. 'target' is the id from their NetUpdate (the host is 0). void SendHit(uint8_t target, const NetHit& hit); // Tell the other players about a blast we caused. Sent over the next few Send() calls. diff --git a/main.cpp b/main.cpp index 0d3ec24..5d7220b 100644 --- a/main.cpp +++ b/main.cpp @@ -83,6 +83,7 @@ #include "SoundSystem.h" #include "ResourceManager.h" #include "Net.h" +#include "Camera.h" // Asks in the console whether to host, join or play offline (before the window opens, // so the window isn't frozen while waiting for typing). @@ -115,8 +116,29 @@ static void PromptNetworkMode() { } } +// Mouse sensitivity as cm of mouse travel per 360 degree turn. Enter keeps the default. +static void PromptSensitivity() { + auto ask = [](const char* label, float& target) { + std::cout << label; + std::string line; + std::getline(std::cin, line); + try { + const float v = std::stof(line); + if (v > 0.0f) target = v; + } + catch (...) {} // empty / not a number: keep the default + }; + std::cout << "\n=== MOUSE ===\n"; + float cm = 0.0f, dpi = 800.0f; + ask("Sensitivity in cm per 360 degrees [default]: ", cm); + if (cm > 0.0f) ask("Mouse DPI [800]: ", dpi); + StartupCmPer360 = cm; + StartupMouseDpi = dpi; +} + int main() { PromptNetworkMode(); + PromptSensitivity(); glfwInit(); glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);