1
0
Fork 0
mirror of https://github.com/imjasonh/terminus synced 2026-07-06 22:12:35 +00:00

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>
This commit is contained in:
Jason Hall 2025-09-24 09:26:55 -04:00
parent bda3f258bd
commit b1694154b7
4 changed files with 139 additions and 48 deletions

2
go.mod
View file

@ -1,4 +1,4 @@
module terminus
module github.com/imjasonh/terminus
go 1.25.1

13
main.go
View file

@ -16,10 +16,10 @@ import (
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
"terminus/game"
"terminus/renderer"
"terminus/screen"
"terminus/server"
"github.com/imjasonh/terminus/game"
"github.com/imjasonh/terminus/renderer"
"github.com/imjasonh/terminus/screen"
"github.com/imjasonh/terminus/server"
)
var gameServer *server.GameServer
@ -224,10 +224,11 @@ func runPlayerSession(s ssh.Session, playerSession *server.PlayerSession, gameSc
gameScreen.SetDebugMessage(debugMsg)
// Render the game with shared projectiles and other players
// Render the game with shared projectiles, other players, and NPCs
lights := gameServer.ProjectileManager.GetActiveLights()
otherPlayers := gameServer.GetOtherPlayers(playerSession.ID)
gameRenderer.Render(player, gameServer.Map, gameScreen, lights, gameServer.ProjectileManager.Projectiles, otherPlayers)
npcs := gameServer.GetNPCs()
gameRenderer.Render(player, gameServer.Map, gameScreen, lights, gameServer.ProjectileManager.Projectiles, otherPlayers, npcs)
fmt.Fprint(s, gameScreen.Render())
case win := <-winCh:

View file

@ -3,8 +3,9 @@ package renderer
import (
"image/color"
"math"
"terminus/game"
"terminus/screen"
"github.com/imjasonh/terminus/game"
"github.com/imjasonh/terminus/screen"
)
type Renderer struct {
@ -21,7 +22,7 @@ func NewRenderer(width, height int) *Renderer {
}
}
func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *screen.Screen, lights []game.LightSource, projectiles []*game.Projectile, otherPlayers []*game.Player) {
func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *screen.Screen, lights []game.LightSource, projectiles []*game.Projectile, otherPlayers []*game.Player, npcs []*game.NPC) {
screen.Clear()
// Clear Z-buffer (initialize with max depth)
@ -167,11 +168,11 @@ func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *scree
}
}
// Render all sprites (projectiles and other players)
r.renderAllSprites(player, screen, projectiles, otherPlayers)
// Render all sprites (projectiles, other players, and NPCs)
r.renderAllSprites(player, screen, projectiles, otherPlayers, npcs)
}
func (r *Renderer) renderAllSprites(player *game.Player, screen *screen.Screen, projectiles []*game.Projectile, otherPlayers []*game.Player) {
func (r *Renderer) renderAllSprites(player *game.Player, screen *screen.Screen, projectiles []*game.Projectile, otherPlayers []*game.Player, npcs []*game.NPC) {
// Collect and sort sprites by distance (far to near)
var sprites []sprite
@ -224,6 +225,28 @@ func (r *Renderer) renderAllSprites(player *game.Player, screen *screen.Screen,
})
}
// Add NPC sprites
for _, npc := range npcs {
// Transform NPC position relative to current player
relativePos := npc.Position.Sub(player.Position)
// Rotate relative to player's view direction using proper 2D rotation
transformedY := relativePos.X*player.Direction.X + relativePos.Y*player.Direction.Y
transformedX := relativePos.X*player.Direction.Y + relativePos.Y*(-player.Direction.X)
// Skip if behind player
if transformedY <= 0.1 {
continue
}
sprites = append(sprites, sprite{
pos: npc.Position,
transformedX: transformedX,
transformedY: transformedY,
spriteType: "npc",
})
}
// Sort sprites from farthest to nearest (painter's algorithm)
for i := 0; i < len(sprites)-1; i++ {
for j := i + 1; j < len(sprites); j++ {
@ -282,6 +305,16 @@ func (r *Renderer) renderSprite(spr sprite, player *game.Player, screen *screen.
}
spriteChar = '@'
spriteColor = color.RGBA{0, 255, 0, 255} // Green player
case "npc":
// NPCs are slightly smaller than players
baseSize := float64(gameHeight) / spr.transformedY * 1.0
spriteSize = int(baseSize + 0.5) // Round properly
// Clamp to reasonable bounds for stability
if spriteSize < 3 {
spriteSize = 3
}
spriteChar = '◐' // Half-filled circle
spriteColor = color.RGBA{0, 150, 255, 255} // Blue NPC
default:
return
}
@ -305,11 +338,14 @@ func (r *Renderer) renderSprite(spr sprite, player *game.Player, screen *screen.
endY = gameHeight - 1
}
// Calculate horizontal width - make players even wider
// Calculate horizontal width - different widths for different sprite types
var spriteWidth int
if spr.spriteType == "player" {
switch spr.spriteType {
case "player":
spriteWidth = (spriteSize * 3) / 4 // Players are much wider - almost as wide as they are tall
} else {
case "npc":
spriteWidth = spriteSize / 2 // NPCs are medium width
default: // fireballs and others
spriteWidth = spriteSize / 3 // Fireballs stay normal width
}
if spriteWidth < 1 {
@ -324,39 +360,42 @@ func (r *Renderer) renderSprite(spr sprite, player *game.Player, screen *screen.
if drawX >= 0 && drawX < r.screenWidth && spr.transformedY < r.zBuffer[drawX]+0.1 {
// Draw the sprite column
for y := startY; y <= endY; y++ {
// Render fireballs with proper appearance
if spr.spriteType == "fireball" {
centerY := startY + (endY-startY)/2
distFromCenter := math.Abs(float64(y-centerY)) / float64(spriteSize/2+1)
distFromCenterX := math.Abs(float64(xOffset)) / float64(spriteWidth/2+1)
var intensity float64
var threshold float64
var brightnessMult float64
switch spr.spriteType {
case "fireball":
// Simple circular pattern for fireballs
centerY := startY + (endY-startY)/2
distFromCenter := math.Abs(float64(y-centerY)) / float64(spriteSize/2+1)
distFromCenterX := math.Abs(float64(xOffset)) / float64(spriteWidth/2+1)
intensity := 1.0 - math.Sqrt(distFromCenter*distFromCenter+distFromCenterX*distFromCenterX)
if intensity > 0.1 { // Low threshold for visibility
finalColor := color.RGBA{
uint8(math.Min(255, float64(spriteColor.R)*intensity*1.2)),
uint8(math.Min(255, float64(spriteColor.G)*intensity*1.2)),
uint8(math.Min(255, float64(spriteColor.B)*intensity*1.2)),
255,
}
screen.SetCell(drawX, y, spriteChar, finalColor, finalColor)
}
} else {
intensity = 1.0 - math.Sqrt(distFromCenter*distFromCenter+distFromCenterX*distFromCenterX)
threshold = 0.1 // Low threshold for visibility
brightnessMult = 1.2
case "player":
// Make player sprites more solid and visible
centerY := startY + (endY-startY)/2
distFromCenter := math.Abs(float64(y-centerY)) / float64(spriteSize/2+1)
distFromCenterX := math.Abs(float64(xOffset)) / float64(spriteWidth/2+1)
intensity = 1.0 - math.Sqrt(distFromCenter*distFromCenter+distFromCenterX*distFromCenterX*0.5) // Less fade on X axis
threshold = 0.05 // Very low threshold for maximum visibility
brightnessMult = 1.5
case "npc":
// NPCs are visible but not as prominent as players
intensity = 1.0 - math.Sqrt(distFromCenter*distFromCenter+distFromCenterX*distFromCenterX*0.7) // Medium fade
threshold = 0.15 // Medium threshold
brightnessMult = 1.3
default:
continue
}
intensity := 1.0 - math.Sqrt(distFromCenter*distFromCenter+distFromCenterX*distFromCenterX*0.5) // Less fade on X axis
if intensity > 0.05 { // Very low threshold for maximum visibility
finalColor := color.RGBA{
uint8(math.Min(255, float64(spriteColor.R)*intensity*1.5)),
uint8(math.Min(255, float64(spriteColor.G)*intensity*1.5)),
uint8(math.Min(255, float64(spriteColor.B)*intensity*1.5)),
255,
}
screen.SetCell(drawX, y, spriteChar, finalColor, finalColor)
if intensity > threshold {
finalColor := color.RGBA{
uint8(math.Min(255, float64(spriteColor.R)*intensity*brightnessMult)),
uint8(math.Min(255, float64(spriteColor.G)*intensity*brightnessMult)),
uint8(math.Min(255, float64(spriteColor.B)*intensity*brightnessMult)),
255,
}
screen.SetCell(drawX, y, spriteChar, finalColor, finalColor)
}
}
}

View file

@ -6,7 +6,7 @@ import (
"sync"
"time"
"terminus/game"
"github.com/imjasonh/terminus/game"
)
// GameServer holds the shared state for all connected players
@ -15,6 +15,8 @@ type GameServer struct {
ProjectileManager *game.ProjectileManager
Players map[string]*PlayerSession
PlayersMutex sync.RWMutex
NPCs []*game.NPC
NPCsMutex sync.RWMutex
MaxPlayers int
}
@ -28,12 +30,18 @@ type PlayerSession struct {
// NewGameServer creates a new game server instance
func NewGameServer(worldMap *game.Map, maxPlayers int) *GameServer {
return &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
@ -116,10 +124,13 @@ func (gs *GameServer) findRandomSpawnPoint() (float64, float64) {
return spawnX, spawnY
}
// Update updates the shared game state (projectiles, etc.)
// 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
@ -153,3 +164,43 @@ func (gs *GameServer) GetDebugInfo() string {
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
}