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:
37
.github/workflows/cleanup.yaml
vendored
Normal file
37
.github/workflows/cleanup.yaml
vendored
Normal file
@@ -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
|
||||||
98
.github/workflows/deploy.yaml
vendored
Normal file
98
.github/workflows/deploy.yaml
vendored
Normal file
@@ -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 }}"
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -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"]
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -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:
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
8
go.sum
Normal file
8
go.sum
Normal file
@@ -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=
|
||||||
42
k8s/app.yaml
Normal file
42
k8s/app.yaml
Normal file
@@ -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}"
|
||||||
25
k8s/ingress.yaml
Normal file
25
k8s/ingress.yaml
Normal file
@@ -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
|
||||||
49
k8s/redis.yaml
Normal file
49
k8s/redis.yaml
Normal file
@@ -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
|
||||||
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
|
||||||
|
}
|
||||||
169
secure/admin.html
Normal file
169
secure/admin.html
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Lehrer Zimmer (Admin)</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; background: #222; color: #ddd; padding: 20px; max-width: 800px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* Header & Suche */
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #555; padding-bottom: 20px; margin-bottom: 20px; }
|
||||||
|
h1 { margin: 0; color: #ffcc00; }
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
padding: 10px; font-size: 16px; width: 250px;
|
||||||
|
background: #333; border: 1px solid #666; color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1; padding: 15px; cursor: pointer; background: #333; border: none; color: #888; font-weight: bold; font-size: 16px;
|
||||||
|
}
|
||||||
|
.tab-btn.active { background: #4caf50; color: white; }
|
||||||
|
.tab-btn#tab-public.active { background: #2196F3; } /* Blau für Public */
|
||||||
|
|
||||||
|
/* Liste */
|
||||||
|
.entry {
|
||||||
|
background: #333; padding: 15px; margin-bottom: 8px;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
border-left: 5px solid #555;
|
||||||
|
}
|
||||||
|
.entry.highlight { border-left-color: #ffeb3b; background: #444; } /* Suchtreffer */
|
||||||
|
|
||||||
|
.info { font-size: 1.1em; }
|
||||||
|
.meta { font-size: 0.85em; color: #aaa; margin-top: 4px; font-family: monospace; }
|
||||||
|
.code { color: #00e5ff; font-weight: bold; font-size: 1.1em; letter-spacing: 1px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button { cursor: pointer; padding: 8px 15px; border: none; font-weight: bold; color: white; border-radius: 4px; }
|
||||||
|
.btn-approve { background: #4caf50; margin-right: 5px; }
|
||||||
|
.btn-delete { background: #f44336; }
|
||||||
|
.btn-delete:hover { background: #d32f2f; }
|
||||||
|
|
||||||
|
.empty { text-align: center; padding: 40px; color: #666; font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>👨🏫 Lehrer Zimmer</h1>
|
||||||
|
<input type="text" id="searchInput" placeholder="Suche Code oder Name..." onkeyup="filterList()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button id="tab-unverified" class="tab-btn active" onclick="switchTab('unverified')">⏳ Warteschlange (Prüfen)</button>
|
||||||
|
<button id="tab-public" class="tab-btn" onclick="switchTab('public')">🏆 Bestenliste (Fertig)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="list">Lade...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentTab = 'unverified';
|
||||||
|
let allEntries = []; // Speichert die geladenen Daten für die Suche
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
const listEl = document.getElementById('list');
|
||||||
|
listEl.innerHTML = '<div style="text-align:center">Lade Daten...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API Aufruf mit Typ (unverified oder public)
|
||||||
|
const res = await fetch('/api/admin/list?type=' + currentTab);
|
||||||
|
allEntries = await res.json();
|
||||||
|
|
||||||
|
renderList(allEntries);
|
||||||
|
} catch(e) {
|
||||||
|
listEl.innerHTML = '<div class="empty">Fehler beim Laden der Daten.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(data) {
|
||||||
|
const listEl = document.getElementById('list');
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
listEl.innerHTML = "<div class='empty'>Liste ist leer.</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
data.forEach(entry => {
|
||||||
|
// Buttons: In der "Public" Liste brauchen wir keinen "Freigeben" Button mehr
|
||||||
|
let actions = '';
|
||||||
|
if (currentTab === 'unverified') {
|
||||||
|
actions += `<button class="btn-approve" onclick="decide('${entry.sessionId}', 'approve')">✔ OK</button>`;
|
||||||
|
}
|
||||||
|
// Löschen darf man immer (falls man sich verklickt hat oder Schüler nervt)
|
||||||
|
actions += `<button class="btn-delete" onclick="decide('${entry.sessionId}', 'delete')">🗑 Weg</button>`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="entry">
|
||||||
|
<div class="info">
|
||||||
|
<strong>${entry.name || 'Unbekannt'}</strong>
|
||||||
|
<span style="color:#ffcc00; font-weight:bold; margin-left:10px;">${entry.score} Pkt</span>
|
||||||
|
<div class="meta">
|
||||||
|
CODE: <span class="code">${entry.code || '---'}</span> | ${entry.time || '?'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
${actions}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
// Filter direkt anwenden, falls noch Text in der Suche steht
|
||||||
|
filterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Such-Logik (Client-Side, rasend schnell)
|
||||||
|
function filterList() {
|
||||||
|
const term = document.getElementById('searchInput').value.toUpperCase();
|
||||||
|
const entries = document.querySelectorAll('.entry');
|
||||||
|
|
||||||
|
entries.forEach(div => {
|
||||||
|
// Wir suchen im gesamten Text des Eintrags (Name, Code, Score)
|
||||||
|
const text = div.innerText.toUpperCase();
|
||||||
|
if (text.includes(term)) {
|
||||||
|
div.style.display = "flex";
|
||||||
|
// Kleines Highlight wenn gesucht wird
|
||||||
|
if(term.length > 0) div.classList.add("highlight");
|
||||||
|
else div.classList.remove("highlight");
|
||||||
|
} else {
|
||||||
|
div.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide(sid, action) {
|
||||||
|
if(!confirm(action === 'approve' ? "Freigeben?" : "Endgültig löschen?")) return;
|
||||||
|
|
||||||
|
await fetch('/api/admin/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ sessionId: sid, action: action })
|
||||||
|
});
|
||||||
|
loadList(); // Neu laden
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + tab).classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('searchInput').value = "";
|
||||||
|
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start
|
||||||
|
loadList();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if(currentTab === 'unverified' && document.getElementById('searchInput').value === "") {
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
static/assets/background.jpg
Normal file
BIN
static/assets/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
211
static/assets/fonts/press-start-2p-v16-latin-regular.svg
Normal file
211
static/assets/fonts/press-start-2p-v16-latin-regular.svg
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<font id="PressStart2P" horiz-adv-x="1000">
|
||||||
|
<font-face font-family="Press Start 2P" units-per-em="1000" panose-1="0 0 5 0 0 0 0 0 0 0" ascent="1000" descent="0" alphabetic="0" />
|
||||||
|
<glyph unicode=" " glyph-name="space" horiz-adv-x="1000" />
|
||||||
|
<glyph unicode="!" glyph-name="exclam" horiz-adv-x="1000" d="M250,375v625H625V625H500V375H250Zm0-250V250H500V125H250Z" />
|
||||||
|
<glyph unicode=""" glyph-name="quotedbl" horiz-adv-x="1000" d="M125,625v375H375V625H125Zm375,0v375H750V625H500Z" />
|
||||||
|
<glyph unicode="#" glyph-name="numbersign" horiz-adv-x="1000" d="M125,125V250H0V375H125V750H0V875H125v125H375V875H500v125H750V875H875V750H750V375H875V250H750V125H500V250H375V125H125ZM375,375H500V750H375V375Z" />
|
||||||
|
<glyph unicode="$" glyph-name="dollar" horiz-adv-x="1000" d="M375,125V250H0V375H375V500H125V625H0V750H125V875H375v125H500V875H750V750H500V625H750V500H875V375H750V250H500V125H375ZM250,625H375V750H250V625ZM500,375H625V500H500V375Z" />
|
||||||
|
<glyph unicode="%" glyph-name="percent" horiz-adv-x="1000" d="M250,875H125v125H375V750H250V875Zm625,125V875H750v125H875ZM250,625H0V875H125V750H250V625ZM625,875H750V750H625V875ZM500,750H625V625H500V750ZM375,625H500V500H375V625ZM250,500H375V375H250V500Zm375,0H875V250H750V375H625V500ZM125,375H250V250H125V375ZM625,250H750V125H500V375H625V250ZM0,250H125V125H0V250Z" />
|
||||||
|
<glyph unicode="&" glyph-name="ampersand" horiz-adv-x="1000" d="M625,375H750V250H875V125H125V250H0V500H125V625H0V875H125v125H500V875H625V625H500V500H625V375ZM250,625H375V875H250V625ZM500,375H375V500H250V250H500V375ZM875,500V375H750V500H875Z" />
|
||||||
|
<glyph unicode="'" glyph-name="quotesingle" horiz-adv-x="1000" d="M250,625v375H500V625H250Z" />
|
||||||
|
<glyph unicode="(" glyph-name="parenleft" horiz-adv-x="1000" d="M500,125V250H375V375H250V750H375V875H500v125H750V875H625V750H500V375H625V250H750V125H500Z" />
|
||||||
|
<glyph unicode=")" glyph-name="parenright" horiz-adv-x="1000" d="M125,125V250H250V375H375V750H250V875H125v125H375V875H500V750H625V375H500V250H375V125H125Z" />
|
||||||
|
<glyph unicode="*" glyph-name="asterisk" horiz-adv-x="1000" d="M125,250V375H250V500H0V625H250V750H125V875H375V750H500V875H750V750H625V625H875V500H625V375H750V250H500V375H375V250H125Z" />
|
||||||
|
<glyph unicode="+" glyph-name="plus" horiz-adv-x="1000" d="M375,250V500H125V625H375V875H625V625H875V500H625V250H375Z" />
|
||||||
|
<glyph unicode="," glyph-name="comma" horiz-adv-x="1000" d="M125,0V125H250V375H500V125H375V0H125Z" />
|
||||||
|
<glyph unicode="-" glyph-name="hyphen" horiz-adv-x="1000" d="M125,500V625H875V500H125Z" />
|
||||||
|
<glyph unicode="." glyph-name="period" horiz-adv-x="1000" d="M250,125V375H500V125H250Z" />
|
||||||
|
<glyph unicode="/" glyph-name="slash" horiz-adv-x="1000" d="M875,1000V875H750v125H875ZM625,875H750V750H625V875ZM500,750H625V625H500V750ZM375,625H500V500H375V625ZM250,500H375V375H250V500ZM125,375H250V250H125V375ZM0,250H125V125H0V250Z" />
|
||||||
|
<glyph unicode="0" glyph-name="zero" horiz-adv-x="1000" d="M250,125V250H125V375H0V750H125V875H250v125H625V875H750V750H875V375H750V250H625V125H250ZM375,250H625V750H500V875H250V375H375V250Z" />
|
||||||
|
<glyph unicode="1" glyph-name="one" horiz-adv-x="1000" d="M125,125V250H375V750H250V875H375v125H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="2" glyph-name="two" horiz-adv-x="1000" d="M0,125V375H125V500H250V625H500V750H625V875H250V750H0V875H125v125H750V875H875V625H750V500H625V375H375V250H875V125H0Z" />
|
||||||
|
<glyph unicode="3" glyph-name="three" horiz-adv-x="1000" d="M125,125V250H0V375H250V250H625V500H250V625H375V750H500V875H125v125H875V875H750V750H625V625H750V500H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="4" glyph-name="four" horiz-adv-x="1000" d="M500,125V375H0V625H125V750H250V875H375v125H750V500H875V375H750V125H500ZM250,500H500V750H375V625H250V500Z" />
|
||||||
|
<glyph unicode="5" glyph-name="five" horiz-adv-x="1000" d="M125,125V250H0V375H250V250H625V625H0v375H750V875H250V750H750V625H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="6" glyph-name="six" horiz-adv-x="1000" d="M125,125V250H0V750H125V875H250v125H750V875H375V750H250V625H750V500H875V250H750V125H125ZM250,250H625V500H250V250Z" />
|
||||||
|
<glyph unicode="7" glyph-name="seven" horiz-adv-x="1000" d="M250,125V500H375V625H500V750H625V875H250V750H0v250H875V750H750V625H625V500H500V125H250Z" />
|
||||||
|
<glyph unicode="8" glyph-name="eight" horiz-adv-x="1000" d="M125,125V250H0V500H125V625H0V875H125v125H625V875H750V625H625V500H875V250H750V125H125ZM375,625H625V875H250V750H375V625ZM125,250H625V375H375V500H125V250Z" />
|
||||||
|
<glyph unicode="9" glyph-name="nine" horiz-adv-x="1000" d="M125,125V250H500V375H625V500H125V625H0V875H125v125H750V875H875V375H750V250H625V125H125ZM250,625H625V875H250V625Z" />
|
||||||
|
<glyph unicode=":" glyph-name="colon" horiz-adv-x="1000" d="M250,625V875H500V625H250Zm0-375V500H500V250H250Z" />
|
||||||
|
<glyph unicode=";" glyph-name="semicolon" horiz-adv-x="1000" d="M250,625V875H500V625H250ZM125,125V250H250V500H500V250H375V125H125Z" />
|
||||||
|
<glyph unicode="<" glyph-name="less" horiz-adv-x="1000" d="M500,125V250H375V375H250V500H125V625H250V750H375V875H500v125H750V875H625V750H500V625H375V500H500V375H625V250H750V125H500Z" />
|
||||||
|
<glyph unicode="=" glyph-name="equal" horiz-adv-x="1000" d="M0,625V750H875V625H0ZM0,375V500H875V375H0Z" />
|
||||||
|
<glyph unicode=">" glyph-name="greater" horiz-adv-x="1000" d="M125,125V250H250V375H375V500H500V625H375V750H250V875H125v125H375V875H500V750H625V625H750V500H625V375H500V250H375V125H125Z" />
|
||||||
|
<glyph unicode="?" glyph-name="question" horiz-adv-x="1000" d="M250,375V500H500V625H625V750H250V625H0V875H125v125H750V875H875V625H750V500H625V375H250Zm0-250V250H625V125H250Z" />
|
||||||
|
<glyph unicode="@" glyph-name="at" horiz-adv-x="1000" d="M750,1000V875H125v125H750ZM0,875H125V250H0V875Zm875,0V375H250V750H625V500H750V875H875ZM500,625H375V500H500V625ZM125,125V250H750V125H125Z" />
|
||||||
|
<glyph unicode="A" glyph-name="A" horiz-adv-x="1000" d="M0,125V750H125V875H250v125H625V875H750V750H875V125H625V375H250V125H0ZM250,500H625V750H500V875H375V750H250V500Z" />
|
||||||
|
<glyph unicode="B" glyph-name="B" horiz-adv-x="1000" d="M0,125v875H750V875H875V625H750V500H875V250H750V125H0ZM250,625H625V875H250V625Zm0-375H625V500H250V250Z" />
|
||||||
|
<glyph unicode="C" glyph-name="C" horiz-adv-x="1000" d="M250,125V250H125V375H0V750H125V875H250v125H750V875H875V750H625V875H375V750H250V375H375V250H625V375H875V250H750V125H250Z" />
|
||||||
|
<glyph unicode="D" glyph-name="D" horiz-adv-x="1000" d="M0,125v875H625V875H750V750H875V375H750V250H625V125H0ZM250,250H500V375H625V750H500V875H250V250Z" />
|
||||||
|
<glyph unicode="E" glyph-name="E" horiz-adv-x="1000" d="M0,125v875H875V875H250V625H750V500H250V250H875V125H0Z" />
|
||||||
|
<glyph unicode="F" glyph-name="F" horiz-adv-x="1000" d="M0,125v875H875V875H250V625H750V500H250V125H0Z" />
|
||||||
|
<glyph unicode="G" glyph-name="G" horiz-adv-x="1000" d="M250,125V250H125V375H0V750H125V875H250v125H875V875H375V750H250V375H375V250H625V500H500V625H875V125H250Z" />
|
||||||
|
<glyph unicode="H" glyph-name="H" horiz-adv-x="1000" d="M0,125v875H250V625H625v375H875V125H625V500H250V125H0Z" />
|
||||||
|
<glyph unicode="I" glyph-name="I" horiz-adv-x="1000" d="M125,125V250H375V875H125v125H875V875H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="J" glyph-name="J" horiz-adv-x="1000" d="M125,125V250H0V375H250V250H625v750H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="K" glyph-name="K" horiz-adv-x="1000" d="M0,125v875H250V625H375V750H500V875H625v125H875V875H750V750H625V625H500V500H625V375H750V250H875V125H500V250H375V375H250V125H0Z" />
|
||||||
|
<glyph unicode="L" glyph-name="L" horiz-adv-x="1000" d="M125,125v875H375V250H875V125H125Z" />
|
||||||
|
<glyph unicode="M" glyph-name="M" horiz-adv-x="1000" d="M0,125v875H250V875H375V750H500V875H625v125H875V125H625V625H500V375H375V625H250V125H0Z" />
|
||||||
|
<glyph unicode="N" glyph-name="N" horiz-adv-x="1000" d="M0,125v875H250V875H375V750H500V625H625v375H875V125H625V375H500V500H375V625H250V125H0Z" />
|
||||||
|
<glyph unicode="O" glyph-name="O" horiz-adv-x="1000" d="M125,125V250H0V875H125v125H750V875H875V250H750V125H125ZM250,250H625V875H250V250Z" />
|
||||||
|
<glyph unicode="P" glyph-name="P" horiz-adv-x="1000" d="M0,125v875H750V875H875V500H750V375H250V125H0ZM250,500H625V875H250V500Z" />
|
||||||
|
<glyph unicode="Q" glyph-name="Q" horiz-adv-x="1000" d="M875,875V375H750V250H625V125H125V250H0V875H125v125H750V875H875Zm-250,0H250V250H500V375H375V500H625V875ZM750,125V250H875V125H750Z" />
|
||||||
|
<glyph unicode="R" glyph-name="R" horiz-adv-x="1000" d="M0,125v875H750V875H875V500H625V375H750V250H875V125H500V250H375V375H250V125H0ZM250,500H500V625H625V875H250V500Z" />
|
||||||
|
<glyph unicode="S" glyph-name="S" horiz-adv-x="1000" d="M125,125V250H0V375H250V250H625V500H125V625H0V875H125v125H750V875H875V750H625V875H250V625H750V500H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="T" glyph-name="T" horiz-adv-x="1000" d="M375,125V875H125v125H875V875H625V125H375Z" />
|
||||||
|
<glyph unicode="U" glyph-name="U" horiz-adv-x="1000" d="M125,125V250H0v750H250V250H625v750H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="V" glyph-name="V" horiz-adv-x="1000" d="M375,125V250H250V375H125V500H0v500H250V625H375V500H500V625H625v375H875V500H750V375H625V250H500V125H375Z" />
|
||||||
|
<glyph unicode="W" glyph-name="W" horiz-adv-x="1000" d="M125,125V250H0v750H250V500H375v500H500V500H625v500H875V250H750V125H625V250H500V375H375V250H250V125H125Z" />
|
||||||
|
<glyph unicode="X" glyph-name="X" horiz-adv-x="1000" d="M0,125V375H125V500H250V625H125V750H0v250H250V750H375V625H500V750H625v250H875V750H750V625H625V500H750V375H875V125H625V375H500V500H375V375H250V125H0Z" />
|
||||||
|
<glyph unicode="Y" glyph-name="Y" horiz-adv-x="1000" d="M375,125V500H250V625H125v375H375V625H625v375H875V625H750V500H625V125H375Z" />
|
||||||
|
<glyph unicode="Z" glyph-name="Z" horiz-adv-x="1000" d="M0,125V375H125V500H250V625H375V750H500V875H0v125H875V750H750V625H625V500H500V375H375V250H875V125H0Z" />
|
||||||
|
<glyph unicode="[" glyph-name="bracketleft" horiz-adv-x="1000" d="M250,125v875H750V875H500V250H750V125H250Z" />
|
||||||
|
<glyph unicode="\" glyph-name="backslash" horiz-adv-x="1000" d="M125,1000V875H0v125H125ZM250,875V750H125V875H250ZM375,750V625H250V750H375ZM500,625V500H375V625H500ZM625,500V375H500V500H625ZM750,375V250H625V375H750ZM875,250V125H750V250H875Z" />
|
||||||
|
<glyph unicode="]" glyph-name="bracketright" horiz-adv-x="1000" d="M125,125V250H375V875H125v125H625V125H125Z" />
|
||||||
|
<glyph unicode="^" glyph-name="asciicircum" horiz-adv-x="1000" d="M125,750V875H250v125H625V875H750V750H500V875H375V750H125Z" />
|
||||||
|
<glyph unicode="_" glyph-name="underscore" horiz-adv-x="1000" d="M0,0V125H875V0H0Z" />
|
||||||
|
<glyph unicode="`" glyph-name="grave" horiz-adv-x="1000" d="M500,1000V875H375v125H500ZM625,875V750H500V875H625Z" />
|
||||||
|
<glyph unicode="a" glyph-name="a" horiz-adv-x="1000" d="M125,125V250H0V375H125V500H625V625H125V750H750V625H875V125H125ZM250,250H625V375H250V250Z" />
|
||||||
|
<glyph unicode="b" glyph-name="b" horiz-adv-x="1000" d="M125,125V250H0v750H250V750H750V625H875V250H750V125H125ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="c" glyph-name="c" horiz-adv-x="1000" d="M125,125V250H0V625H125V750H875V625H250V250H875V125H125Z" />
|
||||||
|
<glyph unicode="d" glyph-name="d" horiz-adv-x="1000" d="M125,125V250H0V625H125V750H625v250H875V125H125ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="e" glyph-name="e" horiz-adv-x="1000" d="M125,125V250H0V625H125V750H750V625H875V375H250V250H750V125H125ZM250,500H625V625H250V500Z" />
|
||||||
|
<glyph unicode="f" glyph-name="f" horiz-adv-x="1000" d="M375,125V625H125V750H375V875H500v125H875V875H625V750H875V625H625V125H375Z" />
|
||||||
|
<glyph unicode="g" glyph-name="g" horiz-adv-x="1000" d="M125,0V125H625V250H125V375H0V625H125V750H875V125H750V0H125ZM250,375H625V625H250V375Z" />
|
||||||
|
<glyph unicode="h" glyph-name="h" horiz-adv-x="1000" d="M0,125v875H250V750H750V625H875V125H625V625H250V125H0Z" />
|
||||||
|
<glyph unicode="i" glyph-name="i" horiz-adv-x="1000" d="M375,875v125H625V875H375ZM125,125V250H375V625H250V750H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="j" glyph-name="j" horiz-adv-x="1000" d="M500,875v125H750V875H500ZM125,0V125H500V625H375V750H750V125H625V0H125Z" />
|
||||||
|
<glyph unicode="k" glyph-name="k" horiz-adv-x="1000" d="M0,125v875H250V500H500V625H625V750H875V625H750V500H625V375H750V250H875V125H625V250H500V375H250V125H0Z" />
|
||||||
|
<glyph unicode="l" glyph-name="l" horiz-adv-x="1000" d="M125,125V250H375V875H250v125H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="m" glyph-name="m" horiz-adv-x="1000" d="M0,125V750H750V625H875V125H625V625H500V125H250V625H125V125H0Z" />
|
||||||
|
<glyph unicode="n" glyph-name="n" horiz-adv-x="1000" d="M0,125V750H750V625H875V125H625V625H250V125H0Z" />
|
||||||
|
<glyph unicode="o" glyph-name="o" horiz-adv-x="1000" d="M125,125V250H0V625H125V750H750V625H875V250H750V125H125ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="p" glyph-name="p" horiz-adv-x="1000" d="M0,0V750H750V625H875V375H750V250H250V0H0ZM250,375H625V625H250V375Z" />
|
||||||
|
<glyph unicode="q" glyph-name="q" horiz-adv-x="1000" d="M625,0V250H125V375H0V625H125V750H875V0H625ZM250,375H625V625H250V375Z" />
|
||||||
|
<glyph unicode="r" glyph-name="r" horiz-adv-x="1000" d="M375,625H500V500H375V125H125V750H375V625ZM875,750V625H500V750H875Z" />
|
||||||
|
<glyph unicode="s" glyph-name="s" horiz-adv-x="1000" d="M0,125V250H625V375H125V500H0V625H125V750H750V625H250V500H750V375H875V250H750V125H0Z" />
|
||||||
|
<glyph unicode="t" glyph-name="t" horiz-adv-x="1000" d="M375,125V625H125V750H375v250H625V750H875V625H625V125H375Z" />
|
||||||
|
<glyph unicode="u" glyph-name="u" horiz-adv-x="1000" d="M125,125V250H0V750H250V250H625V750H875V125H125Z" />
|
||||||
|
<glyph unicode="v" glyph-name="v" horiz-adv-x="1000" d="M375,125V250H250V375H125V750H375V375H625V750H875V375H750V250H625V125H375Z" />
|
||||||
|
<glyph unicode="w" glyph-name="w" horiz-adv-x="1000" d="M250,250H375V125H125V250H0V750H250V250ZM500,750V250H375V750H500Zm375,0V250H750V125H500V250H625V750H875Z" />
|
||||||
|
<glyph unicode="x" glyph-name="x" horiz-adv-x="1000" d="M0,125V250H125V375H250V500H125V625H0V750H250V625H375V500H500V625H625V750H875V625H750V500H625V375H750V250H875V125H625V250H500V375H375V250H250V125H0Z" />
|
||||||
|
<glyph unicode="y" glyph-name="y" horiz-adv-x="1000" d="M125,0V125H625V250H125V375H0V750H250V375H625V750H875V125H750V0H125Z" />
|
||||||
|
<glyph unicode="z" glyph-name="z" horiz-adv-x="1000" d="M0,125V250H125V375H250V500H375V625H0V750H875V625H750V500H625V375H500V250H875V125H0Z" />
|
||||||
|
<glyph unicode="{" glyph-name="braceleft" horiz-adv-x="1000" d="M500,125V250H375V500H250V625H375V875H500v125H750V875H625V625H500V500H625V250H750V125H500Z" />
|
||||||
|
<glyph unicode="|" glyph-name="bar" horiz-adv-x="1000" d="M375,125v875H625V125H375Z" />
|
||||||
|
<glyph unicode="}" glyph-name="braceright" horiz-adv-x="1000" d="M125,125V250H250V500H375V625H250V875H125v125H375V875H500V625H625V500H500V250H375V125H125Z" />
|
||||||
|
<glyph unicode="~" glyph-name="asciitilde" horiz-adv-x="1000" d="M625,500H750V375H375V500H250V625H125V750H500V625H625V500ZM0,625H125V500H0V625Zm875,0V500H750V625H875Z" />
|
||||||
|
<glyph unicode=" " glyph-name="uni00A0" horiz-adv-x="1000" />
|
||||||
|
<glyph unicode="¡" glyph-name="exclamdown" horiz-adv-x="1000" d="M375,875v125H625V875H375ZM250,125V500H375V750H625V125H250Z" />
|
||||||
|
<glyph unicode="¢" glyph-name="cent" horiz-adv-x="1000" d="M375,125V250H125V375H0V750H125V875H375v125H500V875H750V750H875V625H625V750H500V375H625V500H875V375H750V250H500V125H375ZM250,375H375V750H250V375Z" />
|
||||||
|
<glyph unicode="£" glyph-name="sterling" horiz-adv-x="1000" d="M0,125V250H125V500H0V625H125V875H250v125H750V875H875V750H625V875H375V625H750V500H375V250H875V125H0Z" />
|
||||||
|
<glyph unicode="¤" glyph-name="currency" horiz-adv-x="1000" d="M250,875V750H125V875H250ZM625,750H375V875H625V750ZM875,875V750H750V875H875ZM375,750V375H250V750H375Zm250,0H750V375H625V750ZM125,375H250V250H125V375Zm250,0H625V250H375V375ZM750,250V375H875V250H750Z" />
|
||||||
|
<glyph unicode="¥" glyph-name="yen" horiz-adv-x="1000" d="M375,125V250H125V375H375V500H125V625H250V750H125v250H375V750H625v250H875V750H750V625H875V500H625V375H875V250H625V125H375Z" />
|
||||||
|
<glyph unicode="¦" glyph-name="brokenbar" horiz-adv-x="1000" d="M375,625v375H625V625H375Zm0-500V500H625V125H375Z" />
|
||||||
|
<glyph unicode="§" glyph-name="section" horiz-adv-x="1000" d="M875,875V750H625V875H375V750H625V625H375V500H250V625H125V875H250v125H750V875H875ZM625,500V625H750V500H875V250H750V125H250V250H125V375H375V250H625V375H375V500H625Z" />
|
||||||
|
<glyph unicode="¨" glyph-name="dieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500Z" />
|
||||||
|
<glyph unicode="©" glyph-name="copyright" horiz-adv-x="1000" d="M750,875H250v125H750V875ZM250,750H125V875H250V750Zm500,0V875H875V750H750Zm-625,0V250H0V750H125Zm250,0H625V625H375V750Zm625,0V250H875V750h125ZM250,625H375V375H250V625ZM375,250V375H625V250H375Zm-125,0V125H125V250H250Zm500,0H875V125H750V250ZM250,125H750V0H250V125Z" />
|
||||||
|
<glyph unicode="ª" glyph-name="ordfeminine" horiz-adv-x="1000" d="M250,500V625H125V750H250V875H125v125H625V875H750V500H250ZM375,625H500V750H375V625Z" />
|
||||||
|
<glyph unicode="«" glyph-name="guillemotleft" horiz-adv-x="1000" d="M250,250V375H125V500H0V625H125V750H250V875H500V750H625V875H875V750H750V625H625V500H750V375H875V250H625V375H500V250H250ZM375,375H500V500H375V625H500V750H375V625H250V500H375V375Z" />
|
||||||
|
<glyph unicode="¬" glyph-name="logicalnot" horiz-adv-x="1000" d="M625,375V625H125V750H875V375H625Z" />
|
||||||
|
<glyph unicode="­" glyph-name="uni00AD" horiz-adv-x="1000" d="M125,500V625H875V500H125Z" />
|
||||||
|
<glyph unicode="®" glyph-name="registered" horiz-adv-x="1000" d="M750,875H250v125H750V875ZM250,750H125V875H250V750Zm500,0V875H875V750H750Zm-625,0V250H0V750H125ZM250,250V750H625V625H375V500H625V375H375V250H250Zm750,500V250H875V750h125ZM625,500V625H750V500H625Zm0-250V375H750V250H625Zm-375,0V125H125V250H250Zm500,0H875V125H750V250ZM250,125H750V0H250V125Z" />
|
||||||
|
<glyph unicode="¯" glyph-name="macron" horiz-adv-x="1000" d="M125,875v125H750V875H125Z" />
|
||||||
|
<glyph unicode="°" glyph-name="degree" horiz-adv-x="1000" d="M500,875H375v125H500V875Zm-125,0V750H250V875H375Zm250,0V750H500V875H625ZM375,750H500V625H375V750Z" />
|
||||||
|
<glyph unicode="±" glyph-name="plusminus" horiz-adv-x="1000" d="M375,375V625H125V750H375v250H625V750H875V625H625V375H375ZM125,125V250H875V125H125Z" />
|
||||||
|
<glyph unicode="²" glyph-name="uni00B2" horiz-adv-x="1000" d="M250,500V625H375V750H500V875H250v125H625V875H750V750H625V625H750V500H250Z" />
|
||||||
|
<glyph unicode="³" glyph-name="uni00B3" horiz-adv-x="1000" d="M250,500V625H500V750H375V875H250v125H750V875H625V750H750V625H625V500H250Z" />
|
||||||
|
<glyph unicode="´" glyph-name="acute" horiz-adv-x="1000" d="M625,1000V875H500v125H625ZM375,875H500V750H375V875Z" />
|
||||||
|
<glyph unicode="µ" glyph-name="uni00B5" horiz-adv-x="1000" d="M250,250H500V125H250V0H0V750H250V250Zm625,0V125H625V250H500V750H750V250H875Z" />
|
||||||
|
<glyph unicode="¶" glyph-name="paragraph" horiz-adv-x="1000" d="M500,125V375H250V500H125V875H250v125H875V125H750V375H625V125H500ZM375,500H500V875H375V750H250V625H375V500Zm250,0H750V875H625V500Z" />
|
||||||
|
<glyph unicode="·" glyph-name="periodcentered" horiz-adv-x="1000" d="M250,375V625H500V375H250Z" />
|
||||||
|
<glyph unicode="¸" glyph-name="cedilla" horiz-adv-x="1000" d="M625,250V125H500V250H625ZM250,125H500V0H250V125Z" />
|
||||||
|
<glyph unicode="¹" glyph-name="uni00B9" horiz-adv-x="1000" d="M250,500V625H375V750H250V875H375v125H625V625H750V500H250Z" />
|
||||||
|
<glyph unicode="º" glyph-name="ordmasculine" horiz-adv-x="1000" d="M250,500V625H125V875H250v125H625V875H750V625H625V500H250ZM375,625H500V875H375V625Z" />
|
||||||
|
<glyph unicode="»" glyph-name="guillemotright" horiz-adv-x="1000" d="M0,250V375H125V500H250V625H125V750H0V875H250V750H375V875H625V750H750V625H875V500H750V375H625V250H375V375H250V250H0ZM375,375H500V500H625V625H500V750H375V625H500V500H375V375Z" />
|
||||||
|
<glyph unicode="¼" glyph-name="onequarter" horiz-adv-x="1000" d="M250,1000V500H125V750H0V875H125v125H250Zm625,0V875H750v125H875ZM625,875H750V750H625V875ZM500,750H625V625H500V750ZM375,625H500V500H375V625Zm375,0H875V125H750V250H500V375H625V500H750V625ZM250,500H375V375H250V500ZM125,375H250V250H125V375ZM0,250H125V125H0V250Z" />
|
||||||
|
<glyph unicode="½" glyph-name="onehalf" horiz-adv-x="1000" d="M250,1000V500H125V750H0V875H125v125H250Zm625,0V875H750v125H875ZM625,875H750V750H625V875ZM500,750H625V625H500V750ZM375,625H500V500H375V625ZM625,500V625H875V375H750V500H625Zm-375,0H375V375H250V500ZM125,375H250V250H125V375Zm500,0H750V250H875V125H500V250H625V375ZM0,250H125V125H0V250Z" />
|
||||||
|
<glyph unicode="¾" glyph-name="threequarters" horiz-adv-x="1000" d="M250,875V750H125V875H0v125H375V875H250Zm625,125V875H750v125H875ZM625,875H750V750H625V875ZM375,625H250V750H375V625ZM500,750H625V625H500V750ZM250,625V500H0V625H250Zm125,0H500V500H375V625Zm375,0H875V125H750V250H500V375H625V500H750V625ZM250,500H375V375H250V500ZM125,375H250V250H125V375ZM0,250H125V125H0V250Z" />
|
||||||
|
<glyph unicode="¿" glyph-name="questiondown" horiz-adv-x="1000" d="M250,875v125H625V875H250ZM125,125V250H0V500H125V625H250V750H625V625H375V500H250V375H625V500H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="À" glyph-name="Agrave" horiz-adv-x="1000" d="M375,1000V875H250v125H375ZM875,500V125H625V250H250V125H0V500H125V625H250V750H375V875H500V750H625V625H750V500H875Zm-250,0H500V625H375V500H250V375H625V500Z" />
|
||||||
|
<glyph unicode="Á" glyph-name="Aacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,500V125H625V250H250V125H0V500H125V625H250V750H375V875H500V750H625V625H750V500H875Zm-250,0H500V625H375V500H250V375H625V500Z" />
|
||||||
|
<glyph unicode="Â" glyph-name="Acircumflex" horiz-adv-x="1000" d="M0,125V500H125V625H250V750H125V875H250v125H625V875H750V750H625V625H750V500H875V125H625V250H250V125H0ZM375,750H500V875H375V750ZM250,375H625V500H500V625H375V500H250V375Z" />
|
||||||
|
<glyph unicode="Ã" glyph-name="Atilde" horiz-adv-x="1000" d="M875,500V125H625V250H250V125H0V500H125V625H250V750H375V875H250v125H500V875H625V625H750V500H875ZM750,875H625v125H750V875Zm-500,0V750H125V875H250ZM625,500H500V625H375V500H250V375H625V500Z" />
|
||||||
|
<glyph unicode="Ä" glyph-name="Adieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM0,125V500H125V625H250V750H625V625H750V500H875V125H625V250H250V125H0ZM250,375H625V500H500V625H375V500H250V375Z" />
|
||||||
|
<glyph unicode="Å" glyph-name="Aring" horiz-adv-x="1000" d="M0,125V500H125V625H250V875H375v125H500V875H625V625H750V500H875V125H625V250H250V125H0ZM375,750H500V875H375V750ZM250,375H625V500H500V625H375V500H250V375Z" />
|
||||||
|
<glyph unicode="Æ" glyph-name="AE" horiz-adv-x="1000" d="M0,125V750H125V875H250v125H875V875H625V625H875V500H625V250H875V125H375V375H250V125H0ZM250,500H375V750H250V500Z" />
|
||||||
|
<glyph unicode="Ç" glyph-name="Ccedilla" horiz-adv-x="1000" d="M875,875V750H625V875H375V750H250V500H375V375H625V500H875V375H750V250H625V125H500V250H250V375H125V500H0V750H125V875H250v125H750V875H875ZM250,125H500V0H250V125Z" />
|
||||||
|
<glyph unicode="È" glyph-name="Egrave" horiz-adv-x="1000" d="M375,1000V875H250v125H375ZM875,750V625H250V500H750V375H250V250H875V125H0V750H375V875H500V750H875Z" />
|
||||||
|
<glyph unicode="É" glyph-name="Eacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,750V625H250V500H750V375H250V250H875V125H0V750H375V875H500V750H875Z" />
|
||||||
|
<glyph unicode="Ê" glyph-name="Ecircumflex" horiz-adv-x="1000" d="M0,125V750H125V875H250v125H625V875H750V750H875V625H250V500H750V375H250V250H875V125H0ZM375,750H500V875H375V750Z" />
|
||||||
|
<glyph unicode="Ë" glyph-name="Edieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM0,125V750H875V625H250V500H750V375H250V250H875V125H0Z" />
|
||||||
|
<glyph unicode="Ì" glyph-name="Igrave" horiz-adv-x="1000" d="M500,1000V875H375v125H500ZM875,750V625H625V250H875V125H125V250H375V625H125V750H500V875H625V750H875Z" />
|
||||||
|
<glyph unicode="Í" glyph-name="Iacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,750V625H625V250H875V125H125V250H375V625H125V750H375V875H500V750H875Z" />
|
||||||
|
<glyph unicode="Î" glyph-name="Icircumflex" horiz-adv-x="1000" d="M125,125V250H375V625H125V750H250V875H375v125H625V875H750V750H875V625H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="Ï" glyph-name="Idieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm500,0v125H875V875H625ZM125,125V250H375V625H125V750H875V625H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="Ð" glyph-name="Eth" horiz-adv-x="1000" d="M125,125V500H0V625H125v375H625V875H750V750H875V375H750V250H625V125H125ZM375,250H500V375H625V750H500V875H375V625H500V500H375V250Z" />
|
||||||
|
<glyph unicode="Ñ" glyph-name="Ntilde" horiz-adv-x="1000" d="M375,875H250v125H500V875H625V750H375V875Zm375,0H625v125H750V875ZM875,750V125H500V250H375V375H250V125H0V750H125V875H250V750H375V625H500V500H625V750H875Z" />
|
||||||
|
<glyph unicode="Ò" glyph-name="Ograve" horiz-adv-x="1000" d="M375,1000V875H250v125H375ZM875,625V250H750V125H125V250H0V625H125V750H375V875H500V750H750V625H875Zm-250,0H250V250H625V625Z" />
|
||||||
|
<glyph unicode="Ó" glyph-name="Oacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,625V250H750V125H125V250H0V625H125V750H375V875H500V750H750V625H875Zm-250,0H250V250H625V625Z" />
|
||||||
|
<glyph unicode="Ô" glyph-name="Ocircumflex" horiz-adv-x="1000" d="M125,125V250H0V625H125V875H250v125H625V875H750V625H875V250H750V125H125ZM375,750H500V875H375V750ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="Õ" glyph-name="Otilde" horiz-adv-x="1000" d="M125,125V250H0V625H125V875H250v125H500V875H625v125H750V875H625V750H750V625H875V250H750V125H125ZM250,750H375V875H250V750Zm0-500H625V625H250V250Z" />
|
||||||
|
<glyph unicode="Ö" glyph-name="Odieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H0V625H125V750H750V625H875V250H750V125H125ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="×" glyph-name="multiply" horiz-adv-x="1000" d="M250,875V750H125V875H250Zm500,0V750H625V875H750ZM375,750V625H250V750H375Zm125,0H625V625H500V750ZM375,625H500V500H375V625ZM250,500H375V375H250V500ZM500,375V500H625V375H500Zm-375,0H250V250H125V375ZM625,250V375H750V250H625Z" />
|
||||||
|
<glyph unicode="Ø" glyph-name="Oslash" horiz-adv-x="1000" d="M125,125V250H0V875H125v125H750V875H875V250H750V125H125ZM250,250H625V625H500V750H625V875H250V500H375V375H250V250ZM375,500V625H500V500H375Z" />
|
||||||
|
<glyph unicode="Ù" glyph-name="Ugrave" horiz-adv-x="1000" d="M375,875H250v125H375V875Zm0-125V875H500V750H375Zm500,0V250H750V125H125V250H0V750H250V250H625V750H875Z" />
|
||||||
|
<glyph unicode="Ú" glyph-name="Uacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM500,750H375V875H500V750Zm375,0V250H750V125H125V250H0V750H250V250H625V750H875Z" />
|
||||||
|
<glyph unicode="Û" glyph-name="Ucircumflex" horiz-adv-x="1000" d="M125,750V875H250v125H625V875H750V750H500V875H375V750H125Zm0-625V250H0V625H250V250H625V625H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="Ü" glyph-name="Udieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H0V750H250V250H625V750H875V250H750V125H125Z" />
|
||||||
|
<glyph unicode="Ý" glyph-name="Yacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM500,750H375V875H500V750Zm375,0V500H750V375H625V125H375V375H250V500H125V750H375V500H625V750H875Z" />
|
||||||
|
<glyph unicode="Þ" glyph-name="Thorn" horiz-adv-x="1000" d="M0,125v875H250V875H750V750H875V375H750V250H250V125H0ZM250,375H625V750H250V375Z" />
|
||||||
|
<glyph unicode="ß" glyph-name="germandbls" horiz-adv-x="1000" d="M875,875V625H750V500H875V250H750V125H500V250H625V500H500V625H625V875H375V375H500V250H375V125H125V875H250v125H750V875H875Z" />
|
||||||
|
<glyph unicode="à" glyph-name="agrave" horiz-adv-x="1000" d="M375,1000V875H250v125H375ZM875,625V125H125V250H0V375H125V500H625V625H125V750H375V875H500V750H750V625H875ZM625,375H250V250H625V375Z" />
|
||||||
|
<glyph unicode="á" glyph-name="aacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,625V125H125V250H0V375H125V500H625V625H125V750H375V875H500V750H750V625H875ZM625,375H250V250H625V375Z" />
|
||||||
|
<glyph unicode="â" glyph-name="acircumflex" horiz-adv-x="1000" d="M125,125V250H0V375H125V500H625V625H125V875H250v125H625V875H750V625H875V125H125ZM375,750H500V875H375V750ZM250,250H625V375H250V250Z" />
|
||||||
|
<glyph unicode="ã" glyph-name="atilde" horiz-adv-x="1000" d="M125,125V250H0V375H125V500H625V625H125V875H250v125H500V875H625v125H750V875H625V750H750V625H875V125H125ZM250,750H375V875H250V750Zm0-500H625V375H250V250Z" />
|
||||||
|
<glyph unicode="ä" glyph-name="adieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H0V375H125V500H625V625H125V750H750V625H875V125H125ZM250,250H625V375H250V250Z" />
|
||||||
|
<glyph unicode="å" glyph-name="aring" horiz-adv-x="1000" d="M125,125V250H0V375H125V500H625V625H125V750H250V875H375v125H500V875H625V750H750V625H875V125H125ZM375,750H500V875H375V750ZM250,250H625V375H250V250Z" />
|
||||||
|
<glyph unicode="æ" glyph-name="ae" horiz-adv-x="1000" d="M125,125V250H0V375H125V500H375V625H125V750H750V625H875V375H500V250H750V125H125ZM500,500H625V625H500V500ZM250,250H375V375H250V250Z" />
|
||||||
|
<glyph unicode="ç" glyph-name="ccedilla" horiz-adv-x="1000" d="M875,750V625H250V375H875V250H625V125H500V250H125V375H0V625H125V750H875ZM250,125H500V0H250V125Z" />
|
||||||
|
<glyph unicode="è" glyph-name="egrave" horiz-adv-x="1000" d="M375,1000V875H250v125H375ZM875,625V375H250V250H750V125H125V250H0V625H125V750H375V875H500V750H750V625H875Zm-250,0H250V500H625V625Z" />
|
||||||
|
<glyph unicode="é" glyph-name="eacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,625V375H250V250H750V125H125V250H0V625H125V750H375V875H500V750H750V625H875Zm-250,0H250V500H625V625Z" />
|
||||||
|
<glyph unicode="ê" glyph-name="ecircumflex" horiz-adv-x="1000" d="M125,125V250H0V625H125V875H250v125H625V875H750V625H875V375H250V250H750V125H125ZM375,750H500V875H375V750ZM250,500H625V625H250V500Z" />
|
||||||
|
<glyph unicode="ë" glyph-name="edieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H0V625H125V750H750V625H875V375H250V250H750V125H125ZM250,500H625V625H250V500Z" />
|
||||||
|
<glyph unicode="ì" glyph-name="igrave" horiz-adv-x="1000" d="M375,875H250v125H375V875Zm0-125V875H500V750H375ZM875,250V125H125V250H375V500H250V625H625V250H875Z" />
|
||||||
|
<glyph unicode="í" glyph-name="iacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM500,750H375V875H500V750ZM875,250V125H125V250H375V500H250V625H625V250H875Z" />
|
||||||
|
<glyph unicode="î" glyph-name="icircumflex" horiz-adv-x="1000" d="M125,750V875H250v125H625V875H750V750H500V875H375V750H125Zm0-625V250H375V500H250V625H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="ï" glyph-name="idieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H375V625H250V750H625V250H875V125H125Z" />
|
||||||
|
<glyph unicode="ð" glyph-name="eth" horiz-adv-x="1000" d="M875,500V250H750V125H125V250H0V500H125V625H375V750H125v250H375V875H625V625H750V500H875ZM750,875H625v125H750V875ZM125,750V625H0V750H125ZM625,500H250V250H625V500Z" />
|
||||||
|
<glyph unicode="ñ" glyph-name="ntilde" horiz-adv-x="1000" d="M0,125V750H125V875H250v125H500V875H625v125H750V875H625V750H750V625H875V125H625V625H250V125H0ZM250,750H375V875H250V750Z" />
|
||||||
|
<glyph unicode="ò" glyph-name="ograve" horiz-adv-x="1000" d="M375,1000V875H250v125H375ZM875,625V250H750V125H125V250H0V625H125V750H375V875H500V750H750V625H875Zm-250,0H250V250H625V625Z" />
|
||||||
|
<glyph unicode="ó" glyph-name="oacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM875,625V250H750V125H125V250H0V625H125V750H375V875H500V750H750V625H875Zm-250,0H250V250H625V625Z" />
|
||||||
|
<glyph unicode="ô" glyph-name="ocircumflex" horiz-adv-x="1000" d="M125,125V250H0V625H125V875H250v125H625V875H750V625H875V250H750V125H125ZM375,750H500V875H375V750ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="õ" glyph-name="otilde" horiz-adv-x="1000" d="M125,125V250H0V625H125V875H250v125H500V875H625v125H750V875H625V750H750V625H875V250H750V125H125ZM250,750H375V875H250V750Zm0-500H625V625H250V250Z" />
|
||||||
|
<glyph unicode="ö" glyph-name="odieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H0V625H125V750H750V625H875V250H750V125H125ZM250,250H625V625H250V250Z" />
|
||||||
|
<glyph unicode="÷" glyph-name="divide" horiz-adv-x="1000" d="M375,750V875H625V750H375ZM125,500V625H875V500H125ZM375,250V375H625V250H375Z" />
|
||||||
|
<glyph unicode="ø" glyph-name="oslash" horiz-adv-x="1000" d="M125,125V250H0V625H125V750H750V625H875V250H750V125H125ZM375,250H625V500H500V625H250V375H375V250Zm0,125V500H500V375H375Z" />
|
||||||
|
<glyph unicode="ù" glyph-name="ugrave" horiz-adv-x="1000" d="M375,875H250v125H375V875Zm0-125V875H500V750H375Zm500,0V125H125V250H0V750H250V250H625V750H875Z" />
|
||||||
|
<glyph unicode="ú" glyph-name="uacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM500,750H375V875H500V750Zm375,0V125H125V250H0V750H250V250H625V750H875Z" />
|
||||||
|
<glyph unicode="û" glyph-name="ucircumflex" horiz-adv-x="1000" d="M125,750V875H250v125H625V875H750V750H500V875H375V750H125Zm0-625V250H0V625H250V250H625V625H875V125H125Z" />
|
||||||
|
<glyph unicode="ü" glyph-name="udieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,125V250H0V750H250V250H625V750H875V125H125Z" />
|
||||||
|
<glyph unicode="ý" glyph-name="yacute" horiz-adv-x="1000" d="M625,875H500v125H625V875ZM500,750H375V875H500V750Zm375,0V125H750V0H125V125H625V250H125V375H0V750H250V375H625V750H875Z" />
|
||||||
|
<glyph unicode="þ" glyph-name="thorn" horiz-adv-x="1000" d="M0,0V1000H250V750H750V625H875V375H750V250H250V0H0ZM250,375H625V625H250V375Z" />
|
||||||
|
<glyph unicode="ÿ" glyph-name="ydieresis" horiz-adv-x="1000" d="M125,875v125H375V875H125Zm375,0v125H750V875H500ZM125,0V125H625V250H125V375H0V750H250V375H625V750H875V125H750V0H125Z" />
|
||||||
|
<glyph unicode="–" glyph-name="endash" horiz-adv-x="1000" d="M0,500V625H875V500H0Z" />
|
||||||
|
<glyph unicode="—" glyph-name="emdash" horiz-adv-x="1000" d="M0,500V625H1000V500H0Z" />
|
||||||
|
<glyph unicode="‘" glyph-name="quoteleft" horiz-adv-x="1000" d="M250,625V875H375v125H625V875H500V625H250Z" />
|
||||||
|
<glyph unicode="’" glyph-name="quoteright" horiz-adv-x="1000" d="M250,625V750H375v250H625V750H500V625H250Z" />
|
||||||
|
<glyph unicode="‚" glyph-name="quotesinglbase" horiz-adv-x="1000" d="M250,125V250H375V500H625V250H500V125H250Z" />
|
||||||
|
<glyph unicode="“" glyph-name="quotedblleft" horiz-adv-x="1000" d="M500,1000V875H375V625H125V875H250v125H500Zm375,0V875H750V625H500V875H625v125H875Z" />
|
||||||
|
<glyph unicode="”" glyph-name="quotedblright" horiz-adv-x="1000" d="M500,1000V750H375V625H125V750H250v250H500Zm375,0V750H750V625H500V750H625v250H875Z" />
|
||||||
|
<glyph unicode="„" glyph-name="quotedblbase" horiz-adv-x="1000" d="M375,500V250H250V125H0V250H125V500H375Zm375,0V250H625V125H375V250H500V500H750Z" />
|
||||||
|
<glyph unicode="•" glyph-name="bullet" horiz-adv-x="1000" d="M375,250V375H250V625H375V750H625V625H750V375H625V250H375Z" />
|
||||||
|
<glyph unicode="‹" glyph-name="guilsinglleft" horiz-adv-x="1000" d="M500,250V375H375V500H250V625H375V750H500V875H750V750H625V625H500V500H625V375H750V250H500Z" />
|
||||||
|
<glyph unicode="›" glyph-name="guilsinglright" horiz-adv-x="1000" d="M125,250V375H250V500H375V625H250V750H125V875H375V750H500V625H625V500H500V375H375V250H125Z" />
|
||||||
|
</font>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 35 KiB |
BIN
static/assets/fonts/press-start-2p-v16-latin-regular.ttf
Normal file
BIN
static/assets/fonts/press-start-2p-v16-latin-regular.ttf
Normal file
Binary file not shown.
BIN
static/assets/fonts/press-start-2p-v16-latin-regular.woff
Normal file
BIN
static/assets/fonts/press-start-2p-v16-latin-regular.woff
Normal file
Binary file not shown.
BIN
static/assets/fonts/press-start-2p-v16-latin-regular.woff2
Normal file
BIN
static/assets/fonts/press-start-2p-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
static/assets/player.png
Normal file
BIN
static/assets/player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 B |
538
static/game.js
Normal file
538
static/game.js
Normal file
@@ -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 = "<h3>BESTENLISTE</h3>";
|
||||||
|
entries.forEach(e => {
|
||||||
|
const color = e.isMe ? "yellow" : "white";
|
||||||
|
html += `<div style="display:flex; justify-content:space-between; color:${color}; margin-bottom:5px;">
|
||||||
|
<span>#${e.rank} ${e.name}</span><span>${Math.floor(e.score/10)}</span>
|
||||||
|
</div>`;
|
||||||
|
if(e.rank===3 && entries.length>3) html+="<div style='color:gray'>...</div>";
|
||||||
|
});
|
||||||
|
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 = "<div style='text-align:center; padding:20px; color:#666;'>Noch keine Scores.</div>";
|
||||||
|
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 += `
|
||||||
|
<div class="hof-entry">
|
||||||
|
<span><span class="hof-rank">${icon}</span> ${e.name}</span>
|
||||||
|
<span class="hof-score">${Math.floor(e.score / 10)}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Konnte Leaderboard nicht laden", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initGame();
|
||||||
181
static/index.html
Normal file
181
static/index.html
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>Escape the Teacher</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="rotate-overlay">
|
||||||
|
<div class="icon">📱↻</div>
|
||||||
|
<p>Bitte Gerät drehen!</p>
|
||||||
|
<small>Querformat benötigt</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game-container">
|
||||||
|
<canvas id="gameCanvas"></canvas>
|
||||||
|
|
||||||
|
<div id="ui-layer">
|
||||||
|
SCORE: <span id="score">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="startScreen">
|
||||||
|
<div class="start-left">
|
||||||
|
<h1>ESCAPE THE<br>TEACHER</h1>
|
||||||
|
<p style="font-size: 12px; color: #aaa;">Dein Rekord: <span id="localHighscore" style="color:yellow">0</span></p>
|
||||||
|
|
||||||
|
<button id="startBtn" onclick="startGameClick()">STARTEN</button>
|
||||||
|
<div id="loadingText">Lade Grafiken...</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-title">SCHUL-NEWS</div>
|
||||||
|
<p>
|
||||||
|
• Herr Müller verteilt heute Nachsitzen!<br>
|
||||||
|
• Spring über Tische und Mülleimer.<br>
|
||||||
|
• Lass dich nicht erwischen!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-title">STEUERUNG</div>
|
||||||
|
<p>
|
||||||
|
PC: <strong>Leertaste</strong>, <strong>Pfeil Hoch/Runter</strong> oder <strong>Mausklick</strong><br>
|
||||||
|
Handy: <strong>Tippen</strong> (Springen) oder <strong>Wischen</strong> (Ducken)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-bar">
|
||||||
|
<button class="legal-btn" onclick="openModal('impressum')">Impressum</button>
|
||||||
|
<button class="legal-btn" onclick="openModal('datenschutz')">Datenschutz</button>
|
||||||
|
<button class="legal-btn" onclick="showMyCodes()" style="border-color: yellow; color: yellow;">★ MEINE CODES</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="start-right">
|
||||||
|
<div class="hall-of-fame-box">
|
||||||
|
<h3>🏆 TOP SCHÜLER</h3>
|
||||||
|
<div id="startLeaderboardList">Lade...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gameOverScreen" style="display:none;">
|
||||||
|
<h1>ERWISCHT!</h1>
|
||||||
|
<p>Dein Score: <span id="finalScore" style="color:yellow; font-size: 24px;">0</span></p>
|
||||||
|
|
||||||
|
<div id="inputSection">
|
||||||
|
<input type="text" id="playerNameInput" placeholder="Dein Name" maxlength="10">
|
||||||
|
<button id="submitBtn" onclick="submitScore()">EINTRAGEN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="leaderboard"></div>
|
||||||
|
|
||||||
|
<button class="retry-btn" onclick="location.reload()" style="margin-top: 20px; background: #ff4444; color: white;">NOCHMAL SPIELEN</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-impressum" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close-modal" onclick="closeModal()">X</button>
|
||||||
|
<h2>Impressum</h2>
|
||||||
|
<p><strong>Angaben gemäß § 5 TMG:</strong></p>
|
||||||
|
<p>Max Mustermann<br>Musterschule 1<br>12345 Musterstadt</p>
|
||||||
|
<p>Kontakt: max@schule.de</p>
|
||||||
|
<p><em>Schulprojekt. Keine kommerzielle Absicht.</em></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-codes" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close-modal" onclick="closeModal()">X</button>
|
||||||
|
<h2 style="color:yellow">MEINE BEWEISE</h2>
|
||||||
|
<div id="codesList" style="font-size: 10px; line-height: 1.8;">
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:20px; font-size:9px; color:#888;">Zeige diesen Code dem Lehrer für deinen Preis oder zum Löschen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-datenschutz" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close-modal" onclick="closeModal()">X</button>
|
||||||
|
<h2>Datenschutz</h2>
|
||||||
|
<p>Wir speichern deinen Namen und Score für die Bestenliste.</p>
|
||||||
|
<p>Dein persönlicher Highscore wird lokal auf deinem Gerät gespeichert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="game.js"></script>
|
||||||
|
<script>
|
||||||
|
function openModal(id) { document.getElementById('modal-' + id).style.display = 'flex'; }
|
||||||
|
function closeModal() { document.querySelectorAll('.modal-overlay').forEach(el => el.style.display = 'none'); }
|
||||||
|
window.onclick = function(event) {
|
||||||
|
if (event.target.classList.contains('modal-overlay')) closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClaim(index, sid, code) {
|
||||||
|
if(!confirm("Willst du diesen Score wirklich unwiderruflich löschen?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/claim/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ sessionId: sid, claimCode: code })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert("Fehler: Konnte nicht löschen (Vielleicht Session abgelaufen?)");
|
||||||
|
} else {
|
||||||
|
alert("Erfolgreich gelöscht!");
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||||
|
claims.splice(index, 1);
|
||||||
|
localStorage.setItem('escape_claims', JSON.stringify(claims));
|
||||||
|
|
||||||
|
showMyCodes();
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
alert("Verbindungsfehler");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMyCodes() {
|
||||||
|
openModal('codes');
|
||||||
|
const listEl = document.getElementById('codesList');
|
||||||
|
const claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||||
|
|
||||||
|
if (claims.length === 0) {
|
||||||
|
listEl.innerHTML = "Noch keine Scores eingetragen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
// Wir gehen rückwärts durch (neueste oben)
|
||||||
|
// Wir brauchen den Index 'i', um das Element auch aus dem LocalStorage zu löschen
|
||||||
|
for (let i = claims.length - 1; i >= 0; i--) {
|
||||||
|
const c = claims[i];
|
||||||
|
// Fallback, falls alte Einträge noch keine SessionID haben
|
||||||
|
const btnDisabled = c.sessionId ? "" : "disabled";
|
||||||
|
const btnStyle = c.sessionId ? "cursor:pointer; color:red;" : "color:gray;";
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div style="border-bottom:1px solid #444; padding:8px 0; display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<div>
|
||||||
|
<span style="color:white; font-weight:bold;">${c.code}</span>
|
||||||
|
<span style="color:#ffcc00;">(${c.score} Pkt)</span><br>
|
||||||
|
<span style="color:#aaa;">${c.name} - ${c.date}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="deleteClaim(${i}, '${c.sessionId}', '${c.code}')"
|
||||||
|
style="background:transparent; border:1px solid #555; padding:5px; font-size:10px; ${btnStyle}"
|
||||||
|
${btnDisabled}>
|
||||||
|
LÖSCHEN
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
335
static/style.css
Normal file
335
static/style.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user