commit 6fa7e0a7c7bad68ae53cbd2a495a5ef8004ceb15 Author: Sebastian Unterschütz Date: Mon Nov 24 22:32:10 2025 +0100 Init diff --git a/.github/workflows/cleanup.yaml b/.github/workflows/cleanup.yaml new file mode 100644 index 0000000..2bd2858 --- /dev/null +++ b/.github/workflows/cleanup.yaml @@ -0,0 +1,37 @@ +name: Cleanup Environment +on: [delete] + +jobs: + cleanup: + runs-on: ubuntu-latest + if: github.event.ref_type == 'branch' + + steps: + - name: Prepare Variables + run: | + REPO_LOWER=$(echo "${{ gitea.repository }}" | cut -d'/' -f2 | tr '[:upper:]' '[:lower:]') + BRANCH_NAME=${{ github.event.ref }} + BRANCH_LOWER=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g') + TARGET_NS="${REPO_LOWER}-${BRANCH_LOWER}" + echo "TARGET_NS=$TARGET_NS" >> $GITHUB_ENV + + - name: Protect Main + if: env.TARGET_NS == 'escape-teacher-main' || env.TARGET_NS == 'escape-teacher-master' + run: | + echo "❌ Main darf nicht gelöscht werden!" + exit 1 + + - name: Setup Kubectl + run: | + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + mv kubectl /usr/local/bin/ + mkdir -p $HOME/.kube + echo "${{ secrets.KUBE_CONFIG }}" > $HOME/.kube/config + chmod 600 $HOME/.kube/config + sed -i 's|server: https://.*:6443|server: https://kubernetes.default.svc:443|g' $HOME/.kube/config + + - name: Delete Namespace + run: | + echo "🗑️ Lösche Namespace: ${{ env.TARGET_NS }}" + kubectl delete namespace ${{ env.TARGET_NS }} --ignore-not-found --wait=false \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..adfe229 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,98 @@ +name: Build & Deploy Game +on: [push] + +env: + REGISTRY: git.zb-server.de + BASE_DOMAIN: zb-server.de + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + # 1. Code holen + - name: Checkout Code + uses: actions/checkout@v3 + + # 2. Variablen vorbereiten + - name: Prepare Environment Variables + id: prep + run: | + USERNAME_LOWER=$(echo "${{ gitea.actor }}" | tr '[:upper:]' '[:lower:]') + REPO_LOWER=$(echo "${{ gitea.repository }}" | cut -d'/' -f2 | tr '[:upper:]' '[:lower:]') + OWNER_LOWER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]') + BRANCH_LOWER=$(echo "${{ gitea.ref_name }}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g') + + TARGET_NS="${REPO_LOWER}-${BRANCH_LOWER}" + APP_URL="${TARGET_NS}.${{ env.BASE_DOMAIN }}" + FULL_IMAGE_PATH="${OWNER_LOWER}/${REPO_LOWER}" + IMAGE_TAG="${{ env.REGISTRY }}/${FULL_IMAGE_PATH}:${{ gitea.sha }}" + + echo "TARGET_NS=$TARGET_NS" >> $GITHUB_ENV + echo "APP_URL=$APP_URL" >> $GITHUB_ENV + echo "IMAGE_FULL=$IMAGE_TAG" >> $GITHUB_ENV + + echo "Deploying to Namespace: $TARGET_NS URL: $APP_URL" + + # 3. Docker Image bauen (Kaniko) + - name: Build and Push + uses: aevea/action-kaniko@v0.12.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.PACKAGE_TOKEN }} + image: ${{ env.FULL_IMAGE_PATH }} # Achtung: Hier muss evtl. angepasst werden wie im Prep step berechnet + tag: ${{ gitea.sha }} + cache: true + extra_args: --skip-tls-verify-pull + + # 4. Kubectl einrichten (Interner Trick) + - name: Setup Kubectl + run: | + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + mv kubectl /usr/local/bin/ + + mkdir -p $HOME/.kube + echo "${{ secrets.KUBE_CONFIG }}" > $HOME/.kube/config + chmod 600 $HOME/.kube/config + + # Der "Internal Kubernetes Trick" + sed -i 's|server: https://.*:6443|server: https://kubernetes.default.svc:443|g' $HOME/.kube/config + + # 5. Deploy + - name: Deploy to Kubernetes + run: | + # Namespace erstellen + kubectl create namespace ${{ env.TARGET_NS }} --dry-run=client -o yaml | kubectl apply -f - + + # Platzhalter in den YAML Dateien ersetzen + # Wir nutzen sed, um die Variablen in die Dateien zu schreiben + + # 1. Ingress + sed -i "s|\${APP_URL}|${{ env.APP_URL }}|g" k8s/ingress.yaml + + # 2. App Deployment (Image & Secrets) + # Hinweis: Secrets sollten idealerweise in den Repo-Settings hinterlegt sein + ADMIN_USER="${{ secrets.ADMIN_USER || 'lehrer' }}" + ADMIN_PASS="${{ secrets.ADMIN_PASS || 'geheim123' }}" + + sed -i "s|\${IMAGE_NAME}|${{ env.IMAGE_FULL }}|g" k8s/app.yaml + sed -i "s|\${ADMIN_USER}|$ADMIN_USER|g" k8s/app.yaml + sed -i "s|\${ADMIN_PASS}|$ADMIN_PASS|g" k8s/app.yaml + + # Anwenden im richtigen Namespace + echo "Applying Redis..." + kubectl apply -f k8s/redis.yaml -n ${{ env.TARGET_NS }} + + echo "Applying App..." + kubectl apply -f k8s/app.yaml -n ${{ env.TARGET_NS }} + + echo "Applying Ingress..." + kubectl apply -f k8s/ingress.yaml -n ${{ env.TARGET_NS }} + + # Force Restart damit das neue Image gezogen wird + kubectl rollout restart deployment/escape-game -n ${{ env.TARGET_NS }} + + # 6. Info + - name: Summary + run: echo "🚀 Deployed to https://${{ env.APP_URL }}" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1d41f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# --- STAGE 1: Builder (Kompilieren) --- +FROM golang:1.23-alpine AS builder + +# Arbeitsverzeichnis im Container erstellen +WORKDIR /app + +# Abhängigkeiten kopieren und herunterladen (Caching-Effizienz) +COPY go.mod go.sum ./ +RUN go mod download + +# Den gesamten Rest des Codes kopieren +COPY . . + +# Das Go-Programm kompilieren +# -o server: Nenne die Datei "server" +RUN go build -o server main.go + +# --- STAGE 2: Runner (Ausführen) --- +FROM alpine:latest + +WORKDIR /root/ + +# Wir brauchen evtl. Zertifikate für HTTPS (falls du später externe APIs nutzt) +RUN apk --no-cache add ca-certificates + +# Kopiere das fertige Programm aus Stage 1 +COPY --from=builder /app/server . + +# WICHTIG: Kopiere die statischen Ordner (HTML, CSS, Bilder, Fonts, Admin) +COPY --from=builder /app/static ./static +COPY --from=builder /app/secure ./secure + +# Port freigeben +EXPOSE 8080 + +# Startbefehl +CMD ["./server"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4aac80 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' +services: + redis: + image: redis:alpine + container_name: escape-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + redis_data: \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..168633d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module escape-teacher + +go 1.25.4 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/redis/go-redis/v9 v9.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eb7ba5f --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= +github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= diff --git a/k8s/app.yaml b/k8s/app.yaml new file mode 100644 index 0000000..1ed8e08 --- /dev/null +++ b/k8s/app.yaml @@ -0,0 +1,42 @@ +apiVersion: v1 +kind: Service +metadata: + name: escape-game +spec: + ports: + - port: 80 + targetPort: 8080 + selector: + app: escape-game +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: escape-game + labels: + app: escape-game +spec: + replicas: 1 + selector: + matchLabels: + app: escape-game + template: + metadata: + labels: + app: escape-game + spec: + containers: + - name: game + image: ${IMAGE_NAME} # Wird von CI ersetzt + ports: + - containerPort: 8080 + env: + # Kubernetes DNS: "service-name:port" + # Da wir im selben Namespace sind, reicht "redis:6379" + - name: REDIS_ADDR + value: "redis:6379" + # Admin Zugangsdaten (werden von CI injected oder hier fest) + - name: ADMIN_USER + value: "${ADMIN_USER}" + - name: ADMIN_PASS + value: "${ADMIN_PASS}" \ No newline at end of file diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..2f3855c --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: game-ingress + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: web, websecure + traefik.ingress.kubernetes.io/router.middlewares: gitea-redirect-https@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - ${APP_URL} # Wird von CI ersetzt + secretName: game-tls-secret + rules: + - host: ${APP_URL} # Wird von CI ersetzt + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: escape-game + port: + number: 80 \ No newline at end of file diff --git a/k8s/redis.yaml b/k8s/redis.yaml new file mode 100644 index 0000000..f22d1b6 --- /dev/null +++ b/k8s/redis.yaml @@ -0,0 +1,49 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-data +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis +spec: + ports: + - port: 6379 + selector: + app: redis +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + labels: + app: redis +spec: + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:alpine + ports: + - containerPort: 6379 + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + persistentVolumeClaim: + claimName: redis-data \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..335584e --- /dev/null +++ b/main.go @@ -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 +} diff --git a/secure/admin.html b/secure/admin.html new file mode 100644 index 0000000..1e4a820 --- /dev/null +++ b/secure/admin.html @@ -0,0 +1,169 @@ + + + + + Lehrer Zimmer (Admin) + + + + +
+

