1
0

Add login GUI and location/color persistence

This commit is contained in:
Sky Johnson 2025-09-08 19:14:46 -05:00
parent c05a27a621
commit 8d08958280
11 changed files with 6443 additions and 81 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.o
server/players.json
client/game

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -2,7 +2,7 @@ CXX = g++
CXXFLAGS = -std=c++20 -Wall -Wextra -O2 -I.
LDFLAGS = -lraylib -lboost_system -lpthread -lGL -lm -ldl -lrt -lX11
TARGET = game_client
TARGET = game
SOURCES = main.cpp PlayerController.cpp net/NetworkManager.cpp
OBJECTS = $(SOURCES:.cpp=.o)

5993
client/includes/raygui.h Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,17 @@
#include <raylib.h>
#include <raymath.h>
#define RAYGUI_IMPLEMENTATION
#include "includes/raygui.h"
#include <iostream>
#include <vector>
#include <fstream>
#include <unordered_map>
#include <cstdint>
#include <cstring>
#include <thread>
#include <chrono>
#include "PlayerController.hpp"
#include "net/NetworkManager.hpp"
@ -93,7 +100,11 @@ struct Heightmap {
}
};
enum GameState {
STATE_LOGIN,
STATE_CONNECTING,
STATE_PLAYING
};
class Game {
PlayerController playerController;
@ -106,6 +117,13 @@ class Game {
std::unordered_map<std::string, Texture2D> playerTextures;
std::unordered_map<uint32_t, Model> remotePlayerModels;
// Login UI state
GameState gameState = STATE_LOGIN;
char usernameBuffer[32] = "";
bool editMode = false;
std::string loginError = "";
std::string currentUsername = "";
public:
Game() {
InitWindow(1280, 720, "Multiplayer Terrain Game");
@ -134,12 +152,14 @@ public:
// Create player cube (texture will be set when we know our color)
auto cubeMesh = GenMeshCube(1.0f, 2.0f, 1.0f);
playerModel = LoadModelFromMesh(cubeMesh);
// Connect to server
network.sendLogin();
}
~Game() {
// Send logout if we're connected
if (gameState == STATE_PLAYING && network.isConnected()) {
network.sendLogout();
}
UnloadTexture(terrainTexture);
for (auto& [color, texture] : playerTextures) {
UnloadTexture(texture);
@ -157,14 +177,44 @@ public:
update();
render();
}
// Clean logout when window is closing
if (gameState == STATE_PLAYING && network.isConnected()) {
network.sendLogout();
// Give the network time to send the packet
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
private:
void update() {
switch (gameState) {
case STATE_LOGIN:
// Login screen - no game updates
break;
case STATE_CONNECTING:
// Check if we've received a response from the server
if (network.isConnected()) {
gameState = STATE_PLAYING;
currentUsername = std::string(usernameBuffer);
} else if (network.hasLoginError()) {
gameState = STATE_LOGIN;
loginError = network.getLoginError();
}
break;
case STATE_PLAYING:
updateGame();
break;
}
}
void updateGame() {
if (!network.isConnected()) {
if (IsKeyPressed(KEY_SPACE)) {
network.sendLogin();
}
// Player disconnected, go back to login
gameState = STATE_LOGIN;
loginError = "Disconnected from server";
return;
}
@ -244,12 +294,80 @@ private:
// Send color change to server
network.sendColorChange(NetworkManager::AVAILABLE_COLORS[currentColorIndex]);
}
// Handle logout
if (IsKeyPressed(KEY_ESCAPE)) {
network.sendLogout();
gameState = STATE_LOGIN;
loginError = "";
}
}
void render() {
BeginDrawing();
ClearBackground(SKYBLUE);
switch (gameState) {
case STATE_LOGIN:
renderLoginScreen();
break;
case STATE_CONNECTING:
renderConnectingScreen();
break;
case STATE_PLAYING:
renderGame();
break;
}
EndDrawing();
}
void renderLoginScreen() {
// Center the login box
int boxWidth = 300;
int boxHeight = 200;
int boxX = (GetScreenWidth() - boxWidth) / 2;
int boxY = (GetScreenHeight() - boxHeight) / 2;
// Draw login panel
GuiPanel((Rectangle){(float)boxX, (float)boxY, (float)boxWidth, (float)boxHeight}, "Login");
// Username label and text box
GuiLabel((Rectangle){(float)(boxX + 20), (float)(boxY + 50), 80, 30}, "Username:");
if (GuiTextBox((Rectangle){(float)(boxX + 100), (float)(boxY + 50), 180, 30},
usernameBuffer, 32, editMode)) {
editMode = !editMode;
}
// Login button
if (GuiButton((Rectangle){(float)(boxX + 100), (float)(boxY + 100), 100, 30}, "Login")) {
if (strlen(usernameBuffer) > 0) {
// Send login with username
network.sendLoginWithUsername(usernameBuffer);
gameState = STATE_CONNECTING;
loginError = "";
} else {
loginError = "Please enter a username";
}
}
// Error message
if (!loginError.empty()) {
DrawText(loginError.c_str(), boxX + 20, boxY + 150, 20, RED);
}
// Instructions
DrawText("Enter your username to join the game", 10, 10, 20, WHITE);
}
void renderConnectingScreen() {
DrawText("Connecting to server...",
GetScreenWidth()/2 - 100, GetScreenHeight()/2 - 10, 20, WHITE);
}
void renderGame() {
BeginMode3D(playerController.getCamera());
// Draw terrain
@ -271,16 +389,14 @@ private:
EndMode3D();
// UI
DrawText(network.isConnected() ? "Connected" : "Press SPACE to connect", 10, 10, 20, WHITE);
DrawText(TextFormat("Logged in as: %s", currentUsername.c_str()), 10, 10, 20, WHITE);
DrawText("WASD: Move | Q/E: Strafe | Right-Click: Rotate Camera", 10, 35, 20, WHITE);
DrawText("Left/Right Arrow: Change Color | Mouse Wheel: Zoom", 10, 60, 20, WHITE);
DrawText("Left/Right Arrow: Change Color | Mouse Wheel: Zoom | ESC: Logout", 10, 60, 20, WHITE);
if (network.isConnected()) {
std::string colorText = "Your color: " + network.getPlayerColor();
DrawText(colorText.c_str(), 10, 85, 20, WHITE);
}
DrawFPS(10, 110);
EndDrawing();
}
};

View File

@ -53,6 +53,9 @@ void NetworkManager::processMessage(const uint8_t* data, std::size_t size) {
case MessageType::ColorChanged:
handleColorChanged(data, size);
break;
case MessageType::LoginResponse:
handleLoginResponse(data, size);
break;
default:
break;
}
@ -179,6 +182,45 @@ void NetworkManager::sendLogin() {
socket.send_to(buffer(msg), serverEndpoint);
}
void NetworkManager::sendLoginWithUsername(const std::string& username) {
std::cout << "Attempting login with username: " << username << "\n";
loginErrorMsg = "";
currentUsername = username;
std::vector<uint8_t> msg(2 + username.size());
msg[0] = static_cast<uint8_t>(MessageType::Login);
msg[1] = static_cast<uint8_t>(username.size());
std::memcpy(&msg[2], username.data(), username.size());
socket.send_to(buffer(msg), serverEndpoint);
std::cout << "Login packet sent for username: " << username << "\n";
}
void NetworkManager::sendLogout() {
if (!connected) {
std::cout << "Warning: sendLogout called but not connected\n";
return;
}
std::cout << "Sending logout for player ID " << playerID << " (username: " << currentUsername << ")\n";
std::array<uint8_t, 5> msg{};
msg[0] = static_cast<uint8_t>(MessageType::Logout);
uint32_t id = playerID;
std::memcpy(&msg[1], &id, sizeof(id));
socket.send_to(buffer(msg), serverEndpoint);
std::cout << "Logout packet sent, resetting client state\n";
// Reset state
connected = false;
playerID = 0;
currentUsername = "";
loginErrorMsg = ""; // Clear any error messages
{
std::lock_guard lock(remotePlayersMutex);
remotePlayers.clear();
}
}
void NetworkManager::sendMove(float dx, float dy, float dz) {
if (!connected) return;
@ -241,3 +283,25 @@ std::unordered_map<uint32_t, RemotePlayer> NetworkManager::getRemotePlayers() {
std::lock_guard lock(remotePlayersMutex);
return remotePlayers;
}
void NetworkManager::handleLoginResponse(const uint8_t* data, std::size_t size) {
// Message format: [type(1)][success(1)][messageLen(1)][message(messageLen)]
if (size < 3) return;
uint8_t success = data[1];
uint8_t msgLen = data[2];
if (size >= 3 + msgLen) {
std::string message(reinterpret_cast<const char*>(&data[3]), msgLen);
if (success == 0) {
// Login failed
loginErrorMsg = message;
connected = false;
std::cout << "Login failed: " << message << "\n";
} else {
// Login succeeded, wait for spawn message
std::cout << "Login accepted, waiting for spawn...\n";
}
}
}

View File

@ -22,7 +22,9 @@ enum class MessageType : uint8_t {
PlayerLeft = 0x07,
PlayerList = 0x08,
ChangeColor = 0x09,
ColorChanged = 0x0A
ColorChanged = 0x0A,
LoginResponse = 0x0B,
Logout = 0x0C
};
struct RemotePlayer {
@ -38,11 +40,15 @@ public:
~NetworkManager();
void sendLogin();
void sendLoginWithUsername(const std::string& username);
void sendLogout();
void sendMove(float dx, float dy, float dz);
void sendColorChange(const std::string& newColor);
Vector3 getPosition();
bool isConnected() const { return connected; }
bool hasLoginError() const { return !loginErrorMsg.empty(); }
std::string getLoginError() const { return loginErrorMsg; }
uint32_t getPlayerID() const { return playerID; }
std::string getPlayerColor() const { return playerColor; }
@ -60,6 +66,8 @@ private:
std::atomic<uint32_t> playerID{0};
std::string playerColor{"red"};
std::string currentUsername{""};
std::string loginErrorMsg{""};
std::mutex positionMutex;
Vector3 serverPosition{0, 0, 0};
std::atomic<bool> connected{false};
@ -75,4 +83,5 @@ private:
void handlePlayerLeft(const uint8_t* data, std::size_t size);
void handlePlayerList(const uint8_t* data, std::size_t size);
void handleColorChanged(const uint8_t* data, std::size_t size);
void handleLoginResponse(const uint8_t* data, std::size_t size);
};

View File

@ -7,16 +7,18 @@ import (
// Message type constants
const (
MSG_LOGIN = 0x01
MSG_POSITION = 0x02
MSG_SPAWN = 0x03
MSG_MOVE = 0x04
MSG_UPDATE = 0x05
MSG_PLAYER_JOINED = 0x06
MSG_PLAYER_LEFT = 0x07
MSG_PLAYER_LIST = 0x08
MSG_CHANGE_COLOR = 0x09
MSG_COLOR_CHANGED = 0x0A
MSG_LOGIN = 0x01
MSG_POSITION = 0x02
MSG_SPAWN = 0x03
MSG_MOVE = 0x04
MSG_UPDATE = 0x05
MSG_PLAYER_JOINED = 0x06
MSG_PLAYER_LEFT = 0x07
MSG_PLAYER_LIST = 0x08
MSG_CHANGE_COLOR = 0x09
MSG_COLOR_CHANGED = 0x0A
MSG_LOGIN_RESPONSE = 0x0B
MSG_LOGOUT = 0x0C
)
// Vec3 represents a 3D vector
@ -142,3 +144,43 @@ func DecodeColorChangePacket(data []byte) (playerID uint32, color string, ok boo
color = string(data[6 : 6+colorLen])
return playerID, color, true
}
// EncodeLoginResponsePacket creates a login response packet
func EncodeLoginResponsePacket(success bool, message string) []byte {
msgBytes := []byte(message)
msg := make([]byte, 3+len(msgBytes))
msg[0] = MSG_LOGIN_RESPONSE
if success {
msg[1] = 1
} else {
msg[1] = 0
}
msg[2] = uint8(len(msgBytes))
copy(msg[3:], msgBytes)
return msg
}
// DecodeLoginPacket decodes a login packet with username
func DecodeLoginPacket(data []byte) (username string, ok bool) {
if len(data) < 2 {
return "", false
}
usernameLen := data[1]
if len(data) < 2+int(usernameLen) {
return "", false
}
username = string(data[2 : 2+usernameLen])
return username, true
}
// DecodeLogoutPacket decodes a logout packet
func DecodeLogoutPacket(data []byte) (playerID uint32, ok bool) {
if len(data) < 5 {
return 0, false
}
playerID = binary.LittleEndian.Uint32(data[1:5])
return playerID, true
}

View File

@ -16,6 +16,7 @@ import (
// Player represents a connected player
type Player struct {
ID uint32
Username string
Position Vec3
Velocity Vec3
Color string
@ -23,13 +24,22 @@ type Player struct {
LastSeen time.Time
}
// UserData represents persistent user data
type UserData struct {
Username string `json:"username"`
Color string `json:"color"`
Position Vec3 `json:"position"`
}
// Server manages the game state and networking
type Server struct {
conn *net.UDPConn
players map[uint32]*Player
heightmap [][]float32
mutex sync.RWMutex
nextID uint32
conn *net.UDPConn
players map[uint32]*Player
usersByName map[string]*Player // Track by username for preventing duplicates
userData map[string]*UserData // Persistent user data
heightmap [][]float32
mutex sync.RWMutex
nextID uint32
}
// NewServer creates a new game server
@ -45,13 +55,15 @@ func NewServer(port string, heightmap [][]float32) (*Server, error) {
}
server := &Server{
conn: conn,
players: make(map[uint32]*Player),
heightmap: heightmap,
nextID: 0,
conn: conn,
players: make(map[uint32]*Player),
usersByName: make(map[string]*Player),
userData: make(map[string]*UserData),
heightmap: heightmap,
nextID: 0,
}
server.loadPlayerPositions()
server.loadUserData()
return server, nil
}
@ -65,7 +77,7 @@ func (s *Server) Run() error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
s.savePlayerPositions()
s.saveUserData()
}
}()
@ -96,34 +108,69 @@ func (s *Server) Run() error {
switch msgType {
case MSG_LOGIN:
s.handleLogin(addr)
s.handleLogin(buffer[:n], addr)
case MSG_MOVE:
s.handleMove(buffer[:n], addr)
case MSG_CHANGE_COLOR:
s.handleColorChange(buffer[:n], addr)
case MSG_LOGOUT:
s.handleLogout(buffer[:n], addr)
}
}
}
func (s *Server) handleLogin(addr *net.UDPAddr) {
func (s *Server) handleLogin(data []byte, addr *net.UDPAddr) {
username, ok := DecodeLoginPacket(data)
if !ok || username == "" {
// Invalid login packet
log.Printf("Received invalid login packet from %s", addr)
responseMsg := EncodeLoginResponsePacket(false, "Invalid username")
s.conn.WriteToUDP(responseMsg, addr)
return
}
log.Printf("Login attempt for username: %s from %s", username, addr)
s.mutex.Lock()
// Check if username is already logged in
if existingPlayer, exists := s.usersByName[username]; exists {
s.mutex.Unlock()
responseMsg := EncodeLoginResponsePacket(false, "User already logged in")
s.conn.WriteToUDP(responseMsg, addr)
log.Printf("Login rejected for %s: already logged in (ID: %d)", username, existingPlayer.ID)
return
}
s.nextID++
playerID := s.nextID
// Assign color based on player ID
colors := []string{"red", "green", "orange", "purple", "white"}
colorIndex := (playerID - 1) % uint32(len(colors))
color := colors[colorIndex]
// Load saved user data or create new
var userData *UserData
var exists bool
if userData, exists = s.userData[username]; !exists {
// New user - assign default color and random position
colors := []string{"red", "green", "orange", "purple", "white"}
colorIndex := (playerID - 1) % uint32(len(colors))
color := colors[colorIndex]
// Spawn at random position on heightmap
x := rand.Float32()*100 - 50
z := rand.Float32()*100 - 50
y := s.getHeightAt(x, z) + 1.0
x := rand.Float32()*100 - 50
z := rand.Float32()*100 - 50
y := s.getHeightAt(x, z) + 1.0
userData = &UserData{
Username: username,
Color: color,
Position: Vec3{x, y, z},
}
s.userData[username] = userData
}
player := &Player{
ID: playerID,
Position: Vec3{x, y, z},
Color: color,
Username: username,
Position: userData.Position,
Color: userData.Color,
Address: addr,
LastSeen: time.Now(),
}
@ -137,10 +184,15 @@ func (s *Server) handleLogin(addr *net.UDPAddr) {
}
s.players[playerID] = player
s.usersByName[username] = player
s.mutex.Unlock()
// Send spawn message with color
spawnMsg := EncodeSpawnPacket(playerID, player.Position, color)
// Send login success response
responseMsg := EncodeLoginResponsePacket(true, "Login successful")
s.conn.WriteToUDP(responseMsg, addr)
// Send spawn message with saved position and color
spawnMsg := EncodeSpawnPacket(playerID, userData.Position, userData.Color)
s.conn.WriteToUDP(spawnMsg, addr)
// Send player list to new player
@ -152,10 +204,10 @@ func (s *Server) handleLogin(addr *net.UDPAddr) {
// Notify other players about new player
s.broadcastPlayerJoined(player)
log.Printf("Player %d logged in at (%.2f, %.2f, %.2f) with color %s",
playerID, x, y, z, color)
log.Printf("Player %s (ID %d) logged in at (%.2f, %.2f, %.2f) with color %s",
username, playerID, userData.Position.X, userData.Position.Y, userData.Position.Z, userData.Color)
s.savePlayerPositions()
s.saveUserData()
}
func (s *Server) handleMove(data []byte, _ *net.UDPAddr) {
@ -190,12 +242,53 @@ func (s *Server) handleMove(data []byte, _ *net.UDPAddr) {
player.Position.Z = newZ
player.LastSeen = time.Now()
// Update persistent user data
if userData, exists := s.userData[player.Username]; exists {
userData.Position = player.Position
}
s.mutex.Unlock()
// Broadcast position update to all players
s.broadcastUpdate(player)
}
func (s *Server) handleLogout(data []byte, _ *net.UDPAddr) {
playerID, ok := DecodeLogoutPacket(data)
if !ok {
log.Printf("Failed to decode logout packet")
return
}
log.Printf("Received logout request for player ID %d", playerID)
s.mutex.Lock()
player, exists := s.players[playerID]
if !exists {
s.mutex.Unlock()
log.Printf("Player ID %d not found in active players", playerID)
return
}
// Save final position
if userData, exists := s.userData[player.Username]; exists {
userData.Position = player.Position
userData.Color = player.Color
}
// Remove from active players
username := player.Username
delete(s.players, playerID)
delete(s.usersByName, username)
s.mutex.Unlock()
// Notify other players
s.broadcastPlayerLeft(playerID)
log.Printf("Player %s (ID %d) successfully logged out", username, playerID)
s.saveUserData()
}
func (s *Server) handleColorChange(data []byte, _ *net.UDPAddr) {
playerID, newColor, ok := DecodeColorChangePacket(data)
if !ok {
@ -218,6 +311,12 @@ func (s *Server) handleColorChange(data []byte, _ *net.UDPAddr) {
}
player.Color = newColor
// Update persistent user data
if userData, exists := s.userData[player.Username]; exists {
userData.Color = newColor
}
s.mutex.Unlock()
// Broadcast color change to all players
@ -312,42 +411,58 @@ func (s *Server) checkTimeouts() {
now := time.Now()
for id, player := range s.players {
if now.Sub(player.LastSeen) > 30*time.Second {
// Save final position before removing
if userData, exists := s.userData[player.Username]; exists {
userData.Position = player.Position
userData.Color = player.Color
}
delete(s.players, id)
delete(s.usersByName, player.Username)
go s.broadcastPlayerLeft(id)
log.Printf("Player %d timed out", id)
log.Printf("Player %s (ID %d) timed out", player.Username, id)
go s.saveUserData()
}
}
}
func (s *Server) loadPlayerPositions() {
func (s *Server) loadUserData() {
data, err := os.ReadFile("players.json")
if err != nil {
log.Printf("No existing user data found: %v", err)
return
}
var savedPlayers map[uint32]Vec3
json.Unmarshal(data, &savedPlayers)
for id, pos := range savedPlayers {
if id > s.nextID {
s.nextID = id
}
s.players[id] = &Player{
ID: id,
Position: pos,
LastSeen: time.Now(),
}
var savedUsers map[string]*UserData
if err := json.Unmarshal(data, &savedUsers); err != nil {
log.Printf("Failed to parse user data: %v", err)
return
}
s.userData = savedUsers
log.Printf("Loaded data for %d users", len(savedUsers))
}
func (s *Server) savePlayerPositions() {
func (s *Server) saveUserData() {
s.mutex.RLock()
savedPlayers := make(map[uint32]Vec3)
for id, player := range s.players {
savedPlayers[id] = player.Position
// Deep copy userData to avoid holding lock during file I/O
savedUsers := make(map[string]*UserData)
for username, data := range s.userData {
savedUsers[username] = &UserData{
Username: data.Username,
Color: data.Color,
Position: data.Position,
}
}
s.mutex.RUnlock()
data, _ := json.Marshal(savedPlayers)
os.WriteFile("players.json", data, 0644)
data, err := json.MarshalIndent(savedUsers, "", " ")
if err != nil {
log.Printf("Failed to marshal user data: %v", err)
return
}
if err := os.WriteFile("players.json", data, 0644); err != nil {
log.Printf("Failed to save user data: %v", err)
}
}

View File

@ -1 +1,20 @@
{"1":{"X":-13.046684,"Y":-0.008753866,"Z":15.9760895}}
{
"sky": {
"username": "sky",
"color": "purple",
"position": {
"X": 3.9765263,
"Y": -5.033655,
"Z": 19.824585
}
},
"test": {
"username": "test",
"color": "red",
"position": {
"X": 14.141681,
"Y": 2.2383373,
"Z": -0.06184849
}
}
}