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.
This commit is contained in:
718
cmd/builder/main.go
Normal file
718
cmd/builder/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
328
cmd/client/main.go
Normal file
328
cmd/client/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
cmd/server/main.go
Normal file
146
cmd/server/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -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:
|
||||||
6
pkg/game/assets.go
Normal file
6
pkg/game/assets.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
// Manifest ist die ganze Konfigurationsdatei
|
||||||
|
type Manifest struct {
|
||||||
|
Assets map[string]AssetDefinition `json:"assets"`
|
||||||
|
}
|
||||||
86
pkg/game/data.go
Normal file
86
pkg/game/data.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
25
pkg/physics/engine.go
Normal file
25
pkg/physics/engine.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
pkg/protocol/messages.go
Normal file
20
pkg/protocol/messages.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
32
pkg/stream/adapter.go
Normal file
32
pkg/stream/adapter.go
Normal file
@@ -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.<RoomID>.input -> Client sendet Tasten
|
||||||
|
SubjectGameInput = "game.%s.input"
|
||||||
|
|
||||||
|
// Topic: game.<RoomID>.state -> Server sendet Positionen
|
||||||
|
SubjectGameState = "game.%s.state"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user