diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index bbbb94e..5e76234 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -109,6 +109,7 @@ jobs: # Anwenden echo "Deploying Resources to Namespace: ${{ env.TARGET_NS }}" + kubectl apply -f k8s/pvc.yaml -n ${{ env.TARGET_NS }} kubectl apply -f k8s/redis.yaml -n ${{ env.TARGET_NS }} kubectl apply -f k8s/app.yaml -n ${{ env.TARGET_NS }} kubectl apply -f k8s/ingress.yaml -n ${{ env.TARGET_NS }} diff --git a/Dockerfile b/Dockerfile index 21cb61c..3e793d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,7 +58,8 @@ COPY --from=minifier /minify/game.min.js ./static/js/game.min.js # Schritt A: Lösche alle Zeilen, die ' static/index.html + && sed -i '/<\/body>/i \ ' static/index.html \ + && sed -i '/<\/head>/i \ ' static/index.html # Port und Start EXPOSE 8080 diff --git a/config.go b/config.go index 88eeff4..2384d5f 100644 --- a/config.go +++ b/config.go @@ -36,32 +36,45 @@ func getEnv(key, fallback string) string { return fallback } +// config.go + func initGameConfig() { - defaultConfig = GameConfig{ - Obstacles: []ObstacleDef{ - // --- HINDERNISSE --- - {ID: "desk", Type: "obstacle", Width: 50, Height: 65, Color: "#ff0000", Image: "desk.png", YOffset: -19, ImgScale: 1.3, ImgOffsetX: 1, ImgOffsetY: 3}, // desk - {ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher1.png"}, - {ID: "k-m", Type: "teacher", Width: 45, Height: 80, Color: "#ff0000", Image: "k-m.png", YOffset: 5, ImgScale: 1.2, ImgOffsetX: -1, ImgOffsetY: 8}, // k-m - {ID: "w-l", Type: "teacher", Width: 50, Height: 70, Color: "#ff0000", Image: "w-l.png", ImgScale: 1.1, ImgOffsetX: 1, ImgOffsetY: 3, CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}}, // w-l - {ID: "trashcan", Type: "obstacle", Width: 25, Height: 35, Color: "#555", Image: "trash1.png"}, - {ID: "eraser1", Type: "obstacle", Width: 56, Height: 37, Color: "#ff0000", Image: "eraser.png", YOffset: 35, ImgScale: 1.6, ImgOffsetY: 9}, // eraser1 - {ID: "principal", Type: "teacher", Width: 40, Height: 70, Color: "#000", Image: "principal1.png", CanTalk: true, SpeechLines: []string{"EXMATRIKULATION!"}}, + // 1. Versuche aus Redis zu laden + val, err := rdb.Get(ctx, "config:gamedata").Result() - // --- COINS --- - {ID: "coin0", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", ImgScale: 1.1, ImgOffsetY: 1}, - {ID: "coin1", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", YOffset: 60, ImgScale: 1.1, ImgOffsetY: 1}, + if err == nil && val != "" { + // Redis hat Daten -> Nutzen! + json.Unmarshal([]byte(val), &defaultConfig) + log.Println("💾 Config aus Redis geladen.") + } else { + // Redis ist leer -> Hardcoded Defaults laden (Bootstrap) + log.Println("⚠️ Keine Config in Redis -> Nutze Hardcoded Defaults.") + defaultConfig = GameConfig{ + Obstacles: []ObstacleDef{ + // --- HINDERNISSE --- + {ID: "desk", Type: "obstacle", Width: 50, Height: 65, Color: "#ff0000", Image: "desk.png", YOffset: -19, ImgScale: 1.3, ImgOffsetX: 1, ImgOffsetY: 3}, // desk + {ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher1.png"}, + {ID: "k-m", Type: "teacher", Width: 45, Height: 80, Color: "#ff0000", Image: "k-m.png", YOffset: 5, ImgScale: 1.2, ImgOffsetX: -1, ImgOffsetY: 8}, // k-m + {ID: "w-l", Type: "teacher", Width: 50, Height: 70, Color: "#ff0000", Image: "w-l.png", ImgScale: 1.1, ImgOffsetX: 1, ImgOffsetY: 3, CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}}, // w-l + {ID: "trashcan", Type: "obstacle", Width: 25, Height: 35, Color: "#555", Image: "trash1.png"}, + {ID: "eraser1", Type: "obstacle", Width: 56, Height: 37, Color: "#ff0000", Image: "eraser.png", YOffset: 35, ImgScale: 1.6, ImgOffsetY: 9}, // eraser1 + {ID: "principal", Type: "teacher", Width: 40, Height: 70, Color: "#000", Image: "principal1.png", CanTalk: true, SpeechLines: []string{"EXMATRIKULATION!"}}, - // --- POWERUPS --- - {ID: "p_god", Type: "powerup", Width: 30, Height: 30, Color: "cyan", Image: "powerup_god1.png", YOffset: 20.0}, // Godmode - {ID: "p_bat", Type: "powerup", Width: 30, Height: 30, Color: "red", Image: "powerup_bat1.png", YOffset: 20.0}, // Schläger - {ID: "p_boot", Type: "powerup", Width: 30, Height: 30, Color: "lime", Image: "powerup_boot1.png", YOffset: 20.0}, // Boots - }, + // --- COINS --- + {ID: "coin0", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", ImgScale: 1.1, ImgOffsetY: 1}, + {ID: "coin1", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", YOffset: 60, ImgScale: 1.1, ImgOffsetY: 1}, - Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"}, + // --- POWERUPS --- + {ID: "p_god", Type: "powerup", Width: 30, Height: 30, Color: "cyan", Image: "powerup_god1.png", YOffset: 20.0}, // Godmode + {ID: "p_bat", Type: "powerup", Width: 30, Height: 30, Color: "red", Image: "powerup_bat1.png", YOffset: 20.0}, // Schläger + {ID: "p_boot", Type: "powerup", Width: 30, Height: 30, Color: "lime", Image: "powerup_boot1.png", YOffset: 20.0}, // Boots + }, + + Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"}, + } } - log.Println("✅ Config mit Powerups geladen") + // Chunks laden (bleibt wie vorher) defaultConfig.Chunks = loadChunksFromRedis() } diff --git a/handlers.go b/handlers.go index ac46bd5..d575d81 100644 --- a/handlers.go +++ b/handlers.go @@ -3,9 +3,12 @@ package main import ( "encoding/json" "html" + "io" "log" "math/rand" "net/http" + "os" + "path/filepath" "sort" "strconv" "strings" @@ -344,3 +347,67 @@ func handleAdminBadwords(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } } + +func handleAdminUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", 405) + return + } + + r.ParseMultipartForm(10 << 20) + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "Keine Datei gefunden", 400) + return + } + defer file.Close() + + filename := filepath.Base(header.Filename) + + targetPath := filepath.Join("./static/assets", filename) + + // Datei erstellen + dst, err := os.Create(targetPath) + if err != nil { + http.Error(w, "Konnte Datei nicht speichern (Rechte?)", 500) + return + } + defer dst.Close() + + // Inhalt kopieren + if _, err := io.Copy(dst, file); err != nil { + http.Error(w, "Fehler beim Schreiben", 500) + return + } + + log.Printf("📂 UPLOAD: %s erfolgreich gespeichert.", filename) + + // JSON Antwort mit dem Pfad zurückgeben + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "filename": filename, + }) +} + +// 2. CONFIG SPEICHERN (Redis) +func handleAdminSaveConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST", 405) + return + } + + var newConfig GameConfig + if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil { + http.Error(w, "Bad JSON", 400) + return + } + + data, _ := json.Marshal(newConfig) + rdb.Set(ctx, "config:gamedata", data, 0) + + defaultConfig.Obstacles = newConfig.Obstacles + defaultConfig.Backgrounds = newConfig.Backgrounds + + w.WriteHeader(http.StatusOK) +} diff --git a/k8s/app.yaml b/k8s/app.yaml index 4ce38bb..8f60691 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -1,14 +1,3 @@ -apiVersion: v1 -kind: Service -metadata: - name: escape-game -spec: - ports: - - port: 80 - targetPort: 8080 - selector: - app: escape-game ---- apiVersion: apps/v1 kind: Deployment metadata: @@ -16,7 +5,7 @@ metadata: labels: app: escape-game spec: - replicas: 1 + replicas: 1 # Kannst du bei RWX auch hochskalieren! selector: matchLabels: app: escape-game @@ -25,9 +14,30 @@ spec: labels: app: escape-game spec: + initContainers: + - name: init-assets + image: ${IMAGE_NAME} + command: ["/bin/sh", "-c"] + args: + - | + echo "Prüfe Assets..." + # Wenn das Volume leer ist (nur lost+found), kopiere Originale + if [ -z "$(ls -A /mnt/assets)" ]; then + echo "Volume leer. Kopiere Basis-Assets..." + cp -r /app/static/assets/* /mnt/assets/ + else + echo "Assets existieren bereits. Überspringe Copy." + # Optional: 'cp -n' nutzen um neue Default-Assets zu ergänzen ohne Uploads zu überschreiben + cp -rn /app/static/assets/* /mnt/assets/ || true + fi + volumeMounts: + - name: assets-vol + mountPath: /mnt/assets + + # --- MAIN CONTAINER --- containers: - name: game - image: ${IMAGE_NAME} # Wird von CI ersetzt + image: ${IMAGE_NAME} ports: - containerPort: 8080 env: @@ -37,24 +47,34 @@ spec: value: "${ADMIN_USER}" - name: ADMIN_PASS value: "${ADMIN_PASS}" + + # HIER DAS VOLUME EINHÄNGEN + volumeMounts: + - name: assets-vol + mountPath: /app/static/assets + resources: requests: - memory: "64Mi" - cpu: "50m" + memory: "128Mi" + cpu: "100m" limits: - memory: "256Mi" - cpu: "500m" + memory: "512Mi" + cpu: "1000m" + livenessProbe: httpGet: path: / port: 8080 - initialDelaySeconds: 10 - periodSeconds: 15 - timeoutSeconds: 5 - failureThreshold: 3 + initialDelaySeconds: 15 + periodSeconds: 20 readinessProbe: httpGet: path: / port: 8080 - initialDelaySeconds: 5 - periodSeconds: 10 \ No newline at end of file + initialDelaySeconds: 10 + periodSeconds: 10 + + volumes: + - name: assets-vol + persistentVolumeClaim: + claimName: game-assets-pvc \ No newline at end of file diff --git a/k8s/pvc.yaml b/k8s/pvc.yaml new file mode 100644 index 0000000..a21793d --- /dev/null +++ b/k8s/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: game-assets-pvc +spec: + accessModes: + - ReadWriteMany + storageClassName: longhorn + resources: + requests: + storage: 2Gi \ No newline at end of file diff --git a/main.go b/main.go index 36e2e84..d67d506 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,11 @@ func main() { http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList))) http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction))) http.HandleFunc("/api/admin/chunks", Logger(BasicAuth(handleAdminChunks))) + http.HandleFunc("/api/admin/upload", Logger(BasicAuth(handleAdminUpload))) + http.HandleFunc("/api/admin/save-config", Logger(BasicAuth(handleAdminSaveConfig))) + http.HandleFunc("/admin/assets", Logger(BasicAuth(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./secure/assets.html") + }))) http.HandleFunc("/admin/editor", Logger(BasicAuth(handleEditorPage))) http.HandleFunc("/admin/obstacle_editor", Logger(BasicAuth(handleObstacleEditorPage))) diff --git a/secure/assets.html b/secure/assets.html new file mode 100644 index 0000000..bbfc434 --- /dev/null +++ b/secure/assets.html @@ -0,0 +1,449 @@ + + +
+ +m&&(m=g),b>_&&(_=b),g=o*i+l*e+h,b=a*i+u*e+c,g
m&&(m=g),b>_&&(_=b),g=o*t+l*s+h,b=a*t+u*s+c,g
m&&(m=g),b>_&&(_=b),g=o*i+l*s+h,b=a*i+u*s+c,g
m&&(m=g),b>_&&(_=b),this.minX=p,this.minY=f,this.maxX=m,this.maxY=_}addRect(t,e){this.addFrame(t.x,t.y,t.x+t.width,t.y+t.height,e)}addBounds(t,e){this.addFrame(t.minX,t.minY,t.maxX,t.maxY,e)}addBoundsMask(t){this.minX=this.minX>t.minX?this.minX:t.minX,this.minY=this.minY>t.minY?this.minY:t.minY,this.maxX=this.maxX g-u&&e b-u&&us(t,e,p,b-u,u,c,h)}}const yS=8,cs=11920929e-14,xS=1,qo=.01,ir=0,Re=0;function Zo(r,t,e,i,s,n,o,a,l,u){const h=Math.min(.99,Math.max(0,u!=null?u:ss.defaultOptions.bezierSmoothness));let c=(xS-h)/1;return c*=c,TS(t,e,i,s,n,o,a,l,r,c),r}function TS(r,t,e,i,s,n,o,a,l,u){Qo(r,t,e,i,s,n,o,a,l,u,0),l.push(o,a)}function Qo(r,t,e,i,s,n,o,a,l,u,h){if(h>yS)return;const c=Math.PI,p=(r+e)/2,f=(t+i)/2,m=(e+s)/2,_=(i+n)/2,g=(s+o)/2,b=(n+a)/2,y=(p+m)/2,v=(f+_)/2,x=(m+g)/2,A=(_+b)/2,S=(y+x)/2,E=(v+A)/2;if(h>0){let C=o-r,O=a-t;const P=Math.abs((e-o)*O-(i-a)*C),R=Math.abs((s-o)*O-(n-a)*C);let U,k;if(P>cs&&R>cs){if((P+R)*(P+R)<=u*(C*C+O*O)){if(ir=0;s--)if(t.currentTarget=i[s],this.notifyTarget(t,e),t.propagationStopped||t.propagationImmediatelyStopped)return}}all(t,e,i=this._allInteractiveElements){if(i.length===0)return;t.eventPhase=t.BUBBLING_PHASE;const s=Array.isArray(e)?e:[e];for(let n=i.length-1;n>=0;n--)s.forEach(o=>{t.currentTarget=i[n],this.notifyTarget(t,o)})}propagationPath(t){const e=[t];for(let i=0;i<_T&&t!==this.rootTarget&&t.parent;i++){if(!t.parent)throw new Error("Cannot find propagation path to disconnected target");e.push(t.parent),t=t.parent}return e.reverse(),e}hitTestMoveRecursive(t,e,i,s,n,o=!1){let a=!1;if(this._interactivePrune(t))return null;if((t.eventMode==="dynamic"||e==="dynamic")&&(Vt.pauseUpdate=!1),t.interactiveChildren&&t.children){const h=t.children;for(let c=h.length-1;c>=0;c--){const p=h[c],f=this.hitTestMoveRecursive(p,this._isInteractive(e)?e:p.eventMode,i,s,n,o||n(t,i));if(f){if(f.length>0&&!f[f.length-1].parent)continue;const m=t.isInteractive();(f.length>0||m)&&(m&&this._allInteractiveElements.push(t),f.push(t)),this._hitElements.length===0&&(this._hitElements=f),a=!0}}}const l=this._isInteractive(e),u=t.isInteractive();return u&&u&&this._allInteractiveElements.push(t),o||this._hitElements.length>0?null:a?this._hitElements:l&&!n(t,i)&&s(t,i)?u?[t]:[]:null}hitTestRecursive(t,e,i,s,n){if(this._interactivePrune(t)||n(t,i))return null;if((t.eventMode==="dynamic"||e==="dynamic")&&(Vt.pauseUpdate=!1),t.interactiveChildren&&t.children){const l=t.children,u=i;for(let h=l.length-1;h>=0;h--){const c=l[h],p=this.hitTestRecursive(c,this._isInteractive(e)?e:c.eventMode,u,s,n);if(p){if(p.length>0&&!p[p.length-1].parent)continue;const f=t.isInteractive();return(p.length>0||f)&&p.push(t),p}}}const o=this._isInteractive(e),a=t.isInteractive();return o&&s(t,i)?a?[t]:[]:null}_isInteractive(t){return t==="static"||t==="dynamic"}_interactivePrune(t){return!t||!t.visible||!t.renderable||!t.measurable||t.eventMode==="none"||t.eventMode==="passive"&&!t.interactiveChildren}hitPruneFn(t,e){if(t.hitArea&&(t.worldTransform.applyInverse(e,Pr),!t.hitArea.contains(Pr.x,Pr.y)))return!0;if(t.effects&&t.effects.length)for(let i=0;i0&&(e=t.composedPath()[0]);const i=e!==this.domElement?"outside":"",s=this._normalizeToPointerData(t);for(let n=0,o=s.length;nh&&(h=f),m>c&&(c=m)}u=Math.max(h-a,c-l),u=u!==0?32767/u:0}return Dr(n,o,e,a,l,u,0),o}function hp(r,t,e,i,s){let n;if(s===Mo(r,t,e,i)>0)for(let o=t;o