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:
parent
bda3f258bd
commit
b1694154b7
4 changed files with 139 additions and 48 deletions
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module terminus
|
||||
module github.com/imjasonh/terminus
|
||||
|
||||
go 1.25.1
|
||||
|
||||
|
|
|
|||
13
main.go
13
main.go
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue