package main import ( "bufio" "bytes" "fmt" "io" "math/rand" "sort" "strings" "time" "github.com/dustinkirkland/golang-petname" "github.com/fatih/color" "golang.org/x/crypto/ssh" ) type Hub struct { Sessions map[*Session]struct{} Redraw chan struct{} Register chan *Session Unregister chan *Session } func NewHub() Hub { return Hub{ Sessions: make(map[*Session]struct{}), Redraw: make(chan struct{}), Register: make(chan *Session), Unregister: make(chan *Session), } } func (h *Hub) Run(g *Game) { for { select { case <-h.Redraw: for s := range h.Sessions { go g.Render(s) } case s := <-h.Register: // Hide the cursor fmt.Fprint(s, "\033[?25l") h.Sessions[s] = struct{}{} case s := <-h.Unregister: if _, ok := h.Sessions[s]; ok { fmt.Fprint(s, "\r\n\r\n~ End of Line ~ \r\n\r\nRemember to use WASD to move!\r\n\r\n") // Unhide the cursor fmt.Fprint(s, "\033[?25h") delete(h.Sessions, s) s.c.Close() } } } } type Position struct { X float64 Y float64 } func PositionFromInt(x, y int) Position { return Position{float64(x), float64(y)} } func (p Position) RoundX() int { return int(p.X + 0.5) } func (p Position) RoundY() int { return int(p.Y + 0.5) } type PlayerDirection int const ( verticalPlayerSpeed = 0.007 horizontalPlayerSpeed = 0.01 playerCountScoreMultiplier = 1.25 playerTimeout = 15 * time.Second playerUpRune = '⇡' playerLeftRune = '⇠' playerDownRune = '⇣' playerRightRune = '⇢' playerTrailHorizontal = '┄' playerTrailVertical = '┆' playerTrailLeftCornerUp = '╭' playerTrailLeftCornerDown = '╰' playerTrailRightCornerDown = '╯' playerTrailRightCornerUp = '╮' playerRed = color.FgRed playerGreen = color.FgGreen playerYellow = color.FgYellow playerBlue = color.FgBlue playerMagenta = color.FgMagenta playerCyan = color.FgCyan playerWhite = color.FgWhite PlayerUp PlayerDirection = iota PlayerLeft PlayerDown PlayerRight ) var playerColors = []color.Attribute{ playerRed, playerGreen, playerYellow, playerBlue, playerMagenta, playerCyan, playerWhite, } var playerBorderColors = map[color.Attribute]color.Attribute{ playerRed: color.FgHiRed, playerGreen: color.FgHiGreen, playerYellow: color.FgHiYellow, playerBlue: color.FgHiBlue, playerMagenta: color.FgHiMagenta, playerCyan: color.FgHiCyan, playerWhite: color.FgHiWhite, } var playerColorNames = map[color.Attribute]string{ playerRed: "Red", playerGreen: "Green", playerYellow: "Yellow", playerBlue: "Blue", playerMagenta: "Magenta", playerCyan: "Cyan", playerWhite: "White", } type PlayerTrailSegment struct { Marker rune Pos Position } type Player struct { s *Session Name string CreatedAt time.Time Direction PlayerDirection Marker rune Color color.Attribute Pos *Position Trail []PlayerTrailSegment WinStreak []color.Attribute score float64 } // NewPlayer creates a new player. If color is below 1, a random color is chosen func NewPlayer(s *Session, worldWidth, worldHeight int, color color.Attribute) *Player { rand.Seed(time.Now().UnixNano()) startX := rand.Float64() * float64(worldWidth) startY := rand.Float64() * float64(worldHeight) if color < 0 { color = playerColors[rand.Intn(len(playerColors))] } return &Player{ s: s, CreatedAt: time.Now(), Marker: playerDownRune, Direction: PlayerDown, Color: color, Pos: &Position{startX, startY}, } } func (p *Player) addTrailSegment(pos Position, marker rune) { segment := PlayerTrailSegment{marker, pos} p.Trail = append([]PlayerTrailSegment{segment}, p.Trail...) } func (p *Player) calculateScore(delta float64, playerCount int) float64 { rawIncrement := (delta * (float64(playerCount-1) * playerCountScoreMultiplier)) // Convert millisecond increment to seconds actualIncrement := rawIncrement / 1000 return p.score + actualIncrement } func (p *Player) HandleUp() { if p.Direction == PlayerDown { return } p.Direction = PlayerUp p.Marker = playerUpRune p.s.didAction() } func (p *Player) HandleLeft() { if p.Direction == PlayerRight { return } p.Direction = PlayerLeft p.Marker = playerLeftRune p.s.didAction() } func (p *Player) HandleDown() { if p.Direction == PlayerUp { return } p.Direction = PlayerDown p.Marker = playerDownRune p.s.didAction() } func (p *Player) HandleRight() { if p.Direction == PlayerLeft { return } p.Direction = PlayerRight p.Marker = playerRightRune p.s.didAction() } func (p *Player) Score() int { return int(p.score) } func (p *Player) Update(g *Game, delta float64) { startX, startY := p.Pos.RoundX(), p.Pos.RoundY() switch p.Direction { case PlayerUp: p.Pos.Y -= verticalPlayerSpeed * delta case PlayerLeft: p.Pos.X -= horizontalPlayerSpeed * delta case PlayerDown: p.Pos.Y += verticalPlayerSpeed * delta case PlayerRight: p.Pos.X += horizontalPlayerSpeed * delta } endX, endY := p.Pos.RoundX(), p.Pos.RoundY() // If we moved, add a trail segment. if endX != startX || endY != startY { var lastSeg *PlayerTrailSegment var lastSegX, lastSegY int if len(p.Trail) > 0 { lastSeg = &p.Trail[0] lastSegX = lastSeg.Pos.RoundX() lastSegY = lastSeg.Pos.RoundY() } pos := PositionFromInt(startX, startY) switch { // Handle corners. This took an ungodly amount of time to figure out. Highly // recommend you don't touch. case lastSeg != nil && (p.Direction == PlayerRight && endX > lastSegX && endY < lastSegY) || (p.Direction == PlayerDown && endX < lastSegX && endY > lastSegY): p.addTrailSegment(pos, playerTrailLeftCornerUp) case lastSeg != nil && (p.Direction == PlayerUp && endX > lastSegX && endY < lastSegY) || (p.Direction == PlayerLeft && endX < lastSegX && endY > lastSegY): p.addTrailSegment(pos, playerTrailRightCornerDown) case lastSeg != nil && (p.Direction == PlayerDown && endX > lastSegX && endY > lastSegY) || (p.Direction == PlayerLeft && endX < lastSegX && endY < lastSegY): p.addTrailSegment(pos, playerTrailRightCornerUp) case lastSeg != nil && (p.Direction == PlayerRight && endX > lastSegX && endY > lastSegY) || (p.Direction == PlayerUp && endX < lastSegX && endY < lastSegY): p.addTrailSegment(pos, playerTrailLeftCornerDown) // Vertical and horizontal trails case endX == startX && endY < startY: p.addTrailSegment(pos, playerTrailVertical) case endX < startX && endY == startY: p.addTrailSegment(pos, playerTrailHorizontal) case endX == startX && endY > startY: p.addTrailSegment(pos, playerTrailVertical) case endX > startX && endY == startY: p.addTrailSegment(pos, playerTrailHorizontal) } } p.score = p.calculateScore(delta, len(g.players())) } type ByColor []*Player func (slice ByColor) Len() int { return len(slice) } func (slice ByColor) Less(i, j int) bool { return playerColorNames[slice[i].Color] < playerColorNames[slice[j].Color] } func (slice ByColor) Swap(i, j int) { slice[i], slice[j] = slice[j], slice[i] } type TileType int const ( TileGrass TileType = iota TileBlocker ) type Tile struct { Type TileType } const ( gameWidth = 78 gameHeight = 22 keyW = 'w' keyA = 'a' keyS = 's' keyD = 'd' keyZ = 'z' keyQ = 'q' // keyS and keyD are already defined keyH = 'h' keyJ = 'j' keyK = 'k' keyL = 'l' keyComma = ',' keyO = 'o' keyE = 'e' keyCtrlC = 3 keyEscape = 27 ) type GameManager struct { Games map[string]*Game HandleChannel chan ssh.Channel } func NewGameManager() *GameManager { return &GameManager{ Games: map[string]*Game{}, HandleChannel: make(chan ssh.Channel), } } // getGameWithAvailability returns a reference to a game with available spots for // players. If one does not exist, nil is returned. func (gm *GameManager) getGameWithAvailability() *Game { var g *Game for _, game := range gm.Games { spots := game.AvailableColors() if len(spots) > 0 { g = game break } } return g } func (gm *GameManager) SessionCount() int { sum := 0 for _, game := range gm.Games { sum += game.SessionCount() } return sum } func (gm *GameManager) GameCount() int { return len(gm.Games) } func (gm *GameManager) HandleNewChannel(c ssh.Channel, color string) { g := gm.getGameWithAvailability() if g == nil { g = NewGame(gameWidth, gameHeight) gm.Games[g.Name] = g go g.Run() } colorOptions := g.AvailableColors() finalColor := colorOptions[0] // choose the requested color if available color = strings.ToLower(color) for _, clr := range colorOptions { if strings.ToLower(playerColorNames[clr]) == color { finalColor = clr break } } session := NewSession(c, g.WorldWidth(), g.WorldHeight(), finalColor) g.AddSession(session) go func() { reader := bufio.NewReader(c) for { r, _, err := reader.ReadRune() if err != nil { fmt.Println(err) break } switch r { case keyW, keyZ, keyK, keyComma: session.Player.HandleUp() case keyA, keyQ, keyH: session.Player.HandleLeft() case keyS, keyJ, keyO: session.Player.HandleDown() case keyD, keyL, keyE: session.Player.HandleRight() case keyCtrlC, keyEscape: if g.SessionCount() == 1 { delete(gm.Games, g.Name) } g.RemoveSession(session) } } }() } type Game struct { Name string Redraw chan struct{} HighScore int // Top left is 0,0 level [][]Tile hub Hub } func NewGame(worldWidth, worldHeight int) *Game { g := &Game{ Name: petname.Generate(1, ""), Redraw: make(chan struct{}), hub: NewHub(), } g.initalizeLevel(worldWidth, worldHeight) return g } func (g *Game) initalizeLevel(width, height int) { g.level = make([][]Tile, width) for x := range g.level { g.level[x] = make([]Tile, height) } // Default world to grass for x := range g.level { for y := range g.level[x] { g.setTileType(Position{float64(x), float64(y)}, TileGrass) } } } func (g *Game) setTileType(pos Position, tileType TileType) error { outOfBoundsErr := "The given %s value (%s) is out of bounds" if pos.RoundX() > len(g.level) || pos.RoundX() < 0 { return fmt.Errorf(outOfBoundsErr, "X", pos.X) } else if pos.RoundY() > len(g.level[pos.RoundX()]) || pos.RoundY() < 0 { return fmt.Errorf(outOfBoundsErr, "Y", pos.Y) } g.level[pos.RoundX()][pos.RoundY()].Type = tileType return nil } func (g *Game) players() map[*Player]*Session { players := make(map[*Player]*Session) for session := range g.hub.Sessions { players[session.Player] = session } return players } // Characters for rendering const ( verticalWall = '║' horizontalWall = '═' topLeft = '╔' topRight = '╗' bottomRight = '╝' bottomLeft = '╚' grass = ' ' blocker = '■' ) // Warning: this will only work with square worlds func (g *Game) worldString(s *Session) string { worldWidth := len(g.level) worldHeight := len(g.level[0]) // Create two dimensional slice of strings to represent the world. It's two // characters larger in each direction to accomodate for walls. strWorld := make([][]string, worldWidth+2) for x := range strWorld { strWorld[x] = make([]string, worldHeight+2) } // Load the walls into the rune slice borderColorizer := color.New(playerBorderColors[s.Player.Color]).SprintFunc() for x := 0; x < worldWidth+2; x++ { strWorld[x][0] = borderColorizer(string(horizontalWall)) strWorld[x][worldHeight+1] = borderColorizer(string(horizontalWall)) } for y := 0; y < worldHeight+2; y++ { strWorld[0][y] = borderColorizer(string(verticalWall)) strWorld[worldWidth+1][y] = borderColorizer(string(verticalWall)) } // Time for the edges! strWorld[0][0] = borderColorizer(string(topLeft)) strWorld[worldWidth+1][0] = borderColorizer(string(topRight)) strWorld[worldWidth+1][worldHeight+1] = borderColorizer(string(bottomRight)) strWorld[0][worldHeight+1] = borderColorizer(string(bottomLeft)) // Draw the player's score scoreStr := fmt.Sprintf( " Score: %d : Your High Score: %d : Game High Score: %d ", s.Player.Score(), s.HighScore, g.HighScore, ) for i, r := range scoreStr { strWorld[3+i][0] = borderColorizer(string(r)) } // Draw the player's color colorStr := fmt.Sprintf(" %s ", playerColorNames[s.Player.Color]) colorStrColorizer := color.New(s.Player.Color).SprintFunc() for i, r := range colorStr { charsRemaining := len(colorStr) - i strWorld[len(strWorld)-3-charsRemaining][0] = colorStrColorizer(string(r)) } // Draw everyone's scores if len(g.players()) > 1 { // Sort the players by color name players := []*Player{} for player := range g.players() { if player == s.Player { continue } players = append(players, player) } sort.Sort(ByColor(players)) startX := 3 // Actually draw their scores for _, player := range players { colorizer := color.New(player.Color).SprintFunc() scoreStr := fmt.Sprintf(" %s: %d", playerColorNames[player.Color], player.Score(), ) for _, r := range scoreStr { strWorld[startX][len(strWorld[0])-1] = colorizer(string(r)) startX++ } } // Add final spacing next to wall strWorld[startX][len(strWorld[0])-1] = " " } else { warning := " Warning: Other Players Must be in This Game for You to Score! " for i, r := range warning { strWorld[3+i][len(strWorld[0])-1] = borderColorizer(string(r)) } } // Draw the game's name nameStr := fmt.Sprintf(" %s ", g.Name) for i, r := range nameStr { charsRemaining := len(nameStr) - i strWorld[len(strWorld)-3-charsRemaining][len(strWorld[0])-1] = borderColorizer(string(r)) } // Load the level into the string slice for x := 0; x < worldWidth; x++ { for y := 0; y < worldHeight; y++ { tile := g.level[x][y] switch tile.Type { case TileGrass: strWorld[x+1][y+1] = string(grass) case TileBlocker: strWorld[x+1][y+1] = string(blocker) } } } // Load the players into the rune slice for player := range g.players() { colorizer := color.New(player.Color).SprintFunc() pos := player.Pos strWorld[pos.RoundX()+1][pos.RoundY()+1] = colorizer(string(player.Marker)) // Make the rainbow of trail colors to cycle over trailColorizers := make([](func(...interface{}) string), len(player.WinStreak)+1) trailColorizers[0] = colorizer for i, winColor := range player.WinStreak { trailColorizers[i+1] = color.New(winColor).SprintFunc() } // Load the player's trail into the rune slice for i, segment := range player.Trail { x, y := segment.Pos.RoundX()+1, segment.Pos.RoundY()+1 strWorld[x][y] = trailColorizers[i%len(trailColorizers)](string(segment.Marker)) } } // Convert the rune slice to a string buffer := bytes.NewBuffer(make([]byte, 0, worldWidth*worldHeight*2)) for y := 0; y < len(strWorld[0]); y++ { for x := 0; x < len(strWorld); x++ { buffer.WriteString(strWorld[x][y]) } // Don't add an extra newline if we're on the last iteration if y != len(strWorld[0])-1 { buffer.WriteString("\r\n") } } return buffer.String() } func (g *Game) WorldWidth() int { return len(g.level) } func (g *Game) WorldHeight() int { return len(g.level[0]) } func (g *Game) AvailableColors() []color.Attribute { usedColors := map[color.Attribute]bool{} for _, color := range playerColors { usedColors[color] = false } for player := range g.players() { usedColors[player.Color] = true } availableColors := []color.Attribute{} for color, used := range usedColors { if !used { availableColors = append(availableColors, color) } } return availableColors } func (g *Game) SessionCount() int { return len(g.hub.Sessions) } func (g *Game) Run() { // Proxy g.Redraw's channel to g.hub.Redraw go func() { for { g.hub.Redraw <- <-g.Redraw } }() // Run game loop go func() { var lastUpdate time.Time c := time.Tick(time.Second / 60) for now := range c { g.Update(float64(now.Sub(lastUpdate)) / float64(time.Millisecond)) lastUpdate = now } }() // Redraw regularly. // // TODO: Implement diffing and only redraw when needed go func() { c := time.Tick(time.Second / 10) for range c { g.Redraw <- struct{}{} } }() g.hub.Run(g) } // Update is the main game logic loop. Delta is the time since the last update // in milliseconds. func (g *Game) Update(delta float64) { // We'll use this to make a set of all of the coordinates that are occupied by // trails trailCoordMap := make(map[string]*Player) // Update player data for player, session := range g.players() { player.Update(g, delta) // Update session high score, if applicable if player.Score() > session.HighScore { session.HighScore = player.Score() } // Update global high score, if applicable if player.Score() > g.HighScore { g.HighScore = player.Score() } // Restart the player if they're out of bounds pos := player.Pos if pos.RoundX() < 0 || pos.RoundX() >= len(g.level) || pos.RoundY() < 0 || pos.RoundY() >= len(g.level[0]) { session.StartOver(g.WorldWidth(), g.WorldHeight()) } // Kick the player if they've timed out if time.Now().Sub(session.LastAction) > playerTimeout { fmt.Fprint(session, "\r\n\r\nYou were terminated due to inactivity\r\n") g.RemoveSession(session) return } for _, seg := range player.Trail { coordStr := fmt.Sprintf("%d,%d", seg.Pos.RoundX(), seg.Pos.RoundY()) trailCoordMap[coordStr] = player } } // Check if any players collide with a trail and restart them if so for player, session := range g.players() { playerPos := fmt.Sprintf("%d,%d", player.Pos.RoundX(), player.Pos.RoundY()) if otherPlayer, collided := trailCoordMap[playerPos]; collided { otherPlayer.WinStreak = append(otherPlayer.WinStreak, player.Color) session.StartOver(g.WorldWidth(), g.WorldHeight()) } } } func (g *Game) Render(s *Session) { worldStr := g.worldString(s) var b bytes.Buffer b.WriteString("\033[H\033[2J") b.WriteString(worldStr) // Send over the rendered world io.Copy(s, &b) } func (g *Game) AddSession(s *Session) { g.hub.Register <- s } func (g *Game) RemoveSession(s *Session) { g.hub.Unregister <- s } type Session struct { c ssh.Channel LastAction time.Time HighScore int Player *Player } func NewSession(c ssh.Channel, worldWidth, worldHeight int, color color.Attribute) *Session { s := Session{c: c, LastAction: time.Now()} s.newGame(worldWidth, worldHeight, color) return &s } func (s *Session) newGame(worldWidth, worldHeight int, color color.Attribute) { s.Player = NewPlayer(s, worldWidth, worldHeight, color) } func (s *Session) didAction() { s.LastAction = time.Now() } func (s *Session) StartOver(worldWidth, worldHeight int) { s.newGame(worldWidth, worldHeight, s.Player.Color) } func (s *Session) Read(p []byte) (int, error) { return s.c.Read(p) } func (s *Session) Write(p []byte) (int, error) { return s.c.Write(p) }