Init
Some checks failed
Build & Deploy Game / build-and-deploy (push) Failing after 6s
Some checks failed
Build & Deploy Game / build-and-deploy (push) Failing after 6s
This commit is contained in:
603
main.go
Normal file
603
main.go
Normal file
@@ -0,0 +1,603 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
Gravity = 0.6
|
||||
JumpPower = -12.0
|
||||
GroundY = 350.0
|
||||
PlayerHeight = 50.0
|
||||
PlayerYBase = GroundY - PlayerHeight
|
||||
GameSpeed = 5.0
|
||||
GameWidth = 800.0
|
||||
)
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
rdb *redis.Client
|
||||
defaultConfig GameConfig
|
||||
adminUser string
|
||||
adminPass string
|
||||
)
|
||||
|
||||
type ObstacleDef struct {
|
||||
ID string `json:"id"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Color string `json:"color"`
|
||||
Image string `json:"image"`
|
||||
CanTalk bool `json:"canTalk"`
|
||||
SpeechLines []string `json:"speechLines"`
|
||||
YOffset float64 `json:"yOffset"`
|
||||
}
|
||||
|
||||
type GameConfig struct {
|
||||
Obstacles []ObstacleDef `json:"obstacles"`
|
||||
Backgrounds []string `json:"backgrounds"`
|
||||
}
|
||||
|
||||
type ActiveObstacle struct {
|
||||
ID string `json:"id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"w"`
|
||||
Height float64 `json:"h"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Tick int `json:"t"`
|
||||
Act string `json:"act"`
|
||||
}
|
||||
|
||||
type ValidateRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Inputs []Input `json:"inputs"`
|
||||
TotalTicks int `json:"totalTicks"`
|
||||
}
|
||||
|
||||
type ValidateResponse struct {
|
||||
Status string `json:"status"`
|
||||
VerifiedScore int `json:"verifiedScore"`
|
||||
ServerObs []ActiveObstacle `json:"serverObs"`
|
||||
}
|
||||
|
||||
type StartResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Seed uint32 `json:"seed"`
|
||||
}
|
||||
|
||||
type SubmitNameRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SubmitResponse struct {
|
||||
ClaimCode string `json:"claimCode"`
|
||||
}
|
||||
|
||||
type LeaderboardEntry struct {
|
||||
Rank int64 `json:"rank"`
|
||||
Name string `json:"name"`
|
||||
Score int `json:"score"`
|
||||
IsMe bool `json:"isMe"`
|
||||
}
|
||||
|
||||
type AdminActionRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type AdminEntry struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Name string `json:"name"`
|
||||
Score int `json:"score"`
|
||||
Code string `json:"code"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type ClaimDeleteRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ClaimCode string `json:"claimCode"`
|
||||
}
|
||||
|
||||
type PseudoRNG struct {
|
||||
State uint32
|
||||
}
|
||||
|
||||
func NewRNG(seed int64) *PseudoRNG {
|
||||
return &PseudoRNG{State: uint32(seed)}
|
||||
}
|
||||
|
||||
func (r *PseudoRNG) NextFloat() float64 {
|
||||
calc := (uint64(r.State)*1664525 + 1013904223) % 4294967296
|
||||
r.State = uint32(calc)
|
||||
return float64(r.State) / 4294967296.0
|
||||
}
|
||||
|
||||
func (r *PseudoRNG) NextRange(min, max float64) float64 {
|
||||
return min + (r.NextFloat() * (max - min))
|
||||
}
|
||||
|
||||
func (r *PseudoRNG) PickDef(defs []ObstacleDef) *ObstacleDef {
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
idx := int(r.NextRange(0, float64(len(defs))))
|
||||
return &defs[idx]
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func BasicAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != adminUser || pass != adminPass {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Lehrerzimmer"`)
|
||||
http.Error(w, "Unauthorized", 401)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||
adminUser = getEnv("ADMIN_USER", "lehrer")
|
||||
adminPass = getEnv("ADMIN_PASS", "geheim123")
|
||||
|
||||
rdb = redis.NewClient(&redis.Options{Addr: redisAddr})
|
||||
if _, err := rdb.Ping(ctx).Result(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
initGameConfig()
|
||||
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
http.Handle("/", fs)
|
||||
|
||||
http.HandleFunc("/api/config", handleConfig)
|
||||
http.HandleFunc("/api/start", handleStart)
|
||||
http.HandleFunc("/api/validate", handleValidate)
|
||||
http.HandleFunc("/api/submit-name", handleSubmitName)
|
||||
http.HandleFunc("/api/leaderboard", handleLeaderboard)
|
||||
http.HandleFunc("/api/claim/delete", handleClaimDelete)
|
||||
|
||||
http.HandleFunc("/admin", BasicAuth(handleAdminPage))
|
||||
http.HandleFunc("/api/admin/list", BasicAuth(handleAdminList))
|
||||
http.HandleFunc("/api/admin/action", BasicAuth(handleAdminAction))
|
||||
|
||||
log.Println("Server läuft auf Port 8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
func generateClaimCode() string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func initGameConfig() {
|
||||
defaultConfig = GameConfig{
|
||||
Obstacles: []ObstacleDef{
|
||||
{ID: "desk", Width: 40, Height: 30, Color: "#8B4513", Image: "desk.png"},
|
||||
{ID: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!", "Nachsitzen!"}},
|
||||
{ID: "trashcan", Width: 25, Height: 35, Color: "#555", Image: "trash.png"},
|
||||
{ID: "eraser", Width: 30, Height: 20, Color: "#fff", Image: "eraser.png", YOffset: 45.0},
|
||||
},
|
||||
Backgrounds: []string{"background.png"},
|
||||
}
|
||||
}
|
||||
|
||||
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(defaultConfig)
|
||||
}
|
||||
|
||||
func handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := uuid.New().String()
|
||||
rawSeed := time.Now().UnixNano()
|
||||
seed32 := uint32(rawSeed)
|
||||
|
||||
emptyObs, _ := json.Marshal([]ActiveObstacle{})
|
||||
|
||||
err := rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
|
||||
"seed": seed32,
|
||||
"rng_state": seed32,
|
||||
"score": 0,
|
||||
"is_dead": 0,
|
||||
"pos_y": PlayerYBase,
|
||||
"vel_y": 0.0,
|
||||
"obstacles": string(emptyObs),
|
||||
}).Err()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "DB Error", 500)
|
||||
return
|
||||
}
|
||||
rdb.Expire(ctx, "session:"+sessionID, 1*time.Hour)
|
||||
|
||||
json.NewEncoder(w).Encode(StartResponse{SessionID: sessionID, Seed: seed32})
|
||||
}
|
||||
|
||||
func handleValidate(w http.ResponseWriter, r *http.Request) {
|
||||
var req ValidateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
key := "session:" + req.SessionID
|
||||
vals, err := rdb.HGetAll(ctx, key).Result()
|
||||
if err != nil || len(vals) == 0 {
|
||||
http.Error(w, "Session invalid", 401)
|
||||
return
|
||||
}
|
||||
|
||||
if vals["is_dead"] == "1" {
|
||||
json.NewEncoder(w).Encode(ValidateResponse{Status: "dead", VerifiedScore: 0})
|
||||
return
|
||||
}
|
||||
|
||||
posY := parseOr(vals["pos_y"], PlayerYBase)
|
||||
velY := parseOr(vals["vel_y"], 0.0)
|
||||
score := int(parseOr(vals["score"], 0))
|
||||
|
||||
rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64)
|
||||
rng := NewRNG(rngStateVal)
|
||||
|
||||
var obstacles []ActiveObstacle
|
||||
if val, ok := vals["obstacles"]; ok && val != "" {
|
||||
json.Unmarshal([]byte(val), &obstacles)
|
||||
} else {
|
||||
obstacles = []ActiveObstacle{}
|
||||
}
|
||||
|
||||
playerDead := false
|
||||
|
||||
for i := 0; i < req.TotalTicks; i++ {
|
||||
didJump := false
|
||||
isCrouching := false
|
||||
|
||||
for _, inp := range req.Inputs {
|
||||
if inp.Tick == i {
|
||||
if inp.Act == "JUMP" {
|
||||
didJump = true
|
||||
}
|
||||
if inp.Act == "DUCK" {
|
||||
isCrouching = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isGrounded := posY >= PlayerYBase-1.0
|
||||
|
||||
currentHeight := PlayerHeight
|
||||
if isCrouching {
|
||||
currentHeight = PlayerHeight / 2
|
||||
if !isGrounded {
|
||||
velY += 2.0
|
||||
}
|
||||
}
|
||||
|
||||
if didJump && isGrounded && !isCrouching {
|
||||
velY = JumpPower
|
||||
}
|
||||
|
||||
velY += Gravity
|
||||
posY += velY
|
||||
|
||||
if posY > PlayerYBase {
|
||||
posY = PlayerYBase
|
||||
velY = 0
|
||||
}
|
||||
|
||||
hitboxY := posY
|
||||
if isCrouching {
|
||||
hitboxY = posY + (PlayerHeight - currentHeight)
|
||||
}
|
||||
|
||||
nextObstacles := []ActiveObstacle{}
|
||||
rightmostX := 0.0
|
||||
|
||||
for _, obs := range obstacles {
|
||||
obs.X -= GameSpeed
|
||||
|
||||
if obs.X+obs.Width < 50.0 {
|
||||
continue
|
||||
}
|
||||
|
||||
paddingX := 10.0
|
||||
paddingY_Top := 25.0
|
||||
paddingY_Bottom := 5.0
|
||||
|
||||
pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX
|
||||
pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-paddingY_Bottom
|
||||
|
||||
oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX
|
||||
oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-paddingY_Bottom
|
||||
|
||||
if pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom {
|
||||
playerDead = true
|
||||
}
|
||||
|
||||
if obs.X+obs.Width > -100 {
|
||||
nextObstacles = append(nextObstacles, obs)
|
||||
if obs.X+obs.Width > rightmostX {
|
||||
rightmostX = obs.X + obs.Width
|
||||
}
|
||||
}
|
||||
}
|
||||
obstacles = nextObstacles
|
||||
|
||||
if rightmostX < GameWidth-10.0 {
|
||||
rawGap := 400.0 + rng.NextRange(0, 500)
|
||||
gap := float64(int(rawGap))
|
||||
spawnX := rightmostX + gap
|
||||
if spawnX < GameWidth {
|
||||
spawnX = GameWidth
|
||||
}
|
||||
|
||||
var possibleDefs []ObstacleDef
|
||||
for _, d := range defaultConfig.Obstacles {
|
||||
if d.ID == "eraser" {
|
||||
if score >= 500 {
|
||||
possibleDefs = append(possibleDefs, d)
|
||||
}
|
||||
} else {
|
||||
possibleDefs = append(possibleDefs, d)
|
||||
}
|
||||
}
|
||||
|
||||
def := rng.PickDef(possibleDefs)
|
||||
|
||||
if def != nil && def.CanTalk {
|
||||
if rng.NextFloat() > 0.7 {
|
||||
rng.NextFloat()
|
||||
}
|
||||
}
|
||||
|
||||
if def != nil {
|
||||
spawnY := GroundY - def.Height - def.YOffset
|
||||
obstacles = append(obstacles, ActiveObstacle{
|
||||
ID: def.ID,
|
||||
X: spawnX,
|
||||
Y: spawnY,
|
||||
Width: def.Width,
|
||||
Height: def.Height,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !playerDead {
|
||||
score++
|
||||
}
|
||||
}
|
||||
|
||||
status := "alive"
|
||||
if playerDead {
|
||||
status = "dead"
|
||||
rdb.HSet(ctx, key, "is_dead", 1)
|
||||
}
|
||||
|
||||
obsJson, _ := json.Marshal(obstacles)
|
||||
|
||||
rdb.HSet(ctx, key, map[string]interface{}{
|
||||
"score": score,
|
||||
"pos_y": fmt.Sprintf("%f", posY),
|
||||
"vel_y": fmt.Sprintf("%f", velY),
|
||||
"rng_state": rng.State,
|
||||
"obstacles": string(obsJson),
|
||||
})
|
||||
rdb.Expire(ctx, key, 1*time.Hour)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ValidateResponse{
|
||||
Status: status,
|
||||
VerifiedScore: score,
|
||||
ServerObs: obstacles,
|
||||
})
|
||||
}
|
||||
|
||||
func handleSubmitName(w http.ResponseWriter, r *http.Request) {
|
||||
var req SubmitNameRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
safeName := html.EscapeString(req.Name)
|
||||
|
||||
sessionKey := "session:" + req.SessionID
|
||||
scoreVal, err := rdb.HGet(ctx, sessionKey, "score").Result()
|
||||
if err != nil {
|
||||
http.Error(w, "Session expired", 404)
|
||||
return
|
||||
}
|
||||
scoreInt, _ := strconv.Atoi(scoreVal)
|
||||
|
||||
claimCode := generateClaimCode()
|
||||
timestamp := time.Now().Format("02.01.2006 15:04")
|
||||
|
||||
rdb.HSet(ctx, sessionKey, map[string]interface{}{
|
||||
"name": safeName,
|
||||
"claim_code": claimCode,
|
||||
"created_at": timestamp,
|
||||
})
|
||||
|
||||
rdb.ZAdd(ctx, "leaderboard:unverified", redis.Z{
|
||||
Score: float64(scoreInt),
|
||||
Member: req.SessionID,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SubmitResponse{ClaimCode: claimCode})
|
||||
}
|
||||
|
||||
func handleLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||
mySessionID := r.URL.Query().Get("sessionId")
|
||||
targetKey := "leaderboard:public"
|
||||
|
||||
var entries []LeaderboardEntry
|
||||
|
||||
top3, _ := rdb.ZRevRangeWithScores(ctx, targetKey, 0, 2).Result()
|
||||
|
||||
for i, z := range top3 {
|
||||
rank := int64(i + 1)
|
||||
sid := z.Member.(string)
|
||||
name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result()
|
||||
if name == "" {
|
||||
name = "Unbekannt"
|
||||
}
|
||||
|
||||
entries = append(entries, LeaderboardEntry{
|
||||
Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID),
|
||||
})
|
||||
}
|
||||
|
||||
if mySessionID != "" {
|
||||
myRank, err := rdb.ZRevRank(ctx, targetKey, mySessionID).Result()
|
||||
if err == nil {
|
||||
if myRank > 2 {
|
||||
start := myRank - 1
|
||||
stop := myRank + 1
|
||||
neighbors, _ := rdb.ZRevRangeWithScores(ctx, targetKey, start, stop).Result()
|
||||
|
||||
for i, z := range neighbors {
|
||||
rank := start + int64(i) + 1
|
||||
sid := z.Member.(string)
|
||||
name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result()
|
||||
if name == "" {
|
||||
name = "Unbekannt"
|
||||
}
|
||||
|
||||
entries = append(entries, LeaderboardEntry{
|
||||
Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(entries)
|
||||
}
|
||||
|
||||
func handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "./secure/admin.html")
|
||||
}
|
||||
|
||||
func handleAdminList(w http.ResponseWriter, r *http.Request) {
|
||||
listType := r.URL.Query().Get("type")
|
||||
redisKey := "leaderboard:unverified"
|
||||
if listType == "public" {
|
||||
redisKey = "leaderboard:public"
|
||||
}
|
||||
|
||||
vals, _ := rdb.ZRevRangeWithScores(ctx, redisKey, 0, -1).Result()
|
||||
var adminList []AdminEntry
|
||||
|
||||
for _, z := range vals {
|
||||
sid := z.Member.(string)
|
||||
info, _ := rdb.HGetAll(ctx, "session:"+sid).Result()
|
||||
|
||||
name := info["name"]
|
||||
if name == "" {
|
||||
name = "Unbekannt"
|
||||
}
|
||||
|
||||
adminList = append(adminList, AdminEntry{
|
||||
SessionID: sid,
|
||||
Name: name,
|
||||
Score: int(z.Score),
|
||||
Code: info["claim_code"],
|
||||
Time: info["created_at"],
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(adminList)
|
||||
}
|
||||
|
||||
func handleAdminAction(w http.ResponseWriter, r *http.Request) {
|
||||
var req AdminActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Action == "approve" {
|
||||
score, err := rdb.ZScore(ctx, "leaderboard:unverified", req.SessionID).Result()
|
||||
if err != nil {
|
||||
http.Error(w, "Entry not found", 404)
|
||||
return
|
||||
}
|
||||
rdb.ZAdd(ctx, "leaderboard:public", redis.Z{Score: score, Member: req.SessionID})
|
||||
rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID)
|
||||
|
||||
} else if req.Action == "delete" {
|
||||
rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID)
|
||||
rdb.ZRem(ctx, "leaderboard:public", req.SessionID)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleClaimDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req ClaimDeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
sessionKey := "session:" + req.SessionID
|
||||
realCode, err := rdb.HGet(ctx, sessionKey, "claim_code").Result()
|
||||
|
||||
if err != nil || realCode == "" {
|
||||
http.Error(w, "Not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
if realCode != req.ClaimCode {
|
||||
http.Error(w, "Wrong Code", 403)
|
||||
return
|
||||
}
|
||||
|
||||
rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID)
|
||||
rdb.ZRem(ctx, "leaderboard:public", req.SessionID)
|
||||
rdb.HDel(ctx, sessionKey, "name")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func parseOr(s string, def float64) float64 {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user