commit 3099ac42c002a567394a0134803c3907809f8639 Author: Sebastian Unterschütz Date: Thu Jan 1 15:21:18 2026 +0100 Introduce core components for "Escape From Teacher" game: server, client, physics, asset system, and protocol definitions. Add Docker-Compose setup for Redis and NATS infrastructure. diff --git a/cmd/builder/main.go b/cmd/builder/main.go new file mode 100644 index 0000000..b5e8434 --- /dev/null +++ b/cmd/builder/main.go @@ -0,0 +1,718 @@ +package main + +import ( + "encoding/json" + "fmt" + "image" + "image/color" + _ "image/jpeg" + "image/png" + "io/ioutil" + "log" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" + "github.com/hajimehoshi/ebiten/v2/vector" + "golang.org/x/image/font/basicfont" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// --- CONFIG --- +const ( + RawDir = "./assets_raw" + OutFile = "./cmd/client/assets/assets.json" + + WidthList = 280 // Etwas breiter für die Bilder + WidthInspect = 300 + + CanvasWidth = 1280 + CanvasHeight = 720 + + LineHeight = 35 // Höher für Thumbnails + HeaderHeight = 30 + TextOffset = 22 // Text vertikal zentrieren +) + +// Farben +var ( + ColBg = color.RGBA{30, 30, 30, 255} + ColPanel = color.RGBA{40, 44, 52, 255} + ColText = color.RGBA{220, 220, 220, 255} + ColHighlight = color.RGBA{80, 120, 200, 255} + ColNewFile = color.RGBA{150, 150, 150, 255} + ColAxis = color.RGBA{100, 255, 100, 255} + ColInput = color.RGBA{20, 20, 20, 255} + ColDelete = color.RGBA{255, 100, 100, 255} + ColPlayerRef = color.RGBA{0, 255, 255, 100} +) + +var AssetTypes = []string{"obstacle", "platform", "powerup", "enemy", "deco", "coin"} + +// --- HILFSFUNKTIONEN --- + +func generateBrickTexture(w, h int) *ebiten.Image { + img := ebiten.NewImage(w, h) + img.Fill(color.RGBA{80, 80, 90, 255}) + brickColor := color.RGBA{160, 80, 40, 255} + brickHighlight := color.RGBA{180, 100, 50, 255} + rows := 2 + cols := 4 + brickH := float32(h) / float32(rows) + brickW := float32(w) / float32(cols) + padding := float32(2) + + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + xOffset := float32(0) + if row%2 != 0 { + xOffset = brickW / 2 + } + x := float32(col)*brickW + xOffset + y := float32(row) * brickH + drawBrick := func(bx, by float32) { + vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, brickH-padding*2, brickColor, false) + vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, 2, brickHighlight, false) + } + drawBrick(x, y) + if x+brickW > float32(w) { + drawBrick(x-float32(w), y) + } + } + } + return img +} + +func saveImageToDisk(img *ebiten.Image, filename string) error { + stdImg := img.SubImage(img.Bounds()) + assetDir := filepath.Dir(OutFile) + fullPath := filepath.Join(assetDir, filename) + f, err := os.Create(fullPath) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, stdImg) +} + +// --- EDITOR STRUCT --- + +type Editor struct { + manifest game.AssetManifest + assetsImages map[string]*ebiten.Image + + rawFiles []string + sortedIDs []string + + selectedID string + + inputBuffer string + activeField string + + listScroll float64 + showPlayerRef bool + + isDraggingImage bool + isDraggingHitbox bool + dragStart game.Vec2 +} + +func NewEditor() *Editor { + e := &Editor{ + assetsImages: make(map[string]*ebiten.Image), + manifest: game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}, + showPlayerRef: true, + } + e.ScanRawFiles() + e.LoadManifest() + e.RebuildList() + + if len(e.sortedIDs) > 0 { + e.selectedID = e.sortedIDs[0] + } + return e +} + +func (e *Editor) ScanRawFiles() { + e.rawFiles = []string{} + entries, _ := os.ReadDir(RawDir) + for _, f := range entries { + if f.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name())) + if ext != ".png" && ext != ".jpg" { + continue + } + + e.rawFiles = append(e.rawFiles, f.Name()) + + id := strings.TrimSuffix(f.Name(), ext) + path := filepath.Join(RawDir, f.Name()) + img, _, err := ebitenutil.NewImageFromFile(path) + if err == nil { + e.assetsImages[id] = img + } + } + sort.Strings(e.rawFiles) +} + +func (e *Editor) LoadManifest() { + data, err := ioutil.ReadFile(OutFile) + if err == nil { + var loaded game.AssetManifest + json.Unmarshal(data, &loaded) + for k, v := range loaded.Assets { + e.manifest.Assets[k] = v + // Laden der Bilder für existierende Assets + if v.Filename != "" { + assetPath := filepath.Join(filepath.Dir(OutFile), v.Filename) + if img, _, err := ebitenutil.NewImageFromFile(assetPath); err == nil { + e.assetsImages[v.ID] = img + } + } + } + } +} + +func (e *Editor) RebuildList() { + e.sortedIDs = []string{} + for k := range e.manifest.Assets { + e.sortedIDs = append(e.sortedIDs, k) + } + sort.Strings(e.sortedIDs) +} + +func (e *Editor) Save() { + data, _ := json.MarshalIndent(e.manifest, "", " ") + _ = ioutil.WriteFile(OutFile, data, 0644) + log.Println("Gespeichert.") +} + +func (e *Editor) DeleteAsset(id string) { + delete(e.manifest.Assets, id) + e.RebuildList() + if e.selectedID == id { + if len(e.sortedIDs) > 0 { + e.selectedID = e.sortedIDs[0] + } else { + e.selectedID = "" + } + } + log.Printf("Asset gelöscht: %s", id) +} + +func (e *Editor) CreateAssetFromFile(filename string) { + ext := filepath.Ext(filename) + id := strings.TrimSuffix(filename, ext) + + // Prüfen ob es schon existiert + if _, ok := e.manifest.Assets[id]; ok { + // Neuen Namen generieren um Überschreiben zu vermeiden + id = fmt.Sprintf("%s_%d", id, time.Now().Unix()) + } + + img := e.assetsImages[strings.TrimSuffix(filename, ext)] // Versuch Raw Image zu finden + // Falls nicht gefunden (weil neu reinkopiert), versuchen wir es zu laden + if img == nil { + path := filepath.Join(RawDir, filename) + img, _, _ = ebitenutil.NewImageFromFile(path) + } + + w, h := 64, 64 + if img != nil { + w, h = img.Bounds().Dx(), img.Bounds().Dy() + // Speichern wir das Bild auch in e.assetsImages für die Vorschau + e.assetsImages[id] = img + } + + // Wichtig: Wir kopieren das Raw File in den Client Assets Ordner! + // (Simuliert durch Laden und Speichern, oder wir setzen nur den Pfad wenn wir faul sind. + // Sauberer: Wir gehen davon aus, dass der User es händisch kopiert hat ODER wir kopieren es hier.) + // Für diesen Editor nehmen wir an, der User nutzt "RawDir" als Quelle und wir müssten es eigentlich kopieren. + // Simpler Hack: Wir nutzen das File aus RawDir und speichern es als neues PNG im Zielordner. + if img != nil { + saveImageToDisk(img, filename) + } + + e.manifest.Assets[id] = game.AssetDefinition{ + ID: id, Type: "obstacle", Filename: filename, Scale: 1.0, + Color: game.HexColor{R: 255, G: 0, B: 255, A: 255}, + DrawOffX: float64(-w) / 2, + DrawOffY: float64(-h), + Hitbox: game.Rect{W: float64(w), H: float64(h), OffsetX: float64(-w) / 2, OffsetY: float64(-h)}, + } + e.RebuildList() + e.selectedID = id +} + +func (e *Editor) CreatePlatform() { + w, h := 128, 32 + texImg := generateBrickTexture(w, h) + timestamp := time.Now().Unix() + filename := fmt.Sprintf("gen_plat_%d.png", timestamp) + id := fmt.Sprintf("platform_%d", timestamp) + + if err := saveImageToDisk(texImg, filename); err != nil { + log.Printf("Fehler beim Speichern: %v", err) + return + } + + e.assetsImages[id] = texImg + e.manifest.Assets[id] = game.AssetDefinition{ + ID: id, + Type: "platform", + Filename: filename, + Scale: 1.0, + Color: game.HexColor{R: 255, G: 255, B: 255, A: 255}, + DrawOffX: float64(-w) / 2, + DrawOffY: float64(-h) / 2, + Hitbox: game.Rect{W: float64(w), H: float64(h), OffsetX: float64(-w) / 2, OffsetY: float64(-h) / 2}, + } + e.RebuildList() + e.selectedID = id +} + +func (e *Editor) Update() error { + if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" { + e.Save() + } + if inpututil.IsKeyJustPressed(ebiten.KeyP) && e.activeField == "" { + e.showPlayerRef = !e.showPlayerRef + } + + mx, my := ebiten.CursorPosition() + + // --- LISTE (LINKS) --- + if mx < WidthList { + _, wy := ebiten.Wheel() + e.listScroll -= wy * 20 + if e.listScroll < 0 { + e.listScroll = 0 + } + + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) || inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) { + currentY := 40.0 - e.listScroll + + // ASSETS + currentY += float64(LineHeight) + for _, id := range e.sortedIDs { + if float64(my) >= currentY && float64(my) < currentY+float64(LineHeight) { + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + e.selectedID = id + e.activeField = "" + } else { + e.DeleteAsset(id) + return nil + } + } + currentY += float64(LineHeight) + } + + // RAW FILES + currentY += 40 + for _, f := range e.rawFiles { + ext := filepath.Ext(f) + id := strings.TrimSuffix(f, ext) + if _, exists := e.manifest.Assets[id]; exists { + continue + } + + if float64(my) >= currentY && float64(my) < currentY+float64(LineHeight) { + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + e.CreateAssetFromFile(f) + } + } + currentY += float64(LineHeight) + } + + if my > CanvasHeight-40 { + e.CreatePlatform() + } + } + return nil + } + + // --- INSPECTOR (RECHTS) --- + if mx > CanvasWidth-WidthInspect { + e.UpdateInspector(mx, my) + return nil + } + + // --- CANVAS (MITTE) --- + if e.selectedID != "" { + def := e.manifest.Assets[e.selectedID] + centerX := float64(WidthList) + float64(CanvasWidth-WidthList-WidthInspect)/2 + centerY := float64(CanvasHeight) * 0.75 + + relMX := float64(mx) - centerX - def.DrawOffX + relMY := float64(my) - centerY - def.DrawOffY + + // Hitbox Drag + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) { + e.isDraggingHitbox = true + e.dragStart = game.Vec2{X: relMX, Y: relMY} + def.Hitbox.OffsetX = relMX + def.Hitbox.OffsetY = relMY + def.Hitbox.W = 0 + def.Hitbox.H = 0 + } + + if e.isDraggingHitbox { + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonRight) { + def.Hitbox.W = relMX - def.Hitbox.OffsetX + def.Hitbox.H = relMY - def.Hitbox.OffsetY + } else { + e.isDraggingHitbox = false + // Negative Größen korrigieren + if def.Hitbox.W < 0 { + def.Hitbox.OffsetX += def.Hitbox.W + def.Hitbox.W = math.Abs(def.Hitbox.W) + } + if def.Hitbox.H < 0 { + def.Hitbox.OffsetY += def.Hitbox.H + def.Hitbox.H = math.Abs(def.Hitbox.H) + } + } + e.manifest.Assets[e.selectedID] = def + return nil + } + + // Image Drag + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + e.isDraggingImage = true + e.dragStart = game.Vec2{X: float64(mx), Y: float64(my)} + e.activeField = "" + } + + if e.isDraggingImage && ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { + dx := float64(mx) - e.dragStart.X + dy := float64(my) - e.dragStart.Y + def.DrawOffX += dx + def.DrawOffY += dy + e.dragStart = game.Vec2{X: float64(mx), Y: float64(my)} + e.manifest.Assets[e.selectedID] = def + } else { + e.isDraggingImage = false + } + } + + return nil +} + +func (e *Editor) UpdateInspector(mx, my int) { + if e.selectedID == "" { + return + } + def := e.manifest.Assets[e.selectedID] + y := 40 + y += HeaderHeight + 20 + + // Type toggle + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && my >= y && my < y+20 { + idx := 0 + for i, t := range AssetTypes { + if t == def.Type { + idx = i + } + } + def.Type = AssetTypes[(idx+1)%len(AssetTypes)] + } + y += HeaderHeight + + updateFloat := func(fieldID string, val *float64) { + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && my >= y && my < y+20 { + e.activeField = fieldID + e.inputBuffer = fmt.Sprintf("%.2f", *val) + ebiten.InputChars() + } + if e.activeField == fieldID { + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { + if v, err := strconv.ParseFloat(e.inputBuffer, 64); err == nil { + *val = v + } + e.activeField = "" + } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { + if len(e.inputBuffer) > 0 { + e.inputBuffer = e.inputBuffer[:len(e.inputBuffer)-1] + } + } else { + e.inputBuffer += string(ebiten.InputChars()) + } + } + y += LineHeight + } + + if def.Filename == "" { + y += 20 + updateFloat("proc_w", &def.ProcWidth) + updateFloat("proc_h", &def.ProcHeight) + def.Hitbox.W = def.ProcWidth + def.Hitbox.H = def.ProcHeight + } else { + y += 20 + updateFloat("scale", &def.Scale) + } + + y += 20 + updateFloat("off_x", &def.DrawOffX) + updateFloat("off_y", &def.DrawOffY) + y += 20 + updateFloat("hb_x", &def.Hitbox.OffsetX) + updateFloat("hb_y", &def.Hitbox.OffsetY) + updateFloat("hb_w", &def.Hitbox.W) + updateFloat("hb_h", &def.Hitbox.H) + + y += 20 + updateColorSlider := func(val *uint8) { + sliderRect := image.Rect(CanvasWidth-WidthInspect+10, y, CanvasWidth-10, y+15) + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) && my >= y && my < y+15 { + relX := float64(mx - sliderRect.Min.X) + pct := relX / float64(sliderRect.Dx()) + if pct < 0 { + pct = 0 + } + if pct > 1 { + pct = 1 + } + *val = uint8(pct * 255) + } + y += 20 + } + updateColorSlider(&def.Color.R) + updateColorSlider(&def.Color.G) + updateColorSlider(&def.Color.B) + updateColorSlider(&def.Color.A) + + e.manifest.Assets[e.selectedID] = def +} + +func (e *Editor) Draw(screen *ebiten.Image) { + // --- 1. LISTE LINKS --- + vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false) + + // Button Neu + btnRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10) + vector.DrawFilledRect(screen, float32(btnRect.Min.X), float32(btnRect.Min.Y), float32(btnRect.Dx()), float32(btnRect.Dy()), ColHighlight, false) + text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-18, color.RGBA{255, 255, 255, 255}) + + // SCROLL BEREICH + startY := 40.0 - e.listScroll + currentY := startY + + // Helper Funktion zum Zeichnen von Listeneinträgen mit Bild + drawListItem := func(label string, id string, col color.Color, img *ebiten.Image) { + if currentY > -float64(LineHeight) && currentY < CanvasHeight-50 { + // Bild Vorschau (Thumbnail) + if img != nil { + // Skalierung berechnen (max 28px hoch/breit) + iconSize := float64(LineHeight - 4) // etwas Rand lassen + iw, ih := img.Bounds().Dx(), img.Bounds().Dy() + scale := iconSize / float64(ih) + if float64(iw)*scale > iconSize { + scale = iconSize / float64(iw) + } + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + // Zentrieren im Icon-Bereich (links) + op.GeoM.Translate(10, currentY+2) + screen.DrawImage(img, op) + } else { + // Platzhalter Box wenn kein Bild + vector.DrawFilledRect(screen, 10, float32(currentY+2), 28, 28, color.RGBA{60, 60, 60, 255}, false) + } + + // Text daneben + text.Draw(screen, label, basicfont.Face7x13, 45, int(currentY+float64(TextOffset)), col) + } + currentY += float64(LineHeight) + } + + text.Draw(screen, "--- ASSETS ---", basicfont.Face7x13, 10, int(currentY), color.RGBA{150, 150, 150, 255}) + currentY += float64(LineHeight) + + for _, id := range e.sortedIDs { + col := ColText + if id == e.selectedID { + col = ColHighlight + } + // Bild holen aus e.assetsImages + img := e.assetsImages[id] + // Falls AssetDefinition sagt es gibt ein Filename, aber img nil ist, versuchen wir es via ID + if img == nil { + if def, ok := e.manifest.Assets[id]; ok && def.Filename != "" { + // ID könnte abweichen wenn Filename anders heißt, wir suchen in assetsImages nach ID + img = e.assetsImages[def.ID] + } + } + drawListItem(id, id, col, img) + } + + currentY += 20 + text.Draw(screen, "--- RAW FILES ---", basicfont.Face7x13, 10, int(currentY), color.RGBA{150, 150, 150, 255}) + currentY += float64(LineHeight) + + for _, f := range e.rawFiles { + ext := filepath.Ext(f) + id := strings.TrimSuffix(f, ext) + if _, exists := e.manifest.Assets[id]; exists { + continue + } + + // Bild für Raw File + img := e.assetsImages[id] + drawListItem("+ "+f, id, ColNewFile, img) + } + + // --- 2. CANVAS MITTE --- + viewX := float64(WidthList) + centerX := float64(WidthList) + float64(CanvasWidth-WidthList-WidthInspect)/2 + centerY := float64(CanvasHeight) * 0.75 + + vector.StrokeLine(screen, float32(viewX), float32(centerY), float32(CanvasWidth-WidthInspect), float32(centerY), 2, ColAxis, false) + text.Draw(screen, "BODEN (Y=0) | [P] Player Ref Toggle", basicfont.Face7x13, int(viewX)+10, int(centerY)-10, ColAxis) + vector.StrokeLine(screen, float32(centerX), 0, float32(centerX), CanvasHeight, 1, color.RGBA{100, 100, 100, 255}, false) + + // A. PLAYER GHOST + if e.showPlayerRef && e.selectedID != "player" { + if playerDef, ok := e.manifest.Assets["player"]; ok { + posX := centerX + playerDef.DrawOffX + posY := centerY + playerDef.DrawOffY + col := ColPlayerRef + if playerDef.Filename != "" { + if img := e.assetsImages[playerDef.ID]; img != nil { + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(playerDef.Scale, playerDef.Scale) + op.GeoM.Translate(posX, posY) + op.ColorScale.ScaleAlpha(0.5) + screen.DrawImage(img, op) + } + } else { + vector.DrawFilledRect(screen, float32(posX), float32(posY), float32(playerDef.ProcWidth), float32(playerDef.ProcHeight), col, false) + } + hx := float32(centerX + playerDef.DrawOffX + playerDef.Hitbox.OffsetX) + hy := float32(centerY + playerDef.DrawOffY + playerDef.Hitbox.OffsetY) + vector.StrokeRect(screen, hx, hy, float32(playerDef.Hitbox.W), float32(playerDef.Hitbox.H), 1, col, false) + } + } + + // B. CURRENT OBJECT + if e.selectedID != "" { + def := e.manifest.Assets[e.selectedID] + posX := centerX + def.DrawOffX + posY := centerY + def.DrawOffY + img := e.assetsImages[def.ID] + + if def.Filename != "" { + if img != nil { + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(def.Scale, def.Scale) + op.GeoM.Translate(posX, posY) + screen.DrawImage(img, op) + } else { + fallbackCol := def.Color.ToRGBA() + vector.DrawFilledRect(screen, float32(posX+def.Hitbox.OffsetX), float32(posY+def.Hitbox.OffsetY), float32(def.Hitbox.W), float32(def.Hitbox.H), fallbackCol, false) + text.Draw(screen, "MISSING IMG", basicfont.Face7x13, int(posX), int(posY), color.RGBA{255, 255, 255, 255}) + } + } else { + col := def.Color.ToRGBA() + vector.DrawFilledRect(screen, float32(posX), float32(posY), float32(def.ProcWidth), float32(def.ProcHeight), col, false) + } + + // Hitbox Current + hx := float32(centerX + def.DrawOffX + def.Hitbox.OffsetX) + hy := float32(centerY + def.DrawOffY + def.Hitbox.OffsetY) + vector.DrawFilledRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), color.RGBA{255, 0, 0, 80}, false) + vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 2, color.RGBA{255, 0, 0, 255}, false) + } + + // --- 3. INSPECTOR RECHTS --- + panelX := float64(CanvasWidth - WidthInspect) + vector.DrawFilledRect(screen, float32(panelX), 0, WidthInspect, CanvasHeight, ColPanel, false) + + if e.selectedID != "" { + def := e.manifest.Assets[e.selectedID] + y := 40 + + drawLabel := func(txt string) { + text.Draw(screen, txt, basicfont.Face7x13, int(panelX)+10, y+TextOffset, ColHighlight) + } + + text.Draw(screen, "ID: "+def.ID, basicfont.Face7x13, int(panelX)+10, y+TextOffset, color.RGBA{255, 255, 255, 255}) + y += HeaderHeight + + drawLabel("TYPE: [Click to change]") + y += 20 + vector.DrawFilledRect(screen, float32(panelX)+10, float32(y), WidthInspect-20, 20, ColInput, false) + text.Draw(screen, strings.ToUpper(def.Type), basicfont.Face7x13, int(panelX)+20, y+14, color.RGBA{255, 255, 255, 255}) + y += HeaderHeight + + drawVal := func(lbl string, val float64, fieldID string) { + str := fmt.Sprintf("%.2f", val) + col := color.RGBA{255, 255, 255, 255} + if e.activeField == fieldID { + str = e.inputBuffer + "_" + col = ColHighlight + } + text.Draw(screen, lbl+": "+str, basicfont.Face7x13, int(panelX)+20, y+TextOffset, col) + y += LineHeight + } + + if def.Filename == "" { + drawLabel("--- PROCEDURAL ---") + y += 20 + drawVal("W", def.ProcWidth, "proc_w") + drawVal("H", def.ProcHeight, "proc_h") + } else { + drawLabel("--- IMAGE ---") + y += 20 + drawVal("Scale", def.Scale, "scale") + } + + drawLabel("--- OFFSET (L-Click Drag) ---") + y += 20 + drawVal("X", def.DrawOffX, "off_x") + drawVal("Y", def.DrawOffY, "off_y") + drawLabel("--- HITBOX (R-Click Drag) ---") + y += 20 + drawVal("X", def.Hitbox.OffsetX, "hb_x") + drawVal("Y", def.Hitbox.OffsetY, "hb_y") + drawVal("W", def.Hitbox.W, "hb_w") + drawVal("H", def.Hitbox.H, "hb_h") + + drawLabel("--- COLOR ---") + y += 20 + drawBar := func(c color.RGBA) { + vector.DrawFilledRect(screen, float32(panelX)+10, float32(y), WidthInspect-30, 10, c, false) + y += 20 + } + drawBar(color.RGBA{def.Color.R, 0, 0, 255}) + drawBar(color.RGBA{0, def.Color.G, 0, 255}) + drawBar(color.RGBA{0, 0, def.Color.B, 255}) + vector.DrawFilledRect(screen, float32(panelX)+WidthInspect-30, float32(y-60), 20, 50, def.Color.ToRGBA(), false) + + text.Draw(screen, "[Enter] Confirm | [S] Save", basicfont.Face7x13, int(panelX)+10, CanvasHeight-20, color.RGBA{100, 100, 100, 255}) + } +} + +func (e *Editor) Layout(w, h int) (int, int) { return CanvasWidth, CanvasHeight } + +func main() { + os.MkdirAll(RawDir, 0755) + os.MkdirAll(filepath.Dir(OutFile), 0755) + ebiten.SetWindowSize(CanvasWidth, CanvasHeight) + ebiten.SetWindowTitle("Escape Prefab Editor - Pro") + if err := ebiten.RunGame(NewEditor()); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..69b6d8b --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "encoding/json" + "fmt" + "image/color" + _ "image/png" + "io/ioutil" + "log" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" + "github.com/hajimehoshi/ebiten/v2/vector" + "github.com/nats-io/nats.go" + "golang.org/x/image/font/basicfont" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// --- KONFIGURATION --- +const ( + ScreenWidth = 1280 + ScreenHeight = 720 + StateMenu = 0 + StateGame = 1 + RefFloorY = 540 +) + +var ( + ColText = color.White + ColBtnNormal = color.RGBA{40, 44, 52, 255} + ColBtnHover = color.RGBA{60, 66, 78, 255} + ColSky = color.RGBA{135, 206, 235, 255} + ColGrass = color.RGBA{34, 139, 34, 255} + ColDirt = color.RGBA{101, 67, 33, 255} +) + +// --- GAME STRUCT --- +type Game struct { + appState int + conn *nats.EncodedConn + gameState game.GameState + stateMutex sync.Mutex + connected bool + world *game.World + assetsImages map[string]*ebiten.Image + + // Spieler Info + playerName string + roomID string // <-- NEU: Raum ID + activeField string // "name" oder "room" + + // Kamera + camX float64 + + // Touch State + joyBaseX, joyBaseY float64 + joyStickX, joyStickY float64 + joyActive bool + joyTouchID ebiten.TouchID + btnJumpActive bool +} + +func NewGame() *Game { + g := &Game{ + appState: StateMenu, + world: game.NewWorld(), + assetsImages: make(map[string]*ebiten.Image), + gameState: game.GameState{Players: make(map[string]game.PlayerState)}, + + playerName: "Student", + roomID: "room1", // Standard Raum + activeField: "name", + + joyBaseX: 150, joyBaseY: ScreenHeight - 150, + joyStickX: 150, joyStickY: ScreenHeight - 150, + } + g.loadAssets() + return g +} + +func (g *Game) loadAssets() { + // Pfad anpassen: Wir suchen im relativen Pfad + baseDir := "./cmd/client/assets" + manifestPath := filepath.Join(baseDir, "assets.json") + + data, err := ioutil.ReadFile(manifestPath) + if err == nil { + var m game.AssetManifest + json.Unmarshal(data, &m) + g.world.Manifest = m + fmt.Println("✅ Assets Manifest geladen:", len(m.Assets), "Einträge") + } else { + log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath) + // Fallback: Leeres Manifest, damit das Spiel nicht abstürzt + g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)} + } + + // Bilder vorladen + for id, def := range g.world.Manifest.Assets { + if def.Filename != "" { + path := filepath.Join(baseDir, def.Filename) + img, _, err := ebitenutil.NewImageFromFile(path) + if err == nil { + g.assetsImages[id] = img + } else { + // log.Println("Fehler beim Laden von Bild:", def.Filename) + } + } + } +} + +// --- UPDATE --- +func (g *Game) Update() error { + switch g.appState { + case StateMenu: + g.handleMenuInput() // Text Eingabe Logik + + // Button & Felder Layout + btnW, btnH := 200, 50 + btnX := ScreenWidth/2 - btnW/2 + btnY := ScreenHeight/2 + 80 + + // Feld 1: Name + fieldW, fieldH := 250, 40 + nameX := ScreenWidth/2 - fieldW/2 + nameY := ScreenHeight/2 - 100 + + // Feld 2: Raum (NEU) + roomX := ScreenWidth/2 - fieldW/2 + roomY := ScreenHeight/2 - 20 + + // Klick Checks (Maus & Touch) + if isHit(nameX, nameY, fieldW, fieldH) { + g.activeField = "name" + } else if isHit(roomX, roomY, fieldW, fieldH) { + g.activeField = "room" + } else if isHit(btnX, btnY, btnW, btnH) { + // START + if g.playerName == "" { + g.playerName = "Player" + } + if g.roomID == "" { + g.roomID = "room1" + } + g.appState = StateGame + go g.connectAndStart() + } else if isHit(0, 0, ScreenWidth, ScreenHeight) { + // Klick ins Leere -> Fokus weg + g.activeField = "" + } + + case StateGame: + g.UpdateGame() // In game_render.go + } + return nil +} + +// --- DRAW --- +func (g *Game) Draw(screen *ebiten.Image) { + switch g.appState { + case StateMenu: + g.DrawMenu(screen) + case StateGame: + g.DrawGame(screen) // In game_render.go + } +} + +func (g *Game) DrawMenu(screen *ebiten.Image) { + screen.Fill(color.RGBA{20, 20, 30, 255}) + text.Draw(screen, "ESCAPE FROM TEACHER", basicfont.Face7x13, ScreenWidth/2-60, ScreenHeight/2-140, ColText) + + // Helper zum Zeichnen von Textfeldern + drawField := func(label, value, fieldID string, x, y, w, h int) { + col := color.RGBA{50, 50, 60, 255} + if g.activeField == fieldID { + col = color.RGBA{70, 70, 80, 255} + } + vector.DrawFilledRect(screen, float32(x), float32(y), float32(w), float32(h), col, false) + vector.StrokeRect(screen, float32(x), float32(y), float32(w), float32(h), 1, color.White, false) + + display := value + if g.activeField == fieldID && (time.Now().UnixMilli()/500)%2 == 0 { + display += "|" + } + text.Draw(screen, label+": "+display, basicfont.Face7x13, x+10, y+25, ColText) + } + + fieldW := 250 + drawField("Name", g.playerName, "name", ScreenWidth/2-fieldW/2, ScreenHeight/2-100, fieldW, 40) + drawField("Room Code", g.roomID, "room", ScreenWidth/2-fieldW/2, ScreenHeight/2-20, fieldW, 40) + + // Start Button + btnW, btnH := 200, 50 + btnX := ScreenWidth/2 - btnW/2 + btnY := ScreenHeight/2 + 80 + vector.DrawFilledRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false) + vector.StrokeRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false) + text.Draw(screen, "JOIN GAME", basicfont.Face7x13, btnX+65, btnY+30, ColText) + + text.Draw(screen, "WASD / Arrows to Move - SPACE to Jump\nMobile: Touch Controls", basicfont.Face7x13, ScreenWidth/2-120, ScreenHeight-50, color.Gray{150}) +} + +func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight } + +// --- HELPER --- + +func isHit(x, y, w, h int) bool { + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + mx, my := ebiten.CursorPosition() + if mx >= x && mx <= x+w && my >= y && my <= y+h { + return true + } + } + for _, id := range inpututil.JustPressedTouchIDs() { + tx, ty := ebiten.TouchPosition(id) + if tx >= x && tx <= x+w && ty >= y && ty <= y+h { + return true + } + } + return false +} + +func (g *Game) handleMenuInput() { + if g.activeField == "" { + return + } + + // Text Eingabe + var target *string + if g.activeField == "name" { + target = &g.playerName + } + if g.activeField == "room" { + target = &g.roomID + } + + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { + g.activeField = "" + } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { + if len(*target) > 0 { + *target = (*target)[:len(*target)-1] + } + } else { + *target += string(ebiten.InputChars()) + } +} + +func (g *Game) connectAndStart() { + // URL: Wasm -> WS, Desktop -> TCP + serverURL := "nats://localhost:4222" + if runtime.GOARCH == "wasm" || runtime.GOOS == "js" { + serverURL = "ws://localhost:9222" + } + + nc, err := nats.Connect(serverURL) + if err != nil { + log.Println("❌ NATS Connect Fehler:", err) + return + } + ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER) + g.conn = ec + + sub, err := g.conn.Subscribe("game.update", func(state *game.GameState) { + g.stateMutex.Lock() + g.gameState = *state + g.stateMutex.Unlock() + log.Printf("📦 State empfangen: Players=%d, Chunks=%d, Status=%s", len(state.Players), len(state.WorldChunks), state.Status) + }) + + if err != nil { + log.Println("❌ Fehler beim Subscribe:", err) + return + } + log.Printf("👂 Subscription aktiv (Valid: %v)", sub.IsValid()) + + // Kurze Pause, damit Subscription aktiv ist + time.Sleep(100 * time.Millisecond) + + // JOIN MIT ROOM ID SENDEN + joinReq := game.JoinRequest{ + Name: g.playerName, + RoomID: g.roomID, + } + log.Printf("📤 Sende JOIN Request: Name=%s, RoomID=%s", g.playerName, g.roomID) + err = g.conn.Publish("game.join", joinReq) + if err != nil { + log.Println("❌ Fehler beim Publish:", err) + return + } + g.connected = true + log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...") +} + +func (g *Game) SendCommand(cmdType string) { + if !g.connected { + return + } + // ID Suche (Fallback Name) + myID := "" + g.stateMutex.Lock() + for id, p := range g.gameState.Players { + if p.Name == g.playerName { + myID = id + break + } + } + g.stateMutex.Unlock() + if myID == "" { + myID = g.playerName + } + + g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType}) +} + +func main() { + ebiten.SetWindowSize(ScreenWidth, ScreenHeight) + ebiten.SetWindowTitle("Escape From Teacher") + if err := ebiten.RunGame(NewGame()); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f43a170 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "log" + "path/filepath" + "runtime" + "sync" + + "github.com/nats-io/nats.go" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/server" +) + +// Globaler Zustand des Servers +var ( + rooms = make(map[string]*server.Room) + playerSessions = make(map[string]*server.Room) + mu sync.RWMutex + globalWorld *game.World +) + +func main() { + log.Println("🚀 Escape From Teacher SERVER startet...") + + // 1. WELT & ASSETS LADEN + globalWorld = game.NewWorld() + loadServerAssets(globalWorld) + + // 2. NATS VERBINDUNG + natsURL := "nats://localhost:4222" + nc, err := nats.Connect(natsURL) + if err != nil { + log.Fatal("❌ Konnte nicht zu NATS verbinden: ", err) + } + defer nc.Close() + + ec, err := nats.NewEncodedConn(nc, nats.JSON_ENCODER) + if err != nil { + log.Fatal("❌ JSON Encoder Fehler: ", err) + } + log.Println("✅ Verbunden mit NATS unter", natsURL) + + // 3. HANDLER: GAME JOIN + sub, err := ec.Subscribe("game.join", func(req *game.JoinRequest) { + log.Printf("📥 JOIN empfangen: Name=%s, RoomID=%s", req.Name, req.RoomID) + + playerID := req.Name + if playerID == "" { + playerID = "Unknown" + } + + roomID := req.RoomID + if roomID == "" { + roomID = "lobby" + } + + mu.Lock() + defer mu.Unlock() + + // Raum finden oder erstellen + room, exists := rooms[roomID] + if !exists { + log.Printf("🆕 Erstelle neuen Raum: '%s'", roomID) + room = server.NewRoom(roomID, nc, globalWorld) + rooms[roomID] = room + + // Starte den Game-Loop (Physik) + go room.RunLoop() + } + + // Spieler hinzufügen (ID, Name) + room.AddPlayer(playerID, req.Name) + + // Session speichern + playerSessions[playerID] = room + log.Printf("➡️ Spieler '%s' ist Raum '%s' beigetreten.", playerID, roomID) + }) + + if err != nil { + log.Fatal("❌ Fehler beim Subscribe auf game.join:", err) + } + log.Printf("👂 Lausche auf 'game.join'... (Sub Valid: %v)", sub.IsValid()) + + // TEST: Auch mit Raw-NATS lauschen + nc.Subscribe("game.join", func(m *nats.Msg) { + log.Printf("🔍 RAW NATS: Nachricht empfangen auf game.join: %s", string(m.Data)) + }) + + // 4. HANDLER: INPUT + _, _ = ec.Subscribe("game.input", func(input *game.ClientInput) { + mu.RLock() + room, ok := playerSessions[input.PlayerID] + mu.RUnlock() + + if ok { + room.HandleInput(*input) + } + }) + + log.Println("✅ Server bereit. Warte auf Spieler...") + + // Block forever + select {} +} + +func loadServerAssets(w *game.World) { + assetDir := "./cmd/client/assets" + chunkDir := filepath.Join(assetDir, "chunks") + + // Manifest laden + manifestPath := filepath.Join(assetDir, "assets.json") + data, err := ioutil.ReadFile(manifestPath) + if err == nil { + var m game.AssetManifest + json.Unmarshal(data, &m) + w.Manifest = m + log.Printf("📦 Manifest geladen: %d Assets", len(m.Assets)) + } else { + log.Println("⚠️ Manifest nicht gefunden:", manifestPath) + } + + // Chunks laden + files, err := ioutil.ReadDir(chunkDir) + if err == nil { + for _, f := range files { + if filepath.Ext(f.Name()) == ".json" { + fullPath := filepath.Join(chunkDir, f.Name()) + cData, err := ioutil.ReadFile(fullPath) + if err == nil { + var chunk game.Chunk + json.Unmarshal(cData, &chunk) + if chunk.ID == "" { + chunk.ID = f.Name()[0 : len(f.Name())-5] + } + w.ChunkLibrary[chunk.ID] = chunk + log.Printf("🧩 Chunk geladen: %s", chunk.ID) + } + } + } + } else { + log.Println("⚠️ Chunk Ordner nicht gefunden:", chunkDir) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d0033d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + # 🧠 REDIS - Das Gedächtnis + # Speichert: Wer ist wo? (Room Registry), Highscores, Asset-Config + redis: + image: redis:7-alpine + container_name: escape_redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + + # ⚡ NATS - Das Nervensystem + # Vermittelt: Inputs und Game-States zwischen Pods (Gateway <-> Host) + nats: + image: nats:alpine + container_name: escape_nats + ports: + - "4222:4222" # Client Port (für unsere Go Apps) + - "8222:8222" # Dashboard / Monitoring + command: "-js" # JetStream aktivieren (optional, aber gut für später) + +volumes: + redis_data: \ No newline at end of file diff --git a/pkg/game/assets.go b/pkg/game/assets.go new file mode 100644 index 0000000..cd1f35b --- /dev/null +++ b/pkg/game/assets.go @@ -0,0 +1,6 @@ +package game + +// Manifest ist die ganze Konfigurationsdatei +type Manifest struct { + Assets map[string]AssetDefinition `json:"assets"` +} diff --git a/pkg/game/data.go b/pkg/game/data.go new file mode 100644 index 0000000..d835924 --- /dev/null +++ b/pkg/game/data.go @@ -0,0 +1,86 @@ +package game + +import "image/color" + +type Vec2 struct { + X, Y float64 `json:"x,y"` +} +type Rect struct { + OffsetX, OffsetY, W, H float64 + Type string +} // Type hinzugefügt aus letztem Schritt +type HexColor struct { + R, G, B, A uint8 `json:"r,g,b,a"` +} + +func (h HexColor) ToRGBA() color.RGBA { + if h.A == 0 { + return color.RGBA{h.R, h.G, h.B, 255} + } + return color.RGBA{h.R, h.G, h.B, h.A} +} + +// --- ASSETS & CHUNKS (Bleiben gleich) --- +type AssetDefinition struct { + ID, Type, Filename string + Scale, ProcWidth, ProcHeight, DrawOffX, DrawOffY float64 + Color HexColor + Hitbox Rect +} +type AssetManifest struct { + Assets map[string]AssetDefinition `json:"assets"` +} +type LevelObject struct { + AssetID string + X, Y float64 +} +type Chunk struct { + ID string + Width int + Objects []LevelObject +} +type ActiveChunk struct { + ChunkID string + X float64 +} + +// --- NETZWERK & GAMEPLAY --- + +// Login Request (Client -> Server beim Verbinden) +type LoginPayload struct { + Action string `json:"action"` // "CREATE" oder "JOIN" + RoomID string `json:"room_id"` // Leer bei CREATE, Code bei JOIN + Name string `json:"name"` // Spielername +} + +// Input vom Spieler während des Spiels +type ClientInput struct { + Type string `json:"type"` // "JUMP", "START" + RoomID string `json:"room_id"` + PlayerID string `json:"player_id"` +} + +type JoinRequest struct { + Name string `json:"name"` + RoomID string `json:"room_id"` +} + +type PlayerState struct { + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + State string `json:"state"` + OnGround bool `json:"on_ground"` +} + +type GameState struct { + RoomID string `json:"room_id"` + Players map[string]PlayerState `json:"players"` + Status string `json:"status"` + TimeLeft int `json:"time_left"` + WorldChunks []ActiveChunk `json:"world_chunks"` + HostID string `json:"host_id"` + + ScrollX float64 `json:"scroll_x"` +} diff --git a/pkg/physics/engine.go b/pkg/physics/engine.go new file mode 100644 index 0000000..bc7ef53 --- /dev/null +++ b/pkg/physics/engine.go @@ -0,0 +1,25 @@ +package physics + +// Entity repräsentiert alles, was sich bewegt (Spieler, Hindernis) +type Entity struct { + X, Y float64 + Width, Height float64 + VelocityY float64 +} + +// Config für Konstanten (Schwerkraft etc.) +type Config struct { + Gravity float64 + Speed float64 +} + +// Update simuliert einen Tick (z.B. 1/60 sekunde) +func (e *Entity) Update(cfg Config) { + e.VelocityY += cfg.Gravity + e.Y += e.VelocityY + // Einfache Boden-Kollision (Hardcoded für den Anfang) + if e.Y > 300 { + e.Y = 300 + e.VelocityY = 0 + } +} diff --git a/pkg/protocol/messages.go b/pkg/protocol/messages.go new file mode 100644 index 0000000..35588f6 --- /dev/null +++ b/pkg/protocol/messages.go @@ -0,0 +1,20 @@ +package protocol + +// Input: Was der Spieler drückt +type InputMessage struct { + PlayerID string `json:"id"` + Jump bool `json:"jump"` +} + +// State: Wo alles ist (Server -> Client) +type GameStateMessage struct { + Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16) + Score float64 `json:"score"` + Multiplier int `json:"multiplier"` +} + +type PlayerState struct { + // WICHTIG: Jedes Feld braucht ein eigenes JSON-Tag! + X float64 `json:"x"` + Y float64 `json:"y"` +} diff --git a/pkg/stream/adapter.go b/pkg/stream/adapter.go new file mode 100644 index 0000000..f3e7ac7 --- /dev/null +++ b/pkg/stream/adapter.go @@ -0,0 +1,32 @@ +package stream + +import ( + "log" + + "github.com/nats-io/nats.go" +) + +// Connect verbindet uns mit dem NATS Cluster (oder lokalem Docker Container) +func Connect(url string) (*nats.Conn, error) { + // Default auf localhost, wenn leer + if url == "" { + url = nats.DefaultURL + } + + nc, err := nats.Connect(url) + if err != nil { + return nil, err + } + + log.Printf("🔌 Verbunden mit NATS unter %s", url) + return nc, nil +} + +// Helper Konstanten für unsere Topic-Struktur +const ( + // Topic: game..input -> Client sendet Tasten + SubjectGameInput = "game.%s.input" + + // Topic: game..state -> Server sendet Positionen + SubjectGameState = "game.%s.state" +)