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) } }