init project release
This commit is contained in:
25
Backend/Dockerfile
Normal file
25
Backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.1-alpine AS build
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/app .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=build /out/app /app
|
||||
|
||||
USER nonroot:nonroot
|
||||
|
||||
# EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app"]
|
10
Backend/go.mod
Normal file
10
Backend/go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module main
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
)
|
||||
|
||||
require github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
14
Backend/go.sum
Normal file
14
Backend/go.sum
Normal file
@@ -0,0 +1,14 @@
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
416
Backend/main.go
Normal file
416
Backend/main.go
Normal file
@@ -0,0 +1,416 @@
|
||||
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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user