1
0
Fork 0
mirror of https://github.com/imjasonh/terminus synced 2026-07-06 22:12:35 +00:00
terminus/server/server.go
Jason Hall b1694154b7 Add NPC system with wandering AI and sprite rendering
- Add NPC data structure with random walk movement and collision
detection
- Implement NPC manager in GameServer with thread-safe access and
spawning
- Add blue half-filled circle sprites for NPCs with proper depth testing

- Update renderer to support NPC sprite type with medium size and
visibility
- Integrate NPCs into multiplayer system - all players see shared NPCs
- Spawn 3 NPCs in small maps, 5 NPCs in large maps based on map
dimensions
- Update module path to github.com/imjasonh/terminus
- NPCs move at 1.5 units/sec with direction changes every 2-4 seconds
- Add proper Z-buffer testing and sprite sorting for NPCs

Signed-off-by: Jason Hall <jason@chainguard.dev>
2025-09-24 09:26:55 -04:00

206 lines
5.3 KiB
Go

package server
import (
"fmt"
"math/rand"
"sync"
"time"
"github.com/imjasonh/terminus/game"
)
// GameServer holds the shared state for all connected players
type GameServer struct {
Map *game.Map
ProjectileManager *game.ProjectileManager
Players map[string]*PlayerSession
PlayersMutex sync.RWMutex
NPCs []*game.NPC
NPCsMutex sync.RWMutex
MaxPlayers int
}
// PlayerSession represents a connected player's session
type PlayerSession struct {
ID string
Player *game.Player
Connected bool
ConnectedAt time.Time
}
// NewGameServer creates a new game server instance
func NewGameServer(worldMap *game.Map, maxPlayers int) *GameServer {
gs := &GameServer{
Map: worldMap,
ProjectileManager: game.NewProjectileManager(),
Players: make(map[string]*PlayerSession),
NPCs: make([]*game.NPC, 0),
MaxPlayers: maxPlayers,
}
// Spawn NPCs based on map
gs.spawnNPCs()
return gs
}
// AddPlayer adds a new player to the server
func (gs *GameServer) AddPlayer(sessionID string) (*PlayerSession, error) {
gs.PlayersMutex.Lock()
defer gs.PlayersMutex.Unlock()
// Check player limit
if len(gs.Players) >= gs.MaxPlayers {
return nil, fmt.Errorf("server full: max %d players", gs.MaxPlayers)
}
// Find random spawn point
spawnX, spawnY := gs.findRandomSpawnPoint()
// Create new player
player := game.NewPlayer(spawnX, spawnY)
session := &PlayerSession{
ID: sessionID,
Player: player,
Connected: true,
ConnectedAt: time.Now(),
}
gs.Players[sessionID] = session
return session, nil
}
// RemovePlayer removes a player from the server
func (gs *GameServer) RemovePlayer(sessionID string) {
gs.PlayersMutex.Lock()
defer gs.PlayersMutex.Unlock()
if session, exists := gs.Players[sessionID]; exists {
session.Connected = false
delete(gs.Players, sessionID)
}
}
// GetPlayerCount returns the current number of connected players
func (gs *GameServer) GetPlayerCount() int {
gs.PlayersMutex.RLock()
defer gs.PlayersMutex.RUnlock()
return len(gs.Players)
}
// GetPlayerSession returns a player session by ID
func (gs *GameServer) GetPlayerSession(sessionID string) (*PlayerSession, bool) {
gs.PlayersMutex.RLock()
defer gs.PlayersMutex.RUnlock()
session, exists := gs.Players[sessionID]
return session, exists
}
// findRandomSpawnPoint finds a random empty location on the map
func (gs *GameServer) findRandomSpawnPoint() (float64, float64) {
// Find all empty spaces (value 0)
var emptySpaces [][2]int
for y := 0; y < len(gs.Map.Grid); y++ {
for x := 0; x < len(gs.Map.Grid[y]); x++ {
if gs.Map.Grid[y][x] == 0 {
emptySpaces = append(emptySpaces, [2]int{x, y})
}
}
}
// If no empty spaces found, use default spawn
if len(emptySpaces) == 0 {
return 1.5, 1.5
}
// Pick random empty space
chosen := emptySpaces[rand.Intn(len(emptySpaces))]
// Add some randomness within the cell (0.2 to 0.8 range)
spawnX := float64(chosen[0]) + 0.2 + rand.Float64()*0.6
spawnY := float64(chosen[1]) + 0.2 + rand.Float64()*0.6
return spawnX, spawnY
}
// Update updates the shared game state (projectiles, NPCs, etc.)
func (gs *GameServer) Update(deltaTime float64) {
// Update projectiles (thread-safe as it's called from main server loop)
gs.ProjectileManager.Update(deltaTime, gs.Map)
// Update NPCs
gs.updateNPCs(deltaTime)
}
// GetOtherPlayers returns all players except the specified one
func (gs *GameServer) GetOtherPlayers(excludeSessionID string) []*game.Player {
gs.PlayersMutex.RLock()
defer gs.PlayersMutex.RUnlock()
var otherPlayers []*game.Player
for sessionID, session := range gs.Players {
if sessionID != excludeSessionID && session.Connected {
otherPlayers = append(otherPlayers, session.Player)
}
}
return otherPlayers
}
// GetDebugInfo returns debug information about server state
func (gs *GameServer) GetDebugInfo() string {
gs.PlayersMutex.RLock()
playerCount := len(gs.Players)
gs.PlayersMutex.RUnlock()
// Count active projectiles
activeProjectiles := 0
for _, p := range gs.ProjectileManager.Projectiles {
if p.Active && p.Type == game.Fireball {
activeProjectiles++
}
}
return fmt.Sprintf("Players: %d/%d | Projectiles: %d",
playerCount, gs.MaxPlayers, activeProjectiles)
}
// spawnNPCs creates and places NPCs in the world
func (gs *GameServer) spawnNPCs() {
gs.NPCsMutex.Lock()
defer gs.NPCsMutex.Unlock()
// Different NPC counts based on map size/type
npcCount := 3 // Default for maze
if gs.Map.Width > 15 || gs.Map.Height > 15 {
npcCount = 5 // More NPCs for larger maps like cave
}
for i := 0; i < npcCount; i++ {
// Find random spawn point for NPC
spawnX, spawnY := gs.findRandomSpawnPoint()
npc := game.NewNPC(spawnX, spawnY, game.Wanderer)
gs.NPCs = append(gs.NPCs, npc)
}
}
// updateNPCs updates all NPCs in the world
func (gs *GameServer) updateNPCs(deltaTime float64) {
gs.NPCsMutex.RLock()
defer gs.NPCsMutex.RUnlock()
for _, npc := range gs.NPCs {
npc.Update(deltaTime, gs.Map)
}
}
// GetNPCs returns all NPCs for rendering (thread-safe)
func (gs *GameServer) GetNPCs() []*game.NPC {
gs.NPCsMutex.RLock()
defer gs.NPCsMutex.RUnlock()
// Return a copy to avoid race conditions during rendering
npcs := make([]*game.NPC, len(gs.NPCs))
copy(npcs, gs.NPCs)
return npcs
}