commit 863571b687b725619695e7dec23b401c376f744c Author: Stephanie Gredell Date: Mon Aug 11 09:06:03 2025 -0700 first try at the game. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c06632 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +server.* +.env diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e828b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module stick + +go 1.22.2 + +require ( + github.com/gorilla/sessions v1.1.1 + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 + github.com/markbates/goth v1.81.0 +) + +require ( + github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/mux v1.6.2 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + golang.org/x/oauth2 v0.17.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..17589a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE= +github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..e046d09 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "fmt" + "net/http" + + "os" + + "github.com/gorilla/sessions" + "github.com/markbates/goth/gothic" +) + +func (h *Handler) Auth(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + q.Add("provider", "twitch") + r.URL.RawQuery = q.Encode() + key := os.Getenv("SESSION_SECRET") + fmt.Printf("my secret is this long: %v", len(key)) + gothic.BeginAuthHandler(w, r) +} + +func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { + user, err := gothic.CompleteUserAuth(w, r) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + key := os.Getenv("SESSION_SECRET") // Replace with your SESSION_SECRET or similar + maxAge := 86400 * 30 // 30 days + isProd := false // Set to true when serving over https + + store := sessions.NewCookieStore([]byte(key)) + store.MaxAge(maxAge) + store.Options.Path = "/" + store.Options.HttpOnly = true // HttpOnly should always be enabled + store.Options.Secure = isProd + + gothic.Store = store + session, _ := gothic.Store.Get(r, "user-session") + session.Values["user_name"] = user.Name + session.Values["avatar_url"] = user.AvatarURL + session.Values["user_id"] = user.UserID + session.Values["provider"] = user.Provider + + err = session.Save(r, w) + if err != nil { + fmt.Printf("error saving the session: %v", err) + } + + http.Redirect(w, r, "/", http.StatusFound) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + session, err := gothic.Store.Get(r, "user-session") + if err != nil { + fmt.Printf("error retrieving session: %v", err) + return + } + + // Clear the session data + session.Values = make(map[interface{}]interface{}) + session.Options.MaxAge = -1 + + // Save the empty session + err = session.Save(r, w) + if err != nil { + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} + +func RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, _ := gothic.Store.Get(r, "user-session") + userID, ok := session.Values["user_id"] + if !ok || userID == nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..c15551c --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "fmt" + "html/template" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/markbates/goth/gothic" +) + +type Handler struct { + Template template.Template +} + +type Message struct { + Id string + Action string + Message string +} + +type PageData struct { + Username string +} + +type hub struct { + clients map[*websocket.Conn]bool + Broadcast chan []byte + register chan *websocket.Conn + unregister chan *websocket.Conn + mutex sync.RWMutex +} + +var Hub = &hub{ + clients: make(map[*websocket.Conn]bool), + Broadcast: make(chan []byte), + register: make(chan *websocket.Conn), + unregister: make(chan *websocket.Conn), +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // this should change + }, +} + +func (h *hub) Run() { + for { + select { + case conn := <-h.register: + h.mutex.Lock() + h.clients[conn] = true + h.mutex.Unlock() + fmt.Printf("Client connected. Total clients: %d\n", len(h.clients)) + + case conn := <-h.unregister: + h.mutex.Lock() + if _, ok := h.clients[conn]; ok { + delete(h.clients, conn) + conn.Close() + } + h.mutex.Unlock() + fmt.Printf("Client disconnected. Total clients: %d\n", len(h.clients)) + + case message := <-h.Broadcast: + h.mutex.RLock() + for conn := range h.clients { + if err := conn.WriteMessage(websocket.TextMessage, message); err != nil { + fmt.Printf("Error sending message to client: %v\n", err) + // Remove failed connection + delete(h.clients, conn) + conn.Close() + } + } + h.mutex.RUnlock() + } + } +} + +func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { + session, err := gothic.Store.Get(r, "user-session") + + if err != nil { + http.Error(w, "Error retrieving session for welcome page", http.StatusInternalServerError) + } + username, ok := session.Values["user_name"].(string) + var pagedata PageData + + if ok { + pagedata.Username = username + } else { + pagedata.Username = "" + } + + err = h.Template.ExecuteTemplate(w, "index.html", &pagedata) + if err != nil { + http.Error(w, "Template rendering error", http.StatusInternalServerError) + } +} + +func (h *Handler) WsHandler(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + fmt.Println("upgrade error:", err) + return + } + + Hub.register <- conn + + defer conn.Close() + + for { + _, messageBytes, err := conn.ReadMessage() + if err != nil { + fmt.Printf("error reading message: %v", err) + } + + fmt.Printf("message being broadcasted: %v", string(messageBytes)) + Hub.Broadcast <- messageBytes + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5d7a2d6 --- /dev/null +++ b/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "stick/internal/handlers" + "syscall" + "time" + + "html/template" + + "github.com/joho/godotenv" + "github.com/markbates/goth" + "github.com/markbates/goth/providers/twitch" +) + +func main() { + err := godotenv.Load() + if err != nil { + fmt.Printf("error loading .env file: %v", err) + } + fs := http.FileServer(http.Dir("./static")) + tmpl, err := template.ParseGlob("templates/*.html") + mux := http.NewServeMux() + h := handlers.Handler{Template: *tmpl} + + mux.Handle("/static/", http.StripPrefix("/static", fs)) + mux.HandleFunc("/", h.Home) + goth.UseProviders( + twitch.New( + os.Getenv("TWITCH_CLIENT_ID"), + os.Getenv("TWITCH_SECRET"), + "https://localhost:8080/auth/twitch/callback", + ), + ) + + if err != nil { + panic(fmt.Errorf("failed to parse templates: %w\n", err)) + } + + go func() { + hub := handlers.Hub + hub.Run() + }() + + mux.HandleFunc("/ws", h.WsHandler) + mux.HandleFunc("/auth/twitch", h.Auth) + mux.HandleFunc("/auth/twitch/callback", h.Callback) + mux.HandleFunc("/logout", h.Logout) + + server := &http.Server{ + Addr: ":8080", + Handler: mux, + } + + go func() { + fmt.Println("Server started...") + if err := server.ListenAndServe(); err != http.ErrServerClosed { + fmt.Printf("Serve error: %v\n", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + <-stop + fmt.Println("Shutting down server") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + fmt.Printf("Server force to shutdown: %v\n", err) + } else { + fmt.Println("Server exit gracefully") + } +} diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..d010dbc --- /dev/null +++ b/static/script.js @@ -0,0 +1,491 @@ +window.addEventListener("DOMContentLoaded", function() { + class StickFigure { + constructor(x, facing, id, canvas) { + this.canvas = canvas; + this.id = id; + this.x = x; + this.y = canvas.height - 60; + this.facing = facing; + this.action = "idle"; + this.hitFrame = 0; + this.speed = 2; + this.vx = 0; + this.vy = 0; + this.gravity = 0.5; + this.jumpStrength = -10; + this.onGround = true; + this.talking = false; + this.talkTimer = 0; + this.message = ""; + this.keys = { + h: false, + l: false, + }; + this.crouching = false; + this.isPunching = false; + this.punchHasHit = false; + } + + + + update() { + this.vx = 0; + if (this.keys.h) { + this.vx = -this.speed; + this.facing = -1; + } + + if (this.keys.l) { + this.vx = this.speed; + this.facing = 1; + } + + this.x += this.vx; + + this.vy += this.gravity; + this.y += this.vy; + + const groundY = this.canvas.height; + const totalHeight = 60; + + if (this.y + totalHeight >= groundY) { + this.y = groundY - totalHeight; + this.vy = 0; + this.onGround = true; + } else { + this.onGround = false; + } + + this.x = Math.max(20, Math.min(this.canvas.width - 20, this.x)); + + if (this.action === "punch") { + this.hitFrame++; + if (this.hitFrame > 15) { + this.action = 'idle'; + this.hitFrame = 0; + } + } + + if (this.talking) { + this.talkTimer--; + if (this.talkTimer <= 0) { + this.talking = false; + } + } + } + + draw(ctx, time) { + const headRadius = 10; + const x = this.x; + let y = this.y; + + const crouch = !!this.crouching; + const bodyLength = crouch ? 20 : 30; + const legLength = crouch ? 20 : 25; + if (crouch) y += 15; // shift down a touch so crouch looks grounded + + + // head + ctx.beginPath(); + ctx.arc(x, y, headRadius, 0, Math.PI * 2); + ctx.stroke(); + + // eye + ctx.beginPath(); + ctx.arc(x + this.facing * 5, y - 3, 2, 0, Math.PI * 2); + ctx.fill(); + + // body + const chestY = y + headRadius; + const hipY = y + headRadius + bodyLength; + ctx.beginPath(); + ctx.moveTo(x, chestY); + ctx.lineTo(x, hipY); + ctx.stroke(); + + // arms + ctx.beginPath(); + ctx.moveTo(x, y + 20); + const walking = this.vx !== 0; + const armSwing = walking ? Math.sin(time * 0.2) * 4 : 0; // subtle swing + if (this.action === "punch" && this.hitFrame < 10) { + ctx.lineTo(x + 30 * this.facing, y + 10); + } else { + ctx.lineTo(x + (20 + armSwing) * this.facing, y + 20); + } + ctx.moveTo(x, y + 20); + ctx.lineTo(x - (20 + armSwing) * this.facing, y + 20); + ctx.stroke(); + + // legs + ctx.beginPath(); + if (this.vx !== 0) { + const step = Math.sin(time * 0.75); // cadence; lower = slower + const stride = 14; // how far forward/back feet go + const lift = 0; // little foot lift + + // right leg + ctx.moveTo(x, hipY); + ctx.lineTo(x + stride * step, hipY + legLength + (-lift * Math.abs(step))); + + // left leg (opposite phase) + ctx.moveTo(x, hipY); + ctx.lineTo(x - stride * step, hipY + legLength + (-lift * Math.abs(step))); + } else { + const idleSpread = 10; + ctx.moveTo(x, hipY); + ctx.lineTo(x + idleSpread, hipY + legLength); + ctx.moveTo(x, hipY); + ctx.lineTo(x - idleSpread, hipY + legLength); + } + ctx.stroke(); + + // speech bubble + if (this.talking) { + const padding = 12; + const maxMessageWidth = 150; + + // set font BEFORE measuring + ctx.font = "12px sans-serif"; + + const lines = this._wrapText(ctx, this.message, maxMessageWidth); + const textWidth = ctx.measureText(this.message).width; + const widths = lines.map(line => ctx.measureText(line).width); + const contentWidth = Math.max(...widths, textWidth); + const bubbleWidth = Math.min(contentWidth + padding * 2, maxMessageWidth + padding * 2); + const lineHeight = 18; + const bubbleHeight = lines.length * lineHeight + padding; + const bubbleX = x - bubbleWidth; + const bubbleY = y - 30; + + ctx.beginPath(); + ctx.fillStyle = "white"; + ctx.strokeStyle = "black"; + if (typeof ctx.roundRect === "function") { + ctx.roundRect(bubbleX, bubbleY, bubbleWidth, bubbleHeight, 4); + } else { + ctx.rect(bubbleX, bubbleY, bubbleWidth, bubbleHeight); + } + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "black"; + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], bubbleX + padding, bubbleY + padding + i * lineHeight); + } + } + } + + punch() { + this.isPunching = true; + this.punchHasHit = false; + + if (this.action === 'idle') { + this.action = 'punch'; + this.hitFrame = 0; + } + } + + talk(message) { + this.talking = true; + this.talkTimer = 120; + this.message = message; + } + + jump() { + if (this.onGround) { + this.vy = this.jumpStrength; + this.onGround = false; + } + } + + _wrapText(ctx, text, maxWidth) { + const words = text.split(" "); + const lines = []; + let line = ""; + + for (let i = 0; i < words.length; i++) { + const testLine = line + words[i] + " "; + const testWidth = ctx.measureText(testLine).width; + + if (testWidth > maxWidth && i > 0) { + lines.push(line.trimEnd()); + line = words[i] + " "; + } else { + line = testLine; + } + } + + lines.push(line.trim()); + return lines; + } + + getBodyHitbox() { + const w = 24; + const h = 60 - (this.crouching ? 10 : 0); + return { + x: this.x - w / 2, + y: this.y - 10, + w, + h + }; + } + + getPunchHitbox() { + if (this.action === 'punch' && this.hitFrame < 10) { + const w = 18; + const h = 14; + const frontX = this.x + this.facing * 22; + const x = this.facing === 1 ? frontX : frontX - w; + const y = this.y + (this.crouching ? 18 : 10); + return { + x, + y, + w, + h + }; + } + + return null; + } + } + + class Game { + constructor({ canvasId = 'canvas', chatInputId = 'msg', sendBtnId = "send" }) { + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas.getContext('2d'); + + this.chatInput = document.getElementById(chatInputId); + this.sendBtn = document.getElementById(sendBtnId); + + this.time = 0; + this.players = {}; + this.pageData = this._getPageData(); + + this.ws = null; + + this.loop = this.loop.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); + this._onSendClick = this._onSendClick.bind(this); + } + + + start() { + document.addEventListener("keydown", this._onKeyDown); + document.addEventListener("keyup", this._onKeyUp); + if (this.sendBtn) this.sendBtn.addEventListener("click", this._onSendClick); + + this._ensurePlayer(this.pageData.username); + this._initWebSocket(); + + requestAnimationFrame(this.loop); + } + + destroy() { + document.removeEventListener("keydown", this._onKeyDown); + document.removeEventListener("keyup", this._onKeyUp); + if (this.sendBtn) this.sendBtn.removeEventListener("click", this._onSendClick); + if (this.ws) this.ws.close(); + } + + loop() { + this.time += 0.1; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + const playerArray = Object.values(this.players); + + playerArray.forEach(player => { + player.update(); // Call the update method for each player + }); + + for (let i = 0; i < playerArray.length; i++) { + for (let j = i + 1; j < playerArray.length; j++) { + const playerA = playerArray[i]; + const playerB = playerArray[j]; + + const playerAPunch = playerA.getPunchHitbox(); + const playerBPunch = playerB.getPunchHitbox(); + const playerABody = playerA.getBodyHitbox(); + const playerBBody = playerB.getBodyHitbox(); + + if (playerAPunch && rectangleOverlap(playerAPunch, playerBBody && !playerA.punchHasHit)) { + playerA.punchHasHit = true; + console.log("Player A hits player B"); + } + + if (playerBPunch && rectangleOverlap(playerBPunch, playerABody) && !playerB.punchHasHit) { + playerB.punchHasHit = true; + console.log("Player B hits player A") + } + } + } + + playerArray.forEach(player => { + player.draw(this.ctx, this.time); // Call the draw method for each player + + }) + + requestAnimationFrame(this.loop) + } + + _getPageData() { + const metaTag = document.querySelector('meta[name="pagedata"]'); + const json = JSON.parse(metaTag.getAttribute("content")); + return json; + + } + + _isTypingInChat() { + return document.activeElement === this.chatInput; + } + + _onKeyDown(e) { + if (this._isTypingInChat()) return; + + const me = this._me() + if (!me) return; + + switch (e.key) { + case 'e': + me.punch() + this._sendAction('e', "keyDown") + break; + case 'k': + me.jump(); + this._sendAction('k', "keyDown") + break; + case 'j': + me.crouching = true; + this._sendAction('j', 'keyDown') + break; + case 'h': + case 'l': + // avoid redundant work if already true + if (!me.keys) me.keys = {}; + if (!me.keys[e.key]) me.keys[e.key] = true; + this._sendAction(e.key, 'keyDown') + break; + default: + break; + } + } + + _me() { + return this.players[this.pageData.username]; + } + + _onKeyUp(e) { + const me = this._me(); + if (!me) return; + + if (e.key === 'j') { + me.crouching = false; + this._sendAction('j', 'keyUp') + } + + if (e.key === 'h' || e.key === 'l') { + if (!me.keys) me.keys = {}; + me.keys[e.key] = false; + this._sendAction(e.key, 'keyUp') + } + } + + _ensurePlayer(id) { + if (!this.players[id] && id !== "") { + this.players[id] = new StickFigure(250, 1, id, this.canvas) + } + } + + _onSendClick() { + const inputEl = this.chatInput; + if (!inputEl) return; + + const text = inputEl.value.trim(); + if (!text) return; + + const me = this._me(); + if (me) me.talk(text); + + this._sendAction("t", "", text); + + inputEl.value = ""; + } + + _initWebSocket() { + const scheme = location.protocol === "https:" ? "wss://" : "ws://"; + this.ws = new WebSocket(scheme + location.host + "/ws"); + + this.ws.onopen = () => { + if (this.pageData.username !== "") { + this._send({ + Id: this.pageData.username, + Action: "join", + Message: "" + }); + } + }; + + this.ws.onmessage = (e) => { + const data = JSON.parse(e.data); + this._ensurePlayer(data.Id); + + // ignore echo + if (data.Id === this.pageData.username) return; + + const p = this.players[data.Id]; + switch (data.Action) { + case "t": + p.talk(data.Message); + break; + case "e": + p.punch(); + break; + case "k": + p.jump(); + break; + case "h": + case "l": + p.keys[data.Action] = data.ActionType === "keyDown"; + break; + } + }; + + this.ws.onerror = (err) => { + console.log("ws error:", err); + }; + } + + _sendAction(action, actionType = "", message = "") { + this._send({ + Id: this.pageData.username, + Action: action, + ActionType: actionType, + Message: message + }); + } + + _send(obj) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); + } + } + + } + + const game = new Game({ + canvasId: 'canvas', + chatInputId: 'msg', + sendBtnId: 'send', + }); + game.start(); +}); + +function rectangleOverlap(a, b) { + return ( + a.x < b.x + b.w && + a.x + a.w > b.x && + a.y < b.y + b.h && + a.y + a.h > b.y + ) +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..740f47e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,73 @@ + + + + + + + + + +
+ {{ if eq .Username ""}} + + {{ else }} + Logged In as {{ .Username}} + {{ end }} +
+ +
+ + +
+ + + + + diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..4f87878 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main new file mode 100755 index 0000000..3f6e09c Binary files /dev/null and b/tmp/main differ