diff --git a/client-go/assets/ThaleahFat.ttf b/client-go/assets/ThaleahFat.ttf new file mode 100644 index 0000000..91fc7dc Binary files /dev/null and b/client-go/assets/ThaleahFat.ttf differ diff --git a/client-go/assets/block.png b/client-go/assets/block.png new file mode 100644 index 0000000..1358f38 Binary files /dev/null and b/client-go/assets/block.png differ diff --git a/client-go/assets/player-sheet.png b/client-go/assets/player-sheet.png new file mode 100644 index 0000000..a712aad Binary files /dev/null and b/client-go/assets/player-sheet.png differ diff --git a/client-go/assets/tilemap.png b/client-go/assets/tilemap.png new file mode 100644 index 0000000..90be683 Binary files /dev/null and b/client-go/assets/tilemap.png differ diff --git a/client-go/client b/client-go/client new file mode 100755 index 0000000..9f98006 Binary files /dev/null and b/client-go/client differ diff --git a/client-go/cmd/client/main.go b/client-go/cmd/client/main.go new file mode 100644 index 0000000..795d7cf --- /dev/null +++ b/client-go/cmd/client/main.go @@ -0,0 +1,280 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + rl "github.com/gen2brain/raylib-go/raylib" + "tinyhold/client/internal/network" + "tinyhold/client/internal/render" + "tinyhold/client/internal/ui" + "tinyhold/client/internal/world" +) + +const ( + ScreenWidth = 1280 + ScreenHeight = 720 + TargetFPS = 60 +) + +type GameState int + +const ( + GameStateMenu GameState = iota + GameStateGame + GameStateExiting +) + +func main() { + // Initialize raylib + rl.InitWindow(ScreenWidth, ScreenHeight, "Tinyhold - Go Client") + defer rl.CloseWindow() + + rl.SetTargetFPS(TargetFPS) + + // Set working directory to client-go + os.Chdir(filepath.Dir(os.Args[0])) + + // Create world + gameWorld := world.NewWorld(0) + + // Create renderer + renderer := render.NewRenderer(gameWorld) + if err := renderer.Init(); err != nil { + fmt.Printf("Failed to initialize renderer: %v\n", err) + return + } + defer renderer.Cleanup() + + // Create menu + menu := ui.NewMainMenu() + + // Create network client + netClient := network.NewClient() + + // Game state + gameState := GameStateMenu + + // Local server + var localServer *network.LocalServer + + // Setup network callbacks + netClient.SetHandshakeCallback(func(playerID uint16, seed int64) { + fmt.Printf("Handshake received: playerID=%d, seed=%d\n", playerID, seed) + gameWorld.SetSeed(seed) + gameWorld.SetNetworkClient(netClient) + + // Spawn local player + gameWorld.AddPlayer(playerID, 0, 0, true) + + // Request initial chunks + go func() { + time.Sleep(100 * time.Millisecond) + gameWorld.RequestMissingChunks() + }() + }) + + netClient.SetPlayerStateCallback(func(state network.PlayerState) { + if state.ID == netClient.PlayerID() { + // Update local player + if p := gameWorld.GetPlayer(state.ID); p != nil { + p.X = state.X + p.Y = state.Y + } + } else { + // Update or create remote player + if p := gameWorld.GetPlayer(state.ID); p != nil { + p.X = state.X + p.Y = state.Y + } else { + gameWorld.AddPlayer(state.ID, state.X, state.Y, false) + } + } + }) + + netClient.SetWorldChunkCallback(func(chunk network.ChunkData) { + gameWorld.ApplyChunk(chunk.CX, chunk.CY, chunk.Tiles) + }) + + netClient.SetBlockUpdateCallback(func(update network.BlockUpdate) { + gameWorld.ApplyBlock(update.X, update.Y, update.Type) + }) + + netClient.SetDisconnectCallback(func() { + fmt.Println("Disconnected from server") + gameState = GameStateMenu + menu.SetState(ui.MenuStateMain) + + // Cleanup + if localServer != nil { + localServer.Stop() + localServer = nil + } + + // Reset world + gameWorld = world.NewWorld(0) + renderer = render.NewRenderer(gameWorld) + if err := renderer.Init(); err != nil { + fmt.Printf("Failed to reinitialize renderer: %v\n", err) + } + }) + + // Main game loop + for !rl.WindowShouldClose() { + // Get delta time + dt := rl.GetFrameTime() + + // Handle window close + if rl.IsKeyPressed(rl.KeyQ) && rl.IsKeyDown(rl.KeyLeftControl) { + gameState = GameStateExiting + } + + switch gameState { + case GameStateMenu: + menu.Update(dt) + menu.Draw() + + // Handle menu actions + if menu.IsSingleplayerSelected() { + // Start local server + port := 42069 + serverPath := "../server/build/tinyhold-server" + if _, err := os.Stat(serverPath); err != nil { + serverPath = "../../server/build/tinyhold-server" + } + + var err error + localServer, err = network.StartLocalServer(serverPath, port) + if err != nil { + fmt.Printf("Failed to start local server: %v\n", err) + menu.SetError(fmt.Sprintf("Failed to start server: %v", err)) + break + } + + // Connect to local server + if err := netClient.Connect("127.0.0.1", port); err != nil { + fmt.Printf("Failed to connect: %v\n", err) + menu.SetError(fmt.Sprintf("Failed to connect: %v", err)) + localServer.Stop() + localServer = nil + break + } + + gameState = GameStateGame + menu.SetState(ui.MenuStateConnecting) + } + + if menu.IsConnectSelected() { + ip, portStr := menu.GetConnectionInfo() + port, err := strconv.Atoi(portStr) + if err != nil { + port = 42069 + } + + if err := netClient.Connect(ip, port); err != nil { + fmt.Printf("Failed to connect: %v\n", err) + menu.SetError(fmt.Sprintf("Failed to connect: %v", err)) + break + } + + gameState = GameStateGame + menu.SetState(ui.MenuStateConnecting) + } + + if menu.IsBackSelected() { + menu.SetState(ui.MenuStateMain) + } + + case GameStateGame: + // Update game + if netClient.IsConnected() { + // Handle input + handleInput(netClient, renderer) + + // Update camera + renderer.UpdateCamera() + + // Request chunks as player moves + gameWorld.RequestMissingChunks() + + // Draw game + renderer.Draw() + + // Check if disconnected + if !netClient.IsConnected() { + gameState = GameStateMenu + menu.SetState(ui.MenuStateMain) + } + } else { + // Connection failed or disconnected + gameState = GameStateMenu + menu.SetState(ui.MenuStateMain) + } + + // Handle escape to return to menu + if rl.IsKeyPressed(rl.KeyEscape) { + netClient.Disconnect() + if localServer != nil { + localServer.Stop() + localServer = nil + } + gameState = GameStateMenu + menu.SetState(ui.MenuStateMain) + + // Reset world + gameWorld = world.NewWorld(0) + renderer = render.NewRenderer(gameWorld) + if err := renderer.Init(); err != nil { + fmt.Printf("Failed to reinitialize renderer: %v\n", err) + } + } + + case GameStateExiting: + return + } + } + + // Cleanup local server if exiting + if localServer != nil { + localServer.Stop() + } +} + +func handleInput(client *network.Client, renderer *render.Renderer) { + var keys uint8 + + if rl.IsKeyDown(rl.KeyW) || rl.IsKeyDown(rl.KeyUp) { + keys |= 1 // Up + } + if rl.IsKeyDown(rl.KeyS) || rl.IsKeyDown(rl.KeyDown) { + keys |= 2 // Down + } + if rl.IsKeyDown(rl.KeyA) || rl.IsKeyDown(rl.KeyLeft) { + keys |= 4 // Left + } + if rl.IsKeyDown(rl.KeyD) || rl.IsKeyDown(rl.KeyRight) { + keys |= 8 // Right + } + + if keys != 0 { + client.SendPlayerInput(keys) + } + + // Handle block placement with mouse + if rl.IsMouseButtonPressed(rl.MouseLeftButton) { + mousePos := rl.GetMousePosition() + camera := renderer.Camera() + + // Convert screen position to world position + worldPos := rl.GetScreenToWorld2D(mousePos, camera) + + // Convert to tile coordinates + tileX := int32(worldPos.X * 100.0 / 16.0) + tileY := int32(worldPos.Y * 100.0 / 16.0) + + client.SendBlockPlace(tileX, tileY) + } +} diff --git a/client-go/go.mod b/client-go/go.mod new file mode 100644 index 0000000..6b8239f --- /dev/null +++ b/client-go/go.mod @@ -0,0 +1,12 @@ +module tinyhold/client + +go 1.23 + +require github.com/gen2brain/raylib-go/raylib v0.55.1 + +require ( + github.com/ebitengine/purego v0.10.0 // indirect + github.com/jupiterrider/ffi v0.7.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/client-go/go.sum b/client-go/go.sum new file mode 100644 index 0000000..c353590 --- /dev/null +++ b/client-go/go.sum @@ -0,0 +1,12 @@ +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8= +github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +github.com/gen2brain/raylib-go/raylib v0.60.0 h1:KsP7W3EMkmb9zztivdgbh1bXR78pEuqP6jeUR8GCbEA= +github.com/gen2brain/raylib-go/raylib v0.60.0/go.mod h1:puAMU7Zcx6VJ6pcZSSs3gGFPyFvJuTwQlfm4KzeoXy8= +github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs= +github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/client-go/internal/network/client.go b/client-go/internal/network/client.go new file mode 100644 index 0000000..7e3022c --- /dev/null +++ b/client-go/internal/network/client.go @@ -0,0 +1,253 @@ +package network + +import ( + "encoding/binary" + "fmt" + "net" + "os/exec" + "sync" + "syscall" + "time" + + "tinyhold/client/internal/protocol" +) + +type PlayerState struct { + ID uint16 + X, Y int32 +} + +type BlockUpdate struct { + X, Y int32 + Type uint8 +} + +type ChunkData struct { + CX, CY int32 + Tiles []byte +} + +type Client struct { + conn net.Conn + connected bool + playerID uint16 + seed int64 + mu sync.Mutex + + // Callbacks + onHandshake func(playerID uint16, seed int64) + onPlayerState func(state PlayerState) + onWorldChunk func(chunk ChunkData) + onBlockUpdate func(update BlockUpdate) + onDisconnect func() +} + +func NewClient() *Client { + return &Client{} +} + +func (c *Client) Connect(host string, port int) error { + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := net.Dial("tcp", addr) + if err != nil { + return err + } + c.conn = conn + c.connected = true + + // Send handshake + handshake := []byte{0, 1, 0, 1} + if err := protocol.Write(conn, protocol.PacketHandshake, handshake); err != nil { + return err + } + + // Start read loop + go c.readLoop() + + return nil +} + +func (c *Client) Disconnect() { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connected = false +} + +func (c *Client) IsConnected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.connected +} + +func (c *Client) PlayerID() uint16 { + return c.playerID +} + +func (c *Client) Seed() int64 { + return c.seed +} + +func (c *Client) SendPlayerInput(keys uint8) error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.connected || c.conn == nil { + return fmt.Errorf("not connected") + } + payload := []byte{keys, 0, 0, 0, 0} + return protocol.Write(c.conn, protocol.PacketPlayerInput, payload) +} + +func (c *Client) SendChunkRequest(cx, cy int32) error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.connected || c.conn == nil { + return fmt.Errorf("not connected") + } + payload := make([]byte, 8) + binary.LittleEndian.PutUint32(payload[0:4], uint32(cx)) + binary.LittleEndian.PutUint32(payload[4:8], uint32(cy)) + return protocol.Write(c.conn, protocol.PacketChunkRequest, payload) +} + +func (c *Client) SendBlockPlace(x, y int32) error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.connected || c.conn == nil { + return fmt.Errorf("not connected") + } + payload := make([]byte, 8) + binary.LittleEndian.PutUint32(payload[0:4], uint32(x)) + binary.LittleEndian.PutUint32(payload[4:8], uint32(y)) + return protocol.Write(c.conn, protocol.PacketBlockPlace, payload) +} + +func (c *Client) SetHandshakeCallback(fn func(playerID uint16, seed int64)) { + c.onHandshake = fn +} + +func (c *Client) SetPlayerStateCallback(fn func(state PlayerState)) { + c.onPlayerState = fn +} + +func (c *Client) SetWorldChunkCallback(fn func(chunk ChunkData)) { + c.onWorldChunk = fn +} + +func (c *Client) SetBlockUpdateCallback(fn func(update BlockUpdate)) { + c.onBlockUpdate = fn +} + +func (c *Client) SetDisconnectCallback(fn func()) { + c.onDisconnect = fn +} + +func (c *Client) readLoop() { + defer func() { + c.mu.Lock() + c.connected = false + if c.onDisconnect != nil { + c.onDisconnect() + } + c.mu.Unlock() + }() + + for { + ptype, payload, err := protocol.Read(c.conn) + if err != nil { + return + } + + c.handlePacket(ptype, payload) + } +} + +func (c *Client) handlePacket(ptype protocol.PacketType, payload []byte) { + switch ptype { + case protocol.PacketHandshake: + if len(payload) >= 11 { + version := payload[0] + playerID := binary.LittleEndian.Uint16(payload[1:3]) + seed := int64(binary.LittleEndian.Uint64(payload[3:11])) + c.playerID = playerID + c.seed = seed + if c.onHandshake != nil { + c.onHandshake(playerID, seed) + } + fmt.Printf("Handshake: version=%d, playerID=%d, seed=%d\n", version, playerID, seed) + } + case protocol.PacketPlayerState: + if len(payload) >= 10 { + id := binary.LittleEndian.Uint16(payload[0:2]) + x := int32(binary.LittleEndian.Uint32(payload[2:6])) + y := int32(binary.LittleEndian.Uint32(payload[6:10])) + if c.onPlayerState != nil { + c.onPlayerState(PlayerState{ID: id, X: x, Y: y}) + } + } + case protocol.PacketWorldChunk: + if len(payload) >= 8 { + cx := int32(binary.LittleEndian.Uint32(payload[0:4])) + cy := int32(binary.LittleEndian.Uint32(payload[4:8])) + tiles := payload[8:] + if c.onWorldChunk != nil { + c.onWorldChunk(ChunkData{CX: cx, CY: cy, Tiles: tiles}) + } + } + case protocol.PacketBlockUpdate: + if len(payload) >= 9 { + x := int32(binary.LittleEndian.Uint32(payload[0:4])) + y := int32(binary.LittleEndian.Uint32(payload[4:8])) + typ := payload[8] + if c.onBlockUpdate != nil { + c.onBlockUpdate(BlockUpdate{X: x, Y: y, Type: typ}) + } + } + default: + fmt.Printf("Unknown packet type: %d\n", ptype) + } +} + +// LocalServer manages a local server process +type LocalServer struct { + cmd *exec.Cmd + port int + running bool +} + +func StartLocalServer(serverPath string, port int) (*LocalServer, error) { + args := []string{"--local", fmt.Sprintf("--port=%d", port)} + cmd := exec.Command(serverPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := cmd.Start(); err != nil { + return nil, err + } + + // Wait a bit for server to start + time.Sleep(500 * time.Millisecond) + + return &LocalServer{ + cmd: cmd, + port: port, + running: true, + }, nil +} + +func (ls *LocalServer) Stop() { + if ls.running && ls.cmd != nil && ls.cmd.Process != nil { + // Try to kill the process group + if ls.cmd.Process.Pid > 0 { + syscall.Kill(-ls.cmd.Process.Pid, syscall.SIGKILL) + } + ls.cmd.Wait() + ls.running = false + } +} + +func (ls *LocalServer) Port() int { + return ls.port +} diff --git a/client-go/internal/protocol/protocol.go b/client-go/internal/protocol/protocol.go new file mode 100644 index 0000000..09739b6 --- /dev/null +++ b/client-go/internal/protocol/protocol.go @@ -0,0 +1,42 @@ +package protocol + +import ( + "encoding/binary" + "io" +) + +type PacketType byte + +const ( + PacketHandshake PacketType = iota // Client -> Server: join request + PacketWorldChunk // Server -> Client: world chunk data + PacketPlayerInput // Client -> Server: key state + PacketPlayerState // Server -> Client: position snapshot + PacketChunkRequest // Client -> Server: request a chunk + PacketBlockPlace // Client -> Server: request to place block + PacketBlockUpdate // Server -> Client: block placed broadcast +) + +func Write(w io.Writer, ptype PacketType, payload []byte) error { + buf := make([]byte, 3+len(payload)) + buf[0] = byte(ptype) + binary.LittleEndian.PutUint16(buf[1:3], uint16(len(payload))) + copy(buf[3:], payload) + _, err := w.Write(buf) + return err +} + +func Read(r io.Reader) (PacketType, []byte, error) { + header := make([]byte, 3) + if _, err := io.ReadFull(r, header); err != nil { + return 0, nil, err + } + ptype := PacketType(header[0]) + length := binary.LittleEndian.Uint16(header[1:3]) + + payload := make([]byte, length) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + return ptype, payload, nil +} diff --git a/client-go/internal/render/renderer.go b/client-go/internal/render/renderer.go new file mode 100644 index 0000000..8c538b4 --- /dev/null +++ b/client-go/internal/render/renderer.go @@ -0,0 +1,318 @@ +package render + +import ( + "fmt" + + rl "github.com/gen2brain/raylib-go/raylib" + "tinyhold/client/internal/world" +) + +const ( + ScreenWidth = 1280 + ScreenHeight = 720 + TileSize = 16 +) + +type Renderer struct { + world *world.World + + // Textures + grassTexture rl.Texture2D + pathTexture rl.Texture2D + blockTexture rl.Texture2D + playerSheet rl.Texture2D + + // Camera + camera rl.Camera2D + cameraZoom float32 + + // Font + font rl.Font +} + +func NewRenderer(w *world.World) *Renderer { + r := &Renderer{ + world: w, + cameraZoom: 2.0, + } + return r +} + +func (r *Renderer) Init() error { + // Load textures + grassTexture := rl.LoadTexture("assets/tilemap.png") + pathTexture := rl.LoadTexture("assets/tilemap.png") + blockTexture := rl.LoadTexture("assets/block.png") + playerSheet := rl.LoadTexture("assets/player-sheet.png") + + if grassTexture.ID == 0 || pathTexture.ID == 0 || blockTexture.ID == 0 || playerSheet.ID == 0 { + return fmt.Errorf("failed to load textures") + } + + r.grassTexture = grassTexture + r.pathTexture = pathTexture + r.blockTexture = blockTexture + r.playerSheet = playerSheet + + // Load font + r.font = rl.LoadFont("assets/ThaleahFat.ttf") + if r.font.Texture.ID == 0 { + // Fallback to default font + r.font = rl.GetFontDefault() + } + + // Setup camera + r.camera = rl.NewCamera2D( + rl.NewVector2(0, 0), + rl.NewVector2(0, 0), + 0, + r.cameraZoom, + ) + r.camera.Target = rl.NewVector2(ScreenWidth/2, ScreenHeight/2) + r.camera.Offset = rl.NewVector2(ScreenWidth/2, ScreenHeight/2) + + // Set texture atlas + atlas := &world.TextureAtlas{ + GrassTexture: r.grassTexture, + PathTexture: r.pathTexture, + BlockTexture: r.blockTexture, + PlayerSheet: r.playerSheet, + } + r.world.SetTextureAtlas(atlas) + + return nil +} + +func (r *Renderer) UpdateCamera() { + localPlayer := r.world.GetLocalPlayer() + if localPlayer != nil { + // Center camera on local player + r.camera.Target = rl.NewVector2( + float32(localPlayer.X)/100.0, + float32(localPlayer.Y)/100.0, + ) + } +} + +func (r *Renderer) Camera() rl.Camera2D { + return r.camera +} + +func (r *Renderer) Draw() { + rl.BeginDrawing() + + // Clear screen + rl.ClearBackground(rl.RayWhite) + + // Begin 2D mode with camera + rl.BeginMode2D(r.camera) + + // Draw world + r.drawWorld() + + // Draw players + r.drawPlayers() + + // End 2D mode + rl.EndMode2D() + + // Draw UI (HUD) + r.drawHUD() + + rl.EndDrawing() +} + +func (r *Renderer) drawWorld() { + localPlayer := r.world.GetLocalPlayer() + if localPlayer == nil { + return + } + + // Calculate visible area + camPos := r.camera.Target + screenWidth := float32(ScreenWidth) / r.cameraZoom + screenHeight := float32(ScreenHeight) / r.cameraZoom + + startX := int32(camPos.X - screenWidth/2) + startY := int32(camPos.Y - screenHeight/2) + endX := int32(camPos.X + screenWidth/2) + endY := int32(camPos.Y + screenHeight/2) + + // Draw tiles in visible area + for y := startY; y <= endY; y++ { + for x := startX; x <= endX; x++ { + tile := r.world.GetTile(x, y) + r.drawTile(x, y, tile) + } + } + + // Draw placed blocks + placedBlocks := r.world.GetPlacedBlocks() + for coord, blockType := range placedBlocks { + if blockType >= 2 { + r.drawBlock(coord.X, coord.Y, blockType) + } + } +} + +func (r *Renderer) drawTile(x, y int32, tileType uint8) { + var texture rl.Texture2D + switch tileType { + case world.TileGrass: + texture = r.grassTexture + case world.TilePath: + texture = r.pathTexture + default: + return + } + + // Calculate source rectangle (tile atlas) + srcRect := rl.NewRectangle( + float32(tileType%2)*TileSize, + float32(tileType/2)*TileSize, + TileSize, + TileSize, + ) + + // Calculate destination position + destPos := rl.NewVector2( + float32(x)*TileSize/100.0, + float32(y)*TileSize/100.0, + ) + + // Draw tile + rl.DrawTextureRec( + texture, + srcRect, + destPos, + rl.White, + ) +} + +func (r *Renderer) drawBlock(x, y int32, blockType uint8) { + // Draw block sprite centered on tile + destPos := rl.NewVector2( + float32(x)*TileSize/100.0 + TileSize/2.0, + float32(y)*TileSize/100.0 + TileSize/2.0, + ) + + rl.DrawTextureV( + r.blockTexture, + destPos, + rl.White, + ) +} + +func (r *Renderer) drawPlayers() { + players := r.world.GetPlayers() + + for _, p := range players { + r.drawPlayer(p) + } +} + +func (r *Renderer) drawPlayer(p *world.Player) { + // Player position (converted from server coordinates) + posX := float32(p.X) / 100.0 + posY := float32(p.Y) / 100.0 + + // Draw player sprite + frameWidth := float32(r.playerSheet.Width) / 2 // 2 frames: idle and run + frameHeight := float32(r.playerSheet.Height) + + // Determine animation frame + var frameX float32 + if p.Keys != 0 { + // Running animation + frameX = frameWidth + } else { + // Idle animation + frameX = 0 + } + + srcRect := rl.NewRectangle( + frameX, + 0, + frameWidth, + frameHeight, + ) + + destRect := rl.NewRectangle( + posX - frameWidth/2.0, + posY - frameHeight/2.0, + frameWidth, + frameHeight, + ) + + // Flip based on movement direction + origin := rl.NewVector2(0, 0) + rotation := float32(0) + + // Check if moving left or right + if p.Keys&4 != 0 { // Left + origin = rl.NewVector2(frameWidth, 0) + rotation = 0 + } else if p.Keys&8 != 0 { // Right + origin = rl.NewVector2(0, 0) + rotation = 0 + } + + // Draw player with tint based on whether it's local player + color := rl.White + if p.IsLocal { + color = rl.Green + } else { + color = rl.Red + } + + rl.DrawTexturePro( + r.playerSheet, + srcRect, + destRect, + origin, + rotation, + color, + ) + + // Draw player ID + idText := fmt.Sprintf("%d", p.ID) + textWidth := float32(rl.MeasureText(idText, 10)) + textPos := rl.NewVector2( + posX - textWidth/2.0, + posY - frameHeight/2.0 - 15, + ) + rl.DrawText(idText, int32(textPos.X), int32(textPos.Y), 10, rl.Black) +} + +func (r *Renderer) drawHUD() { + localPlayer := r.world.GetLocalPlayer() + if localPlayer == nil { + return + } + + // Draw player position + posText := fmt.Sprintf("X: %.1f, Y: %.1f", + float32(localPlayer.X)/100.0, + float32(localPlayer.Y)/100.0, + ) + rl.DrawText(posText, 10, 10, 20, rl.Black) + + // Draw connection status + if r.world.Seed() != 0 { + seedText := fmt.Sprintf("Seed: %d", r.world.Seed()) + rl.DrawText(seedText, 10, 40, 20, rl.Black) + } + + // Draw FPS + fps := rl.GetFPS() + fpsText := fmt.Sprintf("FPS: %d", fps) + rl.DrawText(fpsText, 10, 70, 20, rl.Black) +} + +func (r *Renderer) Cleanup() { + rl.UnloadTexture(r.grassTexture) + rl.UnloadTexture(r.pathTexture) + rl.UnloadTexture(r.blockTexture) + rl.UnloadTexture(r.playerSheet) + rl.UnloadFont(r.font) +} diff --git a/client-go/internal/ui/menu.go b/client-go/internal/ui/menu.go new file mode 100644 index 0000000..604608d --- /dev/null +++ b/client-go/internal/ui/menu.go @@ -0,0 +1,330 @@ +package ui + +import ( + rl "github.com/gen2brain/raylib-go/raylib" +) + +const ( + ScreenWidth = 1280 + ScreenHeight = 720 +) + +type MenuState int + +const ( + MenuStateMain MenuState = iota + MenuStateConnecting + MenuStateError +) + +type MainMenu struct { + state MenuState + errorMessage string + inputIP string + inputPort string + font rl.Font + + // UI elements + singleplayerBtn rl.Rectangle + multiplayerBtn rl.Rectangle + ipInputBox rl.Rectangle + portInputBox rl.Rectangle + connectBtn rl.Rectangle + backBtn rl.Rectangle + + // State + activeInput string // "ip" or "port" + cursorTimer float32 + showCursor bool +} + +func NewMainMenu() *MainMenu { + m := &MainMenu{ + state: MenuStateMain, + inputIP: "127.0.0.1", + inputPort: "42069", + showCursor: true, + font: rl.GetFontDefault(), + } + + // Setup UI elements + buttonWidth := 200 + buttonHeight := 50 + padding := 20 + + m.singleplayerBtn = rl.NewRectangle( + float32(ScreenWidth/2-buttonWidth/2), + float32(ScreenHeight/2-buttonHeight*2-padding), + float32(buttonWidth), + float32(buttonHeight), + ) + + m.multiplayerBtn = rl.NewRectangle( + float32(ScreenWidth/2-buttonWidth/2), + float32(ScreenHeight/2-buttonHeight-padding/2), + float32(buttonWidth), + float32(buttonHeight), + ) + + m.ipInputBox = rl.NewRectangle( + float32(ScreenWidth/2-buttonWidth/2), + float32(ScreenHeight/2+padding), + float32(buttonWidth), + float32(buttonHeight), + ) + + m.portInputBox = rl.NewRectangle( + float32(ScreenWidth/2-buttonWidth/2), + float32(ScreenHeight/2+buttonHeight+padding*2), + float32(buttonWidth), + float32(buttonHeight), + ) + + m.connectBtn = rl.NewRectangle( + float32(ScreenWidth/2-buttonWidth/2), + float32(ScreenHeight/2+buttonHeight*2+padding*3), + float32(buttonWidth), + float32(buttonHeight), + ) + + m.backBtn = rl.NewRectangle( + float32(ScreenWidth/2-buttonWidth/2), + float32(ScreenHeight/2+buttonHeight*2+padding*3), + float32(buttonWidth), + float32(buttonHeight), + ) + + return m +} + +func (m *MainMenu) SetFont(font rl.Font) { + m.font = font +} + +func (m *MainMenu) Update(dt float32) { + // Blink cursor + m.cursorTimer += dt + if m.cursorTimer >= 0.5 { + m.cursorTimer = 0 + m.showCursor = !m.showCursor + } + + // Handle input + if m.state == MenuStateMain { + // Check for button clicks + mousePos := rl.GetMousePosition() + + if rl.IsMouseButtonPressed(rl.MouseLeftButton) { + if rl.CheckCollisionPointRec(mousePos, m.singleplayerBtn) { + m.state = MenuStateConnecting + // Return singleplayer action + } + if rl.CheckCollisionPointRec(mousePos, m.multiplayerBtn) { + m.state = MenuStateMain // Show IP input + } + } + + // Handle text input for IP and port + if m.activeInput == "ip" { + m.handleTextInput(&m.inputIP) + } else if m.activeInput == "port" { + m.handleTextInput(&m.inputPort) + } + + // Check for input box clicks + if rl.IsMouseButtonPressed(rl.MouseLeftButton) { + if rl.CheckCollisionPointRec(mousePos, m.ipInputBox) { + m.activeInput = "ip" + } else if rl.CheckCollisionPointRec(mousePos, m.portInputBox) { + m.activeInput = "port" + } else { + m.activeInput = "" + } + + if m.activeInput != "" && rl.CheckCollisionPointRec(mousePos, m.connectBtn) { + m.state = MenuStateConnecting + } + } + } +} + +func (m *MainMenu) handleTextInput(target *string) { + // Get key pressed + key := rl.GetKeyPressed() + + // Backspace + if key == rl.KeyBackspace && len(*target) > 0 { + *target = (*target)[:len(*target)-1] + } + + // Enter + if key == rl.KeyEnter { + m.activeInput = "" + return + } + + // Handle character input + char := rl.GetCharPressed() + if char > 0 && char < 128 { + // Allow only valid characters + if (char >= '0' && char <= '9') || char == '.' || char == ':' { + *target += string(char) + } + } +} + +func (m *MainMenu) Draw() { + rl.BeginDrawing() + + // Clear screen + rl.ClearBackground(rl.RayWhite) + + // Draw title + title := "Tinyhold" + titleWidth := rl.MeasureText(title, 60) + rl.DrawText(title, ScreenWidth/2-titleWidth/2, 100, 60, rl.Black) + + // Draw buttons based on state + if m.state == MenuStateMain { + m.drawMainMenu() + } else if m.state == MenuStateConnecting { + m.drawConnecting() + } else if m.state == MenuStateError { + m.drawError() + } + + rl.EndDrawing() +} + +func (m *MainMenu) drawMainMenu() { + // Draw singleplayer button + m.drawButton(m.singleplayerBtn, "Singleplayer", true) + + // Draw multiplayer button + m.drawButton(m.multiplayerBtn, "Multiplayer", true) + + // Draw IP input + m.drawTextBox(m.ipInputBox, m.inputIP, "IP Address") + + // Draw port input + m.drawTextBox(m.portInputBox, m.inputPort, "Port") + + // Draw connect button + m.drawButton(m.connectBtn, "Connect", true) + + // Draw instructions + instructions := "Click Singleplayer for local game or enter IP:Port and click Connect" + instructionsWidth := rl.MeasureText(instructions, 20) + rl.DrawText(instructions, ScreenWidth/2-instructionsWidth/2, ScreenHeight-50, 20, rl.Gray) +} + +func (m *MainMenu) drawConnecting() { + // Draw connecting message + msg := "Connecting..." + msgWidth := rl.MeasureText(msg, 40) + rl.DrawText(msg, ScreenWidth/2-msgWidth/2, ScreenHeight/2-20, 40, rl.Black) + + // Draw spinner + spinner := "..." + spinnerWidth := rl.MeasureText(spinner, 30) + rl.DrawText(spinner, ScreenWidth/2-spinnerWidth/2, ScreenHeight/2+20, 30, rl.Gray) +} + +func (m *MainMenu) drawError() { + // Draw error message + if m.errorMessage != "" { + msgWidth := rl.MeasureText(m.errorMessage, 30) + rl.DrawText(m.errorMessage, ScreenWidth/2-msgWidth/2, ScreenHeight/2-40, 30, rl.Red) + } + + // Draw back button + m.drawButton(m.backBtn, "Back", true) +} + +func (m *MainMenu) drawButton(rect rl.Rectangle, text string, enabled bool) { + color := rl.LightGray + if enabled { + color = rl.SkyBlue + } + + // Draw button background + rl.DrawRectangleRec(rect, color) + + // Draw button border + rl.DrawRectangleLinesEx(rect, 2, rl.Black) + + // Draw button text + textWidth := rl.MeasureText(text, 20) + textX := int32(rect.X + rect.Width/2 - float32(textWidth)/2) + textY := int32(rect.Y + rect.Height/2 - 10) + rl.DrawText(text, textX, textY, 20, rl.Black) +} + +func (m *MainMenu) drawTextBox(rect rl.Rectangle, text, placeholder string) { + // Draw background + rl.DrawRectangleRec(rect, rl.LightGray) + rl.DrawRectangleLinesEx(rect, 2, rl.Black) + + // Draw text or placeholder + displayText := text + if text == "" { + displayText = placeholder + } + + textWidth := rl.MeasureText(displayText, 20) + textX := int32(rect.X + 10) + textY := int32(rect.Y + rect.Height/2 - 10) + + textColor := rl.Black + if text == "" { + textColor = rl.Gray + } + + rl.DrawText(displayText, textX, textY, 20, textColor) + + // Draw cursor if active + if m.activeInput != "" && m.showCursor { + cursorX := textX + textWidth + cursorY := textY + rl.DrawRectangle(cursorX, cursorY, 2, 20, rl.Black) + } +} + +func (m *MainMenu) GetState() MenuState { + return m.state +} + +func (m *MainMenu) SetState(state MenuState) { + m.state = state +} + +func (m *MainMenu) SetError(msg string) { + m.errorMessage = msg + m.state = MenuStateError +} + +func (m *MainMenu) GetConnectionInfo() (string, string) { + return m.inputIP, m.inputPort +} + +func (m *MainMenu) IsSingleplayerSelected() bool { + mousePos := rl.GetMousePosition() + return rl.IsMouseButtonPressed(rl.MouseLeftButton) && + rl.CheckCollisionPointRec(mousePos, m.singleplayerBtn) +} + +func (m *MainMenu) IsConnectSelected() bool { + mousePos := rl.GetMousePosition() + return rl.IsMouseButtonPressed(rl.MouseLeftButton) && + rl.CheckCollisionPointRec(mousePos, m.connectBtn) +} + +func (m *MainMenu) IsBackSelected() bool { + mousePos := rl.GetMousePosition() + return rl.IsMouseButtonPressed(rl.MouseLeftButton) && + rl.CheckCollisionPointRec(mousePos, m.backBtn) +} + +func (m *MainMenu) Cleanup() { + // Font is managed by renderer +} diff --git a/client-go/internal/world/world.go b/client-go/internal/world/world.go new file mode 100644 index 0000000..be38f5d --- /dev/null +++ b/client-go/internal/world/world.go @@ -0,0 +1,268 @@ +package world + +import ( + "sync" + + rl "github.com/gen2brain/raylib-go/raylib" + "tinyhold/client/internal/network" +) + +const ( + TileSize = 16 + ChunkSize = 16 + LoadRadius = 2 +) + +const ( + TileGrass uint8 = iota + TilePath +) + +type TileCoord struct { + X int32 + Y int32 +} + +type World struct { + mu sync.Mutex + seed int64 + chunks map[TileCoord][]byte + placedBlocks map[TileCoord]uint8 + players map[uint16]*Player + localPlayer *Player + + // Rendering + textureAtlas *TextureAtlas + + // Network reference + networkClient *network.Client + + // Camera + cameraX, cameraY float32 + + // Loaded area + loadedChunks map[TileCoord]bool + lastChunk TileCoord +} + +type Player struct { + ID uint16 + X, Y int32 + Keys uint8 + IsLocal bool +} + +func NewWorld(seed int64) *World { + return &World{ + seed: seed, + chunks: make(map[TileCoord][]byte), + placedBlocks: make(map[TileCoord]uint8), + players: make(map[uint16]*Player), + loadedChunks: make(map[TileCoord]bool), + lastChunk: TileCoord{X: 99999, Y: 99999}, + } +} + +func (w *World) SetNetworkClient(client *network.Client) { + w.networkClient = client +} + +func (w *World) SetSeed(seed int64) { + w.seed = seed +} + +func (w *World) Seed() int64 { + return w.seed +} + +func (w *World) SetCamera(x, y float32) { + w.cameraX = x + w.cameraY = y +} + +func (w *World) AddPlayer(id uint16, x, y int32, isLocal bool) *Player { + w.mu.Lock() + defer w.mu.Unlock() + + p := &Player{ + ID: id, + X: x, + Y: y, + IsLocal: isLocal, + } + w.players[id] = p + if isLocal { + w.localPlayer = p + } + return p +} + +func (w *World) RemovePlayer(id uint16) { + w.mu.Lock() + defer w.mu.Unlock() + delete(w.players, id) + if w.localPlayer != nil && w.localPlayer.ID == id { + w.localPlayer = nil + } +} + +func (w *World) UpdatePlayerState(id uint16, x, y int32) { + w.mu.Lock() + defer w.mu.Unlock() + if p, ok := w.players[id]; ok { + p.X = x + p.Y = y + } +} + +func (w *World) GetPlayer(id uint16) *Player { + w.mu.Lock() + defer w.mu.Unlock() + return w.players[id] +} + +func (w *World) GetPlayers() map[uint16]*Player { + w.mu.Lock() + defer w.mu.Unlock() + result := make(map[uint16]*Player) + for k, v := range w.players { + result[k] = v + } + return result +} + +func (w *World) GetLocalPlayer() *Player { + w.mu.Lock() + defer w.mu.Unlock() + return w.localPlayer +} + +func (w *World) ApplyChunk(cx, cy int32, tiles []byte) { + w.mu.Lock() + defer w.mu.Unlock() + + coord := TileCoord{X: cx, Y: cy} + w.chunks[coord] = make([]byte, len(tiles)) + copy(w.chunks[coord], tiles) + w.loadedChunks[coord] = true +} + +func (w *World) ApplyBlock(x, y int32, blockType uint8) { + w.mu.Lock() + defer w.mu.Unlock() + + coord := TileCoord{X: x, Y: y} + w.placedBlocks[coord] = blockType +} + +func (w *World) GetTile(x, y int32) uint8 { + w.mu.Lock() + defer w.mu.Unlock() + + // Check placed blocks first + if b, ok := w.placedBlocks[TileCoord{X: x, Y: y}]; ok { + return b + } + + // Check chunks + chunkX, chunkY := ChunkCoord(x, y) + if chunk, ok := w.chunks[TileCoord{X: chunkX, Y: chunkY}]; ok { + localX := int(x - chunkX*ChunkSize) + localY := int(y - chunkY*ChunkSize) + if localX >= 0 && localX < ChunkSize && localY >= 0 && localY < ChunkSize { + return chunk[localY*ChunkSize+localX] + } + } + + // Generate tile + return TileAt(x, y, w.seed) +} + +func (w *World) RequestMissingChunks() { + if w.localPlayer == nil || w.networkClient == nil { + return + } + + w.mu.Lock() + defer w.mu.Unlock() + + tileX := w.localPlayer.X / TileSize + tileY := w.localPlayer.Y / TileSize + chunkX, chunkY := ChunkCoord(tileX, tileY) + + currentChunk := TileCoord{X: chunkX, Y: chunkY} + if currentChunk == w.lastChunk { + return + } + + w.lastChunk = currentChunk + + for dy := -LoadRadius; dy <= LoadRadius; dy++ { + for dx := -LoadRadius; dx <= LoadRadius; dx++ { + key := TileCoord{X: chunkX + int32(dx), Y: chunkY + int32(dy)} + if !w.loadedChunks[key] { + w.loadedChunks[key] = true + go w.networkClient.SendChunkRequest(key.X, key.Y) + } + } + } +} + +// ChunkCoord converts tile coordinates to chunk coordinates +func ChunkCoord(tileX, tileY int32) (int32, int32) { + cx := tileX / ChunkSize + cy := tileY / ChunkSize + if tileX < 0 { + cx = (tileX+1)/ChunkSize - 1 + } + if tileY < 0 { + cy = (tileY+1)/ChunkSize - 1 + } + return cx, cy +} + +// TileAt generates a tile at the given world coordinates +func TileAt(x, y int32, seed int64) uint8 { + rx := x / 8 + ry := y / 8 + h := int64(rx)*374761393 + int64(ry)*668265263 + seed*1274126177 + h = (h ^ (h >> 13)) * 1274126177 + h = h ^ (h >> 16) + + if uint32(h)%100 < 25 { + fineH := int64(x)*1619 + int64(y)*31337 + seed*54321 + fineH = (fineH ^ (fineH >> 8)) * 1103515245 + fineH = fineH ^ (fineH >> 16) + if uint32(fineH)%100 < 80 { + return TilePath + } + } + return TileGrass +} + +// TextureAtlas holds the tile textures +type TextureAtlas struct { + GrassTexture rl.Texture2D + PathTexture rl.Texture2D + BlockTexture rl.Texture2D + PlayerSheet rl.Texture2D +} + +func (w *World) SetTextureAtlas(atlas *TextureAtlas) { + w.textureAtlas = atlas +} + +func (w *World) GetTextureAtlas() *TextureAtlas { + return w.textureAtlas +} + +// GetPlacedBlocks returns all placed blocks for rendering +func (w *World) GetPlacedBlocks() map[TileCoord]uint8 { + w.mu.Lock() + defer w.mu.Unlock() + result := make(map[TileCoord]uint8) + for k, v := range w.placedBlocks { + result[k] = v + } + return result +}