👨‍🏫 Lehrer Zimmer

+ +
+ +
+ + +
+ +
Lade...
+ + + + \ No newline at end of file diff --git a/static/assets/background.jpg b/static/assets/background.jpg new file mode 100644 index 0000000..b049512 Binary files /dev/null and b/static/assets/background.jpg differ diff --git a/static/assets/fonts/press-start-2p-v16-latin-regular.svg b/static/assets/fonts/press-start-2p-v16-latin-regular.svg new file mode 100644 index 0000000..44c7495 --- /dev/null +++ b/static/assets/fonts/press-start-2p-v16-latin-regular.svg @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/assets/fonts/press-start-2p-v16-latin-regular.ttf b/static/assets/fonts/press-start-2p-v16-latin-regular.ttf new file mode 100644 index 0000000..4932a6e Binary files /dev/null and b/static/assets/fonts/press-start-2p-v16-latin-regular.ttf differ diff --git a/static/assets/fonts/press-start-2p-v16-latin-regular.woff b/static/assets/fonts/press-start-2p-v16-latin-regular.woff new file mode 100644 index 0000000..af42662 Binary files /dev/null and b/static/assets/fonts/press-start-2p-v16-latin-regular.woff differ diff --git a/static/assets/fonts/press-start-2p-v16-latin-regular.woff2 b/static/assets/fonts/press-start-2p-v16-latin-regular.woff2 new file mode 100644 index 0000000..947a979 Binary files /dev/null and b/static/assets/fonts/press-start-2p-v16-latin-regular.woff2 differ diff --git a/static/assets/player.png b/static/assets/player.png new file mode 100644 index 0000000..fc2131c Binary files /dev/null and b/static/assets/player.png differ diff --git a/static/game.js b/static/game.js new file mode 100644 index 0000000..b5680d8 --- /dev/null +++ b/static/game.js @@ -0,0 +1,538 @@ +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d'); +const container = document.getElementById('game-container'); + +const startScreen = document.getElementById('startScreen'); +const startBtn = document.getElementById('startBtn'); +const loadingText = document.getElementById('loadingText'); +const gameOverScreen = document.getElementById('gameOverScreen'); + +class PseudoRNG { + constructor(seed) { + this.state = BigInt(seed); + } + + nextFloat() { + const a = 1664525n; + const c = 1013904223n; + const m = 4294967296n; + this.state = (this.state * a + c) % m; + return Number(this.state) / Number(m); + } + + nextRange(min, max) { + return min + (this.nextFloat() * (max - min)); + } + + pick(array) { + if (!array || array.length === 0) return null; + const idx = Math.floor(this.nextRange(0, array.length)); + return array[idx]; + } +} + +const GAME_WIDTH = 800; +const GAME_HEIGHT = 400; +canvas.width = GAME_WIDTH; +canvas.height = GAME_HEIGHT; + +const GRAVITY = 0.6; +const JUMP_POWER = -12; +const GROUND_Y = 350; +const GAME_SPEED = 5; +const CHUNK_SIZE = 60; + +let gameConfig = null; +let isLoaded = false; +let isGameRunning = false; +let isGameOver = false; +let sessionID = null; + +let rng = null; +let score = 0; +let currentTick = 0; +let lastSentTick = 0; +let inputLog = []; +let isCrouching = false; + +let sprites = {}; +let playerSprite = new Image(); +let bgSprite = new Image(); + +let player = { + x: 50, y: 300, w: 30, h: 50, color: "red", + vy: 0, grounded: false +}; + +let obstacles = []; + +function resize() { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const targetRatio = GAME_WIDTH / GAME_HEIGHT; + + let finalWidth, finalHeight; + if (windowWidth / windowHeight < targetRatio) { + finalWidth = windowWidth; + finalHeight = windowWidth / targetRatio; + } else { + finalHeight = windowHeight; + finalWidth = finalHeight * targetRatio; + } + + canvas.style.width = `${finalWidth}px`; + canvas.style.height = `${finalHeight}px`; + if(container) { + container.style.width = `${finalWidth}px`; + container.style.height = `${finalHeight}px`; + } +} +window.addEventListener('resize', resize); +resize(); + +async function loadAssets() { + playerSprite.src = "assets/player.png"; + + if (gameConfig.backgrounds && gameConfig.backgrounds.length > 0) { + const bgName = gameConfig.backgrounds[0]; + if (!bgName.startsWith("#")) { + bgSprite.src = "assets/" + bgName; + } + } + + const promises = gameConfig.obstacles.map(def => { + return new Promise((resolve) => { + if (!def.image) { resolve(); return; } + const img = new Image(); + img.src = "assets/" + def.image; + img.onload = () => { sprites[def.id] = img; resolve(); }; + img.onerror = () => { resolve(); }; + }); + }); + + if (bgSprite.src) { + promises.push(new Promise(r => { + bgSprite.onload = r; + bgSprite.onerror = r; + })); + } + + await Promise.all(promises); +} + +window.startGameClick = async function() { + if (!isLoaded) return; + startScreen.style.display = 'none'; + + try { + const sRes = await fetch('/api/start', {method:'POST'}); + const sData = await sRes.json(); + + sessionID = sData.sessionId; + rng = new PseudoRNG(sData.seed); + isGameRunning = true; + } catch(e) { + location.reload(); + } +}; + +function handleInput(action, active) { + if (isGameOver) { if(active) location.reload(); return; } + + const relativeTick = currentTick - lastSentTick; + + if (action === "JUMP" && active) { + if (player.grounded && !isCrouching) { + player.vy = JUMP_POWER; + player.grounded = false; + inputLog.push({ t: relativeTick, act: "JUMP" }); + } + } + + if (action === "DUCK") { + isCrouching = active; + } +} + +window.addEventListener('keydown', (e) => { + if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true); + if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true); +}); + +window.addEventListener('keyup', (e) => { + if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false); +}); + +window.addEventListener('mousedown', (e) => { + if (e.target === canvas && e.button === 0) { + handleInput("JUMP", true); + } +}); + +let touchStartY = 0; +window.addEventListener('touchstart', (e) => { + if(e.target === canvas) { + e.preventDefault(); + touchStartY = e.touches[0].clientY; + } +}, { passive: false }); + +window.addEventListener('touchend', (e) => { + if(e.target === canvas) { + e.preventDefault(); + const touchEndY = e.changedTouches[0].clientY; + const diff = touchEndY - touchStartY; + + if (diff < -30) { + handleInput("JUMP", true); + } else if (diff > 30) { + handleInput("DUCK", true); + setTimeout(() => handleInput("DUCK", false), 800); + } else if (Math.abs(diff) < 10) { + handleInput("JUMP", true); + } + } +}); + +async function sendChunk() { + const ticksToSend = currentTick - lastSentTick; + if (ticksToSend <= 0) return; + + const payload = { + sessionId: sessionID, + inputs: [...inputLog], + totalTicks: ticksToSend + }; + + inputLog = []; + lastSentTick = currentTick; + + try { + const res = await fetch('/api/validate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + + const data = await res.json(); + + if (data.status === "dead") { + gameOver("Vom Server gestoppt"); + } else { + const sScore = data.verifiedScore; + if (Math.abs(score - sScore) > 200) { + score = sScore; + } + } + } catch (e) { + console.error(e); + } +} + +function updateGameLogic() { + if (isCrouching) { + const relativeTick = currentTick - lastSentTick; + inputLog.push({ t: relativeTick, act: "DUCK" }); + } + + const originalHeight = 50; + const crouchHeight = 25; + player.h = isCrouching ? crouchHeight : originalHeight; + + let drawY = player.y; + if (isCrouching) { + drawY = player.y + (originalHeight - crouchHeight); + } + + player.vy += GRAVITY; + if (isCrouching && !player.grounded) player.vy += 2.0; + + player.y += player.vy; + + if (player.y + originalHeight >= GROUND_Y) { + player.y = GROUND_Y - originalHeight; + player.vy = 0; + player.grounded = true; + } else { + player.grounded = false; + } + + let nextObstacles = []; + let rightmostX = 0; + + for (let obs of obstacles) { + obs.x -= GAME_SPEED; + + const playerHitbox = { + x: player.x, + y: drawY, + w: player.w, + h: player.h + }; + + if (checkCollision(playerHitbox, obs)) { + player.color = "darkred"; + if (!isGameOver) { + sendChunk(); + gameOver("Kollision (Client)"); + } + } + + if (obs.x + obs.def.width > -100) { + nextObstacles.push(obs); + if (obs.x + obs.def.width > rightmostX) { + rightmostX = obs.x + obs.def.width; + } + } + } + obstacles = nextObstacles; + + if (rightmostX < GAME_WIDTH - 10 && gameConfig && gameConfig.obstacles) { + const gap = Math.floor(400 + rng.nextRange(0, 500)); + let spawnX = rightmostX + gap; + if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH; + + let possibleObs = []; + gameConfig.obstacles.forEach(def => { + if (def.id === "eraser") { + if (score >= 500) possibleObs.push(def); + } else { + possibleObs.push(def); + } + }); + + const def = rng.pick(possibleObs); + + let speech = null; + if (def && def.canTalk) { + if (rng.nextFloat() > 0.7) { + speech = rng.pick(def.speechLines); + } + } + + if (def) { + const yOffset = def.yOffset || 0; + obstacles.push({ + x: spawnX, + y: GROUND_Y - def.height - yOffset, + def: def, + speech: speech + }); + } + } +} + +function checkCollision(p, obs) { + const paddingX = 10; + const paddingY_Top = 25; + const paddingY_Bottom = 5; + + return ( + p.x + p.w - paddingX > obs.x + paddingX && + p.x + paddingX < obs.x + obs.def.width - paddingX && + p.y + p.h - paddingY_Bottom > obs.y + paddingY_Top && + p.y + paddingY_Top < obs.y + obs.def.height - paddingY_Bottom + ); +} + +window.submitScore = async function() { + const nameInput = document.getElementById('playerNameInput'); + const name = nameInput.value; + const btn = document.getElementById('submitBtn'); + + if (!name) return alert("Namen eingeben!"); + btn.disabled = true; + + try { + const res = await fetch('/api/submit-name', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ sessionId: sessionID, name: name }) + }); + + const data = await res.json(); + const claimCode = data.claimCode; + + let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]'); + + myClaims.push({ + name: name, + score: Math.floor(score / 10), + code: claimCode, + date: new Date().toLocaleString('de-DE'), + sessionId: sessionID + }); + + localStorage.setItem('escape_claims', JSON.stringify(myClaims)); + + alert(`Gespeichert!\nDein Beweis-Code: ${claimCode}\n(Findest du unter "Meine Codes")`); + + document.getElementById('inputSection').style.display = 'none'; + loadLeaderboard(); + + } catch (e) { + console.error(e); + alert("Fehler beim Speichern!"); + btn.disabled = false; + } +}; + +async function loadLeaderboard() { + const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`); + const entries = await res.json(); + let html = "

