417 lines
8.8 KiB
Go
417 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
cryptoRand "crypto/rand"
|
|
"encoding/hex"
|
|
"flag"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/vmihailenco/msgpack/v5"
|
|
)
|
|
|
|
// -------------------- Config --------------------
|
|
const (
|
|
AddrHTTP = ":8080"
|
|
MaxPlayers = 10
|
|
TickRateHz = 15
|
|
NameMaxLen = 25
|
|
ChatMaxLen = 500
|
|
StateBufLimit = 256
|
|
|
|
// Chat Anti-spam
|
|
MinChatInterval = 400 * time.Millisecond
|
|
)
|
|
|
|
// Allowed specified origins for browser WS connections
|
|
var allowedOrigins = map[string]bool{
|
|
"https://world.seppjm.com": true,
|
|
"https://seppjm.com": true,
|
|
"https://cv.seppjm.com": true,
|
|
"https://seppdroid.com": true,
|
|
"https://sepp.mx": true,
|
|
//"http://world.seppjm.com": true, // (dev)
|
|
//"http://localhost:5173": true, // (dev)
|
|
//"http://localhost:8080": true, // (dev)
|
|
}
|
|
|
|
// -------------------- Types --------------------
|
|
type Role string
|
|
|
|
const (
|
|
RolePlayer Role = "player"
|
|
RoleSpectator Role = "spectator"
|
|
)
|
|
|
|
type Vec3 [3]float64
|
|
|
|
type Player struct {
|
|
ID string `msgpack:"id"`
|
|
Name string `msgpack:"name"`
|
|
Pos Vec3 `msgpack:"pos"`
|
|
RotY float64 `msgpack:"rotY"`
|
|
}
|
|
|
|
type Client struct {
|
|
Conn *websocket.Conn
|
|
Hub *Hub
|
|
ID string
|
|
Name string
|
|
Role Role
|
|
Send chan []byte
|
|
Closed chan struct{}
|
|
LastChat time.Time
|
|
connected time.Time
|
|
}
|
|
|
|
type Hub struct {
|
|
mu sync.RWMutex
|
|
clients map[string]*Client // id -> client
|
|
players map[string]*Player // id -> player
|
|
joinCh chan *Client
|
|
leaveCh chan *Client
|
|
broadcast chan []byte
|
|
}
|
|
|
|
func NewHub() *Hub {
|
|
return &Hub{
|
|
clients: make(map[string]*Client),
|
|
players: make(map[string]*Player),
|
|
joinCh: make(chan *Client, 64),
|
|
leaveCh: make(chan *Client, 64),
|
|
broadcast: make(chan []byte, 1024),
|
|
}
|
|
}
|
|
|
|
func (h *Hub) Run() {
|
|
ticker := time.NewTicker(time.Second / TickRateHz)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case c := <-h.joinCh:
|
|
h.addClient(c)
|
|
case c := <-h.leaveCh:
|
|
h.removeClient(c)
|
|
case msg := <-h.broadcast:
|
|
h.mu.RLock()
|
|
for _, cl := range h.clients {
|
|
select {
|
|
case cl.Send <- msg:
|
|
default:
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
case <-ticker.C:
|
|
h.tickState()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Hub) addClient(c *Client) {
|
|
h.mu.Lock()
|
|
h.clients[c.ID] = c
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
func (h *Hub) removeClient(c *Client) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
delete(h.clients, c.ID)
|
|
if _, ok := h.players[c.ID]; ok {
|
|
delete(h.players, c.ID)
|
|
packet := map[string]any{"type": "player_leave", "id": c.ID}
|
|
if b, _ := msgpack.Marshal(packet); b != nil {
|
|
for _, cl := range h.clients {
|
|
select {
|
|
case cl.Send <- b:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Hub) tickState() {
|
|
h.mu.RLock()
|
|
if len(h.players) == 0 || len(h.clients) == 0 {
|
|
h.mu.RUnlock()
|
|
return
|
|
}
|
|
updates := make([]map[string]any, 0, len(h.players))
|
|
for _, p := range h.players {
|
|
updates = append(updates, map[string]any{
|
|
"id": p.ID,
|
|
"pos": p.Pos,
|
|
"rotY": p.RotY,
|
|
})
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
packet := map[string]any{"type": "state", "updates": updates}
|
|
if b, _ := msgpack.Marshal(packet); b != nil {
|
|
select {
|
|
case h.broadcast <- b:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------- WS Handling --------------------
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
EnableCompression: true,
|
|
HandshakeTimeout: 10 * time.Second,
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
origin := r.Header.Get("Origin")
|
|
return allowedOrigins[origin]
|
|
},
|
|
}
|
|
|
|
type inbound struct {
|
|
Type string `msgpack:"type"`
|
|
Name string `msgpack:"name"`
|
|
Role Role `msgpack:"role"`
|
|
Pos *Vec3 `msgpack:"pos"`
|
|
RotY *float64 `msgpack:"rotY"`
|
|
Text string `msgpack:"text"`
|
|
}
|
|
|
|
func wsHandler(h *Hub) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Println("upgrade:", err)
|
|
return
|
|
}
|
|
id := genID()
|
|
c := &Client{
|
|
Conn: conn,
|
|
Hub: h,
|
|
ID: id,
|
|
Send: make(chan []byte, StateBufLimit),
|
|
Closed: make(chan struct{}),
|
|
connected: time.Now(),
|
|
}
|
|
h.joinCh <- c
|
|
|
|
go writer(c)
|
|
reader(c)
|
|
}
|
|
}
|
|
|
|
func reader(c *Client) {
|
|
defer func() {
|
|
c.Hub.leaveCh <- c
|
|
close(c.Closed)
|
|
_ = c.Conn.Close()
|
|
}()
|
|
|
|
c.Conn.SetReadLimit(1 << 20)
|
|
_ = c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
c.Conn.SetPongHandler(func(string) error {
|
|
return c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
})
|
|
|
|
// Expect a join first
|
|
mt, data, err := c.Conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if mt != websocket.BinaryMessage && mt != websocket.TextMessage {
|
|
return
|
|
}
|
|
var first inbound
|
|
if msgpack.Unmarshal(data, &first) != nil || first.Type != "join" {
|
|
sendPacket(c, map[string]any{"type": "error", "message": "expected join"})
|
|
return
|
|
}
|
|
name := sanitizeName(first.Name)
|
|
if name == "" {
|
|
name = randomName()
|
|
}
|
|
role := first.Role
|
|
if role != RoleSpectator {
|
|
role = RolePlayer
|
|
}
|
|
|
|
// Enforce player cap
|
|
c.Hub.mu.Lock()
|
|
nPlayers := 0
|
|
for _, cl := range c.Hub.clients {
|
|
if cl.Role == RolePlayer {
|
|
nPlayers++
|
|
}
|
|
}
|
|
if role == RolePlayer && nPlayers >= MaxPlayers {
|
|
c.Hub.mu.Unlock()
|
|
sendPacket(c, map[string]any{"type": "full", "maxPlayers": MaxPlayers})
|
|
return
|
|
}
|
|
c.Name = name
|
|
c.Role = role
|
|
|
|
// Register player entity
|
|
if c.Role == RolePlayer {
|
|
spawn := Player{ID: c.ID, Name: c.Name, Pos: Vec3{0, 1.6, 0}, RotY: 0}
|
|
c.Hub.players[c.ID] = &spawn
|
|
}
|
|
|
|
// Welcome snapshot
|
|
players := make([]Player, 0, len(c.Hub.players))
|
|
for _, p := range c.Hub.players {
|
|
players = append(players, *p)
|
|
}
|
|
c.Hub.mu.Unlock()
|
|
|
|
sendPacket(c, map[string]any{
|
|
"type": "welcome",
|
|
"id": c.ID,
|
|
"players": players,
|
|
"maxPlayers": MaxPlayers,
|
|
})
|
|
|
|
if c.Role == RolePlayer {
|
|
announce := map[string]any{"type": "player_join", "player": Player{ID: c.ID, Name: c.Name, Pos: Vec3{0, 1.6, 0}, RotY: 0}}
|
|
if b, _ := msgpack.Marshal(announce); b != nil {
|
|
select {
|
|
case c.Hub.broadcast <- b:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// loop
|
|
for {
|
|
mt, msg, err := c.Conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if mt != websocket.BinaryMessage && mt != websocket.TextMessage {
|
|
continue
|
|
}
|
|
var in inbound
|
|
if msgpack.Unmarshal(msg, &in) != nil {
|
|
continue
|
|
}
|
|
switch in.Type {
|
|
case "state":
|
|
if c.Role != RolePlayer || in.Pos == nil || in.RotY == nil {
|
|
continue
|
|
}
|
|
c.Hub.mu.Lock()
|
|
if p, ok := c.Hub.players[c.ID]; ok {
|
|
p.Pos = *in.Pos
|
|
p.RotY = *in.RotY
|
|
}
|
|
c.Hub.mu.Unlock()
|
|
case "chat":
|
|
now := time.Now()
|
|
if now.Sub(c.LastChat) < MinChatInterval {
|
|
continue
|
|
}
|
|
c.LastChat = now
|
|
text := trimLen(strings.TrimSpace(in.Text), ChatMaxLen)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
packet := map[string]any{
|
|
"type": "chat", "id": c.ID, "name": c.Name, "text": text, "ts": now.Unix(),
|
|
}
|
|
if b, _ := msgpack.Marshal(packet); b != nil {
|
|
select {
|
|
case c.Hub.broadcast <- b:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writer(c *Client) {
|
|
ping := time.NewTicker(20 * time.Second)
|
|
defer ping.Stop()
|
|
for {
|
|
select {
|
|
case msg := <-c.Send:
|
|
_ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
if err := c.Conn.WriteMessage(websocket.BinaryMessage, msg); err != nil {
|
|
return
|
|
}
|
|
case <-ping.C:
|
|
_ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
case <-c.Closed:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendPacket(c *Client, v any) {
|
|
b, _ := msgpack.Marshal(v)
|
|
_ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
_ = c.Conn.WriteMessage(websocket.BinaryMessage, b)
|
|
}
|
|
|
|
// -------------------- Utis --------------------
|
|
func genID() string {
|
|
var b [8]byte
|
|
_, _ = cryptoRand.Read(b[:])
|
|
return "p_" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
func sanitizeName(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
s = strings.ReplaceAll(s, "\r", " ")
|
|
if len(s) > NameMaxLen {
|
|
s = s[:NameMaxLen]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func trimLen(s string, max int) string {
|
|
if len(s) > max {
|
|
return s[:max]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func randomName() string {
|
|
adjs := []string{"Blue", "Crimson", "Silent", "Swift", "Lucky", "Rusty", "Cosmic", "Mossy"}
|
|
nouns := []string{"Fox", "Raven", "Badger", "Otter", "Falcon", "Lynx", "Marmot", "Golem"}
|
|
return adjs[rand.Intn(len(adjs))] + nouns[rand.Intn(len(nouns))]
|
|
}
|
|
|
|
// -------------------- main --------------------
|
|
func main() {
|
|
rand.Seed(time.Now().UnixNano())
|
|
addr := flag.String("addr", AddrHTTP, "http listen addr")
|
|
flag.Parse()
|
|
|
|
h := NewHub()
|
|
go h.Run()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/ws", wsHandler(h))
|
|
|
|
srv := &http.Server{
|
|
Addr: *addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
IdleTimeout: 75 * time.Second,
|
|
}
|
|
|
|
log.Println("SEPPJM-World WebSocket server listening on", *addr, "path /ws")
|
|
if err := srv.ListenAndServe(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|