Add platform-specific implementations for assets, audio, WebSocket, and rendering on Desktop and WebAssembly platforms. Introduce embedded assets for WebAssembly and native file handling for Desktop. Add platform-specific chunk loading and game state synchronization.
17
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="0@localhost" uuid="5887da40-2735-46aa-8690-99c28ab9eb2e">
|
||||||
|
<driver-ref>redis</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>jdbc.RedisDriver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:redis://localhost:6379/0</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="5887da40-2735-46aa-8690-99c28ab9eb2e" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
0
.idea/queries/Query.sql
generated
Normal file
6
.idea/sqldialects.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" dialect="Redis" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
BIN
assets_raw/godmode.png
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
assets_raw/jump0.png
Normal file
|
After Width: | Height: | Size: 545 KiB |
BIN
assets_raw/jump1.png
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
assets_raw/jumpboost.png
Normal file
|
After Width: | Height: | Size: 685 KiB |
|
Before Width: | Height: | Size: 384 B |
BIN
assets_raw/playernew.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
bin/builder
Executable file
BIN
bin/client
Executable file
BIN
bin/levelbuilder
Executable file
BIN
bin/server
Executable file
@@ -56,7 +56,7 @@ var (
|
|||||||
ColPlayerRef = color.RGBA{0, 255, 255, 100}
|
ColPlayerRef = color.RGBA{0, 255, 255, 100}
|
||||||
)
|
)
|
||||||
|
|
||||||
var AssetTypes = []string{"obstacle", "platform", "powerup", "enemy", "deco", "coin"}
|
var AssetTypes = []string{"obstacle", "platform", "wall", "powerup", "enemy", "deco", "coin"}
|
||||||
|
|
||||||
// --- HILFSFUNKTIONEN ---
|
// --- HILFSFUNKTIONEN ---
|
||||||
|
|
||||||
@@ -92,6 +92,58 @@ func generateBrickTexture(w, h int) *ebiten.Image {
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateWallTexture(w, h int) *ebiten.Image {
|
||||||
|
img := ebiten.NewImage(w, h)
|
||||||
|
// Dunklerer Hintergrund für Wände
|
||||||
|
img.Fill(color.RGBA{60, 60, 70, 255})
|
||||||
|
|
||||||
|
stoneColor := color.RGBA{100, 100, 110, 255}
|
||||||
|
stoneDark := color.RGBA{80, 80, 90, 255}
|
||||||
|
stoneLight := color.RGBA{120, 120, 130, 255}
|
||||||
|
|
||||||
|
// Mehr Reihen und Spalten für Wände
|
||||||
|
rows := h / 16
|
||||||
|
if rows < 2 {
|
||||||
|
rows = 2
|
||||||
|
}
|
||||||
|
cols := w / 16
|
||||||
|
if cols < 2 {
|
||||||
|
cols = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
brickH := float32(h) / float32(rows)
|
||||||
|
brickW := float32(w) / float32(cols)
|
||||||
|
padding := float32(1)
|
||||||
|
|
||||||
|
for row := 0; row < rows; row++ {
|
||||||
|
for col := 0; col < cols; col++ {
|
||||||
|
// Versatz für ungeraden Reihen (Mauerwerk-Muster)
|
||||||
|
xOffset := float32(0)
|
||||||
|
if row%2 != 0 {
|
||||||
|
xOffset = brickW / 2
|
||||||
|
}
|
||||||
|
x := float32(col)*brickW + xOffset
|
||||||
|
y := float32(row) * brickH
|
||||||
|
|
||||||
|
drawStone := func(bx, by float32) {
|
||||||
|
// Hauptstein
|
||||||
|
vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, brickH-padding*2, stoneColor, false)
|
||||||
|
// Schatten unten
|
||||||
|
vector.DrawFilledRect(img, bx+padding, by+brickH-padding-2, brickW-padding*2, 2, stoneDark, false)
|
||||||
|
// Highlight oben
|
||||||
|
vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, 2, stoneLight, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStone(x, y)
|
||||||
|
// Wrap-around für versetzten Offset
|
||||||
|
if x+brickW > float32(w) {
|
||||||
|
drawStone(x-float32(w), y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
func saveImageToDisk(img *ebiten.Image, filename string) error {
|
func saveImageToDisk(img *ebiten.Image, filename string) error {
|
||||||
stdImg := img.SubImage(img.Bounds())
|
stdImg := img.SubImage(img.Bounds())
|
||||||
assetDir := filepath.Dir(OutFile)
|
assetDir := filepath.Dir(OutFile)
|
||||||
@@ -282,6 +334,33 @@ func (e *Editor) CreatePlatform() {
|
|||||||
e.selectedID = id
|
e.selectedID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Editor) CreateWall() {
|
||||||
|
w, h := 64, 128
|
||||||
|
texImg := generateWallTexture(w, h)
|
||||||
|
timestamp := time.Now().Unix()
|
||||||
|
filename := fmt.Sprintf("gen_wall_%d.png", timestamp)
|
||||||
|
id := fmt.Sprintf("wall_%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: "wall", // Neuer Type für kletterbare Wände
|
||||||
|
Filename: filename,
|
||||||
|
Scale: 1.0,
|
||||||
|
Color: game.HexColor{R: 255, G: 255, 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) Update() error {
|
func (e *Editor) Update() error {
|
||||||
if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" {
|
if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" {
|
||||||
e.Save()
|
e.Save()
|
||||||
@@ -335,9 +414,13 @@ func (e *Editor) Update() error {
|
|||||||
currentY += float64(LineHeight)
|
currentY += float64(LineHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
if my > CanvasHeight-40 {
|
// Button-Bereich unten
|
||||||
|
if my > CanvasHeight-75 && my <= CanvasHeight-40 {
|
||||||
e.CreatePlatform()
|
e.CreatePlatform()
|
||||||
}
|
}
|
||||||
|
if my > CanvasHeight-40 {
|
||||||
|
e.CreateWall()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -500,10 +583,15 @@ func (e *Editor) Draw(screen *ebiten.Image) {
|
|||||||
// --- 1. LISTE LINKS ---
|
// --- 1. LISTE LINKS ---
|
||||||
vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false)
|
vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false)
|
||||||
|
|
||||||
// Button Neu
|
// Button Platform
|
||||||
btnRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10)
|
btnPlatRect := image.Rect(10, CanvasHeight-70, WidthList-10, CanvasHeight-45)
|
||||||
vector.DrawFilledRect(screen, float32(btnRect.Min.X), float32(btnRect.Min.Y), float32(btnRect.Dx()), float32(btnRect.Dy()), ColHighlight, false)
|
vector.DrawFilledRect(screen, float32(btnPlatRect.Min.X), float32(btnPlatRect.Min.Y), float32(btnPlatRect.Dx()), float32(btnPlatRect.Dy()), ColHighlight, false)
|
||||||
text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-18, color.RGBA{255, 255, 255, 255})
|
text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-53, color.RGBA{255, 255, 255, 255})
|
||||||
|
|
||||||
|
// Button Wall
|
||||||
|
btnWallRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10)
|
||||||
|
vector.DrawFilledRect(screen, float32(btnWallRect.Min.X), float32(btnWallRect.Min.Y), float32(btnWallRect.Dx()), float32(btnWallRect.Dy()), color.RGBA{100, 100, 120, 255}, false)
|
||||||
|
text.Draw(screen, "+ NEW WALL", basicfont.Face7x13, 35, CanvasHeight-18, color.RGBA{255, 255, 255, 255})
|
||||||
|
|
||||||
// SCROLL BEREICH
|
// SCROLL BEREICH
|
||||||
startY := 40.0 - e.listScroll
|
startY := 40.0 - e.listScroll
|
||||||
@@ -511,7 +599,7 @@ func (e *Editor) Draw(screen *ebiten.Image) {
|
|||||||
|
|
||||||
// Helper Funktion zum Zeichnen von Listeneinträgen mit Bild
|
// Helper Funktion zum Zeichnen von Listeneinträgen mit Bild
|
||||||
drawListItem := func(label string, id string, col color.Color, img *ebiten.Image) {
|
drawListItem := func(label string, id string, col color.Color, img *ebiten.Image) {
|
||||||
if currentY > -float64(LineHeight) && currentY < CanvasHeight-50 {
|
if currentY > -float64(LineHeight) && currentY < CanvasHeight-80 {
|
||||||
// Bild Vorschau (Thumbnail)
|
// Bild Vorschau (Thumbnail)
|
||||||
if img != nil {
|
if img != nil {
|
||||||
// Skalierung berechnen (max 28px hoch/breit)
|
// Skalierung berechnen (max 28px hoch/breit)
|
||||||
|
|||||||
@@ -90,6 +90,24 @@
|
|||||||
"Type": ""
|
"Type": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"godmode": {
|
||||||
|
"ID": "godmode",
|
||||||
|
"Type": "powerup",
|
||||||
|
"Filename": "godmode.png",
|
||||||
|
"Scale": 0.08,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": 3,
|
||||||
|
"DrawOffY": -90,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": -1,
|
||||||
|
"OffsetY": 3,
|
||||||
|
"W": 59,
|
||||||
|
"H": 85,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"h-l": {
|
"h-l": {
|
||||||
"ID": "h-l",
|
"ID": "h-l",
|
||||||
"Type": "obstacle",
|
"Type": "obstacle",
|
||||||
@@ -108,6 +126,78 @@
|
|||||||
"Type": ""
|
"Type": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jump0": {
|
||||||
|
"ID": "jump0",
|
||||||
|
"Type": "obstacle",
|
||||||
|
"Filename": "jump0.png",
|
||||||
|
"Scale": 0.17,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": -8,
|
||||||
|
"DrawOffY": -193,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 22,
|
||||||
|
"OffsetY": 6,
|
||||||
|
"W": 72,
|
||||||
|
"H": 183,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jump1": {
|
||||||
|
"ID": "jump1",
|
||||||
|
"Type": "obstacle",
|
||||||
|
"Filename": "jump1.png",
|
||||||
|
"Scale": 0.16,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": -1,
|
||||||
|
"DrawOffY": -167,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 18,
|
||||||
|
"OffsetY": 11,
|
||||||
|
"W": 72,
|
||||||
|
"H": 149,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jumpboost": {
|
||||||
|
"ID": "jumpboost",
|
||||||
|
"Type": "powerup",
|
||||||
|
"Filename": "jumpboost.png",
|
||||||
|
"Scale": 0.09,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": 1,
|
||||||
|
"DrawOffY": -81,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 3,
|
||||||
|
"OffsetY": 2,
|
||||||
|
"W": 97,
|
||||||
|
"H": 76,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"k-l": {
|
||||||
|
"ID": "k-l",
|
||||||
|
"Type": "obstacle",
|
||||||
|
"Filename": "k-l.png",
|
||||||
|
"Scale": 0.12,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": -43,
|
||||||
|
"DrawOffY": -228,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 43,
|
||||||
|
"OffsetY": 5,
|
||||||
|
"W": 78,
|
||||||
|
"H": 222,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"k-l-monitor": {
|
"k-l-monitor": {
|
||||||
"ID": "k-l-monitor",
|
"ID": "k-l-monitor",
|
||||||
"Type": "obstacle",
|
"Type": "obstacle",
|
||||||
@@ -126,6 +216,24 @@
|
|||||||
"Type": ""
|
"Type": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"k-m": {
|
||||||
|
"ID": "k-m",
|
||||||
|
"Type": "obstacle",
|
||||||
|
"Filename": "k-m.png",
|
||||||
|
"Scale": 1,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": -528,
|
||||||
|
"DrawOffY": -2280,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": -528,
|
||||||
|
"OffsetY": -2280,
|
||||||
|
"W": 1056,
|
||||||
|
"H": 2280,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"pc-trash": {
|
"pc-trash": {
|
||||||
"ID": "pc-trash",
|
"ID": "pc-trash",
|
||||||
"Type": "obstacle",
|
"Type": "obstacle",
|
||||||
@@ -165,18 +273,90 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"ID": "player",
|
"ID": "player",
|
||||||
"Type": "obstacle",
|
"Type": "obstacle",
|
||||||
"Filename": "player.png",
|
"Filename": "playernew.png",
|
||||||
"Scale": 7,
|
"Scale": 0.08,
|
||||||
"ProcWidth": 0,
|
"ProcWidth": 0,
|
||||||
"ProcHeight": 0,
|
"ProcHeight": 0,
|
||||||
"DrawOffX": -53,
|
"DrawOffX": -56,
|
||||||
"DrawOffY": -216,
|
"DrawOffY": -231,
|
||||||
"Color": {},
|
"Color": {},
|
||||||
"Hitbox": {
|
"Hitbox": {
|
||||||
"OffsetX": 53,
|
"OffsetX": 68,
|
||||||
|
"OffsetY": 42,
|
||||||
|
"W": 73,
|
||||||
|
"H": 184,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"ID": "background",
|
||||||
|
"Type": "background",
|
||||||
|
"Filename": "background.jpg",
|
||||||
|
"Scale": 1,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": 0,
|
||||||
|
"DrawOffY": 0,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 0,
|
||||||
|
"OffsetY": 0,
|
||||||
|
"W": 0,
|
||||||
|
"H": 0,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background1": {
|
||||||
|
"ID": "background1",
|
||||||
|
"Type": "background",
|
||||||
|
"Filename": "background1.jpg",
|
||||||
|
"Scale": 1,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": 0,
|
||||||
|
"DrawOffY": 0,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 0,
|
||||||
|
"OffsetY": 0,
|
||||||
|
"W": 0,
|
||||||
|
"H": 0,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background2": {
|
||||||
|
"ID": "background2",
|
||||||
|
"Type": "background",
|
||||||
|
"Filename": "background2.jpg",
|
||||||
|
"Scale": 1,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": 0,
|
||||||
|
"DrawOffY": 0,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 0,
|
||||||
|
"OffsetY": 0,
|
||||||
|
"W": 0,
|
||||||
|
"H": 0,
|
||||||
|
"Type": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wall_1767369789": {
|
||||||
|
"ID": "wall_1767369789",
|
||||||
|
"Type": "obstacle",
|
||||||
|
"Filename": "gen_wall_1767369789.png",
|
||||||
|
"Scale": 1,
|
||||||
|
"ProcWidth": 0,
|
||||||
|
"ProcHeight": 0,
|
||||||
|
"DrawOffX": 1,
|
||||||
|
"DrawOffY": -127,
|
||||||
|
"Color": {},
|
||||||
|
"Hitbox": {
|
||||||
|
"OffsetX": 4,
|
||||||
"OffsetY": 12,
|
"OffsetY": 12,
|
||||||
"W": 108,
|
"W": 55,
|
||||||
"H": 203,
|
"H": 113,
|
||||||
"Type": ""
|
"Type": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
@@ -31,6 +31,11 @@
|
|||||||
"AssetID": "pc-trash",
|
"AssetID": "pc-trash",
|
||||||
"X": 1960,
|
"X": 1960,
|
||||||
"Y": 533
|
"Y": 533
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 1024,
|
||||||
|
"Y": 412
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
61
cmd/client/assets/chunks/chunk_02.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"ID": "chunk_02",
|
||||||
|
"Width": 50,
|
||||||
|
"Objects": [
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 512,
|
||||||
|
"Y": 476
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 704,
|
||||||
|
"Y": 476
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 1024,
|
||||||
|
"Y": 476
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1152,
|
||||||
|
"Y": 484
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1344,
|
||||||
|
"Y": 420
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1472,
|
||||||
|
"Y": 420
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1600,
|
||||||
|
"Y": 420
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1792,
|
||||||
|
"Y": 292
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1920,
|
||||||
|
"Y": 292
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 2048,
|
||||||
|
"Y": 292
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 2112,
|
||||||
|
"Y": 220
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
65
cmd/client/assets/chunks/chunk_03.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"ID": "chunk_03",
|
||||||
|
"Width": 50,
|
||||||
|
"Objects": [
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 514,
|
||||||
|
"Y": 519,
|
||||||
|
"moving_platform": {
|
||||||
|
"start_x": 514,
|
||||||
|
"start_y": 522,
|
||||||
|
"end_x": 800,
|
||||||
|
"end_y": 239,
|
||||||
|
"speed": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 834,
|
||||||
|
"Y": 204
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "wall_1767369789",
|
||||||
|
"X": 1026,
|
||||||
|
"Y": 539
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "wall_1767369789",
|
||||||
|
"X": 1026,
|
||||||
|
"Y": 412
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "platform_1767135546",
|
||||||
|
"X": 1091,
|
||||||
|
"Y": 318,
|
||||||
|
"moving_platform": {
|
||||||
|
"start_x": 1109,
|
||||||
|
"start_y": 304,
|
||||||
|
"end_x": 1898,
|
||||||
|
"end_y": 307,
|
||||||
|
"speed": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "desk",
|
||||||
|
"X": 1421,
|
||||||
|
"Y": 534
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "desk",
|
||||||
|
"X": 1794,
|
||||||
|
"Y": 535
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 1169,
|
||||||
|
"Y": 272
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "coin",
|
||||||
|
"X": 1598,
|
||||||
|
"Y": 260
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
cmd/client/assets/chunks/chunk_04.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"ID": "chunk_04",
|
||||||
|
"Width": 50,
|
||||||
|
"Objects": [
|
||||||
|
{
|
||||||
|
"AssetID": "godmode",
|
||||||
|
"X": 569,
|
||||||
|
"Y": 535
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AssetID": "jumpboost",
|
||||||
|
"X": 680,
|
||||||
|
"Y": 538
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
cmd/client/assets/fonts/press-start-2p-v16-latin-regular.woff2
Normal file
BIN
cmd/client/assets/front.ttf
Normal file
BIN
cmd/client/assets/game.mp3
Normal file
BIN
cmd/client/assets/game.wav
Normal file
BIN
cmd/client/assets/gen_plat_1767369130.png
Normal file
|
After Width: | Height: | Size: 222 B |
BIN
cmd/client/assets/gen_wall_1767369789.png
Normal file
|
After Width: | Height: | Size: 307 B |
BIN
cmd/client/assets/godmode.png
Normal file
|
After Width: | Height: | Size: 653 KiB |
|
Before Width: | Height: | Size: 184 KiB |
BIN
cmd/client/assets/jump.wav
Normal file
BIN
cmd/client/assets/jump0.png
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
cmd/client/assets/jump1.png
Normal file
|
After Width: | Height: | Size: 545 KiB |
BIN
cmd/client/assets/jumpboost.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1.6 MiB |
BIN
cmd/client/assets/pickupCoin.wav
Normal file
BIN
cmd/client/assets/playernew.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
cmd/client/assets/powerUp.wav
Normal file
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 1.3 MiB |
55
cmd/client/assets_native.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
// +build !wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (Native):", 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)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunks laden (plattformspezifisch via Build-Tags)
|
||||||
|
g.loadChunks()
|
||||||
|
|
||||||
|
// Bilder vorladen
|
||||||
|
loadedImages := 0
|
||||||
|
failedImages := 0
|
||||||
|
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
|
||||||
|
loadedImages++
|
||||||
|
} else {
|
||||||
|
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
|
||||||
|
failedImages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("🖼️ Bilder (Native): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
|
||||||
|
}
|
||||||
53
cmd/client/assets_wasm.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//go:build wasm
|
||||||
|
// +build wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/assets.json
|
||||||
|
var assetsJSON []byte
|
||||||
|
|
||||||
|
func (g *Game) loadAssets() {
|
||||||
|
// Assets Manifest aus eingebetteter Datei laden
|
||||||
|
var m game.AssetManifest
|
||||||
|
if err := json.Unmarshal(assetsJSON, &m); err != nil {
|
||||||
|
log.Printf("⚠️ Fehler beim Parsen von assets.json: %v", err)
|
||||||
|
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
|
||||||
|
} else {
|
||||||
|
g.world.Manifest = m
|
||||||
|
fmt.Println("✅ Assets Manifest geladen (WASM):", len(m.Assets), "Einträge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunks laden (auch per go:embed in chunks_wasm.go)
|
||||||
|
g.loadChunks()
|
||||||
|
|
||||||
|
// Bilder vorladen - In WASM sind die Assets relativ zum HTML
|
||||||
|
baseDir := "./assets"
|
||||||
|
loadedImages := 0
|
||||||
|
failedImages := 0
|
||||||
|
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
|
||||||
|
loadedImages++
|
||||||
|
} else {
|
||||||
|
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
|
||||||
|
failedImages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("🖼️ Bilder (WASM): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
|
||||||
|
}
|
||||||
237
cmd/client/audio.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/audio"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/audio/wav"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SampleRate = 44100 // Muss mit der MP3 Sample-Rate übereinstimmen
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/game.mp3
|
||||||
|
var gameMusicData []byte
|
||||||
|
|
||||||
|
//go:embed assets/jump.wav
|
||||||
|
var jumpSoundData []byte
|
||||||
|
|
||||||
|
//go:embed assets/pickupCoin.wav
|
||||||
|
var coinSoundData []byte
|
||||||
|
|
||||||
|
//go:embed assets/powerUp.wav
|
||||||
|
var powerUpSoundData []byte
|
||||||
|
|
||||||
|
// AudioSystem verwaltet Musik und Sound-Effekte
|
||||||
|
type AudioSystem struct {
|
||||||
|
audioContext *audio.Context
|
||||||
|
|
||||||
|
// Musik
|
||||||
|
musicPlayer *audio.Player
|
||||||
|
musicVolume float64
|
||||||
|
|
||||||
|
// Sound-Effekte
|
||||||
|
jumpSound []byte
|
||||||
|
coinSound []byte
|
||||||
|
powerUpSound []byte
|
||||||
|
sfxVolume float64
|
||||||
|
|
||||||
|
// Mute
|
||||||
|
muted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAudioSystem erstellt ein neues Audio-System
|
||||||
|
func NewAudioSystem() *AudioSystem {
|
||||||
|
log.Println("🎵 Initialisiere Audio-System...")
|
||||||
|
ctx := audio.NewContext(SampleRate)
|
||||||
|
|
||||||
|
as := &AudioSystem{
|
||||||
|
audioContext: ctx,
|
||||||
|
musicVolume: 0.3, // 30% Standard-Lautstärke
|
||||||
|
sfxVolume: 0.5, // 50% Standard-Lautstärke
|
||||||
|
muted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Musik laden
|
||||||
|
log.Printf("📀 Lade Musik (%.2f MB)...", float64(len(gameMusicData))/(1024*1024))
|
||||||
|
as.loadMusic()
|
||||||
|
|
||||||
|
// Sound-Effekte dekodieren
|
||||||
|
log.Println("🔊 Lade Sound-Effekte...")
|
||||||
|
as.jumpSound = as.loadWav(jumpSoundData)
|
||||||
|
as.coinSound = as.loadWav(coinSoundData)
|
||||||
|
as.powerUpSound = as.loadWav(powerUpSoundData)
|
||||||
|
|
||||||
|
log.Println("✅ Audio-System bereit")
|
||||||
|
return as
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMusic lädt und startet die Hintergrundmusik
|
||||||
|
func (as *AudioSystem) loadMusic() {
|
||||||
|
// MP3 dekodieren
|
||||||
|
stream, err := mp3.DecodeWithSampleRate(SampleRate, bytes.NewReader(gameMusicData))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Laden der Musik: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infinite Loop
|
||||||
|
loop := audio.NewInfiniteLoop(stream, stream.Length())
|
||||||
|
|
||||||
|
// Player erstellen
|
||||||
|
player, err := as.audioContext.NewPlayer(loop)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Erstellen des Music Players: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
as.musicPlayer = player
|
||||||
|
as.updateMusicVolume()
|
||||||
|
|
||||||
|
log.Println("🎵 Musik geladen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWav lädt eine WAV-Datei und gibt die dekodierten Bytes zurück
|
||||||
|
func (as *AudioSystem) loadWav(data []byte) []byte {
|
||||||
|
stream, err := wav.DecodeWithSampleRate(SampleRate, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Laden von WAV: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream in Bytes lesen
|
||||||
|
decoded := bytes.NewBuffer(nil)
|
||||||
|
_, err = decoded.ReadFrom(stream)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Dekodieren von WAV: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayMusic startet die Hintergrundmusik
|
||||||
|
func (as *AudioSystem) PlayMusic() {
|
||||||
|
if as.musicPlayer == nil {
|
||||||
|
log.Println("⚠️ Music Player ist nil - Musik kann nicht gestartet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if as.musicPlayer.IsPlaying() {
|
||||||
|
log.Println("⚠️ Musik läuft bereits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
as.musicPlayer.Play()
|
||||||
|
log.Printf("▶️ Musik gestartet (Volume: %.2f, Muted: %v)", as.musicVolume, as.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopMusic stoppt die Hintergrundmusik
|
||||||
|
func (as *AudioSystem) StopMusic() {
|
||||||
|
if as.musicPlayer != nil && as.musicPlayer.IsPlaying() {
|
||||||
|
as.musicPlayer.Pause()
|
||||||
|
log.Println("⏸️ Musik gestoppt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayJump spielt den Jump-Sound ab
|
||||||
|
func (as *AudioSystem) PlayJump() {
|
||||||
|
if as.muted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
as.playSoundEffect(as.jumpSound, as.sfxVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayCoin spielt den Coin-Pickup-Sound ab
|
||||||
|
func (as *AudioSystem) PlayCoin() {
|
||||||
|
if as.muted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
as.playSoundEffect(as.coinSound, as.sfxVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayPowerUp spielt den PowerUp-Sound ab
|
||||||
|
func (as *AudioSystem) PlayPowerUp() {
|
||||||
|
if as.muted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
as.playSoundEffect(as.powerUpSound, as.sfxVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
// playSoundEffect spielt einen Sound-Effekt ab
|
||||||
|
func (as *AudioSystem) playSoundEffect(soundData []byte, volume float64) {
|
||||||
|
if soundData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player := as.audioContext.NewPlayerFromBytes(soundData)
|
||||||
|
if player == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.SetVolume(volume)
|
||||||
|
player.Play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMusicVolume setzt die Musik-Lautstärke (0.0 - 1.0)
|
||||||
|
func (as *AudioSystem) SetMusicVolume(volume float64) {
|
||||||
|
as.musicVolume = clamp(volume, 0.0, 1.0)
|
||||||
|
as.updateMusicVolume()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSFXVolume setzt die Sound-Effekt-Lautstärke (0.0 - 1.0)
|
||||||
|
func (as *AudioSystem) SetSFXVolume(volume float64) {
|
||||||
|
as.sfxVolume = clamp(volume, 0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleMute schaltet Mute an/aus
|
||||||
|
func (as *AudioSystem) ToggleMute() {
|
||||||
|
as.muted = !as.muted
|
||||||
|
as.updateMusicVolume()
|
||||||
|
log.Printf("🔇 Mute: %v", as.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMuted setzt Mute-Status
|
||||||
|
func (as *AudioSystem) SetMuted(muted bool) {
|
||||||
|
as.muted = muted
|
||||||
|
as.updateMusicVolume()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMuted gibt zurück, ob der Sound gemutet ist
|
||||||
|
func (as *AudioSystem) IsMuted() bool {
|
||||||
|
return as.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMusicVolume gibt die aktuelle Musik-Lautstärke zurück
|
||||||
|
func (as *AudioSystem) GetMusicVolume() float64 {
|
||||||
|
return as.musicVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSFXVolume gibt die aktuelle SFX-Lautstärke zurück
|
||||||
|
func (as *AudioSystem) GetSFXVolume() float64 {
|
||||||
|
return as.sfxVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMusicVolume aktualisiert die Musik-Lautstärke
|
||||||
|
func (as *AudioSystem) updateMusicVolume() {
|
||||||
|
if as.musicPlayer != nil {
|
||||||
|
if as.muted {
|
||||||
|
as.musicPlayer.SetVolume(0)
|
||||||
|
} else {
|
||||||
|
as.musicPlayer.SetVolume(as.musicVolume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp begrenzt einen Wert zwischen min und max
|
||||||
|
func clamp(value, min, max float64) float64 {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
25
cmd/client/chunks_native.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
// +build !wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadChunks lädt alle Chunks aus dem Verzeichnis (Native Desktop)
|
||||||
|
func (g *Game) loadChunks() {
|
||||||
|
baseDir := "cmd/client/assets"
|
||||||
|
chunkDir := filepath.Join(baseDir, "chunks")
|
||||||
|
|
||||||
|
err := g.world.LoadChunkLibrary(chunkDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ Chunks geladen: %d Einträge", len(g.world.ChunkLibrary))
|
||||||
|
for id, chunk := range g.world.ChunkLibrary {
|
||||||
|
log.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d", id, chunk.Width, len(chunk.Objects))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
cmd/client/chunks_wasm.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build wasm
|
||||||
|
// +build wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/chunks/start.json
|
||||||
|
var chunkStartData []byte
|
||||||
|
|
||||||
|
//go:embed assets/chunks/chunk_01.json
|
||||||
|
var chunk01Data []byte
|
||||||
|
|
||||||
|
//go:embed assets/chunks/chunk_02.json
|
||||||
|
var chunk02Data []byte
|
||||||
|
|
||||||
|
//go:embed assets/chunks/chunk_03.json
|
||||||
|
var chunk03Data []byte
|
||||||
|
|
||||||
|
//go:embed assets/chunks/chunk_04.json
|
||||||
|
var chunk04Data []byte
|
||||||
|
|
||||||
|
// loadChunks lädt alle Chunks aus eingebetteten Daten (WebAssembly)
|
||||||
|
func (g *Game) loadChunks() {
|
||||||
|
chunkDataMap := map[string][]byte{
|
||||||
|
"start": chunkStartData,
|
||||||
|
"chunk_01": chunk01Data,
|
||||||
|
"chunk_02": chunk02Data,
|
||||||
|
"chunk_03": chunk03Data,
|
||||||
|
"chunk_04": chunk04Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for id, data := range chunkDataMap {
|
||||||
|
var c game.Chunk
|
||||||
|
if err := json.Unmarshal(data, &c); err == nil {
|
||||||
|
if c.ID == "" {
|
||||||
|
c.ID = id
|
||||||
|
}
|
||||||
|
g.world.ChunkLibrary[c.ID] = c
|
||||||
|
count++
|
||||||
|
log.Printf("📦 Chunk geladen: %s (Width=%d, Objects=%d)", c.ID, c.Width, len(c.Objects))
|
||||||
|
} else {
|
||||||
|
log.Printf("⚠️ Fehler beim Laden von Chunk %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ %d Chunks für WASM geladen", count)
|
||||||
|
}
|
||||||
268
cmd/client/connection_native.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
// +build !wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsConn verwaltet die WebSocket-Verbindung (Native Desktop)
|
||||||
|
type wsConn struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
stopChan chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketMessage ist das Format für WebSocket-Nachrichten
|
||||||
|
type WebSocketMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload interface{} `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectToServer verbindet sich über WebSocket mit dem Gateway (Native Desktop)
|
||||||
|
func (g *Game) connectToServer() {
|
||||||
|
serverURL := "ws://localhost:8080/ws"
|
||||||
|
log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL)
|
||||||
|
|
||||||
|
u, err := url.Parse(serverURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ URL Parse Fehler: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ WebSocket Connect Fehler: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsConn := &wsConn{
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 100),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
g.wsConn = wsConn
|
||||||
|
|
||||||
|
log.Println("✅ WebSocket verbunden!")
|
||||||
|
g.connected = true
|
||||||
|
|
||||||
|
// Goroutines für Lesen und Schreiben starten
|
||||||
|
go g.wsReadPump()
|
||||||
|
go g.wsWritePump()
|
||||||
|
|
||||||
|
// JOIN senden
|
||||||
|
g.sendJoinRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsReadPump liest Nachrichten vom WebSocket
|
||||||
|
func (g *Game) wsReadPump() {
|
||||||
|
defer func() {
|
||||||
|
g.wsConn.conn.Close()
|
||||||
|
g.connected = false
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var msg WebSocketMessage
|
||||||
|
err := g.wsConn.conn.ReadJSON(&msg)
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
log.Printf("⚠️ WebSocket Fehler: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "game_update":
|
||||||
|
// GameState Update
|
||||||
|
payloadBytes, _ := json.Marshal(msg.Payload)
|
||||||
|
var state game.GameState
|
||||||
|
if err := json.Unmarshal(payloadBytes, &state); err == nil {
|
||||||
|
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
||||||
|
for _, p := range state.Players {
|
||||||
|
if p.Name == g.playerName {
|
||||||
|
g.ReconcileWithServer(p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.stateMutex.Lock()
|
||||||
|
g.gameState = state
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
case "leaderboard_response":
|
||||||
|
// Leaderboard Response
|
||||||
|
payloadBytes, _ := json.Marshal(msg.Payload)
|
||||||
|
var resp game.LeaderboardResponse
|
||||||
|
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
|
||||||
|
g.leaderboardMutex.Lock()
|
||||||
|
g.leaderboard = resp.Entries
|
||||||
|
g.leaderboardMutex.Unlock()
|
||||||
|
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsWritePump sendet Nachrichten zum WebSocket
|
||||||
|
func (g *Game) wsWritePump() {
|
||||||
|
defer g.wsConn.conn.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-g.wsConn.send:
|
||||||
|
if !ok {
|
||||||
|
g.wsConn.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := g.wsConn.conn.WriteMessage(websocket.TextMessage, message)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Fehler beim Senden: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-g.wsConn.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWebSocketMessage sendet eine Nachricht über WebSocket
|
||||||
|
func (g *Game) sendWebSocketMessage(msg WebSocketMessage) {
|
||||||
|
if g.wsConn == nil || !g.connected {
|
||||||
|
log.Println("⚠️ WebSocket nicht verbunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Marshallen: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case g.wsConn.send <- data:
|
||||||
|
default:
|
||||||
|
log.Println("⚠️ Send channel voll, Nachricht verworfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendJoinRequest sendet Join-Request über WebSocket
|
||||||
|
func (g *Game) sendJoinRequest() {
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "join",
|
||||||
|
Payload: game.JoinRequest{
|
||||||
|
Name: g.playerName,
|
||||||
|
RoomID: g.roomID,
|
||||||
|
GameMode: g.gameMode,
|
||||||
|
IsHost: g.isHost,
|
||||||
|
TeamName: g.teamName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendStartRequest sendet Start-Request über WebSocket
|
||||||
|
func (g *Game) sendStartRequest() {
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "start",
|
||||||
|
Payload: game.StartRequest{
|
||||||
|
RoomID: g.roomID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishInput sendet Input über WebSocket
|
||||||
|
func (g *Game) publishInput(input game.ClientInput) {
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "input",
|
||||||
|
Payload: input,
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectForLeaderboard verbindet für Leaderboard (Native)
|
||||||
|
func (g *Game) connectForLeaderboard() {
|
||||||
|
if g.wsConn != nil && g.connected {
|
||||||
|
// Bereits verbunden
|
||||||
|
g.requestLeaderboard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Verbindung aufbauen
|
||||||
|
g.connectToServer()
|
||||||
|
|
||||||
|
// Kurz warten und dann Leaderboard anfragen
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
g.requestLeaderboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestLeaderboard fordert Leaderboard an (Native)
|
||||||
|
func (g *Game) requestLeaderboard() {
|
||||||
|
mode := "solo"
|
||||||
|
if g.gameMode == "coop" {
|
||||||
|
mode = "coop"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "leaderboard_request",
|
||||||
|
Payload: game.LeaderboardRequest{
|
||||||
|
Mode: mode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitScore sendet Score ans Leaderboard (Native)
|
||||||
|
func (g *Game) submitScore() {
|
||||||
|
if g.scoreSubmitted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.stateMutex.Lock()
|
||||||
|
score := 0
|
||||||
|
for _, p := range g.gameState.Players {
|
||||||
|
if p.Name == g.playerName {
|
||||||
|
score = p.Score
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
|
if score == 0 {
|
||||||
|
log.Println("⚠️ Score ist 0, überspringe Submission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := g.playerName
|
||||||
|
if g.gameMode == "coop" && g.teamName != "" {
|
||||||
|
name = g.teamName
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "score_submit",
|
||||||
|
Payload: game.ScoreSubmission{
|
||||||
|
PlayerCode: g.playerCode,
|
||||||
|
Name: name,
|
||||||
|
Score: score,
|
||||||
|
Mode: g.gameMode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
|
||||||
|
g.scoreSubmitted = true
|
||||||
|
log.Printf("📊 Score submitted: %s = %d", name, score)
|
||||||
|
}
|
||||||
270
cmd/client/connection_wasm.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
//go:build wasm
|
||||||
|
// +build wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"syscall/js"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebSocketMessage ist das Format für WebSocket-Nachrichten
|
||||||
|
type WebSocketMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload interface{} `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsConn verwaltet die WebSocket-Verbindung im Browser
|
||||||
|
type wsConn struct {
|
||||||
|
ws js.Value
|
||||||
|
messagesChan chan []byte
|
||||||
|
connected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectToServer verbindet sich über WebSocket mit dem Gateway
|
||||||
|
func (g *Game) connectToServer() {
|
||||||
|
serverURL := "ws://localhost:8080/ws"
|
||||||
|
log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL)
|
||||||
|
|
||||||
|
ws := js.Global().Get("WebSocket").New(serverURL)
|
||||||
|
|
||||||
|
conn := &wsConn{
|
||||||
|
ws: ws,
|
||||||
|
messagesChan: make(chan []byte, 100),
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnOpen Handler
|
||||||
|
ws.Call("addEventListener", "open", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
log.Println("✅ WebSocket verbunden!")
|
||||||
|
conn.connected = true
|
||||||
|
g.connected = true
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// OnMessage Handler
|
||||||
|
ws.Call("addEventListener", "message", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := args[0].Get("data").String()
|
||||||
|
|
||||||
|
var msg WebSocketMessage
|
||||||
|
if err := json.Unmarshal([]byte(data), &msg); err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Parsen der Nachricht: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "game_update":
|
||||||
|
// GameState Update
|
||||||
|
payloadBytes, _ := json.Marshal(msg.Payload)
|
||||||
|
var state game.GameState
|
||||||
|
if err := json.Unmarshal(payloadBytes, &state); err == nil {
|
||||||
|
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
||||||
|
for _, p := range state.Players {
|
||||||
|
if p.Name == g.playerName {
|
||||||
|
g.ReconcileWithServer(p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.stateMutex.Lock()
|
||||||
|
g.gameState = state
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
case "leaderboard_response":
|
||||||
|
// Leaderboard Response
|
||||||
|
payloadBytes, _ := json.Marshal(msg.Payload)
|
||||||
|
var resp game.LeaderboardResponse
|
||||||
|
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
|
||||||
|
g.leaderboardMutex.Lock()
|
||||||
|
g.leaderboard = resp.Entries
|
||||||
|
g.leaderboardMutex.Unlock()
|
||||||
|
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries))
|
||||||
|
|
||||||
|
// An JavaScript senden
|
||||||
|
g.sendLeaderboardToJS()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// OnError Handler
|
||||||
|
ws.Call("addEventListener", "error", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
log.Println("❌ WebSocket Fehler!")
|
||||||
|
conn.connected = false
|
||||||
|
g.connected = false
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// OnClose Handler
|
||||||
|
ws.Call("addEventListener", "close", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
log.Println("🔌 WebSocket geschlossen")
|
||||||
|
conn.connected = false
|
||||||
|
g.connected = false
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Warte bis Verbindung hergestellt ist
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if conn.connected {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !conn.connected {
|
||||||
|
log.Println("❌ WebSocket-Verbindung Timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket-Wrapper speichern
|
||||||
|
g.wsConn = conn
|
||||||
|
|
||||||
|
// JOIN senden
|
||||||
|
joinMsg := WebSocketMessage{
|
||||||
|
Type: "join",
|
||||||
|
Payload: game.JoinRequest{
|
||||||
|
Name: g.playerName,
|
||||||
|
RoomID: g.roomID,
|
||||||
|
GameMode: g.gameMode,
|
||||||
|
IsHost: g.isHost,
|
||||||
|
TeamName: g.teamName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(joinMsg)
|
||||||
|
|
||||||
|
log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWebSocketMessage sendet eine Nachricht über WebSocket
|
||||||
|
func (g *Game) sendWebSocketMessage(msg WebSocketMessage) {
|
||||||
|
if g.wsConn == nil || !g.wsConn.connected {
|
||||||
|
log.Println("⚠️ WebSocket nicht verbunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Marshallen: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.wsConn.ws.Call("send", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendInput sendet einen Input über WebSocket
|
||||||
|
func (g *Game) sendInput(input game.ClientInput) {
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "input",
|
||||||
|
Payload: input,
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startGame sendet den Start-Befehl über WebSocket
|
||||||
|
func (g *Game) startGame() {
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "start",
|
||||||
|
Payload: game.StartRequest{
|
||||||
|
RoomID: g.roomID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectForLeaderboard verbindet für Leaderboard-Abfrage
|
||||||
|
func (g *Game) connectForLeaderboard() {
|
||||||
|
if g.wsConn != nil && g.wsConn.connected {
|
||||||
|
// Bereits verbunden
|
||||||
|
g.requestLeaderboard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Verbindung aufbauen
|
||||||
|
g.connectToServer()
|
||||||
|
|
||||||
|
// Kurz warten und dann Leaderboard anfragen
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
g.requestLeaderboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestLeaderboard fordert das Leaderboard an
|
||||||
|
func (g *Game) requestLeaderboard() {
|
||||||
|
mode := "solo"
|
||||||
|
if g.gameMode == "coop" {
|
||||||
|
mode = "coop"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "leaderboard_request",
|
||||||
|
Payload: game.LeaderboardRequest{
|
||||||
|
Mode: mode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitScore sendet den Score ans Leaderboard
|
||||||
|
func (g *Game) submitScore() {
|
||||||
|
if g.scoreSubmitted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.stateMutex.Lock()
|
||||||
|
score := 0
|
||||||
|
for _, p := range g.gameState.Players {
|
||||||
|
if p.Name == g.playerName {
|
||||||
|
score = p.Score
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
|
if score == 0 {
|
||||||
|
log.Println("⚠️ Score ist 0, überspringe Submission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := g.playerName
|
||||||
|
if g.gameMode == "coop" && g.teamName != "" {
|
||||||
|
name = g.teamName
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := WebSocketMessage{
|
||||||
|
Type: "score_submit",
|
||||||
|
Payload: game.ScoreSubmission{
|
||||||
|
PlayerCode: g.playerCode,
|
||||||
|
Name: name,
|
||||||
|
Score: score,
|
||||||
|
Mode: g.gameMode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
g.sendWebSocketMessage(msg)
|
||||||
|
|
||||||
|
g.scoreSubmitted = true
|
||||||
|
log.Printf("📊 Score submitted: %s = %d", name, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dummy-Funktionen für Kompatibilität mit anderen Teilen des Codes
|
||||||
|
func (g *Game) sendJoinRequest() {
|
||||||
|
// Wird in connectToServer aufgerufen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) sendStartRequest() {
|
||||||
|
g.startGame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) publishInput(input game.ClientInput) {
|
||||||
|
g.sendInput(input)
|
||||||
|
}
|
||||||
22
cmd/client/draw_native.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
// +build !wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In Native: Nutze die normalen Draw-Funktionen
|
||||||
|
|
||||||
|
func (g *Game) drawMenu(screen *ebiten.Image) {
|
||||||
|
g.DrawMenu(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) drawLobby(screen *ebiten.Image) {
|
||||||
|
g.DrawLobby(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
|
||||||
|
g.DrawLeaderboard(screen)
|
||||||
|
}
|
||||||
28
cmd/client/draw_wasm.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
// +build js,wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In WASM: Menü und Leaderboard werden in HTML angezeigt
|
||||||
|
// Lobby wird aber normal gezeichnet (für Co-op Warteraum)
|
||||||
|
|
||||||
|
func (g *Game) drawMenu(screen *ebiten.Image) {
|
||||||
|
// Schwarzer Hintergrund - HTML-Menü ist darüber
|
||||||
|
screen.Fill(color.RGBA{0, 0, 0, 255})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) drawLobby(screen *ebiten.Image) {
|
||||||
|
// Lobby wird normal gezeichnet (für Co-op Warteraum)
|
||||||
|
g.DrawLobby(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
|
||||||
|
// Schwarzer Hintergrund - HTML-Leaderboard ist darüber
|
||||||
|
screen.Fill(color.RGBA{0, 0, 0, 255})
|
||||||
|
}
|
||||||
@@ -19,16 +19,26 @@ import (
|
|||||||
// --- INPUT & UPDATE LOGIC ---
|
// --- INPUT & UPDATE LOGIC ---
|
||||||
|
|
||||||
func (g *Game) UpdateGame() {
|
func (g *Game) UpdateGame() {
|
||||||
// --- 1. KEYBOARD INPUT ---
|
// --- 1. MUTE TOGGLE ---
|
||||||
|
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
|
||||||
|
g.audio.ToggleMute()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. KEYBOARD INPUT ---
|
||||||
keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
|
keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
|
||||||
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
|
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
|
||||||
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
|
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
|
||||||
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
|
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
|
||||||
|
|
||||||
// --- 2. TOUCH INPUT HANDLING ---
|
// Tastatur-Nutzung erkennen (für Mobile Controls ausblenden)
|
||||||
|
if keyLeft || keyRight || keyDown || keyJump {
|
||||||
|
g.keyboardUsed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. TOUCH INPUT HANDLING ---
|
||||||
g.handleTouchInput()
|
g.handleTouchInput()
|
||||||
|
|
||||||
// --- 3. INPUT STATE ERSTELLEN ---
|
// --- 4. INPUT STATE ERSTELLEN ---
|
||||||
joyDir := 0.0
|
joyDir := 0.0
|
||||||
if g.joyActive {
|
if g.joyActive {
|
||||||
diffX := g.joyStickX - g.joyBaseX
|
diffX := g.joyStickX - g.joyBaseX
|
||||||
@@ -64,6 +74,34 @@ func (g *Game) UpdateGame() {
|
|||||||
|
|
||||||
// Lokale Physik sofort anwenden (Prediction)
|
// Lokale Physik sofort anwenden (Prediction)
|
||||||
g.ApplyInput(input)
|
g.ApplyInput(input)
|
||||||
|
|
||||||
|
// Sanfte Korrektur anwenden (20% pro Frame)
|
||||||
|
const smoothingFactor = 0.2
|
||||||
|
if g.correctionX != 0 || g.correctionY != 0 {
|
||||||
|
g.predictedX += g.correctionX * smoothingFactor
|
||||||
|
g.predictedY += g.correctionY * smoothingFactor
|
||||||
|
|
||||||
|
g.correctionX *= (1.0 - smoothingFactor)
|
||||||
|
g.correctionY *= (1.0 - smoothingFactor)
|
||||||
|
|
||||||
|
// Korrektur beenden wenn sehr klein
|
||||||
|
if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 0.01 {
|
||||||
|
g.correctionX = 0
|
||||||
|
g.correctionY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landing Detection für Partikel
|
||||||
|
if !g.lastGroundState && g.predictedGround {
|
||||||
|
// Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen)
|
||||||
|
// Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H
|
||||||
|
// = Y - 231 + 42 + 184 = Y - 5
|
||||||
|
feetY := g.predictedY - 231 + 42 + 184
|
||||||
|
centerX := g.predictedX - 56 + 68 + 73/2
|
||||||
|
g.SpawnLandingParticles(centerX, feetY)
|
||||||
|
}
|
||||||
|
g.lastGroundState = g.predictedGround
|
||||||
|
|
||||||
g.predictionMutex.Unlock()
|
g.predictionMutex.Unlock()
|
||||||
|
|
||||||
// Input an Server senden
|
// Input an Server senden
|
||||||
@@ -72,10 +110,8 @@ func (g *Game) UpdateGame() {
|
|||||||
|
|
||||||
// --- 5. KAMERA LOGIK ---
|
// --- 5. KAMERA LOGIK ---
|
||||||
g.stateMutex.Lock()
|
g.stateMutex.Lock()
|
||||||
defer g.stateMutex.Unlock()
|
|
||||||
|
|
||||||
// Wir folgen strikt dem Server-Scroll.
|
|
||||||
targetCam := g.gameState.ScrollX
|
targetCam := g.gameState.ScrollX
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
// Negative Kamera verhindern
|
// Negative Kamera verhindern
|
||||||
if targetCam < 0 {
|
if targetCam < 0 {
|
||||||
@@ -84,6 +120,12 @@ func (g *Game) UpdateGame() {
|
|||||||
|
|
||||||
// Kamera hart setzen
|
// Kamera hart setzen
|
||||||
g.camX = targetCam
|
g.camX = targetCam
|
||||||
|
|
||||||
|
// --- 6. PARTIKEL UPDATEN ---
|
||||||
|
g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms
|
||||||
|
|
||||||
|
// --- 7. PARTIKEL SPAWNEN (State Changes Detection) ---
|
||||||
|
g.DetectAndSpawnParticles()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verarbeitet Touch-Eingaben für Joystick und Buttons
|
// Verarbeitet Touch-Eingaben für Joystick und Buttons
|
||||||
@@ -178,6 +220,13 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
g.stateMutex.Unlock()
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
|
// In WASM: HTML Game Over Screen anzeigen
|
||||||
|
if !g.scoreSubmitted {
|
||||||
|
g.scoreSubmitted = true
|
||||||
|
g.submitScore()
|
||||||
|
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
|
||||||
|
}
|
||||||
|
|
||||||
g.DrawGameOverLeaderboard(screen, myScore)
|
g.DrawGameOverLeaderboard(screen, myScore)
|
||||||
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
|
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
|
||||||
}
|
}
|
||||||
@@ -197,12 +246,41 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
g.stateMutex.Unlock()
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
// 1. Hintergrund & Boden
|
// 1. Hintergrund (wechselt alle 5000 Punkte)
|
||||||
screen.Fill(ColSky)
|
backgroundID := "background"
|
||||||
|
if myScore >= 10000 {
|
||||||
|
backgroundID = "background2"
|
||||||
|
} else if myScore >= 5000 {
|
||||||
|
backgroundID = "background1"
|
||||||
|
}
|
||||||
|
|
||||||
floorH := float32(ScreenHeight - RefFloorY)
|
// Hintergrundbild zeichnen (skaliert auf Bildschirmgröße)
|
||||||
vector.DrawFilledRect(screen, 0, float32(RefFloorY), float32(ScreenWidth), floorH, ColGrass, false)
|
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
|
||||||
vector.DrawFilledRect(screen, 0, float32(RefFloorY)+20, float32(ScreenWidth), floorH-20, ColDirt, false)
|
op := &ebiten.DrawImageOptions{}
|
||||||
|
|
||||||
|
// Skalierung berechnen, um Bildschirm zu füllen
|
||||||
|
bgW, bgH := bgImg.Size()
|
||||||
|
scaleX := float64(ScreenWidth) / float64(bgW)
|
||||||
|
scaleY := float64(ScreenHeight) / float64(bgH)
|
||||||
|
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
|
||||||
|
|
||||||
|
op.GeoM.Scale(scale, scale)
|
||||||
|
|
||||||
|
// Zentrieren
|
||||||
|
scaledW := float64(bgW) * scale
|
||||||
|
scaledH := float64(bgH) * scale
|
||||||
|
offsetX := (float64(ScreenWidth) - scaledW) / 2
|
||||||
|
offsetY := (float64(ScreenHeight) - scaledH) / 2
|
||||||
|
op.GeoM.Translate(offsetX, offsetY)
|
||||||
|
|
||||||
|
screen.DrawImage(bgImg, op)
|
||||||
|
} else {
|
||||||
|
// Fallback: Einfarbiger Himmel
|
||||||
|
screen.Fill(ColSky)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera)
|
||||||
|
g.RenderGround(screen, g.camX)
|
||||||
|
|
||||||
// State Locken für Datenzugriff
|
// State Locken für Datenzugriff
|
||||||
g.stateMutex.Lock()
|
g.stateMutex.Lock()
|
||||||
@@ -218,12 +296,38 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
|
|
||||||
// Start-Chunk hat absichtlich keine Objekte
|
// Start-Chunk hat absichtlich keine Objekte
|
||||||
|
|
||||||
for _, obj := range chunkDef.Objects {
|
for objIdx, obj := range chunkDef.Objects {
|
||||||
|
// Skip Moving Platforms - die werden separat gerendert
|
||||||
|
if obj.MovingPlatform != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Coin/Powerup bereits eingesammelt wurde
|
||||||
|
assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID]
|
||||||
|
if hasAsset {
|
||||||
|
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
|
||||||
|
|
||||||
|
if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] {
|
||||||
|
// Coin wurde eingesammelt, nicht zeichnen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] {
|
||||||
|
// Powerup wurde eingesammelt, nicht zeichnen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Asset zeichnen
|
// Asset zeichnen
|
||||||
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y)
|
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.5 Bewegende Plattformen (von Server synchronisiert)
|
||||||
|
for _, mp := range g.gameState.MovingPlatforms {
|
||||||
|
g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Spieler
|
// 3. Spieler
|
||||||
// MyID ohne Lock holen (wir haben bereits den stateMutex)
|
// MyID ohne Lock holen (wir haben bereits den stateMutex)
|
||||||
myID := ""
|
myID := ""
|
||||||
@@ -237,12 +341,33 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
for id, p := range g.gameState.Players {
|
for id, p := range g.gameState.Players {
|
||||||
// Für lokalen Spieler: Verwende vorhergesagte Position
|
// Für lokalen Spieler: Verwende vorhergesagte Position
|
||||||
posX, posY := p.X, p.Y
|
posX, posY := p.X, p.Y
|
||||||
|
vy := p.VY
|
||||||
|
onGround := p.OnGround
|
||||||
if id == myID && g.connected {
|
if id == myID && g.connected {
|
||||||
posX = g.predictedX
|
posX = g.predictedX
|
||||||
posY = g.predictedY
|
posY = g.predictedY
|
||||||
|
vy = g.predictedVY
|
||||||
|
onGround = g.predictedGround
|
||||||
}
|
}
|
||||||
|
|
||||||
g.DrawAsset(screen, "player", posX, posY)
|
// Wähle Sprite basierend auf Sprung-Status
|
||||||
|
sprite := "player" // Default: am Boden
|
||||||
|
|
||||||
|
// Nur Jump-Animation wenn wirklich in der Luft
|
||||||
|
// (nicht auf Boden, nicht auf Platform mit VY ~= 0)
|
||||||
|
isInAir := !onGround && (vy < -1.0 || vy > 1.0)
|
||||||
|
|
||||||
|
if isInAir {
|
||||||
|
if vy < -2.0 {
|
||||||
|
// Springt nach oben
|
||||||
|
sprite = "jump0"
|
||||||
|
} else {
|
||||||
|
// Fällt oder höchster Punkt
|
||||||
|
sprite = "jump1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.DrawAsset(screen, sprite, posX, posY)
|
||||||
|
|
||||||
// Name Tag
|
// Name Tag
|
||||||
name := p.Name
|
name := p.Name
|
||||||
@@ -284,28 +409,32 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
|||||||
vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
|
vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
|
||||||
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
|
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
|
||||||
|
|
||||||
// 6. TOUCH CONTROLS OVERLAY
|
// 6. PARTIKEL RENDERN (vor UI)
|
||||||
|
g.RenderParticles(screen)
|
||||||
|
|
||||||
// A) Joystick Base
|
// 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
|
||||||
baseCol := color.RGBA{255, 255, 255, 50}
|
if !g.keyboardUsed {
|
||||||
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, true)
|
// A) Joystick Base (dunkelgrau und durchsichtig)
|
||||||
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{255, 255, 255, 100}, true)
|
baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig
|
||||||
|
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false)
|
||||||
|
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false)
|
||||||
|
|
||||||
// B) Joystick Knob
|
// B) Joystick Knob (dunkelgrau, außer wenn aktiv)
|
||||||
knobCol := color.RGBA{255, 255, 255, 150}
|
knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig
|
||||||
if g.joyActive {
|
if g.joyActive {
|
||||||
knobCol = color.RGBA{100, 255, 100, 200}
|
knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig
|
||||||
|
}
|
||||||
|
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
|
||||||
|
|
||||||
|
// C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung)
|
||||||
|
jumpX := float32(ScreenWidth - 150)
|
||||||
|
jumpY := float32(ScreenHeight - 150)
|
||||||
|
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false)
|
||||||
|
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false)
|
||||||
|
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150})
|
||||||
}
|
}
|
||||||
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, true)
|
|
||||||
|
|
||||||
// C) Jump Button (Rechts)
|
// 8. DEBUG INFO (Oben Links)
|
||||||
jumpX := float32(ScreenWidth - 150)
|
|
||||||
jumpY := float32(ScreenHeight - 150)
|
|
||||||
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, true)
|
|
||||||
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, true)
|
|
||||||
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.White)
|
|
||||||
|
|
||||||
// 7. DEBUG INFO (Oben Links)
|
|
||||||
myPosStr := "N/A"
|
myPosStr := "N/A"
|
||||||
for _, p := range g.gameState.Players {
|
for _, p := range g.gameState.Players {
|
||||||
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)
|
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)
|
||||||
|
|||||||
9
cmd/client/gameover_native.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
// +build !wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// sendGameOverToJS ist ein Stub für Native (kein HTML)
|
||||||
|
func (g *Game) sendGameOverToJS(score int) {
|
||||||
|
// Native hat kein HTML-Overlay, nichts zu tun
|
||||||
|
}
|
||||||
160
cmd/client/ground_system.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroundTile repräsentiert ein Boden-Segment
|
||||||
|
type GroundTile struct {
|
||||||
|
X float64
|
||||||
|
DirtVariants []DirtPatch
|
||||||
|
Stones []Stone
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirtPatch ist ein Dirt-Fleck im Boden
|
||||||
|
type DirtPatch struct {
|
||||||
|
OffsetX float64
|
||||||
|
OffsetY float64
|
||||||
|
Width float64
|
||||||
|
Height float64
|
||||||
|
Color color.RGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stone ist ein Stein auf dem Boden
|
||||||
|
type Stone struct {
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
Size float64
|
||||||
|
Color color.RGBA
|
||||||
|
Shape int // 0=rund, 1=eckig
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroundCache speichert generierte Tiles
|
||||||
|
var groundCache = make(map[int]GroundTile)
|
||||||
|
|
||||||
|
// ClearGroundCache leert den Cache (z.B. bei Änderungen)
|
||||||
|
func ClearGroundCache() {
|
||||||
|
groundCache = make(map[int]GroundTile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateGroundTile generiert ein prozedurales Boden-Segment (gecacht)
|
||||||
|
func GenerateGroundTile(tileIdx int) GroundTile {
|
||||||
|
// Prüfe Cache
|
||||||
|
if cached, exists := groundCache[tileIdx]; exists {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministischer Seed basierend auf Tile-Index
|
||||||
|
rng := rand.New(rand.NewSource(int64(tileIdx * 12345)))
|
||||||
|
|
||||||
|
tile := GroundTile{
|
||||||
|
X: float64(tileIdx) * 128.0,
|
||||||
|
DirtVariants: make([]DirtPatch, 0),
|
||||||
|
Stones: make([]Stone, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe)
|
||||||
|
numDirt := 15 + rng.Intn(10)
|
||||||
|
dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe
|
||||||
|
for i := 0; i < numDirt; i++ {
|
||||||
|
darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne
|
||||||
|
tile.DirtVariants = append(tile.DirtVariants, DirtPatch{
|
||||||
|
OffsetX: rng.Float64() * 128,
|
||||||
|
OffsetY: rng.Float64()*dirtHeight + 20, // Über die ganze Dirt-Schicht verteilt
|
||||||
|
Width: 10 + rng.Float64()*30,
|
||||||
|
Height: 10 + rng.Float64()*25,
|
||||||
|
Color: color.RGBA{darkness, darkness - 10, darkness - 20, 255},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Steine mehr auf dem Gras
|
||||||
|
|
||||||
|
// In Cache speichern
|
||||||
|
groundCache[tileIdx] = tile
|
||||||
|
return tile
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderGround rendert den Boden mit Bewegung
|
||||||
|
func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
|
||||||
|
floorY := float32(RefFloorY)
|
||||||
|
floorH := float32(ScreenHeight - RefFloorY)
|
||||||
|
|
||||||
|
// 1. Basis Gras-Schicht
|
||||||
|
vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false)
|
||||||
|
|
||||||
|
// 2. Dirt-Schicht (Basis)
|
||||||
|
vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false)
|
||||||
|
|
||||||
|
// 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera)
|
||||||
|
// Berechne welche Tiles sichtbar sind
|
||||||
|
tileWidth := 128.0
|
||||||
|
startTile := int(math.Floor(cameraX / tileWidth))
|
||||||
|
endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth))
|
||||||
|
|
||||||
|
// Tiles rendern
|
||||||
|
for tileIdx := startTile; tileIdx <= endTile; tileIdx++ {
|
||||||
|
tile := GenerateGroundTile(tileIdx)
|
||||||
|
|
||||||
|
// Dirt-Patches rendern
|
||||||
|
for _, dirt := range tile.DirtVariants {
|
||||||
|
worldX := tile.X + dirt.OffsetX
|
||||||
|
screenX := float32(worldX - cameraX)
|
||||||
|
screenY := float32(RefFloorY) + float32(dirt.OffsetY)
|
||||||
|
|
||||||
|
// Nur rendern wenn im sichtbaren Bereich
|
||||||
|
if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) {
|
||||||
|
vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steine rendern (auf dem Gras)
|
||||||
|
for _, stone := range tile.Stones {
|
||||||
|
worldX := tile.X + stone.X
|
||||||
|
screenX := float32(worldX - cameraX)
|
||||||
|
screenY := float32(RefFloorY) + float32(stone.Y)
|
||||||
|
|
||||||
|
// Nur rendern wenn im sichtbaren Bereich
|
||||||
|
if screenX > -20 && screenX < float32(ScreenWidth)+20 {
|
||||||
|
if stone.Shape == 0 {
|
||||||
|
// Runder Stein
|
||||||
|
vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false)
|
||||||
|
// Highlight für 3D-Effekt
|
||||||
|
highlightCol := color.RGBA{
|
||||||
|
uint8(math.Min(float64(stone.Color.R)+40, 255)),
|
||||||
|
uint8(math.Min(float64(stone.Color.G)+40, 255)),
|
||||||
|
uint8(math.Min(float64(stone.Color.B)+40, 255)),
|
||||||
|
200,
|
||||||
|
}
|
||||||
|
vector.DrawFilledCircle(screen, screenX-float32(stone.Size*0.15), screenY-float32(stone.Size*0.15), float32(stone.Size/4), highlightCol, false)
|
||||||
|
} else {
|
||||||
|
// Eckiger Stein
|
||||||
|
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false)
|
||||||
|
// Schatten für 3D-Effekt
|
||||||
|
shadowCol := color.RGBA{
|
||||||
|
uint8(float64(stone.Color.R) * 0.6),
|
||||||
|
uint8(float64(stone.Color.G) * 0.6),
|
||||||
|
uint8(float64(stone.Color.B) * 0.6),
|
||||||
|
150,
|
||||||
|
}
|
||||||
|
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2)+2, screenY-float32(stone.Size/2)+2, float32(stone.Size), float32(stone.Size), shadowCol, false)
|
||||||
|
// Original drüber
|
||||||
|
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache aufräumen (nur Tiles außerhalb des Sichtbereichs entfernen)
|
||||||
|
if len(groundCache) > 100 {
|
||||||
|
for idx := range groundCache {
|
||||||
|
if idx < startTile-10 || idx > endTile+10 {
|
||||||
|
delete(groundCache, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
_ "image/png"
|
_ "image/jpeg" // JPEG-Decoder
|
||||||
"io/ioutil"
|
_ "image/png" // PNG-Decoder
|
||||||
"log"
|
"log"
|
||||||
mrand "math/rand"
|
mrand "math/rand"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
|
||||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||||
"github.com/hajimehoshi/ebiten/v2/text"
|
"github.com/hajimehoshi/ebiten/v2/text"
|
||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
@@ -28,8 +24,8 @@ import (
|
|||||||
|
|
||||||
// --- KONFIGURATION ---
|
// --- KONFIGURATION ---
|
||||||
const (
|
const (
|
||||||
ScreenWidth = 1280
|
ScreenWidth = 1280
|
||||||
ScreenHeight = 720
|
ScreenHeight = 720
|
||||||
StateMenu = 0
|
StateMenu = 0
|
||||||
StateLobby = 1
|
StateLobby = 1
|
||||||
StateGame = 2
|
StateGame = 2
|
||||||
@@ -59,6 +55,7 @@ type InputState struct {
|
|||||||
type Game struct {
|
type Game struct {
|
||||||
appState int
|
appState int
|
||||||
conn *nats.EncodedConn
|
conn *nats.EncodedConn
|
||||||
|
wsConn *wsConn // WebSocket für WASM
|
||||||
gameState game.GameState
|
gameState game.GameState
|
||||||
stateMutex sync.Mutex
|
stateMutex sync.Mutex
|
||||||
connected bool
|
connected bool
|
||||||
@@ -95,6 +92,21 @@ type Game struct {
|
|||||||
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
|
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
|
||||||
predictionMutex sync.Mutex // Mutex für pendingInputs
|
predictionMutex sync.Mutex // Mutex für pendingInputs
|
||||||
|
|
||||||
|
// Smooth Correction
|
||||||
|
correctionX float64 // Verbleibende Korrektur in X
|
||||||
|
correctionY float64 // Verbleibende Korrektur in Y
|
||||||
|
|
||||||
|
// Particle System
|
||||||
|
particles []Particle
|
||||||
|
particlesMutex sync.Mutex
|
||||||
|
lastGroundState bool // Für Landing-Detection
|
||||||
|
lastCollectedCoins map[string]bool // Für Coin-Partikel
|
||||||
|
lastCollectedPowerups map[string]bool // Für Powerup-Partikel
|
||||||
|
lastPlayerStates map[string]game.PlayerState // Für Death-Partikel
|
||||||
|
|
||||||
|
// Audio System
|
||||||
|
audio *AudioSystem
|
||||||
|
|
||||||
// Kamera
|
// Kamera
|
||||||
camX float64
|
camX float64
|
||||||
|
|
||||||
@@ -104,6 +116,7 @@ type Game struct {
|
|||||||
joyActive bool
|
joyActive bool
|
||||||
joyTouchID ebiten.TouchID
|
joyTouchID ebiten.TouchID
|
||||||
btnJumpActive bool
|
btnJumpActive bool
|
||||||
|
keyboardUsed bool // Wurde Tastatur benutzt?
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGame() *Game {
|
func NewGame() *Game {
|
||||||
@@ -114,67 +127,35 @@ func NewGame() *Game {
|
|||||||
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
|
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
|
||||||
|
|
||||||
playerName: "Student",
|
playerName: "Student",
|
||||||
activeField: "name",
|
activeField: "",
|
||||||
gameMode: "",
|
gameMode: "",
|
||||||
pendingInputs: make(map[uint32]InputState),
|
pendingInputs: make(map[uint32]InputState),
|
||||||
leaderboard: make([]game.LeaderboardEntry, 0),
|
leaderboard: make([]game.LeaderboardEntry, 0),
|
||||||
|
|
||||||
|
// Particle tracking
|
||||||
|
lastCollectedCoins: make(map[string]bool),
|
||||||
|
lastCollectedPowerups: make(map[string]bool),
|
||||||
|
lastPlayerStates: make(map[string]game.PlayerState),
|
||||||
|
|
||||||
|
// Audio System
|
||||||
|
audio: NewAudioSystem(),
|
||||||
|
|
||||||
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
|
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
|
||||||
joyStickX: 150, joyStickY: ScreenHeight - 150,
|
joyStickX: 150, joyStickY: ScreenHeight - 150,
|
||||||
}
|
}
|
||||||
g.loadAssets()
|
g.loadAssets()
|
||||||
g.loadOrCreatePlayerCode()
|
g.loadOrCreatePlayerCode()
|
||||||
|
|
||||||
|
// Gespeicherten Namen laden
|
||||||
|
savedName := g.loadPlayerName()
|
||||||
|
if savedName != "" {
|
||||||
|
g.playerName = savedName
|
||||||
|
}
|
||||||
|
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) loadAssets() {
|
// loadAssets() ist jetzt in assets_wasm.go und assets_native.go definiert
|
||||||
// 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)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunks laden
|
|
||||||
chunkDir := filepath.Join(baseDir, "chunks")
|
|
||||||
err = g.world.LoadChunkLibrary(chunkDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("✅ Chunks geladen:", len(g.world.ChunkLibrary), "Einträge")
|
|
||||||
// DEBUG: Details der geladenen Chunks
|
|
||||||
for id, chunk := range g.world.ChunkLibrary {
|
|
||||||
fmt.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d\n", id, chunk.Width, len(chunk.Objects))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bilder vorladen
|
|
||||||
loadedImages := 0
|
|
||||||
failedImages := 0
|
|
||||||
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
|
|
||||||
loadedImages++
|
|
||||||
} else {
|
|
||||||
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
|
|
||||||
failedImages++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("🖼️ Bilder: %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- UPDATE ---
|
// --- UPDATE ---
|
||||||
func (g *Game) Update() error {
|
func (g *Game) Update() error {
|
||||||
@@ -220,6 +201,17 @@ func (g *Game) Update() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Musik-Start-Check (unabhängig vom State)
|
||||||
|
if g.gameState.Status == "RUNNING" && g.lastStatus != "RUNNING" {
|
||||||
|
log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, g.gameState.Status)
|
||||||
|
g.audio.PlayMusic()
|
||||||
|
}
|
||||||
|
// Musik stoppen wenn Game Over
|
||||||
|
if g.gameState.Status == "GAMEOVER" && g.lastStatus == "RUNNING" {
|
||||||
|
g.audio.StopMusic()
|
||||||
|
}
|
||||||
|
g.lastStatus = g.gameState.Status
|
||||||
|
|
||||||
switch g.appState {
|
switch g.appState {
|
||||||
case StateMenu:
|
case StateMenu:
|
||||||
g.updateMenu()
|
g.updateMenu()
|
||||||
@@ -236,6 +228,30 @@ func (g *Game) Update() error {
|
|||||||
func (g *Game) updateMenu() {
|
func (g *Game) updateMenu() {
|
||||||
g.handleMenuInput()
|
g.handleMenuInput()
|
||||||
|
|
||||||
|
// Volume Sliders (unten links)
|
||||||
|
volumeX := 20
|
||||||
|
volumeY := ScreenHeight - 100
|
||||||
|
sliderWidth := 200
|
||||||
|
sliderHeight := 10
|
||||||
|
|
||||||
|
// Music Volume Slider
|
||||||
|
musicSliderY := volumeY + 10
|
||||||
|
if isSliderHit(volumeX, musicSliderY, sliderWidth, sliderHeight) {
|
||||||
|
newVolume := getSliderValue(volumeX, sliderWidth)
|
||||||
|
g.audio.SetMusicVolume(newVolume)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFX Volume Slider
|
||||||
|
sfxSliderY := volumeY + 50
|
||||||
|
if isSliderHit(volumeX, sfxSliderY, sliderWidth, sliderHeight) {
|
||||||
|
newVolume := getSliderValue(volumeX, sliderWidth)
|
||||||
|
g.audio.SetSFXVolume(newVolume)
|
||||||
|
// Test-Sound abspielen
|
||||||
|
g.audio.PlayCoin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Leaderboard Button
|
// Leaderboard Button
|
||||||
lbBtnW, lbBtnH := 200, 50
|
lbBtnW, lbBtnH := 200, 50
|
||||||
lbBtnX := ScreenWidth - lbBtnW - 20
|
lbBtnX := ScreenWidth - lbBtnW - 20
|
||||||
@@ -324,7 +340,7 @@ func (g *Game) updateLobby() {
|
|||||||
|
|
||||||
if isHit(btnX, btnY, btnW, btnH) {
|
if isHit(btnX, btnY, btnW, btnH) {
|
||||||
// START GAME
|
// START GAME
|
||||||
g.SendCommand("START")
|
g.sendStartRequest()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,18 +363,27 @@ func (g *Game) updateLobby() {
|
|||||||
|
|
||||||
// --- DRAW ---
|
// --- DRAW ---
|
||||||
func (g *Game) Draw(screen *ebiten.Image) {
|
func (g *Game) Draw(screen *ebiten.Image) {
|
||||||
|
// In WASM: Nur das Spiel zeichnen, kein Menü/Lobby (HTML übernimmt das)
|
||||||
|
// In Native: Alles zeichnen
|
||||||
|
g.draw(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw ist die plattform-übergreifende Zeichenfunktion
|
||||||
|
func (g *Game) draw(screen *ebiten.Image) {
|
||||||
switch g.appState {
|
switch g.appState {
|
||||||
case StateMenu:
|
case StateMenu:
|
||||||
g.DrawMenu(screen)
|
g.drawMenu(screen)
|
||||||
case StateLobby:
|
case StateLobby:
|
||||||
g.DrawLobby(screen)
|
g.drawLobby(screen)
|
||||||
case StateGame:
|
case StateGame:
|
||||||
g.DrawGame(screen)
|
g.DrawGame(screen)
|
||||||
case StateLeaderboard:
|
case StateLeaderboard:
|
||||||
g.DrawLeaderboard(screen)
|
g.drawLeaderboard(screen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drawMenu, drawLobby, drawLeaderboard sind in draw_wasm.go und draw_native.go definiert
|
||||||
|
|
||||||
func (g *Game) DrawMenu(screen *ebiten.Image) {
|
func (g *Game) DrawMenu(screen *ebiten.Image) {
|
||||||
screen.Fill(color.RGBA{20, 20, 30, 255})
|
screen.Fill(color.RGBA{20, 20, 30, 255})
|
||||||
|
|
||||||
@@ -442,7 +467,19 @@ func (g *Game) DrawMenu(screen *ebiten.Image) {
|
|||||||
vector.StrokeRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, 2, color.RGBA{255, 215, 0, 255}, false)
|
vector.StrokeRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, 2, color.RGBA{255, 215, 0, 255}, false)
|
||||||
text.Draw(screen, "🏆 LEADERBOARD", basicfont.Face7x13, lbBtnX+35, lbBtnY+30, color.RGBA{255, 215, 0, 255})
|
text.Draw(screen, "🏆 LEADERBOARD", basicfont.Face7x13, lbBtnX+35, lbBtnY+30, color.RGBA{255, 215, 0, 255})
|
||||||
|
|
||||||
text.Draw(screen, "WASD / Arrows - SPACE to Jump", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-30, color.Gray{150})
|
// Volume Controls (unten links)
|
||||||
|
volumeX := 20
|
||||||
|
volumeY := ScreenHeight - 100
|
||||||
|
|
||||||
|
// Music Volume
|
||||||
|
text.Draw(screen, "Music Volume:", basicfont.Face7x13, volumeX, volumeY, ColText)
|
||||||
|
g.drawVolumeSlider(screen, volumeX, volumeY+10, 200, g.audio.GetMusicVolume())
|
||||||
|
|
||||||
|
// SFX Volume
|
||||||
|
text.Draw(screen, "SFX Volume:", basicfont.Face7x13, volumeX, volumeY+40, ColText)
|
||||||
|
g.drawVolumeSlider(screen, volumeX, volumeY+50, 200, g.audio.GetSFXVolume())
|
||||||
|
|
||||||
|
text.Draw(screen, "WASD / Arrows - SPACE to Jump - M to Mute", basicfont.Face7x13, ScreenWidth/2-130, ScreenHeight-15, color.Gray{150})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) DrawLobby(screen *ebiten.Image) {
|
func (g *Game) DrawLobby(screen *ebiten.Image) {
|
||||||
@@ -580,6 +617,10 @@ func (g *Game) handleMenuInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
||||||
|
// Namen speichern wenn geändert
|
||||||
|
if g.activeField == "name" && g.playerName != "" {
|
||||||
|
g.savePlayerName(g.playerName)
|
||||||
|
}
|
||||||
g.activeField = ""
|
g.activeField = ""
|
||||||
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
|
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
|
||||||
if len(*target) > 0 {
|
if len(*target) > 0 {
|
||||||
@@ -614,7 +655,7 @@ func (g *Game) handleGameOverInput() {
|
|||||||
|
|
||||||
if isHit(submitBtnX, submitBtnY, submitBtnW, 40) {
|
if isHit(submitBtnX, submitBtnY, submitBtnW, 40) {
|
||||||
if g.teamName != "" {
|
if g.teamName != "" {
|
||||||
g.submitTeamScore()
|
g.submitScore() // submitScore behandelt jetzt beide Modi
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -623,7 +664,7 @@ func (g *Game) handleGameOverInput() {
|
|||||||
if g.activeField == "teamname" {
|
if g.activeField == "teamname" {
|
||||||
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
||||||
if g.teamName != "" {
|
if g.teamName != "" {
|
||||||
g.submitTeamScore()
|
g.submitScore() // submitScore behandelt jetzt beide Modi
|
||||||
}
|
}
|
||||||
g.activeField = ""
|
g.activeField = ""
|
||||||
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
|
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
|
||||||
@@ -650,68 +691,6 @@ func generateRoomCode() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) connectAndStart() {
|
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
|
|
||||||
|
|
||||||
// Subscribe nur auf Updates für DIESEN Raum
|
|
||||||
roomChannel := fmt.Sprintf("game.update.%s", g.roomID)
|
|
||||||
log.Printf("👂 Lausche auf Channel: %s", roomChannel)
|
|
||||||
|
|
||||||
sub, err := g.conn.Subscribe(roomChannel, func(state *game.GameState) {
|
|
||||||
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
|
||||||
for _, p := range state.Players {
|
|
||||||
if p.Name == g.playerName {
|
|
||||||
// Reconcile mit Server-State (verwendet keinen stateMutex)
|
|
||||||
g.ReconcileWithServer(p)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.stateMutex.Lock()
|
|
||||||
oldPlayerCount := len(g.gameState.Players)
|
|
||||||
oldStatus := g.gameState.Status
|
|
||||||
g.gameState = *state
|
|
||||||
g.stateMutex.Unlock()
|
|
||||||
|
|
||||||
// Nur bei Änderungen loggen
|
|
||||||
if len(state.Players) != oldPlayerCount || state.Status != oldStatus {
|
|
||||||
log.Printf("📦 State Update: RoomID=%s, Players=%d, HostID=%s, Status=%s", state.RoomID, len(state.Players), state.HostID, 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
|
|
||||||
|
|
||||||
// Initiale predicted Position
|
// Initiale predicted Position
|
||||||
g.predictedX = 100
|
g.predictedX = 100
|
||||||
g.predictedY = 200
|
g.predictedY = 200
|
||||||
@@ -719,7 +698,8 @@ func (g *Game) connectAndStart() {
|
|||||||
g.predictedVY = 0
|
g.predictedVY = 0
|
||||||
g.predictedGround = false
|
g.predictedGround = false
|
||||||
|
|
||||||
log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...")
|
// Verbindung über plattformspezifische Implementierung
|
||||||
|
g.connectToServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) SendCommand(cmdType string) {
|
func (g *Game) SendCommand(cmdType string) {
|
||||||
@@ -727,7 +707,7 @@ func (g *Game) SendCommand(cmdType string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
myID := g.getMyPlayerID()
|
myID := g.getMyPlayerID()
|
||||||
g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType})
|
g.publishInput(game.ClientInput{PlayerID: myID, Type: cmdType})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) SendInputWithSequence(input InputState) {
|
func (g *Game) SendInputWithSequence(input InputState) {
|
||||||
@@ -739,28 +719,30 @@ func (g *Game) SendInputWithSequence(input InputState) {
|
|||||||
|
|
||||||
// Inputs als einzelne Commands senden
|
// Inputs als einzelne Commands senden
|
||||||
if input.Left {
|
if input.Left {
|
||||||
g.conn.Publish("game.input", game.ClientInput{
|
g.publishInput(game.ClientInput{
|
||||||
PlayerID: myID,
|
PlayerID: myID,
|
||||||
Type: "LEFT_DOWN",
|
Type: "LEFT_DOWN",
|
||||||
Sequence: input.Sequence,
|
Sequence: input.Sequence,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if input.Right {
|
if input.Right {
|
||||||
g.conn.Publish("game.input", game.ClientInput{
|
g.publishInput(game.ClientInput{
|
||||||
PlayerID: myID,
|
PlayerID: myID,
|
||||||
Type: "RIGHT_DOWN",
|
Type: "RIGHT_DOWN",
|
||||||
Sequence: input.Sequence,
|
Sequence: input.Sequence,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if input.Jump {
|
if input.Jump {
|
||||||
g.conn.Publish("game.input", game.ClientInput{
|
g.publishInput(game.ClientInput{
|
||||||
PlayerID: myID,
|
PlayerID: myID,
|
||||||
Type: "JUMP",
|
Type: "JUMP",
|
||||||
Sequence: input.Sequence,
|
Sequence: input.Sequence,
|
||||||
})
|
})
|
||||||
|
// Jump Sound abspielen
|
||||||
|
g.audio.PlayJump()
|
||||||
}
|
}
|
||||||
if input.Down {
|
if input.Down {
|
||||||
g.conn.Publish("game.input", game.ClientInput{
|
g.publishInput(game.ClientInput{
|
||||||
PlayerID: myID,
|
PlayerID: myID,
|
||||||
Type: "DOWN",
|
Type: "DOWN",
|
||||||
Sequence: input.Sequence,
|
Sequence: input.Sequence,
|
||||||
@@ -769,12 +751,12 @@ func (g *Game) SendInputWithSequence(input InputState) {
|
|||||||
|
|
||||||
// Wenn weder Links noch Rechts, sende STOP
|
// Wenn weder Links noch Rechts, sende STOP
|
||||||
if !input.Left && !input.Right {
|
if !input.Left && !input.Right {
|
||||||
g.conn.Publish("game.input", game.ClientInput{
|
g.publishInput(game.ClientInput{
|
||||||
PlayerID: myID,
|
PlayerID: myID,
|
||||||
Type: "LEFT_UP",
|
Type: "LEFT_UP",
|
||||||
Sequence: input.Sequence,
|
Sequence: input.Sequence,
|
||||||
})
|
})
|
||||||
g.conn.Publish("game.input", game.ClientInput{
|
g.publishInput(game.ClientInput{
|
||||||
PlayerID: myID,
|
PlayerID: myID,
|
||||||
Type: "RIGHT_UP",
|
Type: "RIGHT_UP",
|
||||||
Sequence: input.Sequence,
|
Sequence: input.Sequence,
|
||||||
@@ -794,113 +776,8 @@ func (g *Game) getMyPlayerID() string {
|
|||||||
return g.playerName
|
return g.playerName
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOrCreatePlayerCode wird in storage_*.go implementiert (platform-specific)
|
// submitScore, requestLeaderboard, connectForLeaderboard
|
||||||
|
// sind in connection_native.go und connection_wasm.go definiert
|
||||||
// submitScore sendet den individuellen Score an den Server (für Solo-Mode)
|
|
||||||
func (g *Game) submitScore() {
|
|
||||||
if g.scoreSubmitted || !g.connected {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finde eigenen Score
|
|
||||||
myScore := 0
|
|
||||||
for _, p := range g.gameState.Players {
|
|
||||||
if p.Name == g.playerName {
|
|
||||||
myScore = p.Score
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submission := game.ScoreSubmission{
|
|
||||||
PlayerName: g.playerName,
|
|
||||||
PlayerCode: g.playerCode,
|
|
||||||
Score: myScore,
|
|
||||||
}
|
|
||||||
|
|
||||||
g.conn.Publish("score.submit", submission)
|
|
||||||
g.scoreSubmitted = true
|
|
||||||
log.Printf("📊 Score eingereicht: %d Punkte", myScore)
|
|
||||||
|
|
||||||
// Leaderboard abrufen
|
|
||||||
g.requestLeaderboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
// submitTeamScore sendet den Team-Score an den Server (für Coop-Mode)
|
|
||||||
func (g *Game) submitTeamScore() {
|
|
||||||
if g.scoreSubmitted || !g.connected || g.teamName == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Berechne Team-Score (Summe aller Spieler-Scores)
|
|
||||||
teamScore := 0
|
|
||||||
for _, p := range g.gameState.Players {
|
|
||||||
teamScore += p.Score
|
|
||||||
}
|
|
||||||
|
|
||||||
submission := game.ScoreSubmission{
|
|
||||||
PlayerName: g.teamName, // Team-Name statt Spieler-Name
|
|
||||||
PlayerCode: g.playerCode,
|
|
||||||
Score: teamScore,
|
|
||||||
}
|
|
||||||
|
|
||||||
g.conn.Publish("score.submit", submission)
|
|
||||||
g.scoreSubmitted = true
|
|
||||||
g.activeField = ""
|
|
||||||
log.Printf("📊 Team-Score eingereicht: %s - %d Punkte", g.teamName, teamScore)
|
|
||||||
|
|
||||||
// Leaderboard abrufen
|
|
||||||
g.requestLeaderboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestLeaderboard fordert das Leaderboard vom Server an (asynchron)
|
|
||||||
func (g *Game) requestLeaderboard() {
|
|
||||||
if !g.connected {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
inbox := g.conn.Conn.NewRespInbox()
|
|
||||||
sub, err := g.conn.Subscribe(inbox, func(entries *[]game.LeaderboardEntry) {
|
|
||||||
g.leaderboardMutex.Lock()
|
|
||||||
g.leaderboard = *entries
|
|
||||||
g.leaderboardMutex.Unlock()
|
|
||||||
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(*entries))
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("⚠️ Fehler beim Leaderboard-Request: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request senden
|
|
||||||
g.conn.PublishRequest("leaderboard.get", inbox, &struct{}{})
|
|
||||||
|
|
||||||
// Warte kurz auf Antwort, dann unsubscribe
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
sub.Unsubscribe()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) connectForLeaderboard() {
|
|
||||||
serverURL := "nats://localhost:4222"
|
|
||||||
nc, err := nats.Connect(serverURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ NATS Verbindung fehlgeschlagen: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g.conn, err = nats.NewEncodedConn(nc, nats.JSON_ENCODER)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ EncodedConn Fehler: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g.connected = true
|
|
||||||
log.Println("✅ Verbunden für Leaderboard")
|
|
||||||
|
|
||||||
// Leaderboard abrufen
|
|
||||||
g.requestLeaderboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Game) updateLeaderboard() {
|
func (g *Game) updateLeaderboard() {
|
||||||
// Back Button (oben links) - Touch Support
|
// Back Button (oben links) - Touch Support
|
||||||
@@ -977,12 +854,46 @@ func (g *Game) DrawLeaderboard(screen *ebiten.Image) {
|
|||||||
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150})
|
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150})
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
// main() ist jetzt in main_wasm.go und main_native.go definiert
|
||||||
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
|
|
||||||
ebiten.SetWindowTitle("Escape From Teacher")
|
// drawVolumeSlider zeichnet einen Volume-Slider
|
||||||
ebiten.SetTPS(60) // Tick Per Second auf 60 setzen
|
func (g *Game) drawVolumeSlider(screen *ebiten.Image, x, y, width int, volume float64) {
|
||||||
ebiten.SetVsyncEnabled(true) // VSync aktivieren
|
// Hintergrund
|
||||||
if err := ebiten.RunGame(NewGame()); err != nil {
|
vector.DrawFilledRect(screen, float32(x), float32(y), float32(width), 10, color.RGBA{40, 40, 50, 255}, false)
|
||||||
log.Fatal(err)
|
vector.StrokeRect(screen, float32(x), float32(y), float32(width), 10, 1, color.White, false)
|
||||||
}
|
|
||||||
|
// Füllstand
|
||||||
|
fillWidth := int(float64(width) * volume)
|
||||||
|
vector.DrawFilledRect(screen, float32(x), float32(y), float32(fillWidth), 10, color.RGBA{0, 200, 100, 255}, false)
|
||||||
|
|
||||||
|
// Prozent-Anzeige
|
||||||
|
pct := fmt.Sprintf("%.0f%%", volume*100)
|
||||||
|
text.Draw(screen, pct, basicfont.Face7x13, x+width+10, y+10, ColText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSliderHit prüft, ob auf einen Slider geklickt wurde
|
||||||
|
func isSliderHit(x, y, width, height int) bool {
|
||||||
|
// Erweitere den Klickbereich vertikal für bessere Touch-Support
|
||||||
|
return isHit(x, y-10, width, height+20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSliderValue berechnet den Slider-Wert basierend auf Mausposition
|
||||||
|
func getSliderValue(sliderX, sliderWidth int) float64 {
|
||||||
|
mx, _ := ebiten.CursorPosition()
|
||||||
|
// Bei Touch: Ersten Touch nutzen
|
||||||
|
touches := ebiten.TouchIDs()
|
||||||
|
if len(touches) > 0 {
|
||||||
|
mx, _ = ebiten.TouchPosition(touches[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechne relative Position im Slider
|
||||||
|
relX := float64(mx - sliderX)
|
||||||
|
if relX < 0 {
|
||||||
|
relX = 0
|
||||||
|
}
|
||||||
|
if relX > float64(sliderWidth) {
|
||||||
|
relX = float64(sliderWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relX / float64(sliderWidth)
|
||||||
}
|
}
|
||||||
|
|||||||
20
cmd/client/main_native.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build !wasm
|
||||||
|
// +build !wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
|
||||||
|
ebiten.SetWindowTitle("Escape From Teacher")
|
||||||
|
ebiten.SetTPS(60)
|
||||||
|
ebiten.SetVsyncEnabled(true)
|
||||||
|
if err := ebiten.RunGame(NewGame()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
cmd/client/main_wasm.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
// +build js,wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Println("🚀 WASM Version startet...")
|
||||||
|
|
||||||
|
// Spiel initialisieren
|
||||||
|
game := NewGame()
|
||||||
|
|
||||||
|
// JavaScript Bridge registrieren (für HTML-Menü Kommunikation)
|
||||||
|
game.setupJavaScriptBridge()
|
||||||
|
|
||||||
|
// Spiel ohne eigenes Menü starten - HTML übernimmt das Menü
|
||||||
|
// Das Spiel wartet im Hintergrund bis startGame() von JavaScript aufgerufen wird
|
||||||
|
log.Println("⏳ Warte auf Start-Signal vom HTML-Menü...")
|
||||||
|
|
||||||
|
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
|
||||||
|
ebiten.SetWindowTitle("Escape From Teacher")
|
||||||
|
ebiten.SetTPS(60)
|
||||||
|
ebiten.SetVsyncEnabled(true)
|
||||||
|
|
||||||
|
if err := ebiten.RunGame(game); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
336
cmd/client/particles.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Particle definiert ein einzelnes Partikel
|
||||||
|
type Particle struct {
|
||||||
|
X, Y float64
|
||||||
|
VX, VY float64
|
||||||
|
Life float64 // 0.0 bis 1.0 (1.0 = neu, 0.0 = tot)
|
||||||
|
MaxLife float64 // Ursprüngliche Lebensdauer in Sekunden
|
||||||
|
Size float64
|
||||||
|
Color color.RGBA
|
||||||
|
Type string // "coin", "powerup", "landing", "death"
|
||||||
|
Gravity bool // Soll Gravitation angewendet werden?
|
||||||
|
FadeOut bool // Soll ausblenden?
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnCoinParticles erstellt Partikel für Coin-Einsammlung
|
||||||
|
func (g *Game) SpawnCoinParticles(x, y float64) {
|
||||||
|
g.particlesMutex.Lock()
|
||||||
|
defer g.particlesMutex.Unlock()
|
||||||
|
|
||||||
|
// 10-15 goldene Partikel - spawnen in alle Richtungen
|
||||||
|
count := 10 + rand.Intn(6)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
angle := rand.Float64() * 2 * math.Pi
|
||||||
|
speed := 2.0 + rand.Float64()*3.0
|
||||||
|
|
||||||
|
g.particles = append(g.particles, Particle{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
VX: math.Cos(angle) * speed,
|
||||||
|
VY: math.Sin(angle) * speed,
|
||||||
|
Life: 1.0,
|
||||||
|
MaxLife: 0.5 + rand.Float64()*0.3,
|
||||||
|
Size: 3.0 + rand.Float64()*2.0,
|
||||||
|
Color: color.RGBA{255, 215, 0, 255}, // Gold
|
||||||
|
Type: "coin",
|
||||||
|
Gravity: true,
|
||||||
|
FadeOut: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnPowerupAura erstellt Partikel-Aura um Spieler mit aktivem Powerup
|
||||||
|
func (g *Game) SpawnPowerupAura(x, y float64, powerupType string) {
|
||||||
|
g.particlesMutex.Lock()
|
||||||
|
defer g.particlesMutex.Unlock()
|
||||||
|
|
||||||
|
// Farbe je nach Powerup-Typ
|
||||||
|
var particleColor color.RGBA
|
||||||
|
switch powerupType {
|
||||||
|
case "doublejump":
|
||||||
|
particleColor = color.RGBA{100, 200, 255, 200} // Hellblau
|
||||||
|
case "godmode":
|
||||||
|
particleColor = color.RGBA{255, 215, 0, 200} // Gold
|
||||||
|
default:
|
||||||
|
particleColor = color.RGBA{200, 100, 255, 200} // Lila
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur gelegentlich spawnen (nicht jedes Frame) - 1 Partikel alle 3-4 Frames
|
||||||
|
if rand.Intn(3) != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 Partikel für sanfte Aura - spawnen in alle Richtungen
|
||||||
|
angle := rand.Float64() * 2 * math.Pi
|
||||||
|
distance := 25.0 + rand.Float64()*20.0
|
||||||
|
|
||||||
|
g.particles = append(g.particles, Particle{
|
||||||
|
X: x + math.Cos(angle)*distance,
|
||||||
|
Y: y + math.Sin(angle)*distance,
|
||||||
|
VX: math.Cos(angle) * 0.5,
|
||||||
|
VY: math.Sin(angle) * 0.5,
|
||||||
|
Life: 1.0,
|
||||||
|
MaxLife: 1.0 + rand.Float64()*0.5,
|
||||||
|
Size: 2.5 + rand.Float64()*1.5,
|
||||||
|
Color: particleColor,
|
||||||
|
Type: "powerup",
|
||||||
|
Gravity: false,
|
||||||
|
FadeOut: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnLandingParticles erstellt Staub-Partikel beim Landen
|
||||||
|
func (g *Game) SpawnLandingParticles(x, y float64) {
|
||||||
|
g.particlesMutex.Lock()
|
||||||
|
defer g.particlesMutex.Unlock()
|
||||||
|
|
||||||
|
// 5-8 Staub-Partikel
|
||||||
|
count := 5 + rand.Intn(4)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
angle := math.Pi + (rand.Float64()-0.5)*0.8 // Nach unten/seitlich
|
||||||
|
speed := 1.0 + rand.Float64()*2.0
|
||||||
|
|
||||||
|
g.particles = append(g.particles, Particle{
|
||||||
|
X: x + (rand.Float64()-0.5)*30.0,
|
||||||
|
Y: y + 10,
|
||||||
|
VX: math.Cos(angle) * speed,
|
||||||
|
VY: math.Sin(angle) * speed,
|
||||||
|
Life: 1.0,
|
||||||
|
MaxLife: 0.3 + rand.Float64()*0.2,
|
||||||
|
Size: 2.0 + rand.Float64()*2.0,
|
||||||
|
Color: color.RGBA{150, 150, 150, 180}, // Grau
|
||||||
|
Type: "landing",
|
||||||
|
Gravity: false,
|
||||||
|
FadeOut: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnDeathParticles erstellt Explosions-Partikel beim Tod
|
||||||
|
func (g *Game) SpawnDeathParticles(x, y float64) {
|
||||||
|
g.particlesMutex.Lock()
|
||||||
|
defer g.particlesMutex.Unlock()
|
||||||
|
|
||||||
|
// 20-30 rote Partikel
|
||||||
|
count := 20 + rand.Intn(11)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
angle := rand.Float64() * 2 * math.Pi
|
||||||
|
speed := 3.0 + rand.Float64()*5.0
|
||||||
|
|
||||||
|
g.particles = append(g.particles, Particle{
|
||||||
|
X: x,
|
||||||
|
Y: y + 10,
|
||||||
|
VX: math.Cos(angle) * speed,
|
||||||
|
VY: math.Sin(angle) * speed,
|
||||||
|
Life: 1.0,
|
||||||
|
MaxLife: 0.8 + rand.Float64()*0.4,
|
||||||
|
Size: 3.0 + rand.Float64()*3.0,
|
||||||
|
Color: color.RGBA{255, 50, 50, 255}, // Rot
|
||||||
|
Type: "death",
|
||||||
|
Gravity: true,
|
||||||
|
FadeOut: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateParticles aktualisiert alle Partikel
|
||||||
|
func (g *Game) UpdateParticles(dt float64) {
|
||||||
|
g.particlesMutex.Lock()
|
||||||
|
defer g.particlesMutex.Unlock()
|
||||||
|
|
||||||
|
// Filtern: Nur lebende Partikel behalten
|
||||||
|
alive := make([]Particle, 0, len(g.particles))
|
||||||
|
|
||||||
|
for i := range g.particles {
|
||||||
|
p := &g.particles[i]
|
||||||
|
|
||||||
|
// Position updaten
|
||||||
|
p.X += p.VX
|
||||||
|
p.Y += p.VY
|
||||||
|
|
||||||
|
// Gravitation
|
||||||
|
if p.Gravity {
|
||||||
|
p.VY += 0.3 // Gravitation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Friction
|
||||||
|
p.VX *= 0.98
|
||||||
|
p.VY *= 0.98
|
||||||
|
|
||||||
|
// Leben verringern
|
||||||
|
p.Life -= dt / p.MaxLife
|
||||||
|
|
||||||
|
// Nur behalten wenn noch am Leben
|
||||||
|
if p.Life > 0 {
|
||||||
|
alive = append(alive, *p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.particles = alive
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderParticles zeichnet alle Partikel
|
||||||
|
func (g *Game) RenderParticles(screen *ebiten.Image) {
|
||||||
|
g.particlesMutex.Lock()
|
||||||
|
defer g.particlesMutex.Unlock()
|
||||||
|
|
||||||
|
for i := range g.particles {
|
||||||
|
p := &g.particles[i]
|
||||||
|
|
||||||
|
// Alpha-Wert basierend auf Leben
|
||||||
|
alpha := uint8(255)
|
||||||
|
if p.FadeOut {
|
||||||
|
alpha = uint8(float64(p.Color.A) * p.Life)
|
||||||
|
}
|
||||||
|
|
||||||
|
col := color.RGBA{p.Color.R, p.Color.G, p.Color.B, alpha}
|
||||||
|
|
||||||
|
// Position relativ zur Kamera
|
||||||
|
screenX := float32(p.X - g.camX)
|
||||||
|
screenY := float32(p.Y)
|
||||||
|
|
||||||
|
// Partikel als Kreis zeichnen
|
||||||
|
vector.DrawFilledCircle(screen, screenX, screenY, float32(p.Size), col, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectAndSpawnParticles prüft Game-State-Änderungen und spawnt Partikel
|
||||||
|
func (g *Game) DetectAndSpawnParticles() {
|
||||||
|
// Kopiere relevante Daten unter kurzen Lock
|
||||||
|
g.stateMutex.Lock()
|
||||||
|
|
||||||
|
// Kopiere Coins
|
||||||
|
currentCoins := make(map[string]bool, len(g.gameState.CollectedCoins))
|
||||||
|
for k, v := range g.gameState.CollectedCoins {
|
||||||
|
currentCoins[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopiere Powerups
|
||||||
|
currentPowerups := make(map[string]bool, len(g.gameState.CollectedPowerups))
|
||||||
|
for k, v := range g.gameState.CollectedPowerups {
|
||||||
|
currentPowerups[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopiere Spieler
|
||||||
|
currentPlayers := make(map[string]game.PlayerState, len(g.gameState.Players))
|
||||||
|
for k, v := range g.gameState.Players {
|
||||||
|
currentPlayers[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopiere WorldChunks für Position-Lookup
|
||||||
|
worldChunks := make([]game.ActiveChunk, len(g.gameState.WorldChunks))
|
||||||
|
copy(worldChunks, g.gameState.WorldChunks)
|
||||||
|
|
||||||
|
g.stateMutex.Unlock()
|
||||||
|
|
||||||
|
// Ab hier ohne Lock arbeiten
|
||||||
|
|
||||||
|
// Alte Coins entfernen, die nicht mehr in currentCoins sind (Chunk wurde entfernt)
|
||||||
|
for key := range g.lastCollectedCoins {
|
||||||
|
if !currentCoins[key] {
|
||||||
|
delete(g.lastCollectedCoins, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Prüfe neue gesammelte Coins
|
||||||
|
for coinKey := range currentCoins {
|
||||||
|
if !g.lastCollectedCoins[coinKey] {
|
||||||
|
// Neuer Coin gesammelt!
|
||||||
|
if pos := g.findObjectPosition(coinKey, worldChunks, "coin"); pos != nil {
|
||||||
|
g.SpawnCoinParticles(pos.X, pos.Y)
|
||||||
|
g.audio.PlayCoin() // Coin Sound abspielen
|
||||||
|
}
|
||||||
|
g.lastCollectedCoins[coinKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alte Powerups entfernen
|
||||||
|
for key := range g.lastCollectedPowerups {
|
||||||
|
if !currentPowerups[key] {
|
||||||
|
delete(g.lastCollectedPowerups, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Prüfe neue gesammelte Powerups
|
||||||
|
for powerupKey := range currentPowerups {
|
||||||
|
if !g.lastCollectedPowerups[powerupKey] {
|
||||||
|
// Neues Powerup gesammelt!
|
||||||
|
if pos := g.findObjectPosition(powerupKey, worldChunks, "powerup"); pos != nil {
|
||||||
|
g.SpawnCoinParticles(pos.X, pos.Y)
|
||||||
|
g.audio.PlayPowerUp() // PowerUp Sound abspielen
|
||||||
|
}
|
||||||
|
g.lastCollectedPowerups[powerupKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Prüfe Spieler-Status und spawn Aura/Death Partikel
|
||||||
|
for playerID, player := range currentPlayers {
|
||||||
|
lastState, existed := g.lastPlayerStates[playerID]
|
||||||
|
|
||||||
|
// Death Partikel
|
||||||
|
if existed && lastState.IsAlive && !player.IsAlive {
|
||||||
|
// Berechne Spieler-Mitte
|
||||||
|
centerX := player.X - 56 + 68 + 73/2
|
||||||
|
centerY := player.Y - 231 + 42 + 184/2
|
||||||
|
g.SpawnDeathParticles(centerX, centerY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Powerup Aura (kontinuierlich)
|
||||||
|
if player.IsAlive && !player.IsSpectator {
|
||||||
|
// Berechne Spieler-Mitte mit Draw-Offsets
|
||||||
|
// DrawOffX: -56, DrawOffY: -231
|
||||||
|
// Hitbox: OffsetX: 68, OffsetY: 42, W: 73, H: 184
|
||||||
|
centerX := player.X - 56 + 68 + 73/2
|
||||||
|
centerY := player.Y - 231 + 42 + 184/2
|
||||||
|
|
||||||
|
if player.HasDoubleJump {
|
||||||
|
g.SpawnPowerupAura(centerX, centerY, "doublejump")
|
||||||
|
}
|
||||||
|
if player.HasGodMode {
|
||||||
|
g.SpawnPowerupAura(centerX, centerY, "godmode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State aktualisieren
|
||||||
|
g.lastPlayerStates[playerID] = player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findObjectPosition finiert die Welt-Position eines Objects (Coin/Powerup) basierend auf Key
|
||||||
|
func (g *Game) findObjectPosition(objectKey string, worldChunks []game.ActiveChunk, objectType string) *struct{ X, Y float64 } {
|
||||||
|
for _, activeChunk := range worldChunks {
|
||||||
|
chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for objIdx, obj := range chunkDef.Objects {
|
||||||
|
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
|
||||||
|
if key == objectKey {
|
||||||
|
assetDef, ok := g.world.Manifest.Assets[obj.AssetID]
|
||||||
|
if ok && assetDef.Type == objectType {
|
||||||
|
// Berechne die Mitte der Hitbox
|
||||||
|
centerX := activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX + assetDef.Hitbox.W/2
|
||||||
|
centerY := obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY + assetDef.Hitbox.H/2
|
||||||
|
return &struct{ X, Y float64 }{
|
||||||
|
X: centerX,
|
||||||
|
Y: centerY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -63,20 +63,60 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setze vorhergesagte Position auf Server-Position
|
// Temporäre Position für Replay
|
||||||
g.predictedX = serverState.X
|
replayX := serverState.X
|
||||||
g.predictedY = serverState.Y
|
replayY := serverState.Y
|
||||||
g.predictedVX = serverState.VX
|
replayVX := serverState.VX
|
||||||
g.predictedVY = serverState.VY
|
replayVY := serverState.VY
|
||||||
g.predictedGround = serverState.OnGround
|
replayGround := serverState.OnGround
|
||||||
|
|
||||||
// Replay alle noch nicht bestätigten Inputs
|
// Replay alle noch nicht bestätigten Inputs
|
||||||
// (Sortiert nach Sequenz)
|
|
||||||
if len(g.pendingInputs) > 0 {
|
if len(g.pendingInputs) > 0 {
|
||||||
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
|
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
|
||||||
if input, ok := g.pendingInputs[seq]; ok {
|
if input, ok := g.pendingInputs[seq]; ok {
|
||||||
|
// Temporär auf Replay-Position setzen
|
||||||
|
oldX, oldY := g.predictedX, g.predictedY
|
||||||
|
oldVX, oldVY := g.predictedVX, g.predictedVY
|
||||||
|
oldGround := g.predictedGround
|
||||||
|
|
||||||
|
g.predictedX = replayX
|
||||||
|
g.predictedY = replayY
|
||||||
|
g.predictedVX = replayVX
|
||||||
|
g.predictedVY = replayVY
|
||||||
|
g.predictedGround = replayGround
|
||||||
|
|
||||||
g.ApplyInput(input)
|
g.ApplyInput(input)
|
||||||
|
|
||||||
|
replayX = g.predictedX
|
||||||
|
replayY = g.predictedY
|
||||||
|
replayVX = g.predictedVX
|
||||||
|
replayVY = g.predictedVY
|
||||||
|
replayGround = g.predictedGround
|
||||||
|
|
||||||
|
// Zurücksetzen
|
||||||
|
g.predictedX = oldX
|
||||||
|
g.predictedY = oldY
|
||||||
|
g.predictedVX = oldVX
|
||||||
|
g.predictedVY = oldVY
|
||||||
|
g.predictedGround = oldGround
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Berechne Differenz zwischen aktueller Prediction und Server-Replay
|
||||||
|
diffX := replayX - g.predictedX
|
||||||
|
diffY := replayY - g.predictedY
|
||||||
|
|
||||||
|
// Nur korrigieren wenn Differenz signifikant (> 5 Pixel)
|
||||||
|
const threshold = 5.0
|
||||||
|
if diffX*diffX+diffY*diffY > threshold*threshold {
|
||||||
|
// Speichere Korrektur für sanfte Interpolation
|
||||||
|
g.correctionX = diffX
|
||||||
|
g.correctionY = diffY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Velocity und Ground immer sofort übernehmen
|
||||||
|
g.predictedVX = replayVX
|
||||||
|
g.predictedVY = replayVY
|
||||||
|
g.predictedGround = replayGround
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadOrCreatePlayerCode lädt oder erstellt einen eindeutigen Spieler-Code (Desktop Version)
|
// loadOrCreatePlayerCode lädt oder erstellt einen eindeutigen Spieler-Code (Desktop Version)
|
||||||
@@ -35,3 +36,28 @@ func (g *Game) loadOrCreatePlayerCode() {
|
|||||||
|
|
||||||
log.Printf("🆕 Neuer Player-Code erstellt: %s", g.playerCode)
|
log.Printf("🆕 Neuer Player-Code erstellt: %s", g.playerCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPlayerName lädt gespeicherten Spielernamen (Desktop Version)
|
||||||
|
func (g *Game) loadPlayerName() string {
|
||||||
|
const nameFile = "player_name.txt"
|
||||||
|
|
||||||
|
if data, err := ioutil.ReadFile(nameFile); err == nil {
|
||||||
|
name := strings.TrimSpace(string(data))
|
||||||
|
if name != "" {
|
||||||
|
log.Printf("👤 Spielername geladen: %s", name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// savePlayerName speichert Spielernamen (Desktop Version)
|
||||||
|
func (g *Game) savePlayerName(name string) {
|
||||||
|
const nameFile = "player_name.txt"
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(nameFile, []byte(name), 0644); err != nil {
|
||||||
|
log.Printf("⚠️ Fehler beim Speichern des Spielernamens: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("💾 Spielername gespeichert: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,3 +43,33 @@ func (g *Game) loadOrCreatePlayerCode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPlayerName lädt gespeicherten Spielernamen (WebAssembly Version)
|
||||||
|
func (g *Game) loadPlayerName() string {
|
||||||
|
const storageKey = "escape_from_teacher_player_name"
|
||||||
|
|
||||||
|
if jsGlobal := js.Global(); !jsGlobal.IsUndefined() {
|
||||||
|
localStorage := jsGlobal.Get("localStorage")
|
||||||
|
if !localStorage.IsUndefined() {
|
||||||
|
stored := localStorage.Call("getItem", storageKey)
|
||||||
|
if !stored.IsNull() && stored.String() != "" {
|
||||||
|
log.Printf("👤 Spielername aus LocalStorage geladen: %s", stored.String())
|
||||||
|
return stored.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// savePlayerName speichert Spielernamen (WebAssembly Version)
|
||||||
|
func (g *Game) savePlayerName(name string) {
|
||||||
|
const storageKey = "escape_from_teacher_player_name"
|
||||||
|
|
||||||
|
if jsGlobal := js.Global(); !jsGlobal.IsUndefined() {
|
||||||
|
localStorage := jsGlobal.Get("localStorage")
|
||||||
|
if !localStorage.IsUndefined() {
|
||||||
|
localStorage.Call("setItem", storageKey, name)
|
||||||
|
log.Printf("💾 Spielername in LocalStorage gespeichert: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
119
cmd/client/wasm_bridge.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
// +build js,wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"syscall/js"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupJavaScriptBridge registriert JavaScript-Funktionen für HTML-Menü Interaktion
|
||||||
|
func (g *Game) setupJavaScriptBridge() {
|
||||||
|
// startGame(mode, playerName, roomID, teamName, isHost)
|
||||||
|
startGameFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
if len(args) < 2 {
|
||||||
|
log.Println("❌ startGame: Nicht genug Argumente")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := args[0].String() // "solo" oder "coop"
|
||||||
|
playerName := args[1].String() // Spielername
|
||||||
|
|
||||||
|
// Spieler-Daten setzen
|
||||||
|
g.playerName = playerName
|
||||||
|
g.gameMode = mode
|
||||||
|
g.savePlayerName(playerName)
|
||||||
|
|
||||||
|
if mode == "solo" {
|
||||||
|
// Solo Mode - direkt ins Spiel
|
||||||
|
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
|
||||||
|
g.isHost = true
|
||||||
|
g.appState = StateGame
|
||||||
|
log.Printf("🎮 Solo-Spiel gestartet: %s", playerName)
|
||||||
|
} else if mode == "coop" && len(args) >= 5 {
|
||||||
|
// Co-op Mode - in die Lobby
|
||||||
|
roomID := args[2].String()
|
||||||
|
teamName := args[3].String()
|
||||||
|
isHost := args[4].Bool()
|
||||||
|
|
||||||
|
g.roomID = roomID
|
||||||
|
g.teamName = teamName
|
||||||
|
g.isHost = isHost
|
||||||
|
g.appState = StateLobby
|
||||||
|
|
||||||
|
log.Printf("🎮 Co-op-Lobby erstellt: %s (Room: %s, Team: %s, Host: %v)", playerName, roomID, teamName, isHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbindung starten
|
||||||
|
go g.connectAndStart()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// requestLeaderboard()
|
||||||
|
requestLeaderboardFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
log.Println("📊 Leaderboard angefordert")
|
||||||
|
go g.requestLeaderboard()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// setMusicVolume(volume)
|
||||||
|
setMusicVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
if len(args) > 0 {
|
||||||
|
volume := args[0].Float()
|
||||||
|
g.audio.SetMusicVolume(volume)
|
||||||
|
log.Printf("🎵 Musik-Lautstärke: %.0f%%", volume*100)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// setSFXVolume(volume)
|
||||||
|
setSFXVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
if len(args) > 0 {
|
||||||
|
volume := args[0].Float()
|
||||||
|
g.audio.SetSFXVolume(volume)
|
||||||
|
log.Printf("🔊 SFX-Lautstärke: %.0f%%", volume*100)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Im globalen Scope registrieren
|
||||||
|
js.Global().Set("startGame", startGameFunc)
|
||||||
|
js.Global().Set("requestLeaderboard", requestLeaderboardFunc)
|
||||||
|
js.Global().Set("setMusicVolume", setMusicVolumeFunc)
|
||||||
|
js.Global().Set("setSFXVolume", setSFXVolumeFunc)
|
||||||
|
|
||||||
|
log.Println("✅ JavaScript Bridge registriert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaderboard an JavaScript senden
|
||||||
|
func (g *Game) sendLeaderboardToJS() {
|
||||||
|
g.leaderboardMutex.Lock()
|
||||||
|
entries := make([]interface{}, len(g.leaderboard))
|
||||||
|
for i, entry := range g.leaderboard {
|
||||||
|
entries[i] = map[string]interface{}{
|
||||||
|
"player_name": entry.PlayerName,
|
||||||
|
"score": entry.Score,
|
||||||
|
"player_code": entry.PlayerCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.leaderboardMutex.Unlock()
|
||||||
|
|
||||||
|
// JavaScript-Funktion aufrufen
|
||||||
|
if updateFunc := js.Global().Get("updateLeaderboard"); !updateFunc.IsUndefined() {
|
||||||
|
jsEntries := js.ValueOf(entries)
|
||||||
|
updateFunc.Invoke(jsEntries)
|
||||||
|
log.Printf("📤 Leaderboard an JavaScript gesendet: %d Einträge", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game Over Screen an JavaScript senden
|
||||||
|
func (g *Game) sendGameOverToJS(score int) {
|
||||||
|
if gameOverFunc := js.Global().Get("showGameOver"); !gameOverFunc.IsUndefined() {
|
||||||
|
gameOverFunc.Invoke(score)
|
||||||
|
log.Printf("💀 Game Over an JavaScript gesendet: Score=%d", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
cmd/client/web/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Escape From Teacher - WASM Web Version
|
||||||
|
|
||||||
|
## 🎮 Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Im web-Verzeichnis einen HTTP-Server starten
|
||||||
|
cd cmd/client/web
|
||||||
|
python3 -m http.server 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann im Browser öffnen: **http://localhost:8000**
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### Modernes HTML/CSS Menü
|
||||||
|
- **Custom Font**: Nutzt `front.ttf` aus dem assets-Ordner
|
||||||
|
- **Responsive Design**: Funktioniert auf Desktop, Tablet und Smartphone
|
||||||
|
- **Glassmorphism**: Moderner durchsichtiger Look mit Blur-Effekt
|
||||||
|
- **Smooth Animations**: Fade-in, Slide-up, Pulse-Effekte
|
||||||
|
|
||||||
|
### Menü-Optionen
|
||||||
|
1. **Solo spielen**: Einzelspieler-Modus
|
||||||
|
2. **Co-op spielen**: Multiplayer mit Raum-Code
|
||||||
|
3. **Leaderboard**: Top 10 Spieler anzeigen
|
||||||
|
4. **Einstellungen**: Musik & SFX Lautstärke einstellen
|
||||||
|
|
||||||
|
### Mobile-Optimiert
|
||||||
|
- Touch-freundliche Buttons
|
||||||
|
- Responsive Layout für kleine Bildschirme
|
||||||
|
- Viewport-Anpassung für Smartphones
|
||||||
|
|
||||||
|
## 🎨 Design-Features
|
||||||
|
|
||||||
|
- **Gradient Background**: Lila-Blauer Farbverlauf
|
||||||
|
- **Button Hover-Effekte**: Ripple-Animationen
|
||||||
|
- **Custom Scrollbars**: Für Leaderboard
|
||||||
|
- **Loading Screen**: Spinner während WASM lädt
|
||||||
|
- **Emoji-Icons**: 🏃 👥 🏆 ⚙️
|
||||||
|
|
||||||
|
## 📁 Dateien
|
||||||
|
|
||||||
|
- `index.html`: HTML-Struktur
|
||||||
|
- `style.css`: Alle Styles mit Custom Font
|
||||||
|
- `game.js`: JavaScript-Bridge zwischen HTML und WASM
|
||||||
|
- `wasm_exec.js`: Go WASM Runtime (von Go kopiert)
|
||||||
|
- `main.wasm`: Kompiliertes Spiel
|
||||||
|
|
||||||
|
## 🔧 Entwicklung
|
||||||
|
|
||||||
|
### WASM neu kompilieren
|
||||||
|
```bash
|
||||||
|
GOOS=js GOARCH=wasm go build -o cmd/client/web/main.wasm ./cmd/client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Font ändern
|
||||||
|
Ersetze `cmd/client/assets/front.ttf` und aktualisiere den Pfad in `style.css`:
|
||||||
|
```css
|
||||||
|
@font-face {
|
||||||
|
font-family: 'GameFont';
|
||||||
|
src: url('../assets/front.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Keyboard Shortcuts
|
||||||
|
|
||||||
|
- **ESC**: Zurück zum Menü (während des Spiels)
|
||||||
|
|
||||||
|
## 📱 Mobile Testing
|
||||||
|
|
||||||
|
Für lokales Mobile Testing:
|
||||||
|
```bash
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# Öffne auf Smartphone: http://[DEINE-IP]:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Production Deployment
|
||||||
|
|
||||||
|
Für Production alle Dateien hochladen:
|
||||||
|
- index.html
|
||||||
|
- style.css
|
||||||
|
- game.js
|
||||||
|
- wasm_exec.js
|
||||||
|
- main.wasm
|
||||||
|
- assets/ (Font-Ordner)
|
||||||
|
|
||||||
|
Server muss WASM mit korrektem MIME-Type ausliefern:
|
||||||
|
```
|
||||||
|
Content-Type: application/wasm
|
||||||
|
```
|
||||||
1
cmd/client/web/assets
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../assets
|
||||||
341
cmd/client/web/game.js
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
// Game State
|
||||||
|
let wasmReady = false;
|
||||||
|
let gameStarted = false;
|
||||||
|
let audioMuted = false;
|
||||||
|
|
||||||
|
// Initialize WASM
|
||||||
|
async function initWASM() {
|
||||||
|
const go = new Go();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject);
|
||||||
|
go.run(result.instance);
|
||||||
|
wasmReady = true;
|
||||||
|
|
||||||
|
// Hide loading screen
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
|
||||||
|
console.log('✅ WASM loaded successfully');
|
||||||
|
|
||||||
|
// Load initial leaderboard
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.requestLeaderboard) {
|
||||||
|
window.requestLeaderboard();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to load WASM:', err);
|
||||||
|
document.getElementById('loading').innerHTML = '<div class="spinner"></div><p>Fehler beim Laden: ' + err.message + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu Navigation
|
||||||
|
function showMainMenu() {
|
||||||
|
hideAllScreens();
|
||||||
|
document.getElementById('menu').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCoopMenu() {
|
||||||
|
hideAllScreens();
|
||||||
|
document.getElementById('coopMenu').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSettings() {
|
||||||
|
hideAllScreens();
|
||||||
|
document.getElementById('settingsMenu').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLeaderboard() {
|
||||||
|
hideAllScreens();
|
||||||
|
document.getElementById('leaderboardMenu').classList.remove('hidden');
|
||||||
|
loadLeaderboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAllScreens() {
|
||||||
|
document.querySelectorAll('.overlay-screen').forEach(screen => {
|
||||||
|
screen.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMenu() {
|
||||||
|
document.getElementById('menu').style.display = 'none';
|
||||||
|
// Canvas sichtbar machen für Gameplay
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.classList.add('game-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu() {
|
||||||
|
document.getElementById('menu').style.display = 'flex';
|
||||||
|
document.getElementById('menu').classList.remove('hidden');
|
||||||
|
showMainMenu();
|
||||||
|
// Canvas verstecken im Menü
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.classList.remove('game-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game Functions
|
||||||
|
function startSoloGame() {
|
||||||
|
if (!wasmReady) {
|
||||||
|
alert('Spiel wird noch geladen...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerName = document.getElementById('playerName').value || 'ANON';
|
||||||
|
|
||||||
|
// Store in localStorage for WASM to read
|
||||||
|
localStorage.setItem('escape_player_name', playerName);
|
||||||
|
localStorage.setItem('escape_game_mode', 'solo');
|
||||||
|
localStorage.setItem('escape_room_id', '');
|
||||||
|
|
||||||
|
// Hide menu and show canvas
|
||||||
|
hideAllScreens();
|
||||||
|
gameStarted = true;
|
||||||
|
|
||||||
|
// Trigger WASM game start
|
||||||
|
if (window.startGame) {
|
||||||
|
window.startGame('solo', playerName, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎮 Solo game started:', playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoom() {
|
||||||
|
if (!wasmReady) {
|
||||||
|
alert('Spiel wird noch geladen...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerName = document.getElementById('playerName').value || 'ANON';
|
||||||
|
const roomID = 'R' + Math.random().toString(36).substr(2, 5).toUpperCase();
|
||||||
|
const teamName = 'TEAM';
|
||||||
|
|
||||||
|
// Store in localStorage
|
||||||
|
localStorage.setItem('escape_player_name', playerName);
|
||||||
|
localStorage.setItem('escape_game_mode', 'coop');
|
||||||
|
localStorage.setItem('escape_room_id', roomID);
|
||||||
|
localStorage.setItem('escape_team_name', teamName);
|
||||||
|
localStorage.setItem('escape_is_host', 'true');
|
||||||
|
|
||||||
|
// Hide menu and show canvas (Lobby wird vom WASM gezeichnet)
|
||||||
|
hideAllScreens();
|
||||||
|
gameStarted = true;
|
||||||
|
|
||||||
|
// Canvas sichtbar machen für Lobby
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.classList.add('game-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger WASM game start
|
||||||
|
if (window.startGame) {
|
||||||
|
window.startGame('coop', playerName, roomID, teamName, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎮 Room created:', roomID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRoom() {
|
||||||
|
if (!wasmReady) {
|
||||||
|
alert('Spiel wird noch geladen...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerName = document.getElementById('playerName').value || 'ANON';
|
||||||
|
const roomID = document.getElementById('joinRoomCode').value.toUpperCase();
|
||||||
|
const teamName = document.getElementById('teamNameJoin').value || 'TEAM';
|
||||||
|
|
||||||
|
if (!roomID || roomID.length < 4) {
|
||||||
|
alert('Bitte gib einen gültigen Raum-Code ein!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in localStorage
|
||||||
|
localStorage.setItem('escape_player_name', playerName);
|
||||||
|
localStorage.setItem('escape_game_mode', 'coop');
|
||||||
|
localStorage.setItem('escape_room_id', roomID);
|
||||||
|
localStorage.setItem('escape_team_name', teamName);
|
||||||
|
localStorage.setItem('escape_is_host', 'false');
|
||||||
|
|
||||||
|
// Hide menu and show canvas
|
||||||
|
hideAllScreens();
|
||||||
|
gameStarted = true;
|
||||||
|
|
||||||
|
// Canvas sichtbar machen für Lobby
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
canvas.classList.add('game-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger WASM game start
|
||||||
|
if (window.startGame) {
|
||||||
|
window.startGame('coop', playerName, roomID, teamName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎮 Joining room:', roomID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLeaderboard() {
|
||||||
|
const list = document.getElementById('leaderboardList');
|
||||||
|
list.innerHTML = '<div style="text-align:center; padding:20px;">Lädt Leaderboard...</div>';
|
||||||
|
|
||||||
|
// Request leaderboard from WASM
|
||||||
|
if (window.requestLeaderboard) {
|
||||||
|
window.requestLeaderboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
if (list.innerHTML.includes('Lädt')) {
|
||||||
|
list.innerHTML = '<div style="text-align:center; padding:20px; color:#888;">Keine Daten verfügbar</div>';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by WASM to update leaderboard
|
||||||
|
function updateLeaderboard(entries) {
|
||||||
|
// Update main leaderboard page
|
||||||
|
const list = document.getElementById('leaderboardList');
|
||||||
|
|
||||||
|
// Update start screen leaderboard
|
||||||
|
const startList = document.getElementById('startLeaderboardList');
|
||||||
|
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
const emptyMsg = '<div style="text-align:center; padding:20px; color:#888;">Noch keine Einträge</div>';
|
||||||
|
if (list) list.innerHTML = emptyMsg;
|
||||||
|
if (startList) startList.innerHTML = emptyMsg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = entries.slice(0, 10).map((entry, index) => {
|
||||||
|
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}.`;
|
||||||
|
return `
|
||||||
|
<div class="leaderboard-item">
|
||||||
|
<div class="leaderboard-rank">${medal}</div>
|
||||||
|
<div class="leaderboard-name">${entry.player_name}</div>
|
||||||
|
<div class="leaderboard-score">${entry.score}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (list) list.innerHTML = html;
|
||||||
|
if (startList) startList.innerHTML = html;
|
||||||
|
|
||||||
|
console.log('📊 Leaderboard updated with', entries.length, 'entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio Toggle
|
||||||
|
function toggleAudio() {
|
||||||
|
audioMuted = !audioMuted;
|
||||||
|
const btn = document.getElementById('mute-btn');
|
||||||
|
|
||||||
|
if (audioMuted) {
|
||||||
|
btn.textContent = '🔇';
|
||||||
|
if (window.setMusicVolume) window.setMusicVolume(0);
|
||||||
|
if (window.setSFXVolume) window.setSFXVolume(0);
|
||||||
|
} else {
|
||||||
|
btn.textContent = '🔊';
|
||||||
|
const musicVol = parseInt(localStorage.getItem('escape_music_volume') || 70) / 100;
|
||||||
|
const sfxVol = parseInt(localStorage.getItem('escape_sfx_volume') || 70) / 100;
|
||||||
|
if (window.setMusicVolume) window.setMusicVolume(musicVol);
|
||||||
|
if (window.setSFXVolume) window.setSFXVolume(sfxVol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings Volume Sliders
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const musicSlider = document.getElementById('musicVolume');
|
||||||
|
const sfxSlider = document.getElementById('sfxVolume');
|
||||||
|
const musicValue = document.getElementById('musicValue');
|
||||||
|
const sfxValue = document.getElementById('sfxValue');
|
||||||
|
|
||||||
|
if (musicSlider) {
|
||||||
|
musicSlider.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
musicValue.textContent = value + '%';
|
||||||
|
localStorage.setItem('escape_music_volume', value);
|
||||||
|
|
||||||
|
if (window.setMusicVolume && !audioMuted) {
|
||||||
|
window.setMusicVolume(value / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved value
|
||||||
|
const savedMusic = localStorage.getItem('escape_music_volume') || 70;
|
||||||
|
musicSlider.value = savedMusic;
|
||||||
|
musicValue.textContent = savedMusic + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sfxSlider) {
|
||||||
|
sfxSlider.addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
sfxValue.textContent = value + '%';
|
||||||
|
localStorage.setItem('escape_sfx_volume', value);
|
||||||
|
|
||||||
|
if (window.setSFXVolume && !audioMuted) {
|
||||||
|
window.setSFXVolume(value / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved value
|
||||||
|
const savedSFX = localStorage.getItem('escape_sfx_volume') || 70;
|
||||||
|
sfxSlider.value = savedSFX;
|
||||||
|
sfxValue.textContent = savedSFX + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved player name
|
||||||
|
const savedName = localStorage.getItem('escape_player_name');
|
||||||
|
if (savedName) {
|
||||||
|
document.getElementById('playerName').value = savedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load local highscore
|
||||||
|
const highscore = localStorage.getItem('escape_local_highscore') || 0;
|
||||||
|
const hsElement = document.getElementById('localHighscore');
|
||||||
|
if (hsElement) {
|
||||||
|
hsElement.textContent = highscore;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// ESC to show menu (only if game is started)
|
||||||
|
if (e.key === 'Escape' && gameStarted) {
|
||||||
|
showMenu();
|
||||||
|
gameStarted = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show Game Over Screen (called by WASM)
|
||||||
|
function showGameOver(score) {
|
||||||
|
hideAllScreens();
|
||||||
|
document.getElementById('gameOverScreen').classList.remove('hidden');
|
||||||
|
document.getElementById('finalScore').textContent = score;
|
||||||
|
|
||||||
|
// Update local highscore
|
||||||
|
const currentHS = parseInt(localStorage.getItem('escape_local_highscore') || 0);
|
||||||
|
if (score > currentHS) {
|
||||||
|
localStorage.setItem('escape_local_highscore', score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request leaderboard
|
||||||
|
if (window.requestLeaderboard) {
|
||||||
|
window.requestLeaderboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('💀 Game Over! Score:', score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions for WASM to call
|
||||||
|
window.showMenu = showMenu;
|
||||||
|
window.hideMenu = hideMenu;
|
||||||
|
window.updateLeaderboard = updateLeaderboard;
|
||||||
|
window.showGameOver = showGameOver;
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
initWASM();
|
||||||
|
|
||||||
|
console.log('🎮 Game.js loaded - Retro Edition');
|
||||||
147
cmd/client/web/index.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>Escape From Teacher</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<button id="mute-btn" onclick="toggleAudio()">🔊</button>
|
||||||
|
|
||||||
|
<div id="rotate-overlay">
|
||||||
|
<div class="icon">📱↻</div>
|
||||||
|
<p>Bitte Gerät drehen!</p>
|
||||||
|
<small>Querformat benötigt</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game-container">
|
||||||
|
<!-- MAIN MENU -->
|
||||||
|
<div id="menu" class="overlay-screen">
|
||||||
|
<div id="startScreen">
|
||||||
|
<div class="start-left">
|
||||||
|
<h1>ESCAPE FROM<br>TEACHER</h1>
|
||||||
|
|
||||||
|
<p style="font-size: 12px; color: #aaa;">Dein Rekord: <span id="localHighscore" style="color:yellow">0</span></p>
|
||||||
|
|
||||||
|
<input type="text" id="playerName" placeholder="NAME (4 ZEICHEN)" maxlength="15" style="text-transform:uppercase;">
|
||||||
|
|
||||||
|
<button id="startBtn" onclick="startSoloGame()">SOLO STARTEN</button>
|
||||||
|
<button id="coopBtn" onclick="showCoopMenu()">CO-OP SPIELEN</button>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-title">SCHUL-NEWS</div>
|
||||||
|
<p>
|
||||||
|
• Der Lehrer ist hinter dir her!<br>
|
||||||
|
• Spring über Hindernisse.<br>
|
||||||
|
• Sammle Power-Ups für Vorteile!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-title">STEUERUNG</div>
|
||||||
|
<p>
|
||||||
|
PC: <strong>Leertaste</strong> (Springen), <strong>WASD/Pfeile</strong> (Bewegen)<br>
|
||||||
|
Handy: <strong>Bildschirm-Buttons</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal-bar">
|
||||||
|
<button class="legal-btn" onclick="showLeaderboard()">🏆 TOP 10</button>
|
||||||
|
<button class="legal-btn" onclick="showSettings()">⚙️ EINSTELLUNGEN</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="start-right">
|
||||||
|
<div class="hall-of-fame-box">
|
||||||
|
<h3>🏆 TOP SCHÜLER</h3>
|
||||||
|
<div id="startLeaderboardList">Lade...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CO-OP MENU -->
|
||||||
|
<div id="coopMenu" class="overlay-screen hidden">
|
||||||
|
<div class="center-box">
|
||||||
|
<h1>CO-OP MODUS</h1>
|
||||||
|
|
||||||
|
<button class="big-btn" onclick="createRoom()">RAUM ERSTELLEN</button>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0;">- ODER -</div>
|
||||||
|
|
||||||
|
<input type="text" id="joinRoomCode" placeholder="RAUM-CODE" maxlength="6" style="text-transform:uppercase;">
|
||||||
|
<input type="text" id="teamNameJoin" placeholder="TEAM-NAME" maxlength="15">
|
||||||
|
<button onclick="joinRoom()">RAUM BEITRETEN</button>
|
||||||
|
|
||||||
|
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SETTINGS MENU -->
|
||||||
|
<div id="settingsMenu" class="overlay-screen hidden">
|
||||||
|
<div class="center-box">
|
||||||
|
<h1>EINSTELLUNGEN</h1>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>MUSIK LAUTSTÄRKE:</label>
|
||||||
|
<input type="range" id="musicVolume" min="0" max="100" value="70">
|
||||||
|
<span id="musicValue">70%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>SFX LAUTSTÄRKE:</label>
|
||||||
|
<input type="range" id="sfxVolume" min="0" max="100" value="70">
|
||||||
|
<span id="sfxValue">70%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LEADERBOARD MENU -->
|
||||||
|
<div id="leaderboardMenu" class="overlay-screen hidden">
|
||||||
|
<div class="center-box">
|
||||||
|
<h1>🏆 TOP 10 LEADERBOARD</h1>
|
||||||
|
|
||||||
|
<div id="leaderboardList" class="leaderboard-box">
|
||||||
|
Lade Leaderboard...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GAME OVER SCREEN -->
|
||||||
|
<div id="gameOverScreen" class="overlay-screen hidden">
|
||||||
|
<div class="center-box">
|
||||||
|
<h1>ERWISCHT!</h1>
|
||||||
|
|
||||||
|
<p style="font-size: 18px; margin: 20px 0;">
|
||||||
|
Dein Score: <span id="finalScore" style="color:yellow; font-size: 24px;">0</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="leaderboardList" class="leaderboard-box" style="margin: 20px 0;">
|
||||||
|
Lade Leaderboard...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="big-btn" onclick="location.reload()" style="background: #ff4444; margin-top: 20px;">NOCHMAL SPIELEN</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOADING SCREEN -->
|
||||||
|
<div id="loading" class="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Lade Escape From Teacher...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WASM Execution -->
|
||||||
|
<script src="wasm_exec.js"></script>
|
||||||
|
<script src="game.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
cmd/client/web/main.wasm
Executable file
1
cmd/client/web/style.css
Normal file
575
cmd/client/web/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -28,10 +28,11 @@ const (
|
|||||||
AssetFile = "./cmd/client/assets/assets.json"
|
AssetFile = "./cmd/client/assets/assets.json"
|
||||||
ChunkDir = "./cmd/client/assets/chunks"
|
ChunkDir = "./cmd/client/assets/chunks"
|
||||||
|
|
||||||
SidebarWidth = 250
|
LeftSidebarWidth = 250
|
||||||
TopBarHeight = 40
|
RightSidebarWidth = 250
|
||||||
CanvasHeight = 720
|
TopBarHeight = 40
|
||||||
CanvasWidth = 1280
|
CanvasHeight = 720
|
||||||
|
CanvasWidth = 1280
|
||||||
|
|
||||||
TileSize = 64
|
TileSize = 64
|
||||||
RefFloorY = 540
|
RefFloorY = 540
|
||||||
@@ -55,12 +56,15 @@ type LevelEditor struct {
|
|||||||
assetList []string
|
assetList []string
|
||||||
assetsImages map[string]*ebiten.Image
|
assetsImages map[string]*ebiten.Image
|
||||||
|
|
||||||
currentChunk game.Chunk
|
currentChunk game.Chunk
|
||||||
|
currentChunkFile string // Aktuell geladene Datei
|
||||||
|
chunkFiles []string // Liste aller Chunk-Dateien
|
||||||
|
|
||||||
scrollX float64
|
scrollX float64
|
||||||
zoom float64
|
zoom float64
|
||||||
listScroll float64
|
listScroll float64
|
||||||
statusMsg string
|
chunkListScroll float64 // Scroll für Chunk-Liste
|
||||||
|
statusMsg string
|
||||||
|
|
||||||
showGrid bool
|
showGrid bool
|
||||||
enableSnap bool
|
enableSnap bool
|
||||||
@@ -75,20 +79,31 @@ type LevelEditor struct {
|
|||||||
dragAssetID string
|
dragAssetID string
|
||||||
dragTargetIndex int
|
dragTargetIndex int
|
||||||
dragOffset game.Vec2
|
dragOffset game.Vec2
|
||||||
|
|
||||||
|
// Bewegende Plattform-Modus
|
||||||
|
movingPlatformMode bool // Ist Bewegende-Plattform-Modus aktiv?
|
||||||
|
movingPlatformObjIndex int // Index des aktuellen Plattform-Objekts
|
||||||
|
movingPlatformSetStart bool // true = setze Start, false = setze End
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLevelEditor() *LevelEditor {
|
func NewLevelEditor() *LevelEditor {
|
||||||
le := &LevelEditor{
|
le := &LevelEditor{
|
||||||
assetsImages: make(map[string]*ebiten.Image),
|
assetsImages: make(map[string]*ebiten.Image),
|
||||||
currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}},
|
currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}},
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
showGrid: true,
|
showGrid: true,
|
||||||
enableSnap: true,
|
enableSnap: true,
|
||||||
showHitbox: true,
|
showHitbox: true,
|
||||||
showPlayerRef: true, // Standardmäßig an
|
showPlayerRef: true, // Standardmäßig an
|
||||||
|
movingPlatformObjIndex: -1,
|
||||||
}
|
}
|
||||||
le.LoadAssets()
|
le.LoadAssets()
|
||||||
le.LoadChunk("chunk_01.json")
|
le.RefreshChunkList()
|
||||||
|
if len(le.chunkFiles) > 0 {
|
||||||
|
le.LoadChunk(le.chunkFiles[0])
|
||||||
|
} else {
|
||||||
|
le.currentChunkFile = "chunk_new.json"
|
||||||
|
}
|
||||||
return le
|
return le
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,29 +132,82 @@ func (le *LevelEditor) LoadAssets() {
|
|||||||
sort.Strings(le.assetList)
|
sort.Strings(le.assetList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (le *LevelEditor) RefreshChunkList() {
|
||||||
|
le.chunkFiles = []string{}
|
||||||
|
files, err := ioutil.ReadDir(ChunkDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if !f.IsDir() && strings.HasSuffix(f.Name(), ".json") {
|
||||||
|
le.chunkFiles = append(le.chunkFiles, f.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(le.chunkFiles)
|
||||||
|
}
|
||||||
|
|
||||||
func (le *LevelEditor) LoadChunk(filename string) {
|
func (le *LevelEditor) LoadChunk(filename string) {
|
||||||
path := filepath.Join(ChunkDir, filename)
|
path := filepath.Join(ChunkDir, filename)
|
||||||
data, err := ioutil.ReadFile(path)
|
data, err := ioutil.ReadFile(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
json.Unmarshal(data, &le.currentChunk)
|
json.Unmarshal(data, &le.currentChunk)
|
||||||
|
le.currentChunkFile = filename
|
||||||
le.statusMsg = "Geladen: " + filename
|
le.statusMsg = "Geladen: " + filename
|
||||||
} else {
|
} else {
|
||||||
le.currentChunk.ID = strings.TrimSuffix(filename, filepath.Ext(filename))
|
le.currentChunk.ID = strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
le.currentChunk.Width = 50
|
||||||
|
le.currentChunk.Objects = []game.LevelObject{}
|
||||||
|
le.currentChunkFile = filename
|
||||||
le.statusMsg = "Neu erstellt: " + le.currentChunk.ID
|
le.statusMsg = "Neu erstellt: " + le.currentChunk.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (le *LevelEditor) CreateNewChunk(name string) {
|
||||||
|
if name == "" {
|
||||||
|
name = "chunk_new"
|
||||||
|
}
|
||||||
|
filename := name + ".json"
|
||||||
|
le.currentChunk = game.Chunk{
|
||||||
|
ID: name,
|
||||||
|
Width: 50,
|
||||||
|
Objects: []game.LevelObject{},
|
||||||
|
}
|
||||||
|
le.currentChunkFile = filename
|
||||||
|
le.SaveChunk()
|
||||||
|
le.RefreshChunkList()
|
||||||
|
le.statusMsg = "Neuer Chunk erstellt: " + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *LevelEditor) DeleteChunk(filename string) {
|
||||||
|
path := filepath.Join(ChunkDir, filename)
|
||||||
|
err := os.Remove(path)
|
||||||
|
if err == nil {
|
||||||
|
le.statusMsg = "Gelöscht: " + filename
|
||||||
|
le.RefreshChunkList()
|
||||||
|
// Lade ersten verfügbaren Chunk oder erstelle neuen
|
||||||
|
if len(le.chunkFiles) > 0 {
|
||||||
|
le.LoadChunk(le.chunkFiles[0])
|
||||||
|
} else {
|
||||||
|
le.CreateNewChunk("chunk_new")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
le.statusMsg = "Fehler beim Löschen: " + err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (le *LevelEditor) SaveChunk() {
|
func (le *LevelEditor) SaveChunk() {
|
||||||
os.MkdirAll(ChunkDir, 0755)
|
os.MkdirAll(ChunkDir, 0755)
|
||||||
filename := le.currentChunk.ID + ".json"
|
filename := le.currentChunk.ID + ".json"
|
||||||
path := filepath.Join(ChunkDir, filename)
|
path := filepath.Join(ChunkDir, filename)
|
||||||
data, _ := json.MarshalIndent(le.currentChunk, "", " ")
|
data, _ := json.MarshalIndent(le.currentChunk, "", " ")
|
||||||
ioutil.WriteFile(path, data, 0644)
|
ioutil.WriteFile(path, data, 0644)
|
||||||
|
le.currentChunkFile = filename
|
||||||
|
le.RefreshChunkList()
|
||||||
le.statusMsg = "GESPEICHERT als " + filename
|
le.statusMsg = "GESPEICHERT als " + filename
|
||||||
}
|
}
|
||||||
|
|
||||||
func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) {
|
func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) {
|
||||||
screenX := float64(mx - SidebarWidth)
|
screenX := float64(mx - LeftSidebarWidth)
|
||||||
screenY := float64(my - TopBarHeight)
|
screenY := float64(my - TopBarHeight)
|
||||||
worldX := (screenX / le.zoom) + le.scrollX
|
worldX := (screenX / le.zoom) + le.scrollX
|
||||||
worldY := screenY / le.zoom
|
worldY := screenY / le.zoom
|
||||||
@@ -200,6 +268,17 @@ func (le *LevelEditor) Update() error {
|
|||||||
le.currentChunk.Width = v
|
le.currentChunk.Width = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if le.activeField == "newchunk" {
|
||||||
|
le.CreateNewChunk(le.inputBuffer)
|
||||||
|
}
|
||||||
|
if le.activeField == "mpspeed" {
|
||||||
|
if v, err := strconv.ParseFloat(le.inputBuffer, 64); err == nil && le.movingPlatformObjIndex != -1 {
|
||||||
|
if le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform != nil {
|
||||||
|
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.Speed = v
|
||||||
|
le.statusMsg = fmt.Sprintf("Speed gesetzt: %.0f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
le.activeField = ""
|
le.activeField = ""
|
||||||
} else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
} else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
||||||
le.activeField = ""
|
le.activeField = ""
|
||||||
@@ -214,7 +293,9 @@ func (le *LevelEditor) Update() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hotkeys
|
// Hotkeys
|
||||||
if mx > SidebarWidth {
|
canvasStartX := LeftSidebarWidth
|
||||||
|
canvasEndX := CanvasWidth - RightSidebarWidth
|
||||||
|
if mx > canvasStartX && mx < canvasEndX {
|
||||||
_, wy := ebiten.Wheel()
|
_, wy := ebiten.Wheel()
|
||||||
if wy != 0 {
|
if wy != 0 {
|
||||||
le.zoom += wy * 0.1
|
le.zoom += wy * 0.1
|
||||||
@@ -238,6 +319,15 @@ func (le *LevelEditor) Update() error {
|
|||||||
if inpututil.IsKeyJustPressed(ebiten.KeyP) {
|
if inpututil.IsKeyJustPressed(ebiten.KeyP) {
|
||||||
le.showPlayerRef = !le.showPlayerRef
|
le.showPlayerRef = !le.showPlayerRef
|
||||||
} // NEU: Toggle Player
|
} // NEU: Toggle Player
|
||||||
|
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
|
||||||
|
le.movingPlatformMode = !le.movingPlatformMode
|
||||||
|
if !le.movingPlatformMode {
|
||||||
|
le.movingPlatformObjIndex = -1
|
||||||
|
le.statusMsg = "Moving Platform Mode deaktiviert"
|
||||||
|
} else {
|
||||||
|
le.statusMsg = "Moving Platform Mode: Plattform auswählen"
|
||||||
|
}
|
||||||
|
}
|
||||||
if ebiten.IsKeyPressed(ebiten.KeyRight) {
|
if ebiten.IsKeyPressed(ebiten.KeyRight) {
|
||||||
le.scrollX += 10 / le.zoom
|
le.scrollX += 10 / le.zoom
|
||||||
}
|
}
|
||||||
@@ -263,8 +353,8 @@ func (le *LevelEditor) Update() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Palette
|
// Left Sidebar - Asset Palette
|
||||||
if mx < SidebarWidth {
|
if mx < LeftSidebarWidth {
|
||||||
_, wy := ebiten.Wheel()
|
_, wy := ebiten.Wheel()
|
||||||
le.listScroll -= wy * 20
|
le.listScroll -= wy * 20
|
||||||
if le.listScroll < 0 {
|
if le.listScroll < 0 {
|
||||||
@@ -284,23 +374,119 @@ func (le *LevelEditor) Update() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canvas Logic
|
// Right Sidebar - Chunk Manager
|
||||||
|
if mx > CanvasWidth-RightSidebarWidth {
|
||||||
|
_, wy := ebiten.Wheel()
|
||||||
|
le.chunkListScroll -= wy * 20
|
||||||
|
if le.chunkListScroll < 0 {
|
||||||
|
le.chunkListScroll = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
|
||||||
|
// "Neuer Chunk" Button (Y: TopBarHeight+30 bis TopBarHeight+60)
|
||||||
|
if mx >= CanvasWidth-RightSidebarWidth+10 && mx < CanvasWidth-RightSidebarWidth+240 &&
|
||||||
|
my >= TopBarHeight+30 && my < TopBarHeight+60 {
|
||||||
|
le.activeField = "newchunk"
|
||||||
|
le.inputBuffer = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk-Liste (startet bei TopBarHeight+70)
|
||||||
|
if my >= TopBarHeight+70 {
|
||||||
|
clickY := float64(my-TopBarHeight-70) + le.chunkListScroll
|
||||||
|
idx := int(clickY / 30)
|
||||||
|
if idx >= 0 && idx < len(le.chunkFiles) {
|
||||||
|
le.LoadChunk(le.chunkFiles[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechtsklick zum Löschen in Chunk-Liste
|
||||||
|
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) && my >= TopBarHeight+70 {
|
||||||
|
clickY := float64(my-TopBarHeight-70) + le.chunkListScroll
|
||||||
|
idx := int(clickY / 30)
|
||||||
|
if idx >= 0 && idx < len(le.chunkFiles) {
|
||||||
|
if len(le.chunkFiles) > 1 || le.chunkFiles[idx] != le.currentChunkFile {
|
||||||
|
le.DeleteChunk(le.chunkFiles[idx])
|
||||||
|
} else {
|
||||||
|
le.statusMsg = "Kann einzigen Chunk nicht löschen!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas Logic (nur wenn wir wirklich im Canvas-Bereich sind)
|
||||||
|
if mx < LeftSidebarWidth || mx > CanvasWidth-RightSidebarWidth || my < TopBarHeight {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
worldX, worldY := le.ScreenToWorld(mx, my)
|
worldX, worldY := le.ScreenToWorld(mx, my)
|
||||||
|
|
||||||
// DELETE
|
// MOVING PLATFORM MODE
|
||||||
|
if le.movingPlatformMode && inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
|
||||||
|
// Keine Plattform ausgewählt? → Plattform auswählen
|
||||||
|
if le.movingPlatformObjIndex == -1 {
|
||||||
|
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
||||||
|
obj := le.currentChunk.Objects[i]
|
||||||
|
assetDef, ok := le.assetManifest.Assets[obj.AssetID]
|
||||||
|
if !ok || assetDef.Type != "platform" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w, h := le.GetAssetSize(obj.AssetID)
|
||||||
|
if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
|
||||||
|
le.movingPlatformObjIndex = i
|
||||||
|
le.movingPlatformSetStart = true
|
||||||
|
|
||||||
|
// Wenn noch keine MovingPlatform-Daten → initialisiere
|
||||||
|
if obj.MovingPlatform == nil {
|
||||||
|
le.currentChunk.Objects[i].MovingPlatform = &game.MovingPlatformData{
|
||||||
|
StartX: obj.X,
|
||||||
|
StartY: obj.Y,
|
||||||
|
EndX: obj.X + 200, // Default Endpunkt
|
||||||
|
EndY: obj.Y,
|
||||||
|
Speed: 100, // Default Speed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
le.statusMsg = "Plattform gewählt - Klicke Start-Punkt"
|
||||||
|
le.activeField = "mpspeed"
|
||||||
|
le.inputBuffer = fmt.Sprintf("%.0f", le.currentChunk.Objects[i].MovingPlatform.Speed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plattform ist ausgewählt → setze Start oder End
|
||||||
|
if le.movingPlatformSetStart {
|
||||||
|
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.StartX = worldX
|
||||||
|
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.StartY = worldY
|
||||||
|
le.movingPlatformSetStart = false
|
||||||
|
le.statusMsg = "Start gesetzt - Klicke End-Punkt"
|
||||||
|
} else {
|
||||||
|
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.EndX = worldX
|
||||||
|
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.EndY = worldY
|
||||||
|
le.statusMsg = "End gesetzt - Drücke M zum Beenden oder wähle neue Plattform"
|
||||||
|
le.movingPlatformObjIndex = -1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE mit Rechtsklick
|
||||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {
|
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {
|
||||||
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
||||||
obj := le.currentChunk.Objects[i]
|
obj := le.currentChunk.Objects[i]
|
||||||
w, h := le.GetAssetSize(obj.AssetID)
|
w, h := le.GetAssetSize(obj.AssetID)
|
||||||
if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
|
if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
|
||||||
le.currentChunk.Objects = append(le.currentChunk.Objects[:i], le.currentChunk.Objects[i+1:]...)
|
le.currentChunk.Objects = append(le.currentChunk.Objects[:i], le.currentChunk.Objects[i+1:]...)
|
||||||
|
le.statusMsg = fmt.Sprintf("Objekt gelöscht: %s", obj.AssetID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOVE
|
// MOVE
|
||||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging {
|
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging && !le.movingPlatformMode {
|
||||||
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
||||||
obj := le.currentChunk.Objects[i]
|
obj := le.currentChunk.Objects[i]
|
||||||
w, h := le.GetAssetSize(obj.AssetID)
|
w, h := le.GetAssetSize(obj.AssetID)
|
||||||
@@ -352,11 +538,13 @@ func (le *LevelEditor) Update() error {
|
|||||||
func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
||||||
// UI HINTERGRUND
|
// UI HINTERGRUND
|
||||||
vector.DrawFilledRect(screen, 0, 0, CanvasWidth, TopBarHeight, ColBgTop, false)
|
vector.DrawFilledRect(screen, 0, 0, CanvasWidth, TopBarHeight, ColBgTop, false)
|
||||||
vector.DrawFilledRect(screen, 0, TopBarHeight, SidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
|
vector.DrawFilledRect(screen, 0, TopBarHeight, LeftSidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
|
||||||
|
vector.DrawFilledRect(screen, CanvasWidth-RightSidebarWidth, TopBarHeight, RightSidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
|
||||||
|
|
||||||
text.Draw(screen, "ID: "+le.currentChunk.ID, basicfont.Face7x13, 75, 25, color.White)
|
text.Draw(screen, "ID: "+le.currentChunk.ID, basicfont.Face7x13, 75, 25, color.White)
|
||||||
|
|
||||||
// ASSET LISTE
|
// LEFT SIDEBAR - ASSET LISTE
|
||||||
|
text.Draw(screen, "ASSETS", basicfont.Face7x13, 10, TopBarHeight+20, ColHighlight)
|
||||||
startY := float64(TopBarHeight+40) - le.listScroll
|
startY := float64(TopBarHeight+40) - le.listScroll
|
||||||
for i, id := range le.assetList {
|
for i, id := range le.assetList {
|
||||||
y := startY + float64(i*25)
|
y := startY + float64(i*25)
|
||||||
@@ -370,26 +558,81 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
|||||||
text.Draw(screen, id, basicfont.Face7x13, 10, int(y+15), col)
|
text.Draw(screen, id, basicfont.Face7x13, 10, int(y+15), col)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RIGHT SIDEBAR - CHUNK MANAGER
|
||||||
|
rightX := CanvasWidth - RightSidebarWidth
|
||||||
|
text.Draw(screen, "CHUNKS", basicfont.Face7x13, rightX+10, TopBarHeight+20, ColHighlight)
|
||||||
|
|
||||||
|
// "Neuer Chunk" Button
|
||||||
|
btnX := float32(rightX + 10)
|
||||||
|
btnY := float32(TopBarHeight + 30)
|
||||||
|
btnW := float32(230)
|
||||||
|
btnH := float32(30)
|
||||||
|
vector.DrawFilledRect(screen, btnX, btnY, btnW, btnH, color.RGBA{60, 120, 80, 255}, false)
|
||||||
|
vector.StrokeRect(screen, btnX, btnY, btnW, btnH, 2, ColHighlight, false)
|
||||||
|
|
||||||
|
if le.activeField == "newchunk" {
|
||||||
|
text.Draw(screen, "Name: "+le.inputBuffer+"_", basicfont.Face7x13, rightX+15, TopBarHeight+50, color.White)
|
||||||
|
} else {
|
||||||
|
text.Draw(screen, "[+] Neuer Chunk", basicfont.Face7x13, rightX+65, TopBarHeight+50, color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk-Liste
|
||||||
|
chunkStartY := float64(TopBarHeight+70) - le.chunkListScroll
|
||||||
|
for i, filename := range le.chunkFiles {
|
||||||
|
y := chunkStartY + float64(i*30)
|
||||||
|
if y < float64(TopBarHeight+70) || y > CanvasHeight {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
col := ColText
|
||||||
|
bgCol := color.RGBA{50, 54, 62, 255}
|
||||||
|
if filename == le.currentChunkFile {
|
||||||
|
col = color.RGBA{100, 255, 100, 255}
|
||||||
|
bgCol = color.RGBA{40, 80, 40, 255}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hintergrund für aktuellen Chunk
|
||||||
|
vector.DrawFilledRect(screen, float32(rightX+5), float32(y), float32(RightSidebarWidth-10), 28, bgCol, false)
|
||||||
|
|
||||||
|
// Dateiname
|
||||||
|
displayName := strings.TrimSuffix(filename, ".json")
|
||||||
|
if len(displayName) > 20 {
|
||||||
|
displayName = displayName[:20] + "..."
|
||||||
|
}
|
||||||
|
text.Draw(screen, displayName, basicfont.Face7x13, rightX+10, int(y+18), col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinweis
|
||||||
|
text.Draw(screen, "L-Click: Load", basicfont.Face7x13, rightX+10, CanvasHeight-40, color.Gray{100})
|
||||||
|
text.Draw(screen, "R-Click: Delete", basicfont.Face7x13, rightX+10, CanvasHeight-25, color.Gray{100})
|
||||||
|
|
||||||
// CANVAS
|
// CANVAS
|
||||||
canvasOffX := float64(SidebarWidth)
|
canvasOffX := float64(LeftSidebarWidth)
|
||||||
canvasOffY := float64(TopBarHeight)
|
canvasOffY := float64(TopBarHeight)
|
||||||
|
canvasWidth := float32(CanvasWidth - LeftSidebarWidth - RightSidebarWidth)
|
||||||
|
|
||||||
|
// Canvas Hintergrund
|
||||||
|
vector.DrawFilledRect(screen, float32(canvasOffX), float32(canvasOffY), canvasWidth, CanvasHeight-TopBarHeight, ColBgCanvas, false)
|
||||||
|
|
||||||
// GRID
|
// GRID
|
||||||
|
canvasEndX := float32(CanvasWidth - RightSidebarWidth)
|
||||||
if le.showGrid {
|
if le.showGrid {
|
||||||
startGridX := int(le.scrollX/TileSize) * TileSize
|
startGridX := int(le.scrollX/TileSize) * TileSize
|
||||||
for x := startGridX; x < startGridX+int(CanvasWidth/le.zoom)+TileSize; x += TileSize {
|
for x := startGridX; x < startGridX+int(float64(canvasWidth)/le.zoom)+TileSize; x += TileSize {
|
||||||
drawX := float32((float64(x)-le.scrollX)*le.zoom + canvasOffX)
|
drawX := float32((float64(x)-le.scrollX)*le.zoom + canvasOffX)
|
||||||
vector.StrokeLine(screen, drawX, float32(canvasOffY), drawX, CanvasHeight, 1, ColGrid, false)
|
if drawX >= float32(canvasOffX) && drawX <= canvasEndX {
|
||||||
|
vector.StrokeLine(screen, drawX, float32(canvasOffY), drawX, CanvasHeight, 1, ColGrid, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for y := 0; y < int(CanvasHeight/le.zoom); y += TileSize {
|
for y := 0; y < int(CanvasHeight/le.zoom); y += TileSize {
|
||||||
drawY := float32(float64(y)*le.zoom + canvasOffY)
|
drawY := float32(float64(y)*le.zoom + canvasOffY)
|
||||||
vector.StrokeLine(screen, float32(canvasOffX), drawY, CanvasWidth, drawY, 1, ColGrid, false)
|
vector.StrokeLine(screen, float32(canvasOffX), drawY, canvasEndX, drawY, 1, ColGrid, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BODEN LINIE
|
// BODEN LINIE
|
||||||
floorScreenY := float32((RefFloorY * le.zoom) + canvasOffY)
|
floorScreenY := float32((RefFloorY * le.zoom) + canvasOffY)
|
||||||
vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, float32(CanvasWidth), floorScreenY, 2, ColFloor, false)
|
vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, canvasEndX, floorScreenY, 2, ColFloor, false)
|
||||||
|
|
||||||
// PLAYER REFERENCE (GHOST)
|
// PLAYER REFERENCE (GHOST)
|
||||||
// PLAYER REFERENCE (GHOST)
|
// PLAYER REFERENCE (GHOST)
|
||||||
@@ -422,14 +665,54 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OBJEKTE
|
// OBJEKTE
|
||||||
for _, obj := range le.currentChunk.Objects {
|
for i, obj := range le.currentChunk.Objects {
|
||||||
le.DrawAsset(screen, obj.AssetID, obj.X, obj.Y, canvasOffX, canvasOffY, 1.0)
|
le.DrawAsset(screen, obj.AssetID, obj.X, obj.Y, canvasOffX, canvasOffY, 1.0)
|
||||||
|
|
||||||
|
// MOVING PLATFORM MARKER
|
||||||
|
if obj.MovingPlatform != nil {
|
||||||
|
mpd := obj.MovingPlatform
|
||||||
|
|
||||||
|
// Start-Punkt (grün)
|
||||||
|
sxStart := float32((mpd.StartX-le.scrollX)*le.zoom + canvasOffX)
|
||||||
|
syStart := float32(mpd.StartY*le.zoom + canvasOffY)
|
||||||
|
|
||||||
|
// End-Punkt (rot)
|
||||||
|
sxEnd := float32((mpd.EndX-le.scrollX)*le.zoom + canvasOffX)
|
||||||
|
syEnd := float32(mpd.EndY*le.zoom + canvasOffY)
|
||||||
|
|
||||||
|
// Linie zwischen Start und End (gelb gestrichelt)
|
||||||
|
vector.StrokeLine(screen, sxStart, syStart, sxEnd, syEnd, 2, color.RGBA{255, 255, 0, 200}, false)
|
||||||
|
|
||||||
|
// Start-Marker (grüner Kreis)
|
||||||
|
vector.DrawFilledCircle(screen, sxStart, syStart, 8, color.RGBA{0, 255, 0, 255}, false)
|
||||||
|
vector.StrokeCircle(screen, sxStart, syStart, 8, 2, color.RGBA{0, 200, 0, 255}, false)
|
||||||
|
|
||||||
|
// End-Marker (roter Kreis)
|
||||||
|
vector.DrawFilledCircle(screen, sxEnd, syEnd, 8, color.RGBA{255, 0, 0, 255}, false)
|
||||||
|
vector.StrokeCircle(screen, sxEnd, syEnd, 8, 2, color.RGBA{200, 0, 0, 255}, false)
|
||||||
|
|
||||||
|
// Speed Label
|
||||||
|
midX := int((sxStart + sxEnd) / 2)
|
||||||
|
midY := int((syStart + syEnd) / 2)
|
||||||
|
speedLabel := fmt.Sprintf("%.0f u/s", mpd.Speed)
|
||||||
|
text.Draw(screen, speedLabel, basicfont.Face7x13, midX-20, midY-10, color.RGBA{255, 255, 0, 255})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight wenn ausgewählt im Moving Platform Mode
|
||||||
|
if le.movingPlatformMode && i == le.movingPlatformObjIndex {
|
||||||
|
w, h := le.GetAssetSize(obj.AssetID)
|
||||||
|
sX := float32((obj.X-le.scrollX)*le.zoom + canvasOffX)
|
||||||
|
sY := float32(obj.Y*le.zoom + canvasOffY)
|
||||||
|
sW := float32(w * le.zoom)
|
||||||
|
sH := float32(h * le.zoom)
|
||||||
|
vector.StrokeRect(screen, sX, sY, sW, sH, 3, color.RGBA{255, 255, 0, 255}, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DRAG GHOST
|
// DRAG GHOST
|
||||||
if le.isDragging && le.dragType == "new" {
|
if le.isDragging && le.dragType == "new" {
|
||||||
mx, my := ebiten.CursorPosition()
|
mx, my := ebiten.CursorPosition()
|
||||||
if mx > SidebarWidth && my > TopBarHeight {
|
if mx > LeftSidebarWidth && mx < CanvasWidth-RightSidebarWidth && my > TopBarHeight {
|
||||||
wRawX, wRawY := le.ScreenToWorld(mx, my)
|
wRawX, wRawY := le.ScreenToWorld(mx, my)
|
||||||
_, h := le.GetAssetSize(le.dragAssetID)
|
_, h := le.GetAssetSize(le.dragAssetID)
|
||||||
snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h)
|
snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h)
|
||||||
@@ -441,15 +724,33 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// STATUS
|
// STATUS
|
||||||
text.Draw(screen, "[S]ave | [G]rid | [H]itbox | [P]layer Ref | R-Click=Del", basicfont.Face7x13, 400, 25, color.Gray{100})
|
statusText := "[S]ave | [G]rid | [H]itbox | [P]layer | [M]oving Platform | R-Click=Del"
|
||||||
text.Draw(screen, le.statusMsg, basicfont.Face7x13, SidebarWidth+10, CanvasHeight-10, ColHighlight)
|
text.Draw(screen, statusText, basicfont.Face7x13, 380, 25, color.Gray{100})
|
||||||
|
|
||||||
|
// Moving Platform Mode Indicator
|
||||||
|
if le.movingPlatformMode {
|
||||||
|
modeText := "🟡 MOVING PLATFORM MODE"
|
||||||
|
text.Draw(screen, modeText, basicfont.Face7x13, LeftSidebarWidth+10, TopBarHeight+20, color.RGBA{255, 255, 0, 255})
|
||||||
|
|
||||||
|
// Speed Input Field wenn Plattform ausgewählt
|
||||||
|
if le.movingPlatformObjIndex != -1 && le.activeField == "mpspeed" {
|
||||||
|
speedFieldX := LeftSidebarWidth + 10
|
||||||
|
speedFieldY := TopBarHeight + 40
|
||||||
|
fieldText := "Speed: " + le.inputBuffer + "_"
|
||||||
|
text.Draw(screen, fieldText, basicfont.Face7x13, speedFieldX, speedFieldY, color.RGBA{0, 255, 0, 255})
|
||||||
|
text.Draw(screen, "[Enter] to confirm", basicfont.Face7x13, speedFieldX, speedFieldY+20, color.Gray{150})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.Draw(screen, le.statusMsg, basicfont.Face7x13, LeftSidebarWidth+10, CanvasHeight-10, ColHighlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (le *LevelEditor) DrawAsset(screen *ebiten.Image, id string, wX, wY, offX, offY float64, alpha float32) {
|
func (le *LevelEditor) DrawAsset(screen *ebiten.Image, id string, wX, wY, offX, offY float64, alpha float32) {
|
||||||
sX := (wX-le.scrollX)*le.zoom + offX
|
sX := (wX-le.scrollX)*le.zoom + offX
|
||||||
sY := wY*le.zoom + offY
|
sY := wY*le.zoom + offY
|
||||||
|
|
||||||
if sX < SidebarWidth-100 || sX > CanvasWidth {
|
// Culling: Nicht zeichnen wenn außerhalb Canvas
|
||||||
|
if sX < float64(LeftSidebarWidth)-100 || sX > float64(CanvasWidth-RightSidebarWidth)+100 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,15 +113,33 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 6. HANDLER: LEADERBOARD REQUEST
|
// 6. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität)
|
||||||
_, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
|
_, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
|
||||||
top10 := server.GlobalLeaderboard.GetTop10()
|
top10 := server.GlobalLeaderboard.GetTop10()
|
||||||
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10))
|
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10))
|
||||||
ec.Publish(reply, top10)
|
ec.Publish(reply, top10)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 7. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway)
|
||||||
|
_, _ = ec.Subscribe("leaderboard.request", func(req *game.LeaderboardRequest) {
|
||||||
|
top10 := server.GlobalLeaderboard.GetTop10()
|
||||||
|
log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge", req.Mode, len(top10))
|
||||||
|
|
||||||
|
// Response an den angegebenen Channel senden
|
||||||
|
if req.ResponseChannel != "" {
|
||||||
|
resp := game.LeaderboardResponse{
|
||||||
|
Entries: top10,
|
||||||
|
}
|
||||||
|
ec.Publish(req.ResponseChannel, &resp)
|
||||||
|
log.Printf("📤 Leaderboard-Response gesendet an %s", req.ResponseChannel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
log.Println("✅ Server bereit. Warte auf Spieler...")
|
log.Println("✅ Server bereit. Warte auf Spieler...")
|
||||||
|
|
||||||
|
// 5. WEBSOCKET-GATEWAY STARTEN (für Browser-Clients)
|
||||||
|
go StartWebSocketGateway("8080", ec)
|
||||||
|
|
||||||
// Block forever
|
// Block forever
|
||||||
select {}
|
select {}
|
||||||
}
|
}
|
||||||
|
|||||||
228
cmd/server/websocket_gateway.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // Erlaube alle Origins für Development
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketMessage ist das allgemeine Format für WebSocket-Nachrichten
|
||||||
|
type WebSocketMessage struct {
|
||||||
|
Type string `json:"type"` // "join", "input", "start", "leaderboard_request"
|
||||||
|
Payload json.RawMessage `json:"payload"` // Beliebige JSON-Daten
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketClient repräsentiert einen verbundenen WebSocket-Client
|
||||||
|
type WebSocketClient struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
natsConn *nats.EncodedConn
|
||||||
|
playerID string
|
||||||
|
roomID string
|
||||||
|
send chan []byte
|
||||||
|
mutex sync.Mutex
|
||||||
|
subUpdates *nats.Subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocket verwaltet eine WebSocket-Verbindung
|
||||||
|
func handleWebSocket(w http.ResponseWriter, r *http.Request, ec *nats.EncodedConn) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ WebSocket Upgrade Fehler: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &WebSocketClient{
|
||||||
|
conn: conn,
|
||||||
|
natsConn: ec,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🔌 Neuer WebSocket-Client verbunden: %s", conn.RemoteAddr())
|
||||||
|
|
||||||
|
// Goroutinen für Lesen und Schreiben starten
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump liest Nachrichten vom WebSocket-Client
|
||||||
|
func (c *WebSocketClient) readPump() {
|
||||||
|
defer func() {
|
||||||
|
if c.subUpdates != nil {
|
||||||
|
c.subUpdates.Unsubscribe()
|
||||||
|
}
|
||||||
|
c.conn.Close()
|
||||||
|
log.Printf("🔌 WebSocket-Client getrennt: %s", c.conn.RemoteAddr())
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var msg WebSocketMessage
|
||||||
|
err := c.conn.ReadJSON(&msg)
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
log.Printf("⚠️ WebSocket Fehler: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nachricht basierend auf Typ verarbeiten
|
||||||
|
c.handleMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump sendet Nachrichten zum WebSocket-Client
|
||||||
|
func (c *WebSocketClient) writePump() {
|
||||||
|
defer c.conn.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
message, ok := <-c.send
|
||||||
|
if !ok {
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.conn.WriteMessage(websocket.TextMessage, message)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Fehler beim Senden: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage verarbeitet eingehende Nachrichten vom Client
|
||||||
|
func (c *WebSocketClient) handleMessage(msg WebSocketMessage) {
|
||||||
|
switch msg.Type {
|
||||||
|
case "join":
|
||||||
|
var req game.JoinRequest
|
||||||
|
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
||||||
|
log.Printf("❌ Join-Payload ungültig: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.playerID = req.Name
|
||||||
|
c.roomID = req.RoomID
|
||||||
|
if c.roomID == "" {
|
||||||
|
c.roomID = "lobby"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📥 WebSocket JOIN: Name=%s, RoomID=%s", req.Name, req.RoomID)
|
||||||
|
|
||||||
|
// An NATS weiterleiten
|
||||||
|
c.natsConn.Publish("game.join", &req)
|
||||||
|
|
||||||
|
// Auf Game-Updates für diesen Raum subscriben
|
||||||
|
roomChannel := "game.update." + c.roomID
|
||||||
|
sub, err := c.natsConn.Subscribe(roomChannel, func(state *game.GameState) {
|
||||||
|
// GameState an WebSocket-Client senden
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"type": "game_update",
|
||||||
|
"payload": state,
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case c.send <- data:
|
||||||
|
default:
|
||||||
|
log.Printf("⚠️ Send channel voll, Nachricht verworfen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Fehler beim Subscribe auf %s: %v", roomChannel, err)
|
||||||
|
} else {
|
||||||
|
c.subUpdates = sub
|
||||||
|
log.Printf("👂 WebSocket-Client lauscht auf %s", roomChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "input":
|
||||||
|
var input game.ClientInput
|
||||||
|
if err := json.Unmarshal(msg.Payload, &input); err != nil {
|
||||||
|
log.Printf("❌ Input-Payload ungültig: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerID setzen falls nicht vorhanden
|
||||||
|
if input.PlayerID == "" {
|
||||||
|
input.PlayerID = c.playerID
|
||||||
|
}
|
||||||
|
|
||||||
|
// An NATS weiterleiten
|
||||||
|
c.natsConn.Publish("game.input", &input)
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
var req game.StartRequest
|
||||||
|
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
||||||
|
log.Printf("❌ Start-Payload ungültig: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("▶️ WebSocket START: RoomID=%s", req.RoomID)
|
||||||
|
c.natsConn.Publish("game.start", &req)
|
||||||
|
|
||||||
|
case "leaderboard_request":
|
||||||
|
var req game.LeaderboardRequest
|
||||||
|
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
||||||
|
log.Printf("❌ Leaderboard-Request ungültig: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🏆 WebSocket Leaderboard-Request: Mode=%s", req.Mode)
|
||||||
|
|
||||||
|
// Auf Leaderboard-Response subscriben (einmalig)
|
||||||
|
responseChannel := "leaderboard.response." + c.playerID
|
||||||
|
sub, _ := c.natsConn.Subscribe(responseChannel, func(resp *game.LeaderboardResponse) {
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"type": "leaderboard_response",
|
||||||
|
"payload": resp,
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case c.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nach 5 Sekunden unsubscriben
|
||||||
|
go func() {
|
||||||
|
<-make(chan struct{})
|
||||||
|
sub.Unsubscribe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Request mit ResponseChannel an NATS senden
|
||||||
|
req.ResponseChannel = responseChannel
|
||||||
|
c.natsConn.Publish("leaderboard.request", &req)
|
||||||
|
|
||||||
|
case "score_submit":
|
||||||
|
var submit game.ScoreSubmission
|
||||||
|
if err := json.Unmarshal(msg.Payload, &submit); err != nil {
|
||||||
|
log.Printf("❌ Score-Submit ungültig: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📊 WebSocket Score-Submit: Player=%s, Score=%d", submit.PlayerCode, submit.Score)
|
||||||
|
c.natsConn.Publish("score.submit", &submit)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Printf("⚠️ Unbekannter Nachrichtentyp: %s", msg.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartWebSocketGateway startet den WebSocket-Server
|
||||||
|
func StartWebSocketGateway(port string, ec *nats.EncodedConn) {
|
||||||
|
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleWebSocket(w, r, ec)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("🌐 WebSocket-Gateway läuft auf http://localhost:%s/ws", port)
|
||||||
|
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
||||||
|
log.Fatal("❌ WebSocket-Server Fehler: ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4222:4222" # Client Port (für unsere Go Apps)
|
- "4222:4222" # Client Port (für unsere Go Apps)
|
||||||
- "8222:8222" # Dashboard / Monitoring
|
- "8222:8222" # Dashboard / Monitoring
|
||||||
command: "-js" # JetStream aktivieren (optional, aber gut für später)
|
command: ["-js"] # JetStream aktivieren
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis_data:
|
redis_data:
|
||||||
1
go.mod
@@ -12,4 +12,5 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
@@ -2,6 +2,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hajimehoshi/ebiten/v2 v2.9.6/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
|
github.com/hajimehoshi/ebiten/v2 v2.9.6/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
|
|||||||
12
nats.conf
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# NATS Server Konfiguration mit WebSocket Support
|
||||||
|
port: 4222
|
||||||
|
http_port: 8222
|
||||||
|
|
||||||
|
# WebSocket Support für Browser-Clients
|
||||||
|
websocket {
|
||||||
|
port: 9222
|
||||||
|
no_tls: true
|
||||||
|
}
|
||||||
|
|
||||||
|
# JetStream aktivieren
|
||||||
|
jetstream: enabled
|
||||||
@@ -33,6 +33,17 @@ type AssetManifest struct {
|
|||||||
type LevelObject struct {
|
type LevelObject struct {
|
||||||
AssetID string
|
AssetID string
|
||||||
X, Y float64
|
X, Y float64
|
||||||
|
|
||||||
|
// Für bewegende Plattformen
|
||||||
|
MovingPlatform *MovingPlatformData `json:"moving_platform,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MovingPlatformData struct {
|
||||||
|
StartX float64 `json:"start_x"` // Start-Position X (relativ zum Chunk)
|
||||||
|
StartY float64 `json:"start_y"` // Start-Position Y
|
||||||
|
EndX float64 `json:"end_x"` // End-Position X
|
||||||
|
EndY float64 `json:"end_y"` // End-Position Y
|
||||||
|
Speed float64 `json:"speed"` // Geschwindigkeit (Einheiten pro Sekunde)
|
||||||
}
|
}
|
||||||
type Chunk struct {
|
type Chunk struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -62,34 +73,51 @@ type ClientInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JoinRequest struct {
|
type JoinRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
RoomID string `json:"room_id"`
|
RoomID string `json:"room_id"`
|
||||||
|
GameMode string `json:"game_mode"` // "solo" oder "coop"
|
||||||
|
IsHost bool `json:"is_host"`
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerState struct {
|
type PlayerState struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
VX float64 `json:"vx"`
|
VX float64 `json:"vx"`
|
||||||
VY float64 `json:"vy"`
|
VY float64 `json:"vy"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
OnGround bool `json:"on_ground"`
|
OnGround bool `json:"on_ground"`
|
||||||
LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz
|
OnWall bool `json:"on_wall"` // Ist an einer Wand
|
||||||
Score int `json:"score"` // Punkte des Spielers
|
LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz
|
||||||
IsAlive bool `json:"is_alive"` // Lebt der Spieler noch?
|
Score int `json:"score"` // Punkte des Spielers
|
||||||
IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus
|
IsAlive bool `json:"is_alive"` // Lebt der Spieler noch?
|
||||||
|
IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus
|
||||||
|
HasDoubleJump bool `json:"has_double_jump"` // Hat Double Jump Powerup
|
||||||
|
HasGodMode bool `json:"has_godmode"` // Hat Godmode Powerup
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameState struct {
|
type GameState struct {
|
||||||
RoomID string `json:"room_id"`
|
RoomID string `json:"room_id"`
|
||||||
Players map[string]PlayerState `json:"players"`
|
Players map[string]PlayerState `json:"players"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TimeLeft int `json:"time_left"`
|
TimeLeft int `json:"time_left"`
|
||||||
WorldChunks []ActiveChunk `json:"world_chunks"`
|
WorldChunks []ActiveChunk `json:"world_chunks"`
|
||||||
HostID string `json:"host_id"`
|
HostID string `json:"host_id"`
|
||||||
ScrollX float64 `json:"scroll_x"`
|
ScrollX float64 `json:"scroll_x"`
|
||||||
CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex)
|
CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex)
|
||||||
|
CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt
|
||||||
|
MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen
|
||||||
|
}
|
||||||
|
|
||||||
|
// MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform
|
||||||
|
type MovingPlatformSync struct {
|
||||||
|
ChunkID string `json:"chunk_id"`
|
||||||
|
ObjectIdx int `json:"object_idx"`
|
||||||
|
AssetID string `json:"asset_id"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leaderboard-Eintrag
|
// Leaderboard-Eintrag
|
||||||
@@ -105,4 +133,22 @@ type ScoreSubmission struct {
|
|||||||
PlayerName string `json:"player_name"`
|
PlayerName string `json:"player_name"`
|
||||||
PlayerCode string `json:"player_code"`
|
PlayerCode string `json:"player_code"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
|
Name string `json:"name"` // Alternativer Name-Feld (für Kompatibilität)
|
||||||
|
Mode string `json:"mode"` // "solo" oder "coop"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start-Request vom Client
|
||||||
|
type StartRequest struct {
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaderboard-Request vom Client
|
||||||
|
type LeaderboardRequest struct {
|
||||||
|
Mode string `json:"mode"` // "solo" oder "coop"
|
||||||
|
ResponseChannel string `json:"response_channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaderboard-Response vom Server
|
||||||
|
type LeaderboardResponse struct {
|
||||||
|
Entries []LeaderboardEntry `json:"entries"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ type InputMessage struct {
|
|||||||
|
|
||||||
// State: Wo alles ist (Server -> Client)
|
// State: Wo alles ist (Server -> Client)
|
||||||
type GameStateMessage struct {
|
type GameStateMessage struct {
|
||||||
Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16)
|
Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16)
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Multiplier int `json:"multiplier"`
|
Multiplier int `json:"multiplier"`
|
||||||
|
MovingPlatforms []*MovingPlatformState `json:"moving_platforms"` // Bewegende Plattformen
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerState struct {
|
type PlayerState struct {
|
||||||
@@ -18,3 +19,11 @@ type PlayerState struct {
|
|||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MovingPlatformState struct {
|
||||||
|
ChunkID string `json:"chunk_id"`
|
||||||
|
ObjectIdx int `json:"object_idx"`
|
||||||
|
AssetID string `json:"asset_id"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,48 +40,42 @@ func InitLeaderboard(redisAddr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (lb *Leaderboard) AddScore(name, code string, score int) bool {
|
func (lb *Leaderboard) AddScore(name, code string, score int) bool {
|
||||||
// Prüfe ob Spieler bereits existiert
|
// Erstelle eindeutigen Key für diesen Score: PlayerCode + Timestamp
|
||||||
existingScoreStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result()
|
timestamp := time.Now().Unix()
|
||||||
if err == nil {
|
uniqueKey := code + "_" + time.Now().Format("20060102_150405")
|
||||||
var existingScore int
|
|
||||||
json.Unmarshal([]byte(existingScoreStr), &existingScore)
|
|
||||||
if score <= existingScore {
|
|
||||||
return false // Neuer Score nicht besser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Score speichern
|
// Score speichern
|
||||||
entry := game.LeaderboardEntry{
|
entry := game.LeaderboardEntry{
|
||||||
PlayerName: name,
|
PlayerName: name,
|
||||||
PlayerCode: code,
|
PlayerCode: code,
|
||||||
Score: score,
|
Score: score,
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: timestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := json.Marshal(entry)
|
data, _ := json.Marshal(entry)
|
||||||
lb.rdb.HSet(lb.ctx, "leaderboard:players", code, string(data))
|
lb.rdb.HSet(lb.ctx, "leaderboard:entries", uniqueKey, string(data))
|
||||||
|
|
||||||
// In Sorted Set mit Score als Wert
|
// In Sorted Set mit Score als Wert (uniqueKey statt code!)
|
||||||
lb.rdb.ZAdd(lb.ctx, leaderboardKey, redis.Z{
|
lb.rdb.ZAdd(lb.ctx, leaderboardKey, redis.Z{
|
||||||
Score: float64(score),
|
Score: float64(score),
|
||||||
Member: code,
|
Member: uniqueKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Printf("🏆 Leaderboard Update: %s mit %d Punkten", name, score)
|
log.Printf("🏆 Leaderboard: %s mit %d Punkten (Entry: %s)", name, score, uniqueKey)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry {
|
func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry {
|
||||||
// Hole Top 10 (höchste Scores zuerst)
|
// Hole Top 10 (höchste Scores zuerst)
|
||||||
codes, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result()
|
uniqueKeys, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ Fehler beim Abrufen des Leaderboards: %v", err)
|
log.Printf("⚠️ Fehler beim Abrufen des Leaderboards: %v", err)
|
||||||
return []game.LeaderboardEntry{}
|
return []game.LeaderboardEntry{}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := make([]game.LeaderboardEntry, 0)
|
entries := make([]game.LeaderboardEntry, 0)
|
||||||
for _, code := range codes {
|
for _, uniqueKey := range uniqueKeys {
|
||||||
dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result()
|
dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:entries", uniqueKey).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,34 +13,70 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ServerPlayer struct {
|
type ServerPlayer struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
X, Y float64
|
X, Y float64
|
||||||
VX, VY float64
|
VX, VY float64
|
||||||
OnGround bool
|
OnGround bool
|
||||||
InputX float64 // -1 (Links), 0, 1 (Rechts)
|
OnWall bool // Ist an einer Wand
|
||||||
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
|
OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht
|
||||||
Score int
|
InputX float64 // -1 (Links), 0, 1 (Rechts)
|
||||||
IsAlive bool
|
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
|
||||||
IsSpectator bool
|
Score int
|
||||||
|
IsAlive bool
|
||||||
|
IsSpectator bool
|
||||||
|
|
||||||
|
// Powerups
|
||||||
|
HasDoubleJump bool // Doppelsprung aktiv?
|
||||||
|
DoubleJumpUsed bool // Wurde zweiter Sprung schon benutzt?
|
||||||
|
HasGodMode bool // Godmode aktiv?
|
||||||
|
GodModeEndTime time.Time // Wann endet Godmode?
|
||||||
|
}
|
||||||
|
|
||||||
|
type MovingPlatform struct {
|
||||||
|
ChunkID string // Welcher Chunk
|
||||||
|
ObjectIdx int // Index im Chunk
|
||||||
|
AssetID string // Asset-ID
|
||||||
|
CurrentX float64 // Aktuelle Position X (Welt-Koordinaten)
|
||||||
|
CurrentY float64 // Aktuelle Position Y
|
||||||
|
StartX float64 // Start-Position X (Welt-Koordinaten)
|
||||||
|
StartY float64 // Start-Position Y
|
||||||
|
EndX float64 // End-Position X (Welt-Koordinaten)
|
||||||
|
EndY float64 // End-Position Y
|
||||||
|
Speed float64 // Geschwindigkeit
|
||||||
|
Direction float64 // 1.0 = zu End, -1.0 = zu Start
|
||||||
|
IsActive bool // Hat die Bewegung bereits begonnen?
|
||||||
|
HitboxW float64 // Cached Hitbox
|
||||||
|
HitboxH float64
|
||||||
|
DrawOffX float64
|
||||||
|
DrawOffY float64
|
||||||
|
HitboxOffX float64
|
||||||
|
HitboxOffY float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Room struct {
|
type Room struct {
|
||||||
ID string
|
ID string
|
||||||
NC *nats.Conn
|
NC *nats.Conn
|
||||||
World *game.World
|
World *game.World
|
||||||
Mutex sync.RWMutex
|
Mutex sync.RWMutex
|
||||||
Players map[string]*ServerPlayer
|
Players map[string]*ServerPlayer
|
||||||
ActiveChunks []game.ActiveChunk
|
ActiveChunks []game.ActiveChunk
|
||||||
Colliders []game.Collider
|
Colliders []game.Collider
|
||||||
Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER"
|
Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER"
|
||||||
GlobalScrollX float64
|
GlobalScrollX float64
|
||||||
MapEndX float64
|
MapEndX float64
|
||||||
Countdown int
|
Countdown int
|
||||||
NextStart time.Time
|
NextStart time.Time
|
||||||
HostID string
|
HostID string
|
||||||
CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
|
CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
|
||||||
ScoreAccum float64 // Akkumulator für Distanz-Score
|
CollectedPowerups map[string]bool // Key: "chunkID_objectIndex"
|
||||||
|
ScoreAccum float64 // Akkumulator für Distanz-Score
|
||||||
|
|
||||||
|
// Chunk-Pool für fairen Random-Spawn
|
||||||
|
ChunkPool []string // Verfügbare Chunks für nächsten Spawn
|
||||||
|
ChunkSpawnedCount map[string]int // Wie oft wurde jeder Chunk gespawnt
|
||||||
|
MovingPlatforms []*MovingPlatform // Aktive bewegende Plattformen
|
||||||
|
firstBroadcast bool // Wurde bereits geloggt?
|
||||||
|
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|
||||||
@@ -54,16 +91,21 @@ type Room struct {
|
|||||||
// Konstruktor
|
// Konstruktor
|
||||||
func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
|
func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
|
||||||
r := &Room{
|
r := &Room{
|
||||||
ID: id,
|
ID: id,
|
||||||
NC: nc,
|
NC: nc,
|
||||||
World: w,
|
World: w,
|
||||||
Players: make(map[string]*ServerPlayer),
|
Players: make(map[string]*ServerPlayer),
|
||||||
Status: "LOBBY",
|
Status: "LOBBY",
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
CollectedCoins: make(map[string]bool),
|
CollectedCoins: make(map[string]bool),
|
||||||
pW: 40, pH: 60, // Fallback
|
CollectedPowerups: make(map[string]bool),
|
||||||
|
ChunkSpawnedCount: make(map[string]int),
|
||||||
|
pW: 40, pH: 60, // Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialisiere Chunk-Pool mit allen verfügbaren Chunks
|
||||||
|
r.RefillChunkPool()
|
||||||
|
|
||||||
// Player Werte aus Manifest laden
|
// Player Werte aus Manifest laden
|
||||||
if def, ok := w.Manifest.Assets["player"]; ok {
|
if def, ok := w.Manifest.Assets["player"]; ok {
|
||||||
r.pW = def.Hitbox.W
|
r.pW = def.Hitbox.W
|
||||||
@@ -214,6 +256,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
|
|||||||
if p.OnGround {
|
if p.OnGround {
|
||||||
p.VY = -14.0
|
p.VY = -14.0
|
||||||
p.OnGround = false
|
p.OnGround = false
|
||||||
|
p.DoubleJumpUsed = false // Reset double jump on ground jump
|
||||||
|
} else if p.HasDoubleJump && !p.DoubleJumpUsed {
|
||||||
|
// Double Jump in der Luft
|
||||||
|
p.VY = -14.0
|
||||||
|
p.DoubleJumpUsed = true
|
||||||
|
log.Printf("⚡ %s verwendet Double Jump!", p.Name)
|
||||||
}
|
}
|
||||||
case "DOWN":
|
case "DOWN":
|
||||||
p.VY = 15.0
|
p.VY = 15.0
|
||||||
@@ -256,6 +304,8 @@ func (r *Room) Update() {
|
|||||||
}
|
}
|
||||||
} else if r.Status == "RUNNING" {
|
} else if r.Status == "RUNNING" {
|
||||||
r.GlobalScrollX += config.RunSpeed
|
r.GlobalScrollX += config.RunSpeed
|
||||||
|
// Bewegende Plattformen updaten
|
||||||
|
r.UpdateMovingPlatforms()
|
||||||
}
|
}
|
||||||
|
|
||||||
maxX := r.GlobalScrollX
|
maxX := r.GlobalScrollX
|
||||||
@@ -282,20 +332,38 @@ func (r *Room) Update() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// X Bewegung
|
// X Bewegung
|
||||||
currentSpeed := config.RunSpeed + (p.InputX * 4.0)
|
// Symmetrische Geschwindigkeit: Links = Rechts
|
||||||
|
// Nach rechts: RunSpeed + 11, Nach links: RunSpeed - 11
|
||||||
|
// Ergebnis: Rechts = 18, Links = -4 (beide gleich weit vom Scroll)
|
||||||
|
currentSpeed := config.RunSpeed + (p.InputX * 11.0)
|
||||||
nextX := p.X + currentSpeed
|
nextX := p.X + currentSpeed
|
||||||
|
|
||||||
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||||||
if hitX {
|
if hitX {
|
||||||
if typeX == "obstacle" {
|
if typeX == "wall" {
|
||||||
// Nicht blocken, sondern weiterlaufen und töten
|
// Wand getroffen - kann klettern!
|
||||||
p.X = nextX
|
p.OnWall = true
|
||||||
r.KillPlayer(p)
|
// X-Position nicht ändern (bleibt an der Wand)
|
||||||
continue
|
} else if typeX == "obstacle" {
|
||||||
|
// Godmode prüfen
|
||||||
|
if p.HasGodMode && time.Now().Before(p.GodModeEndTime) {
|
||||||
|
// Mit Godmode - Obstacle wird zerstört, Spieler überlebt
|
||||||
|
p.X = nextX
|
||||||
|
// TODO: Obstacle aus colliders entfernen (benötigt Referenz zum Obstacle)
|
||||||
|
log.Printf("🛡️ %s zerstört Obstacle mit Godmode!", p.Name)
|
||||||
|
} else {
|
||||||
|
// Ohne Godmode - Spieler stirbt
|
||||||
|
p.X = nextX
|
||||||
|
r.KillPlayer(p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Platform blockiert
|
||||||
|
p.OnWall = false
|
||||||
}
|
}
|
||||||
// Platform blockiert
|
|
||||||
} else {
|
} else {
|
||||||
p.X = nextX
|
p.X = nextX
|
||||||
|
p.OnWall = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grenzen
|
// Grenzen
|
||||||
@@ -312,28 +380,85 @@ func (r *Room) Update() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Y Bewegung
|
// Y Bewegung
|
||||||
p.VY += config.Gravity
|
// An der Wand: Reduzierte Gravität + Klettern mit InputX
|
||||||
if p.VY > config.MaxFall {
|
if p.OnWall {
|
||||||
p.VY = config.MaxFall
|
// Wandrutschen (langsame Fallgeschwindigkeit)
|
||||||
|
p.VY += config.Gravity * 0.3 // 30% Gravität an der Wand
|
||||||
|
if p.VY > 3.0 {
|
||||||
|
p.VY = 3.0 // Maximal 3.0 beim Rutschen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hochklettern wenn nach oben gedrückt (InputX in Wandrichtung)
|
||||||
|
if p.InputX != 0 {
|
||||||
|
p.VY = -5.0 // Kletter-Geschwindigkeit nach oben
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal: Volle Gravität
|
||||||
|
p.VY += config.Gravity
|
||||||
|
if p.VY > config.MaxFall {
|
||||||
|
p.VY = config.MaxFall
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextY := p.Y + p.VY
|
nextY := p.Y + p.VY
|
||||||
|
|
||||||
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||||||
if hitY {
|
if hitY {
|
||||||
if typeY == "obstacle" {
|
if typeY == "wall" {
|
||||||
// Nicht blocken, sondern weiterlaufen und töten
|
// An der Wand: Nicht töten, sondern Position halten
|
||||||
|
if p.OnWall {
|
||||||
|
p.VY = 0
|
||||||
|
} else {
|
||||||
|
// Von oben/unten gegen Wand - töten (kein Klettern in Y-Richtung)
|
||||||
|
p.Y = nextY
|
||||||
|
r.KillPlayer(p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if typeY == "obstacle" {
|
||||||
|
// Obstacle - immer töten
|
||||||
p.Y = nextY
|
p.Y = nextY
|
||||||
r.KillPlayer(p)
|
r.KillPlayer(p)
|
||||||
continue
|
continue
|
||||||
|
} else {
|
||||||
|
// Platform blockiert
|
||||||
|
if p.VY > 0 {
|
||||||
|
p.OnGround = true
|
||||||
|
// Prüfe ob auf bewegender Plattform
|
||||||
|
platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||||||
|
p.OnMovingPlatform = platform
|
||||||
|
}
|
||||||
|
p.VY = 0
|
||||||
}
|
}
|
||||||
// Platform blockiert
|
|
||||||
if p.VY > 0 {
|
|
||||||
p.OnGround = true
|
|
||||||
}
|
|
||||||
p.VY = 0
|
|
||||||
} else {
|
} else {
|
||||||
p.Y += p.VY
|
p.Y += p.VY
|
||||||
p.OnGround = false
|
p.OnGround = false
|
||||||
|
p.OnMovingPlatform = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spieler bewegt sich mit Plattform mit
|
||||||
|
if p.OnMovingPlatform != nil && p.OnGround {
|
||||||
|
// Berechne Plattform-Geschwindigkeit
|
||||||
|
mp := p.OnMovingPlatform
|
||||||
|
var targetX, targetY float64
|
||||||
|
if mp.Direction > 0 {
|
||||||
|
targetX, targetY = mp.EndX, mp.EndY
|
||||||
|
} else {
|
||||||
|
targetX, targetY = mp.StartX, mp.StartY
|
||||||
|
}
|
||||||
|
|
||||||
|
dx := targetX - mp.CurrentX
|
||||||
|
dy := targetY - mp.CurrentY
|
||||||
|
dist := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
|
||||||
|
if dist > 0.1 {
|
||||||
|
movePerTick := mp.Speed / 60.0
|
||||||
|
platformVelX := (dx / dist) * movePerTick
|
||||||
|
platformVelY := (dy / dist) * movePerTick
|
||||||
|
|
||||||
|
// Übertrage Plattform-Geschwindigkeit auf Spieler
|
||||||
|
p.X += platformVelX
|
||||||
|
p.Y += platformVelY
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Y > 1000 {
|
if p.Y > 1000 {
|
||||||
@@ -342,6 +467,15 @@ func (r *Room) Update() {
|
|||||||
|
|
||||||
// Coin Kollision prüfen
|
// Coin Kollision prüfen
|
||||||
r.CheckCoinCollision(p)
|
r.CheckCoinCollision(p)
|
||||||
|
|
||||||
|
// Powerup Kollision prüfen
|
||||||
|
r.CheckPowerupCollision(p)
|
||||||
|
|
||||||
|
// Godmode Timeout prüfen
|
||||||
|
if p.HasGodMode && time.Now().After(p.GodModeEndTime) {
|
||||||
|
p.HasGodMode = false
|
||||||
|
log.Printf("🛡️ Godmode von %s ist abgelaufen", p.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. Distanz-Score updaten
|
// 2b. Distanz-Score updaten
|
||||||
@@ -380,6 +514,7 @@ func (r *Room) Update() {
|
|||||||
func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
|
func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
|
||||||
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
|
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
|
||||||
|
|
||||||
|
// 1. Statische Colliders (Chunks)
|
||||||
for _, c := range r.Colliders {
|
for _, c := range r.Colliders {
|
||||||
if game.CheckRectCollision(playerRect, c.Rect) {
|
if game.CheckRectCollision(playerRect, c.Rect) {
|
||||||
log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)",
|
log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)",
|
||||||
@@ -390,9 +525,44 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Bewegende Plattformen (dynamische Colliders)
|
||||||
|
for _, mp := range r.MovingPlatforms {
|
||||||
|
// Berechne Plattform-Hitbox an aktueller Position
|
||||||
|
mpRect := game.Rect{
|
||||||
|
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
|
||||||
|
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
|
||||||
|
W: mp.HitboxW,
|
||||||
|
H: mp.HitboxH,
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.CheckRectCollision(playerRect, mpRect) {
|
||||||
|
return true, "platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckMovingPlatformLanding prüft ob Spieler auf einer bewegenden Plattform landet
|
||||||
|
func (r *Room) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform {
|
||||||
|
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
|
||||||
|
|
||||||
|
for _, mp := range r.MovingPlatforms {
|
||||||
|
mpRect := game.Rect{
|
||||||
|
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
|
||||||
|
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
|
||||||
|
W: mp.HitboxW,
|
||||||
|
H: mp.HitboxH,
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.CheckRectCollision(playerRect, mpRect) {
|
||||||
|
return mp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Room) UpdateMapLogic(maxX float64) {
|
func (r *Room) UpdateMapLogic(maxX float64) {
|
||||||
if r.Status != "RUNNING" {
|
if r.Status != "RUNNING" {
|
||||||
return
|
return
|
||||||
@@ -413,26 +583,178 @@ func (r *Room) UpdateMapLogic(maxX float64) {
|
|||||||
chunkWidth := float64(chunkDef.Width * config.TileSize)
|
chunkWidth := float64(chunkDef.Width * config.TileSize)
|
||||||
|
|
||||||
if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 {
|
if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 {
|
||||||
|
// Lösche alle Coins dieses Chunks aus CollectedCoins
|
||||||
|
r.ClearChunkCoins(firstChunk.ChunkID)
|
||||||
|
|
||||||
|
// Lösche alle Powerups dieses Chunks
|
||||||
|
r.ClearChunkPowerups(firstChunk.ChunkID)
|
||||||
|
|
||||||
|
// Entferne bewegende Plattformen dieses Chunks
|
||||||
|
r.RemoveMovingPlatforms(firstChunk.ChunkID)
|
||||||
|
|
||||||
r.ActiveChunks = r.ActiveChunks[1:]
|
r.ActiveChunks = r.ActiveChunks[1:]
|
||||||
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
|
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
|
||||||
|
log.Printf("🗑️ Chunk despawned: %s", firstChunk.ChunkID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Room) SpawnNextChunk() {
|
// ClearChunkCoins löscht alle eingesammelten Coins eines Chunks
|
||||||
keys := make([]string, 0, len(r.World.ChunkLibrary))
|
func (r *Room) ClearChunkCoins(chunkID string) {
|
||||||
for k := range r.World.ChunkLibrary {
|
prefix := chunkID + "_"
|
||||||
keys = append(keys, k)
|
coinsCleared := 0
|
||||||
|
for key := range r.CollectedCoins {
|
||||||
|
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
||||||
|
delete(r.CollectedCoins, key)
|
||||||
|
coinsCleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if coinsCleared > 0 {
|
||||||
|
log.Printf("💰 %d Coins von Chunk %s zurückgesetzt", coinsCleared, chunkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitMovingPlatforms initialisiert bewegende Plattformen für einen Chunk
|
||||||
|
func (r *Room) InitMovingPlatforms(chunkID string, chunkWorldX float64) {
|
||||||
|
chunkDef, exists := r.World.ChunkLibrary[chunkID]
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(keys) > 0 {
|
for objIdx, obj := range chunkDef.Objects {
|
||||||
// Zufälligen Chunk wählen
|
if obj.MovingPlatform != nil {
|
||||||
randomID := keys[rand.Intn(len(keys))]
|
assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
|
||||||
chunkDef := r.World.ChunkLibrary[randomID]
|
if !ok || assetDef.Type != "platform" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mpData := obj.MovingPlatform
|
||||||
|
platform := &MovingPlatform{
|
||||||
|
ChunkID: chunkID,
|
||||||
|
ObjectIdx: objIdx,
|
||||||
|
AssetID: obj.AssetID,
|
||||||
|
StartX: chunkWorldX + mpData.StartX,
|
||||||
|
StartY: mpData.StartY,
|
||||||
|
EndX: chunkWorldX + mpData.EndX,
|
||||||
|
EndY: mpData.EndY,
|
||||||
|
Speed: mpData.Speed,
|
||||||
|
Direction: 1.0, // Start bei StartX, bewege zu EndX
|
||||||
|
HitboxW: assetDef.Hitbox.W,
|
||||||
|
HitboxH: assetDef.Hitbox.H,
|
||||||
|
DrawOffX: assetDef.DrawOffX,
|
||||||
|
DrawOffY: assetDef.DrawOffY,
|
||||||
|
HitboxOffX: assetDef.Hitbox.OffsetX,
|
||||||
|
HitboxOffY: assetDef.Hitbox.OffsetY,
|
||||||
|
}
|
||||||
|
platform.CurrentX = platform.StartX
|
||||||
|
platform.CurrentY = platform.StartY
|
||||||
|
|
||||||
|
r.MovingPlatforms = append(r.MovingPlatforms, platform)
|
||||||
|
log.Printf("🔄 Bewegende Plattform initialisiert: %s in Chunk %s", obj.AssetID, chunkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMovingPlatforms entfernt alle Plattformen eines Chunks
|
||||||
|
func (r *Room) RemoveMovingPlatforms(chunkID string) {
|
||||||
|
newPlatforms := make([]*MovingPlatform, 0)
|
||||||
|
removedCount := 0
|
||||||
|
for _, p := range r.MovingPlatforms {
|
||||||
|
if p.ChunkID != chunkID {
|
||||||
|
newPlatforms = append(newPlatforms, p)
|
||||||
|
} else {
|
||||||
|
removedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.MovingPlatforms = newPlatforms
|
||||||
|
if removedCount > 0 {
|
||||||
|
log.Printf("🗑️ %d bewegende Plattformen von Chunk %s entfernt", removedCount, chunkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMovingPlatforms bewegt alle aktiven Plattformen
|
||||||
|
func (r *Room) UpdateMovingPlatforms() {
|
||||||
|
// Sichtbarer Bereich: GlobalScrollX bis GlobalScrollX + 1400
|
||||||
|
// Aktivierung bei 3/4: GlobalScrollX + (1400 * 3/4) = GlobalScrollX + 1050
|
||||||
|
activationPoint := r.GlobalScrollX + 1050
|
||||||
|
|
||||||
|
for _, p := range r.MovingPlatforms {
|
||||||
|
// Prüfe ob Plattform den Aktivierungspunkt erreicht hat
|
||||||
|
if !p.IsActive {
|
||||||
|
// Aktiviere Plattform, wenn sie bei 3/4 des Bildschirms ist
|
||||||
|
if p.CurrentX <= activationPoint {
|
||||||
|
p.IsActive = true
|
||||||
|
log.Printf("▶️ Plattform aktiviert: %s (X=%.0f)", p.ChunkID, p.CurrentX)
|
||||||
|
} else {
|
||||||
|
// Noch nicht weit genug gescrollt, nicht bewegen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewegung berechnen (Speed pro Sekunde, bei 60 FPS = Speed/60)
|
||||||
|
movePerTick := p.Speed / 60.0
|
||||||
|
|
||||||
|
// Bewegungsvektor von CurrentPos zu Ziel
|
||||||
|
var targetX, targetY float64
|
||||||
|
if p.Direction > 0 {
|
||||||
|
targetX, targetY = p.EndX, p.EndY
|
||||||
|
} else {
|
||||||
|
targetX, targetY = p.StartX, p.StartY
|
||||||
|
}
|
||||||
|
|
||||||
|
dx := targetX - p.CurrentX
|
||||||
|
dy := targetY - p.CurrentY
|
||||||
|
dist := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
|
||||||
|
if dist < movePerTick {
|
||||||
|
// Ziel erreicht, umkehren
|
||||||
|
p.CurrentX = targetX
|
||||||
|
p.CurrentY = targetY
|
||||||
|
p.Direction *= -1.0
|
||||||
|
} else {
|
||||||
|
// Weiterbewegen
|
||||||
|
p.CurrentX += (dx / dist) * movePerTick
|
||||||
|
p.CurrentY += (dy / dist) * movePerTick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefillChunkPool füllt den Pool mit allen verfügbaren Chunks
|
||||||
|
func (r *Room) RefillChunkPool() {
|
||||||
|
r.ChunkPool = make([]string, 0, len(r.World.ChunkLibrary))
|
||||||
|
for chunkID := range r.World.ChunkLibrary {
|
||||||
|
if chunkID != "start" { // Start-Chunk nicht in Pool
|
||||||
|
r.ChunkPool = append(r.ChunkPool, chunkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mische Pool für zufällige Reihenfolge
|
||||||
|
rand.Shuffle(len(r.ChunkPool), func(i, j int) {
|
||||||
|
r.ChunkPool[i], r.ChunkPool[j] = r.ChunkPool[j], r.ChunkPool[i]
|
||||||
|
})
|
||||||
|
log.Printf("🔄 Chunk-Pool neu gefüllt: %d Chunks", len(r.ChunkPool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) SpawnNextChunk() {
|
||||||
|
// Pool leer? Nachfüllen!
|
||||||
|
if len(r.ChunkPool) == 0 {
|
||||||
|
r.RefillChunkPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.ChunkPool) > 0 {
|
||||||
|
// Nimm ersten Chunk aus Pool (bereits gemischt)
|
||||||
|
randomID := r.ChunkPool[0]
|
||||||
|
r.ChunkPool = r.ChunkPool[1:] // Entferne aus Pool
|
||||||
|
|
||||||
|
chunkDef := r.World.ChunkLibrary[randomID]
|
||||||
newChunk := game.ActiveChunk{ChunkID: randomID, X: r.MapEndX}
|
newChunk := game.ActiveChunk{ChunkID: randomID, X: r.MapEndX}
|
||||||
r.ActiveChunks = append(r.ActiveChunks, newChunk)
|
r.ActiveChunks = append(r.ActiveChunks, newChunk)
|
||||||
r.MapEndX += float64(chunkDef.Width * config.TileSize)
|
r.MapEndX += float64(chunkDef.Width * config.TileSize)
|
||||||
|
|
||||||
|
// Initialisiere bewegende Plattformen für diesen Chunk
|
||||||
|
r.InitMovingPlatforms(randomID, newChunk.X)
|
||||||
|
|
||||||
|
r.ChunkSpawnedCount[randomID]++
|
||||||
|
log.Printf("🎲 Chunk gespawnt: %s (Total: %d mal, Pool: %d übrig)", randomID, r.ChunkSpawnedCount[randomID], len(r.ChunkPool))
|
||||||
} else {
|
} else {
|
||||||
// Fallback, falls keine Chunks da sind
|
// Fallback, falls keine Chunks da sind
|
||||||
r.MapEndX += 1280
|
r.MapEndX += 1280
|
||||||
@@ -446,35 +768,52 @@ func (r *Room) Broadcast() {
|
|||||||
defer r.Mutex.RUnlock()
|
defer r.Mutex.RUnlock()
|
||||||
|
|
||||||
state := game.GameState{
|
state := game.GameState{
|
||||||
RoomID: r.ID,
|
RoomID: r.ID,
|
||||||
Players: make(map[string]game.PlayerState),
|
Players: make(map[string]game.PlayerState),
|
||||||
Status: r.Status,
|
Status: r.Status,
|
||||||
TimeLeft: r.Countdown,
|
TimeLeft: r.Countdown,
|
||||||
WorldChunks: r.ActiveChunks,
|
WorldChunks: r.ActiveChunks,
|
||||||
HostID: r.HostID,
|
HostID: r.HostID,
|
||||||
ScrollX: r.GlobalScrollX,
|
ScrollX: r.GlobalScrollX,
|
||||||
CollectedCoins: r.CollectedCoins,
|
CollectedCoins: r.CollectedCoins,
|
||||||
|
CollectedPowerups: r.CollectedPowerups,
|
||||||
|
MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for id, p := range r.Players {
|
for id, p := range r.Players {
|
||||||
state.Players[id] = game.PlayerState{
|
state.Players[id] = game.PlayerState{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
X: p.X,
|
X: p.X,
|
||||||
Y: p.Y,
|
Y: p.Y,
|
||||||
VX: p.VX,
|
VX: p.VX,
|
||||||
VY: p.VY,
|
VY: p.VY,
|
||||||
OnGround: p.OnGround,
|
OnGround: p.OnGround,
|
||||||
LastInputSeq: p.LastInputSeq,
|
OnWall: p.OnWall,
|
||||||
Score: p.Score,
|
LastInputSeq: p.LastInputSeq,
|
||||||
IsAlive: p.IsAlive,
|
Score: p.Score,
|
||||||
IsSpectator: p.IsSpectator,
|
IsAlive: p.IsAlive,
|
||||||
|
IsSpectator: p.IsSpectator,
|
||||||
|
HasDoubleJump: p.HasDoubleJump,
|
||||||
|
HasGodMode: p.HasGodMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bewegende Plattformen synchronisieren
|
||||||
|
for _, mp := range r.MovingPlatforms {
|
||||||
|
state.MovingPlatforms = append(state.MovingPlatforms, game.MovingPlatformSync{
|
||||||
|
ChunkID: mp.ChunkID,
|
||||||
|
ObjectIdx: mp.ObjectIdx,
|
||||||
|
AssetID: mp.AssetID,
|
||||||
|
X: mp.CurrentX,
|
||||||
|
Y: mp.CurrentY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DEBUG: Ersten Broadcast loggen (nur beim ersten Mal)
|
// DEBUG: Ersten Broadcast loggen (nur beim ersten Mal)
|
||||||
if len(r.Players) > 0 && r.Status == "LOBBY" {
|
if !r.firstBroadcast && len(r.Players) > 0 && r.Status == "LOBBY" {
|
||||||
log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status)
|
log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status)
|
||||||
|
r.firstBroadcast = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Senden an raum-spezifischen Channel: "game.update.<ROOMID>"
|
// Senden an raum-spezifischen Channel: "game.update.<ROOMID>"
|
||||||
@@ -490,3 +829,18 @@ func (r *Room) RemovePlayer(id string) {
|
|||||||
delete(r.Players, id)
|
delete(r.Players, id)
|
||||||
log.Printf("➖ Player %s left room %s", id, r.ID)
|
log.Printf("➖ Player %s left room %s", id, r.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearChunkPowerups löscht alle eingesammelten Powerups eines Chunks
|
||||||
|
func (r *Room) ClearChunkPowerups(chunkID string) {
|
||||||
|
prefix := chunkID + "_"
|
||||||
|
powerupsCleared := 0
|
||||||
|
for key := range r.CollectedPowerups {
|
||||||
|
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
||||||
|
delete(r.CollectedPowerups, key)
|
||||||
|
powerupsCleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if powerupsCleared > 0 {
|
||||||
|
log.Printf("⚡ %d Powerups von Chunk %s zurückgesetzt", powerupsCleared, chunkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||||
)
|
)
|
||||||
@@ -47,10 +48,10 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coin-Hitbox
|
// Coin-Hitbox (muss DrawOffX/Y einbeziehen wie bei Obstacles!)
|
||||||
coinHitbox := game.Rect{
|
coinHitbox := game.Rect{
|
||||||
OffsetX: activeChunk.X + obj.X + assetDef.Hitbox.OffsetX,
|
OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
|
||||||
OffsetY: obj.Y + assetDef.Hitbox.OffsetY,
|
OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
|
||||||
W: assetDef.Hitbox.W,
|
W: assetDef.Hitbox.W,
|
||||||
H: assetDef.Hitbox.H,
|
H: assetDef.Hitbox.H,
|
||||||
}
|
}
|
||||||
@@ -66,39 +67,93 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckPowerupCollision prüft ob Spieler Powerups einsammelt
|
||||||
|
func (r *Room) CheckPowerupCollision(p *ServerPlayer) {
|
||||||
|
if !p.IsAlive || p.IsSpectator {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerHitbox := game.Rect{
|
||||||
|
OffsetX: p.X + r.pDrawOffX + r.pHitboxOffX,
|
||||||
|
OffsetY: p.Y + r.pDrawOffY + r.pHitboxOffY,
|
||||||
|
W: r.pW,
|
||||||
|
H: r.pH,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durch alle aktiven Chunks iterieren
|
||||||
|
for _, activeChunk := range r.ActiveChunks {
|
||||||
|
chunkDef, exists := r.World.ChunkLibrary[activeChunk.ChunkID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durch alle Objekte im Chunk
|
||||||
|
for objIdx, obj := range chunkDef.Objects {
|
||||||
|
assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Powerups prüfen
|
||||||
|
if assetDef.Type != "powerup" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eindeutiger Key für dieses Powerup
|
||||||
|
powerupKey := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
|
||||||
|
|
||||||
|
// Wurde bereits eingesammelt?
|
||||||
|
if r.CollectedPowerups[powerupKey] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Powerup-Hitbox
|
||||||
|
powerupHitbox := game.Rect{
|
||||||
|
OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
|
||||||
|
OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
|
||||||
|
W: assetDef.Hitbox.W,
|
||||||
|
H: assetDef.Hitbox.H,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kollision?
|
||||||
|
if game.CheckRectCollision(playerHitbox, powerupHitbox) {
|
||||||
|
// Powerup einsammeln!
|
||||||
|
r.CollectedPowerups[powerupKey] = true
|
||||||
|
|
||||||
|
// Powerup-Effekt anwenden
|
||||||
|
switch obj.AssetID {
|
||||||
|
case "jumpboost":
|
||||||
|
p.HasDoubleJump = true
|
||||||
|
p.DoubleJumpUsed = false
|
||||||
|
log.Printf("⚡ %s hat Double Jump erhalten!", p.Name)
|
||||||
|
|
||||||
|
case "godmode":
|
||||||
|
p.HasGodMode = true
|
||||||
|
p.GodModeEndTime = time.Now().Add(10 * time.Second)
|
||||||
|
log.Printf("🛡️ %s hat Godmode erhalten! (10 Sekunden)", p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateDistanceScore aktualisiert Distanz-basierte Punkte
|
// UpdateDistanceScore aktualisiert Distanz-basierte Punkte
|
||||||
func (r *Room) UpdateDistanceScore() {
|
func (r *Room) UpdateDistanceScore() {
|
||||||
if r.Status != "RUNNING" {
|
if r.Status != "RUNNING" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anzahl lebender Spieler zählen
|
// Jeder Spieler bekommt Punkte basierend auf seiner eigenen Distanz
|
||||||
aliveCount := 0
|
// Punkte = (X-Position / TileSize) = Distanz in Tiles
|
||||||
for _, p := range r.Players {
|
for _, p := range r.Players {
|
||||||
if p.IsAlive && !p.IsSpectator {
|
if p.IsAlive && !p.IsSpectator {
|
||||||
aliveCount++
|
// Berechne Score basierend auf X-Position
|
||||||
}
|
// 1 Punkt pro Tile (64px)
|
||||||
}
|
newScore := int(p.X / 64.0)
|
||||||
|
|
||||||
if aliveCount == 0 {
|
// Nur updaten wenn höher als aktueller Score
|
||||||
return
|
if newScore > p.Score {
|
||||||
}
|
p.Score = newScore
|
||||||
|
|
||||||
// Multiplier = Anzahl lebender Spieler
|
|
||||||
multiplier := float64(aliveCount)
|
|
||||||
|
|
||||||
// Akkumulator erhöhen: multiplier Punkte pro Sekunde
|
|
||||||
// Bei 60 FPS: multiplier / 60.0 Punkte pro Tick
|
|
||||||
r.ScoreAccum += multiplier / 60.0
|
|
||||||
|
|
||||||
// Wenn Akkumulator >= 1.0, Punkte vergeben
|
|
||||||
if r.ScoreAccum >= 1.0 {
|
|
||||||
pointsToAdd := int(r.ScoreAccum)
|
|
||||||
r.ScoreAccum -= float64(pointsToAdd)
|
|
||||||
|
|
||||||
for _, p := range r.Players {
|
|
||||||
if p.IsAlive && !p.IsSpectator {
|
|
||||||
p.Score += pointsToAdd
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
player_code.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
49badef83664a3d83cb4ec6ab0853c9e
|
||||||