BESTENLISTE

"; + entries.forEach(e => { + const color = e.isMe ? "yellow" : "white"; + html += `
+ #${e.rank} ${e.name}${Math.floor(e.score/10)} +
`; + if(e.rank===3 && entries.length>3) html+="
...
"; + }); + document.getElementById('leaderboard').innerHTML = html; +} + +function gameOver(reason) { + if (isGameOver) return; + isGameOver = true; + + const finalScoreVal = Math.floor(score / 10); + const currentHighscore = localStorage.getItem('escape_highscore') || 0; + if (finalScoreVal > currentHighscore) { + localStorage.setItem('escape_highscore', finalScoreVal); + } + + gameOverScreen.style.display = 'flex'; + document.getElementById('finalScore').innerText = finalScoreVal; + + loadLeaderboard(); + drawGame(); +} + +function drawGame() { + ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + if (bgSprite.complete && bgSprite.naturalHeight !== 0) { + ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT); + } else { + const bgColor = (gameConfig && gameConfig.backgrounds) ? gameConfig.backgrounds[0] : "#eee"; + if (bgColor.startsWith("#")) ctx.fillStyle = bgColor; + else ctx.fillStyle = "#f0f0f0"; + ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + } + + ctx.fillStyle = "rgba(60, 60, 60, 0.8)"; + ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50); + + obstacles.forEach(obs => { + const img = sprites[obs.def.id]; + if (img) { + ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height); + } else { + ctx.fillStyle = obs.def.color; + ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height); + } + if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech); + }); + + const drawY = isCrouching ? player.y + 25 : player.y; + const drawH = isCrouching ? 25 : 50; + + if (playerSprite.complete && playerSprite.naturalHeight !== 0) { + ctx.drawImage(playerSprite, player.x, drawY, player.w, drawH); + } else { + ctx.fillStyle = player.color; + ctx.fillRect(player.x, drawY, player.w, drawH); + } + + if (isGameOver) { + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT); + } +} + +function drawSpeechBubble(x, y, text) { + const bX = x-20; const bY = y-40; const bW = 120; const bH = 30; + ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH); + ctx.strokeRect(bX,bY,bW,bH); + ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center"; + ctx.fillText(text, bX+bW/2, bY+20); +} + +function gameLoop() { + if (!isLoaded) return; + + if (isGameRunning && !isGameOver) { + updateGameLogic(); + currentTick++; + score++; + + const scoreEl = document.getElementById('score'); + if (scoreEl) scoreEl.innerText = Math.floor(score / 10); + + if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk(); + } + + drawGame(); + requestAnimationFrame(gameLoop); +} + +async function initGame() { + try { + const cRes = await fetch('/api/config'); + gameConfig = await cRes.json(); + + await loadAssets(); + + await loadStartScreenLeaderboard(); + + isLoaded = true; + if(loadingText) loadingText.style.display = 'none'; + if(startBtn) startBtn.style.display = 'inline-block'; + + const savedHighscore = localStorage.getItem('escape_highscore') || 0; + const hsEl = document.getElementById('localHighscore'); + if(hsEl) hsEl.innerText = savedHighscore; + + requestAnimationFrame(gameLoop); + } catch(e) { + if(loadingText) loadingText.innerText = "Fehler!"; + } +} + +// Lädt die Top-Liste für den Startbildschirm (ohne Session ID) +async function loadStartScreenLeaderboard() { + try { + const listEl = document.getElementById('startLeaderboardList'); + if (!listEl) return; + + // Anfrage an API (ohne SessionID gibt der Server automatisch die Top 3 zurück) + const res = await fetch('/api/leaderboard'); + const entries = await res.json(); + + if (entries.length === 0) { + listEl.innerHTML = "
Noch keine Scores.
"; + return; + } + + let html = ""; + entries.forEach(e => { + // Medaillen Icons für Top 3 + let icon = "#" + e.rank; + if (e.rank === 1) icon = "🥇"; + if (e.rank === 2) icon = "🥈"; + if (e.rank === 3) icon = "🥉"; + + html += ` +
+ ${icon} ${e.name} + ${Math.floor(e.score / 10)} +
`; + }); + + listEl.innerHTML = html; + } catch (e) { + console.error("Konnte Leaderboard nicht laden", e); + } +} + +initGame(); \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..fab8fd2 --- /dev/null +++ b/static/index.html @@ -0,0 +1,181 @@ + + + + + + Escape the Teacher + + + + +
+
📱↻
+

Bitte Gerät drehen!

+ Querformat benötigt +
+ +
+ + +
+ SCORE: 0 +
+ +
+
+

ESCAPE THE
TEACHER

+

Dein Rekord: 0

+ + +
Lade Grafiken...
+ +
+
SCHUL-NEWS
+

+ • Herr Müller verteilt heute Nachsitzen!
+ • Spring über Tische und Mülleimer.
+ • Lass dich nicht erwischen! +

+
+ +
+
STEUERUNG
+

+ PC: Leertaste, Pfeil Hoch/Runter oder Mausklick
+ Handy: Tippen (Springen) oder Wischen (Ducken) +

+
+ + +
+ +
+
+

🏆 TOP SCHÜLER

+
Lade...
+
+
+
+ + +
+ + + + + + + + + + + \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6842036 --- /dev/null +++ b/static/style.css @@ -0,0 +1,335 @@ +/* ========================================= + 0. LOKALE SCHRIFTARTEN (DSGVO Konform) + ========================================= */ +/* press-start-2p-regular - latin */ +@font-face { + font-display: swap; /* Zeigt Text sofort an (Fallback), bis Schrift geladen ist */ + font-family: 'Press Start 2P'; + font-style: normal; + font-weight: 400; + src: url('assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2'), + url('assets/fonts/press-start-2p-v16-latin-regular.woff') format('woff'); +} + + +/* ========================================= + 1. GRUNDLAGEN & GLOBAL + ========================================= */ +body, html { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background-color: #1a1a1a; + color: white; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + font-family: 'Press Start 2P', cursive; + font-size: 14px; +} + +#game-container { + position: relative; + box-shadow: 0 0 50px rgba(0,0,0,0.8); + border: 4px solid #444; + background: #000; + max-width: 100%; + max-height: 100%; +} + +canvas { + display: block; + background-color: #f0f0f0; + image-rendering: pixelated; + image-rendering: crisp-edges; + width: 100%; + height: auto; +} + +/* ========================================= + 2. UI & SCORE + ========================================= */ +#ui-layer { + position: absolute; + top: 25px; + right: 25px; + color: #333; + font-size: 24px; + font-weight: bold; + pointer-events: none; + text-shadow: 2px 2px 0px rgba(255,255,255,0.8); + z-index: 5; +} + +/* ========================================= + 3. OVERLAYS (Start, Game Over) + ========================================= */ +#startScreen, #gameOverScreen { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + justify-content: center; + align-items: center; + z-index: 10; + box-sizing: border-box; + padding: 20px; +} + +h1 { + font-size: 32px; + color: #ff4444; + text-shadow: 4px 4px 0px #000; + line-height: 1.4; + margin: 10px 0 25px 0; + text-align: center; + text-transform: uppercase; +} + +/* --- FIX: INPUT SECTION ZENTRIEREN --- */ +#inputSection { + display: flex; + flex-direction: column; /* Untereinander */ + align-items: center; /* Horizontal mittig */ + justify-content: center; + width: 100%; + margin: 15px 0; +} + +/* ========================================= + 4. START SCREEN LAYOUT + ========================================= */ +#startScreen { + flex-direction: row; + gap: 40px; +} + +.start-left { + flex: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 60%; +} + +.start-right { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 35%; +} + +.hall-of-fame-box { + background: rgba(0, 0, 0, 0.6); + border: 4px solid #ffcc00; + padding: 15px; + width: 100%; + max-height: 300px; + overflow-y: auto; + box-shadow: 0 0 15px rgba(255, 204, 0, 0.1); +} + +.hall-of-fame-box h3 { + color: #ffcc00; + text-align: center; + margin-top: 0; + margin-bottom: 10px; + border-bottom: 2px solid #555; + padding-bottom: 8px; + font-size: 14px; +} + +.hof-entry { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px dotted #444; + font-size: 11px; + font-family: sans-serif; + color: #ddd; + line-height: 1.4; +} +.hof-rank { color: #ffcc00; font-weight: bold; margin-right: 8px; } +.hof-score { color: white; font-weight: bold; } + +/* ========================================= + 5. BUTTONS & INPUTS + ========================================= */ +button { + font-family: 'Press Start 2P', cursive; + background: #ffcc00; + border: 4px solid #fff; + padding: 18px 30px; + font-size: 18px; + cursor: pointer; + color: #000; + box-shadow: 0 6px 0 #997a00; + text-transform: uppercase; + margin: 12px; +} +button:active { transform: translateY(4px); box-shadow: 0 1px 0 #997a00; } +button:disabled { background: #555; color: #888; box-shadow: none; } + +input { + font-family: 'Press Start 2P', cursive; + padding: 12px; + font-size: 16px; + border: 3px solid white; + background: #222; + color: white; + text-align: center; + margin-bottom: 15px; + width: 250px; + outline: none; +} + +/* ========================================= + 6. INFO BOXEN + ========================================= */ +.info-box { + background: rgba(255, 255, 255, 0.1); + border: 2px solid #555; + padding: 12px; + margin: 8px 0; + width: 100%; + max-width: 320px; + text-align: left; + box-sizing: border-box; +} +.info-box p { + font-family: sans-serif; + font-size: 14px; + color: #ccc; + line-height: 1.4; + margin: 0; +} +.info-title { + color: #ffcc00; + font-size: 12px; + margin-bottom: 6px; + text-align: center; + text-decoration: underline; +} + +/* Game Over Screen Anpassung */ +#gameOverScreen { flex-direction: column; } +#leaderboard { + margin-top: 20px; + font-size: 12px; + width: 90%; + max-width: 450px; + background: rgba(0,0,0,0.5); + padding: 15px; + border: 2px solid #666; +} + +/* ========================================= + 7. RECHTLICHES + ========================================= */ +.legal-bar { + margin-top: 20px; + display: flex; + gap: 15px; +} +.legal-btn { + font-size: 10px; + padding: 8px 12px; + margin: 0; +} + +.modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.95); + z-index: 999; + align-items: center; + justify-content: center; +} +.modal-content { + background: #222; + border: 4px solid #fff; + padding: 30px; + width: 80%; + max-width: 600px; + max-height: 80%; + overflow-y: auto; + color: #ccc; + font-family: sans-serif; + text-align: left; + position: relative; + font-size: 16px; + line-height: 1.6; +} +.modal-content h2 { color: #ffcc00; font-family: 'Press Start 2P'; margin-top: 0; font-size: 20px; } +.close-modal { + position: absolute; + top: 10px; right: 10px; + background: #ff4444; border: 2px solid white; + width: 35px; height: 35px; + font-size: 16px; + line-height: 30px; +} + +/* ========================================= + 8. PC / DESKTOP SPEZIAL + ========================================= */ +@media (min-width: 1024px) { + h1 { font-size: 48px; margin-bottom: 40px; } + button { font-size: 22px; padding: 20px 40px; } + input { width: 350px; font-size: 20px; padding: 15px; } + + .info-box { max-width: 500px; } + .info-box p { font-size: 16px; } + .info-title { font-size: 14px; } + + .hall-of-fame-box { max-height: 400px; } + .hof-entry { font-size: 14px; padding: 8px 0; } + .hall-of-fame-box h3 { font-size: 18px; margin-bottom: 15px; } +} + +/* ========================================= + 9. MOBILE ANPASSUNG + ========================================= */ +@media (max-width: 700px) { + #startScreen { + flex-direction: column; + gap: 15px; + justify-content: flex-start; + overflow-y: auto; + } + .start-left, .start-right { max-width: 100%; width: 100%; } + .start-right { height: auto; min-height: 200px; margin-top: 20px; } + .hall-of-fame-box { max-height: 200px; } + h1 { font-size: 24px; margin: 15px 0; } + button { padding: 12px 20px; font-size: 14px; } + input { width: 200px; } +} + +/* ========================================= + 10. ROTATE OVERLAY + ========================================= */ +#rotate-overlay { + display: none; + position: fixed; + top: 0; left: 0; width: 100%; height: 100%; + background: #222; + z-index: 9999; + color: white; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.icon { font-size: 60px; margin-bottom: 20px; } + +@media screen and (orientation: portrait) { + #rotate-overlay { display: flex; } + #game-container { display: none !important; } +} \ No newline at end of file