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

Implement multiplayer player sprites and persistent SSH host keys

- Add Z-buffer depth testing system for proper sprite rendering
  - Implement multiplayer player visibility with green @ sprites
  - Add sprite rendering system supporting both projectiles and players
  - Fix sprite coordinate transformation and positioning accuracy
  - Make player sprites large and wide for better visibility (75%
  width-to-height ratio)
  - Add persistent SSH host key generation and loading to avoid
  regeneration
  - Update renderer to accept and display other connected players
  - Add thread-safe player list access for multiplayer sprite rendering
  - Ignore generated host key files in .gitignore

Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
Jason Hall 2025-09-24 09:14:23 -04:00
parent 5112cb1f5b
commit bda3f258bd
5 changed files with 239 additions and 50 deletions

2
.gitignore vendored
View file

@ -51,3 +51,5 @@ image-to-ansi
# Editor backup files
*.bak
*.backup
*host_key

2
go.mod
View file

@ -6,11 +6,11 @@ require (
github.com/chainguard-dev/clog v1.7.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.31.0
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
)

53
main.go
View file

@ -1,6 +1,10 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"os"
@ -10,6 +14,7 @@ import (
"github.com/google/uuid"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
"terminus/game"
"terminus/renderer"
@ -19,6 +24,38 @@ import (
var gameServer *server.GameServer
// loadOrCreateHostKey loads an existing host key or creates a new one
func loadOrCreateHostKey(filename string) (ssh.Signer, error) {
// Try to load existing key
if keyData, err := os.ReadFile(filename); err == nil {
return gossh.ParsePrivateKey(keyData)
}
// Generate new RSA key
clog.Info("Generating new SSH host key...")
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Convert to PEM format
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
// Save to file
keyData := pem.EncodeToMemory(privateKeyPEM)
if err := os.WriteFile(filename, keyData, 0600); err != nil {
return nil, fmt.Errorf("failed to save private key: %w", err)
}
clog.Infof("Saved new SSH host key to %s", filename)
// Parse the key
return gossh.ParsePrivateKey(keyData)
}
func main() {
// Parse command line arguments
mapFile := "maze.map" // Default map
@ -38,10 +75,17 @@ func main() {
// Start the global game update loop
go globalGameLoop()
// Load or generate SSH host key
hostKey, err := loadOrCreateHostKey("terminus_host_key")
if err != nil {
clog.Fatalf("Failed to load or create host key: %v", err)
}
// Setup SSH server
sshServer := &ssh.Server{
Addr: ":2222",
Handler: handleSSHSession,
Addr: ":2222",
Handler: handleSSHSession,
HostSigners: []ssh.Signer{hostKey},
}
clog.Info("Terminus SSH server starting on port 2222...")
@ -180,9 +224,10 @@ func runPlayerSession(s ssh.Session, playerSession *server.PlayerSession, gameSc
gameScreen.SetDebugMessage(debugMsg)
// Render the game with shared projectiles
// Render the game with shared projectiles and other players
lights := gameServer.ProjectileManager.GetActiveLights()
gameRenderer.Render(player, gameServer.Map, gameScreen, lights, gameServer.ProjectileManager.Projectiles)
otherPlayers := gameServer.GetOtherPlayers(playerSession.ID)
gameRenderer.Render(player, gameServer.Map, gameScreen, lights, gameServer.ProjectileManager.Projectiles, otherPlayers)
fmt.Fprint(s, gameScreen.Render())
case win := <-winCh:

View file

@ -10,18 +10,25 @@ import (
type Renderer struct {
screenWidth int
screenHeight int
zBuffer []float64 // Z-buffer for depth testing
}
func NewRenderer(width, height int) *Renderer {
return &Renderer{
screenWidth: width,
screenHeight: height,
zBuffer: make([]float64, width), // Initialize Z-buffer
}
}
func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *screen.Screen, lights []game.LightSource, projectiles []*game.Projectile) {
func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *screen.Screen, lights []game.LightSource, projectiles []*game.Projectile, otherPlayers []*game.Player) {
screen.Clear()
// Clear Z-buffer (initialize with max depth)
for i := range r.zBuffer {
r.zBuffer[i] = math.Inf(1) // Infinity represents maximum depth
}
// Update renderer to use game area height
gameHeight := screen.GameHeight
@ -121,6 +128,9 @@ func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *scree
wallPos = game.Vector{X: player.Position.X + perpWallDist*rayDir.X, Y: float64(mapY)}
}
// Store wall distance in Z-buffer for sprite depth testing
r.zBuffer[x] = perpWallDist
// Choose wall color based on wall type, side, distance, and lighting
wallType := worldMap.GetWallType(mapX, mapY)
wallColor := r.getWallColor(wallType, side, perpWallDist, wallPos, lights)
@ -157,11 +167,15 @@ func (r *Renderer) Render(player *game.Player, worldMap *game.Map, screen *scree
}
}
// Render fireballs as sprites
r.renderFireballs(player, screen, projectiles)
// Render all sprites (projectiles and other players)
r.renderAllSprites(player, screen, projectiles, otherPlayers)
}
func (r *Renderer) renderFireballs(player *game.Player, screen *screen.Screen, projectiles []*game.Projectile) {
func (r *Renderer) renderAllSprites(player *game.Player, screen *screen.Screen, projectiles []*game.Projectile, otherPlayers []*game.Player) {
// Collect and sort sprites by distance (far to near)
var sprites []sprite
// Add projectile sprites
for _, projectile := range projectiles {
if !projectile.Active || projectile.Type != game.Fireball {
continue
@ -170,65 +184,179 @@ func (r *Renderer) renderFireballs(player *game.Player, screen *screen.Screen, p
// Transform fireball position relative to player
relativePos := projectile.Position.Sub(player.Position)
// Rotate relative to player's view direction
cos := player.Direction.X
sin := -player.Direction.Y
transformedX := cos*relativePos.X - sin*relativePos.Y
transformedY := sin*relativePos.X + cos*relativePos.Y
// Rotate relative to player's view direction using proper 2D rotation
// We want transformedY to be the distance in front of the player
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
}
// Project to screen coordinates
screenX := int((float64(r.screenWidth) / 2) * (1.0 + transformedX/transformedY))
sprites = append(sprites, sprite{
pos: projectile.Position,
transformedX: transformedX,
transformedY: transformedY,
spriteType: "fireball",
})
}
// Check if on screen
if screenX < 0 || screenX >= r.screenWidth {
// Add other player sprites
for _, otherPlayer := range otherPlayers {
// Transform other player position relative to current player
relativePos := otherPlayer.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
}
// Calculate fireball size based on distance - closer = bigger
gameHeight := screen.GameHeight
sprites = append(sprites, sprite{
pos: otherPlayer.Position,
transformedX: transformedX,
transformedY: transformedY,
spriteType: "player",
})
}
// Use proper perspective projection for size
fireballSize := int(float64(gameHeight) / transformedY * 0.3) // Scale factor for good visibility
// Clamp size for reasonable bounds
if fireballSize < 1 {
fireballSize = 1 // Very far away = tiny dot
// 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++ {
if sprites[i].transformedY < sprites[j].transformedY {
sprites[i], sprites[j] = sprites[j], sprites[i]
}
}
if fireballSize > gameHeight/2 {
fireballSize = gameHeight / 2 // Very close = big but not too big
}
// Render each sprite
for _, spr := range sprites {
r.renderSprite(spr, player, screen)
}
}
// sprite represents a renderable sprite in 3D space
type sprite struct {
pos game.Vector
transformedX float64
transformedY float64
spriteType string
}
// renderSprite renders a single sprite with proper Z-buffer testing
func (r *Renderer) renderSprite(spr sprite, player *game.Player, screen *screen.Screen) {
gameHeight := screen.GameHeight
// Project to screen coordinates using same method as wall renderer
// Calculate where this sprite appears on screen relative to camera plane
cameraPlaneLength := math.Sqrt(player.CameraPlane.X*player.CameraPlane.X + player.CameraPlane.Y*player.CameraPlane.Y)
spriteScreenX := spr.transformedX / spr.transformedY / cameraPlaneLength
screenX := int(float64(r.screenWidth) / 2 * (1.0 + spriteScreenX))
// Check if on screen
if screenX < 0 || screenX >= r.screenWidth {
return
}
// Calculate sprite size based on distance
var spriteSize int
var spriteChar rune
var spriteColor color.RGBA
switch spr.spriteType {
case "fireball":
spriteSize = int(float64(gameHeight) / spr.transformedY * 0.5) // Good size for fireballs
spriteChar = '●'
spriteColor = color.RGBA{255, 150, 0, 255} // Bright orange fireball
case "player":
// More stable size calculation - less sensitive to small distance changes
baseSize := float64(gameHeight) / spr.transformedY * 1.2
spriteSize = int(baseSize + 0.5) // Round properly
// Clamp to reasonable bounds for stability
if spriteSize < 4 {
spriteSize = 4
}
spriteChar = '@'
spriteColor = color.RGBA{0, 255, 0, 255} // Green player
default:
return
}
// Draw fireball
startY := gameHeight/2 - fireballSize/2
endY := gameHeight/2 + fireballSize/2
// Clamp size
if spriteSize < 1 {
spriteSize = 1
}
if spriteSize > gameHeight/2 {
spriteSize = gameHeight / 2
}
if startY < 0 {
startY = 0
}
if endY >= gameHeight {
endY = gameHeight - 1
}
// Calculate vertical bounds
startY := gameHeight/2 - spriteSize/2
endY := gameHeight/2 + spriteSize/2
// Bright white fireball
fireballColor := color.RGBA{255, 255, 255, 255} // Pure white
if startY < 0 {
startY = 0
}
if endY >= gameHeight {
endY = gameHeight - 1
}
// Calculate width based on size (bigger fireballs are wider)
fireballWidth := fireballSize / 3 // Width proportional to height
if fireballWidth < 1 {
fireballWidth = 1 // At least 1 pixel wide
}
// Calculate horizontal width - make players even wider
var spriteWidth int
if spr.spriteType == "player" {
spriteWidth = (spriteSize * 3) / 4 // Players are much wider - almost as wide as they are tall
} else {
spriteWidth = spriteSize / 3 // Fireballs stay normal width
}
if spriteWidth < 1 {
spriteWidth = 1
}
// Draw fireball sprite with proper scaling
for y := startY; y <= endY; y++ {
for xOffset := -fireballWidth / 2; xOffset <= fireballWidth/2; xOffset++ {
drawX := screenX + xOffset
if drawX >= 0 && drawX < r.screenWidth {
screen.SetCell(drawX, y, '●', fireballColor, fireballColor)
// Render sprite with Z-buffer testing
for xOffset := -spriteWidth / 2; xOffset <= spriteWidth/2; xOffset++ {
drawX := screenX + xOffset
// Check bounds and Z-buffer for proper depth testing
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" {
// 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 {
// 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
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)
}
}
}
}

View file

@ -122,6 +122,20 @@ func (gs *GameServer) Update(deltaTime float64) {
gs.ProjectileManager.Update(deltaTime, gs.Map)
}
// 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()