OceanSound: L/R-Panning fix via Dual-Source + constant-power panning
Zwei AudioNodes pro Sound (L + R) fest auf ±90° der Kamera positioniert; Lautstärkeverhältnis (constant-power) bestimmt die wahrgenommene Richtung. Behebt HRTF-0°-Stille wenn Kamera aufs Wasser zeigt. Außerdem weitere Soundsystem- und Editor-Korrekturen aus dieser Session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1262,6 +1262,11 @@ public class EditorApp extends Application {
|
|||||||
camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
|
camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
|
||||||
camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
|
camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
|
||||||
|
|
||||||
|
Button camResetBtn = new Button("⌂ Reset");
|
||||||
|
camResetBtn.setStyle("-fx-font-weight:bold;");
|
||||||
|
camResetBtn.setTooltip(new javafx.scene.control.Tooltip("Kamera auf x=0, z=0, y=Terrain+10m zurücksetzen"));
|
||||||
|
camResetBtn.setOnAction(e -> input.resetCameraRequested.set(true));
|
||||||
|
|
||||||
Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief");
|
Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief");
|
||||||
hint.setStyle("-fx-text-fill: #555;");
|
hint.setStyle("-fx-text-fill: #555;");
|
||||||
|
|
||||||
@@ -1274,7 +1279,7 @@ public class EditorApp extends Application {
|
|||||||
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
|
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
|
||||||
new Separator(Orientation.VERTICAL), playToolBtn,
|
new Separator(Orientation.VERTICAL), playToolBtn,
|
||||||
new Separator(Orientation.VERTICAL), voxelBtn,
|
new Separator(Orientation.VERTICAL), voxelBtn,
|
||||||
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn,
|
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
|
||||||
new Separator(Orientation.VERTICAL), hint);
|
new Separator(Orientation.VERTICAL), hint);
|
||||||
|
|
||||||
worldToolBar = toolBar;
|
worldToolBar = toolBar;
|
||||||
@@ -5094,12 +5099,18 @@ public class EditorApp extends Application {
|
|||||||
catch (IOException ignored) {}
|
catch (IOException ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Konvertiert eine beliebige Audiodatei mit ffmpeg zu OGG Vorbis.
|
/**
|
||||||
* Gibt den Pfad zur erzeugten .ogg-Datei zurück. */
|
* Konvertiert / normalisiert eine Audiodatei zu OGG Vorbis bei 48000 Hz.
|
||||||
|
* Das Ziel-Format stimmt mit PipeWire/PulseAudio überein, sodass OpenALSofts
|
||||||
|
* Spatial-Mixer kein On-the-fly-Resampling durchführen muss (kein Knistern).
|
||||||
|
*/
|
||||||
private static Path convertToOgg(Path src, Path destOgg) throws IOException {
|
private static Path convertToOgg(Path src, Path destOgg) throws IOException {
|
||||||
try {
|
try {
|
||||||
Process proc = new ProcessBuilder(
|
Process proc = new ProcessBuilder(
|
||||||
"ffmpeg", "-i", src.toString(), "-q:a", "4", destOgg.toString(), "-y")
|
"ffmpeg", "-i", src.toString(),
|
||||||
|
"-ar", "48000",
|
||||||
|
"-q:a", "4",
|
||||||
|
destOgg.toString(), "-y")
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start();
|
.start();
|
||||||
proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
|
proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
|
||||||
@@ -5158,12 +5169,8 @@ public class EditorApp extends Application {
|
|||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
|
String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
|
||||||
Path destOgg = destDir.resolve(baseName + ".ogg");
|
Path destOgg = destDir.resolve(baseName + ".ogg");
|
||||||
if (name.endsWith(".ogg")) {
|
setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
|
||||||
Files.copy(file.toPath(), destOgg, StandardCopyOption.REPLACE_EXISTING);
|
convertToOgg(file.toPath(), destOgg);
|
||||||
} else {
|
|
||||||
setStatus("Konvertiere " + file.getName() + " → OGG …");
|
|
||||||
convertToOgg(file.toPath(), destOgg);
|
|
||||||
}
|
|
||||||
String finalName = baseName + ".ogg";
|
String finalName = baseName + ".ogg";
|
||||||
TreeItem<String> newItem = new TreeItem<>(finalName);
|
TreeItem<String> newItem = new TreeItem<>(finalName);
|
||||||
itemPaths.put(newItem, destOgg);
|
itemPaths.put(newItem, destOgg);
|
||||||
@@ -5716,12 +5723,8 @@ public class EditorApp extends Application {
|
|||||||
String name = file.getName().toLowerCase();
|
String name = file.getName().toLowerCase();
|
||||||
String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
|
String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
|
||||||
Path dest = destDir.resolve(baseName + ".ogg");
|
Path dest = destDir.resolve(baseName + ".ogg");
|
||||||
if (name.endsWith(".ogg")) {
|
setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
|
||||||
Files.copy(file.toPath(), dest, StandardCopyOption.REPLACE_EXISTING);
|
convertToOgg(file.toPath(), dest);
|
||||||
} else {
|
|
||||||
setStatus("Konvertiere " + file.getName() + " → OGG …");
|
|
||||||
convertToOgg(file.toPath(), dest);
|
|
||||||
}
|
|
||||||
TreeItem<String> item = new TreeItem<>(dest.getFileName().toString());
|
TreeItem<String> item = new TreeItem<>(dest.getFileName().toString());
|
||||||
itemPaths.put(item, dest);
|
itemPaths.put(item, dest);
|
||||||
audioNode.getChildren().add(item);
|
audioNode.getChildren().add(item);
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ public class SharedInput {
|
|||||||
public static final int CAM_ORBIT = 0;
|
public static final int CAM_ORBIT = 0;
|
||||||
public static final int CAM_FREEFLY = 1;
|
public static final int CAM_FREEFLY = 1;
|
||||||
|
|
||||||
|
/** Gesetzt von JavaFX; konsumiert von TerrainEditorState: Kamera auf x=0,z=0,y=terrain+10 zurücksetzen. */
|
||||||
|
public final java.util.concurrent.atomic.AtomicBoolean resetCameraRequested =
|
||||||
|
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||||
|
|
||||||
// ── Kamerabewegung (WASD + QE) ──────────────────────────────────────────
|
// ── Kamerabewegung (WASD + QE) ──────────────────────────────────────────
|
||||||
public volatile boolean forward, backward, left, right, up, down;
|
public volatile boolean forward, backward, left, right, up, down;
|
||||||
|
|
||||||
|
|||||||
@@ -1607,12 +1607,24 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
if (scroll != 0)
|
if (scroll != 0)
|
||||||
camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f));
|
camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f));
|
||||||
|
|
||||||
|
// Kamera-Reset auf x=0,z=0,y=terrain+10
|
||||||
|
if (input.resetCameraRequested.getAndSet(false)) {
|
||||||
|
float h = getTerrainHeightFast(0f, 0f);
|
||||||
|
camPos.set(0f, h + 10f, 0f);
|
||||||
|
camYaw = 0f;
|
||||||
|
camPitch = DEFAULT_PITCH;
|
||||||
|
}
|
||||||
|
|
||||||
// NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert)
|
// NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert)
|
||||||
if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) {
|
if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) {
|
||||||
camPos.set(0f, DEFAULT_CAM_Y, 0f);
|
camPos.set(0f, DEFAULT_CAM_Y, 0f);
|
||||||
}
|
}
|
||||||
camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y);
|
camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y);
|
||||||
|
|
||||||
|
// Kamera nicht unter das Terrain fallen lassen
|
||||||
|
float terrainFloor = getTerrainHeightFast(camPos.x, camPos.z) + 2f;
|
||||||
|
if (camPos.y < terrainFloor) camPos.y = terrainFloor;
|
||||||
|
|
||||||
cam.setLocation(camPos);
|
cam.setLocation(camPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.blight.game.audio;
|
package de.blight.game.audio;
|
||||||
|
|
||||||
public enum SurfaceType {
|
public enum SurfaceType {
|
||||||
GRASS, DIRT, SAND, ROCK, GRAVEL, LEAVES, PAVEMENT, WOOD, UNKNOWN;
|
GRASS, DIRT, SAND, ROCK, GRAVEL, LEAVES, PAVEMENT, WOOD, WATER, UNKNOWN;
|
||||||
|
|
||||||
public static SurfaceType fromTexturePath(String path) {
|
public static SurfaceType fromTexturePath(String path) {
|
||||||
if (path == null || path.isEmpty()) return UNKNOWN;
|
if (path == null || path.isEmpty()) return UNKNOWN;
|
||||||
|
|||||||
@@ -64,9 +64,15 @@ public class PlayerInputControl {
|
|||||||
private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot";
|
private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot";
|
||||||
// Y-Schwelle im Model-Space: Fuß gilt als am Boden wenn Y < FOOT_GROUND_Y
|
// Y-Schwelle im Model-Space: Fuß gilt als am Boden wenn Y < FOOT_GROUND_Y
|
||||||
private static final float FOOT_GROUND_Y = 0.05f;
|
private static final float FOOT_GROUND_Y = 0.05f;
|
||||||
|
/** Wassertiefe (m) ab der Laufen/Sprinten gesperrt ist und Watgeräusch spielt. */
|
||||||
|
public static final float WATER_WADING_DEPTH = 0.5f;
|
||||||
|
|
||||||
private de.blight.game.audio.FootstepSystem footstepSystem;
|
private de.blight.game.audio.FootstepSystem footstepSystem;
|
||||||
private java.util.function.BiFunction<Float, Float, float[]> surfaceQuery;
|
private java.util.function.BiFunction<Float, Float, float[]> surfaceQuery;
|
||||||
|
private java.util.function.BiFunction<Float, Float, Float> waterDepthQuery;
|
||||||
|
private com.jme3.audio.AudioNode wadingNode;
|
||||||
|
/** Aktuell berechnete Wassertiefe – einmal pro Update() gesetzt, in pollFootsteps() gelesen. */
|
||||||
|
private float currentWaterDepth = 0f;
|
||||||
private float lastLeftFootY = Float.MAX_VALUE;
|
private float lastLeftFootY = Float.MAX_VALUE;
|
||||||
private float lastRightFootY = Float.MAX_VALUE;
|
private float lastRightFootY = Float.MAX_VALUE;
|
||||||
|
|
||||||
@@ -191,6 +197,7 @@ public class PlayerInputControl {
|
|||||||
forward = backward = left = right = sprint = walk = false;
|
forward = backward = left = right = sprint = walk = false;
|
||||||
autopilotDir = null;
|
autopilotDir = null;
|
||||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||||
|
stopWading();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +363,18 @@ public class PlayerInputControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sperrt alle Tastatureingaben, lässt aber die aktuelle Laufrichtung des
|
||||||
|
* Physik-Charakters bestehen (Charakter bewegt sich weiter). Wird für den
|
||||||
|
* Ertrink-Fade-out verwendet.
|
||||||
|
*/
|
||||||
|
public void blockInputsKeepMoving() {
|
||||||
|
inputsBlocked = true;
|
||||||
|
forward = backward = left = right = sprint = walk = false;
|
||||||
|
autopilotDir = null;
|
||||||
|
stopWading();
|
||||||
|
}
|
||||||
|
|
||||||
/** Hebt die Input-Blockade auf und gibt Bewegungseingaben wieder frei. */
|
/** Hebt die Input-Blockade auf und gibt Bewegungseingaben wieder frei. */
|
||||||
public void unblockInputs() {
|
public void unblockInputs() {
|
||||||
inputsBlocked = false;
|
inputsBlocked = false;
|
||||||
@@ -554,11 +573,16 @@ public class PlayerInputControl {
|
|||||||
|
|
||||||
boolean moving = moveDir.lengthSquared() > 0.001f;
|
boolean moving = moveDir.lengthSquared() > 0.001f;
|
||||||
|
|
||||||
|
// Wassertiefe bestimmen – einmal pro Frame, auch in pollFootsteps() genutzt
|
||||||
|
Vector3f charPos = physicsChar.getPhysicsLocation();
|
||||||
|
currentWaterDepth = (waterDepthQuery != null) ? waterDepthQuery.apply(charPos.x, charPos.z) : 0f;
|
||||||
|
boolean inDeepWater = currentWaterDepth > WATER_WADING_DEPTH;
|
||||||
|
|
||||||
if (moving) {
|
if (moving) {
|
||||||
moveDir.normalizeLocal();
|
moveDir.normalizeLocal();
|
||||||
float speed = walk ? MOVE_SPEED * WALK_MULT
|
float speed = (inDeepWater || walk) ? MOVE_SPEED * WALK_MULT
|
||||||
: sprint ? MOVE_SPEED * SPRINT_MULT
|
: sprint ? MOVE_SPEED * SPRINT_MULT
|
||||||
: MOVE_SPEED;
|
: MOVE_SPEED;
|
||||||
physicsChar.setWalkDirection(moveDir.mult(speed));
|
physicsChar.setWalkDirection(moveDir.mult(speed));
|
||||||
|
|
||||||
if (visual != null) {
|
if (visual != null) {
|
||||||
@@ -580,9 +604,9 @@ public class PlayerInputControl {
|
|||||||
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
|
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
|
||||||
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
|
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
|
||||||
} else if (moving) {
|
} else if (moving) {
|
||||||
target = walk ? AnimationAction.WALK
|
target = (inDeepWater || walk) ? AnimationAction.WALK
|
||||||
: sprint ? AnimationAction.SPRINT
|
: sprint ? AnimationAction.SPRINT
|
||||||
: AnimationAction.RUN;
|
: AnimationAction.RUN;
|
||||||
} else {
|
} else {
|
||||||
target = AnimationAction.IDLE;
|
target = AnimationAction.IDLE;
|
||||||
}
|
}
|
||||||
@@ -618,6 +642,14 @@ public class PlayerInputControl {
|
|||||||
this.surfaceQuery = query;
|
this.surfaceQuery = query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setWaterDepthQuery(java.util.function.BiFunction<Float, Float, Float> query) {
|
||||||
|
this.waterDepthQuery = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWadingSound(com.jme3.audio.AudioNode node) {
|
||||||
|
this.wadingNode = node;
|
||||||
|
}
|
||||||
|
|
||||||
private void logJointNames(com.jme3.anim.Armature arm) {
|
private void logJointNames(com.jme3.anim.Armature arm) {
|
||||||
if (arm == null) return;
|
if (arm == null) return;
|
||||||
StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):");
|
StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):");
|
||||||
@@ -628,22 +660,34 @@ public class PlayerInputControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void pollFootsteps() {
|
private void pollFootsteps() {
|
||||||
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
|
|
||||||
if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) {
|
if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) {
|
||||||
|
stopWading();
|
||||||
lastLeftFootY = Float.MAX_VALUE;
|
lastLeftFootY = Float.MAX_VALUE;
|
||||||
lastRightFootY = Float.MAX_VALUE;
|
lastRightFootY = Float.MAX_VALUE;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
float speed = physicsChar.getWalkDirection().length();
|
float speed = physicsChar.getWalkDirection().length();
|
||||||
if (speed < 0.001f) {
|
if (speed < 0.001f) {
|
||||||
|
stopWading();
|
||||||
lastLeftFootY = Float.MAX_VALUE;
|
lastLeftFootY = Float.MAX_VALUE;
|
||||||
lastRightFootY = Float.MAX_VALUE;
|
lastRightFootY = Float.MAX_VALUE;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String gait = currentAnimToGait(currentAnim);
|
|
||||||
if (gait == null) {
|
if (currentWaterDepth > WATER_WADING_DEPTH) {
|
||||||
|
// Im Wasser: Watgeräusch abspielen, Fußgeräusche unterdrücken
|
||||||
|
startWading();
|
||||||
|
lastLeftFootY = Float.MAX_VALUE;
|
||||||
|
lastRightFootY = Float.MAX_VALUE;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopWading();
|
||||||
|
|
||||||
|
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
|
||||||
|
String gait = currentAnimToGait(currentAnim);
|
||||||
|
if (gait == null) return;
|
||||||
|
|
||||||
com.jme3.anim.Joint lf = armature.getJoint(BONE_LEFT_FOOT);
|
com.jme3.anim.Joint lf = armature.getJoint(BONE_LEFT_FOOT);
|
||||||
com.jme3.anim.Joint rf = armature.getJoint(BONE_RIGHT_FOOT);
|
com.jme3.anim.Joint rf = armature.getJoint(BONE_RIGHT_FOOT);
|
||||||
if (lf != null) {
|
if (lf != null) {
|
||||||
@@ -662,6 +706,20 @@ public class PlayerInputControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void startWading() {
|
||||||
|
if (wadingNode == null) return;
|
||||||
|
if (wadingNode.getStatus() != com.jme3.audio.AudioSource.Status.Playing) {
|
||||||
|
wadingNode.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopWading() {
|
||||||
|
if (wadingNode == null) return;
|
||||||
|
if (wadingNode.getStatus() == com.jme3.audio.AudioSource.Status.Playing) {
|
||||||
|
wadingNode.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String currentAnimToGait(AnimationAction anim) {
|
private String currentAnimToGait(AnimationAction anim) {
|
||||||
if (anim == AnimationAction.WALK) return "walking";
|
if (anim == AnimationAction.WALK) return "walking";
|
||||||
if (anim == AnimationAction.RUN) return "running";
|
if (anim == AnimationAction.RUN) return "running";
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public class WorldScene extends BaseAppState {
|
|||||||
private de.blight.game.state.OceanSoundState oceanSound;
|
private de.blight.game.state.OceanSoundState oceanSound;
|
||||||
private de.blight.game.state.AmbientSoundSystem ambientSounds;
|
private de.blight.game.state.AmbientSoundSystem ambientSounds;
|
||||||
private de.blight.game.audio.FootstepSystem footstepSystem;
|
private de.blight.game.audio.FootstepSystem footstepSystem;
|
||||||
|
private de.blight.game.state.DrownState drownState;
|
||||||
|
|
||||||
public WorldScene(KeyBindings keyBindings) {
|
public WorldScene(KeyBindings keyBindings) {
|
||||||
this.keyBindings = keyBindings;
|
this.keyBindings = keyBindings;
|
||||||
@@ -107,6 +108,11 @@ public class WorldScene extends BaseAppState {
|
|||||||
*/
|
*/
|
||||||
public float[] querySurfaceWeights(float worldX, float worldZ) {
|
public float[] querySurfaceWeights(float worldX, float worldZ) {
|
||||||
float[] result = new float[de.blight.game.audio.SurfaceType.values().length];
|
float[] result = new float[de.blight.game.audio.SurfaceType.values().length];
|
||||||
|
// Wasser hat Vorrang vor Splatmap: Terrainoberfläche unter Wasserstand → WATER
|
||||||
|
if (terrainChunkState != null && terrainChunkState.getHeightAt(worldX, worldZ) < 0f) {
|
||||||
|
result[de.blight.game.audio.SurfaceType.WATER.ordinal()] = 1.0f;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
if (loadedMapData == null) return result;
|
if (loadedMapData == null) return result;
|
||||||
|
|
||||||
int size = de.blight.common.MapData.SPLAT_SIZE;
|
int size = de.blight.common.MapData.SPLAT_SIZE;
|
||||||
@@ -169,6 +175,12 @@ public class WorldScene extends BaseAppState {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Liefert die Wassertiefe an (worldX, worldZ) in Metern; 0 wenn an Land. */
|
||||||
|
public float queryWaterDepth(float worldX, float worldZ) {
|
||||||
|
if (terrainChunkState == null) return 0f;
|
||||||
|
return Math.max(0f, -terrainChunkState.getHeightAt(worldX, worldZ));
|
||||||
|
}
|
||||||
|
|
||||||
/** Wird von ConfigScreen nach dem Speichern aufgerufen. */
|
/** Wird von ConfigScreen nach dem Speichern aufgerufen. */
|
||||||
public void reloadBindings(KeyBindings kb) {
|
public void reloadBindings(KeyBindings kb) {
|
||||||
if (playerInput != null) playerInput.reloadBindings(kb);
|
if (playerInput != null) playerInput.reloadBindings(kb);
|
||||||
@@ -254,6 +266,20 @@ public class WorldScene extends BaseAppState {
|
|||||||
footstepSystem = new de.blight.game.audio.FootstepSystem(assetManager, rootNode, audioSettings,
|
footstepSystem = new de.blight.game.audio.FootstepSystem(assetManager, rootNode, audioSettings,
|
||||||
AnimationLibrary.findAssetRoot());
|
AnimationLibrary.findAssetRoot());
|
||||||
playerInput.setFootstepSystem(footstepSystem, this::querySurfaceWeights);
|
playerInput.setFootstepSystem(footstepSystem, this::querySurfaceWeights);
|
||||||
|
playerInput.setWaterDepthQuery(this::queryWaterDepth);
|
||||||
|
try {
|
||||||
|
com.jme3.audio.AudioNode wadingNode = new com.jme3.audio.AudioNode(
|
||||||
|
assetManager, "audio/footsteps/water/wading.ogg", com.jme3.audio.AudioData.DataType.Buffer);
|
||||||
|
wadingNode.setLooping(true);
|
||||||
|
wadingNode.setPositional(false);
|
||||||
|
de.blight.game.state.AudioSettingsState _as =
|
||||||
|
app.getStateManager().getState(de.blight.game.state.AudioSettingsState.class);
|
||||||
|
wadingNode.setVolume(_as != null ? _as.effectiveEffects() : 0.7f);
|
||||||
|
rootNode.attachChild(wadingNode);
|
||||||
|
playerInput.setWadingSound(wadingNode);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[WorldScene] wading.ogg nicht ladbar – Watgeräusch deaktiviert: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
|
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
|
||||||
try {
|
try {
|
||||||
@@ -286,8 +312,14 @@ public class WorldScene extends BaseAppState {
|
|||||||
inventoryState = new InventoryState(mc, keyBindings);
|
inventoryState = new InventoryState(mc, keyBindings);
|
||||||
inventoryState.setEnabled(false);
|
inventoryState.setEnabled(false);
|
||||||
app.getStateManager().attach(inventoryState);
|
app.getStateManager().attach(inventoryState);
|
||||||
|
app.getStateManager().attach(new de.blight.game.state.HudState(mc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ertrinken-System (Wassertiefe > 1,8 m → Teleport zum Strand)
|
||||||
|
MainCharacter drownMc = findMainCharacter();
|
||||||
|
drownState = new de.blight.game.state.DrownState(terrainChunkState, physicsChar, playerInput, drownMc);
|
||||||
|
app.getStateManager().attach(drownState);
|
||||||
|
|
||||||
// Maus einfangen – keine Klick-Pflicht für Kamerasteuerung
|
// Maus einfangen – keine Klick-Pflicht für Kamerasteuerung
|
||||||
app.getInputManager().setCursorVisible(false);
|
app.getInputManager().setCursorVisible(false);
|
||||||
}
|
}
|
||||||
@@ -421,10 +453,16 @@ public class WorldScene extends BaseAppState {
|
|||||||
|
|
||||||
playerInput.setInitialFacing(spawnYaw);
|
playerInput.setInitialFacing(spawnYaw);
|
||||||
|
|
||||||
|
String reviveClip = de.blight.game.animation.AnimationLibrary.getClipForAction(
|
||||||
|
AnimationLibrary.findAssetRoot(), setName, de.blight.game.animation.AnimationAction.REVIVE);
|
||||||
|
float reviveLength = playerInput.getReviveClipLength();
|
||||||
|
|
||||||
|
// REVIVE-Info immer an DrownState weitergeben (für Ertrinken-Sequenz)
|
||||||
|
if (drownState != null) {
|
||||||
|
drownState.setReviveInfo(reviveClip, reviveLength);
|
||||||
|
}
|
||||||
|
|
||||||
if ("true".equals(System.getProperty("blight.new.game"))) {
|
if ("true".equals(System.getProperty("blight.new.game"))) {
|
||||||
String reviveClip = de.blight.game.animation.AnimationLibrary.getClipForAction(
|
|
||||||
AnimationLibrary.findAssetRoot(), setName, de.blight.game.animation.AnimationAction.REVIVE);
|
|
||||||
float reviveLength = playerInput.getReviveClipLength();
|
|
||||||
app.getStateManager().attach(
|
app.getStateManager().attach(
|
||||||
new de.blight.game.state.NewGameIntroState(playerInput, reviveClip, reviveLength));
|
new de.blight.game.state.NewGameIntroState(playerInput, reviveClip, reviveLength));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,29 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spielt Wellensounds positional ab. Die AudioNodes werden EINMALIG nach dem
|
* Spielt Wellensounds mit korrektem L/R-Panning ab.
|
||||||
* ersten gültigen Scan positioniert (vermeidet den initialen Sprung von (0,0,0)
|
*
|
||||||
* zur Ozean-Kante, der OpenAL-Knistern verursacht). Danach wird die Position
|
* Strategie: Je Sound zwei Quellen (L + R), die immer genau PAN_DIST Meter
|
||||||
* nur noch alle SCAN_INTERVAL Sekunden aktualisiert, wenn sich der Ozean
|
* links/rechts der Kamera positioniert sind (±90° Azimuth).
|
||||||
* signifikant verschoben hat.
|
* Das Lautstärkeverhältnis (constant-power Panning) bestimmt die wahrgenommene
|
||||||
|
* Richtung. So entsteht kein HRTF-0°-Problem: Wasser direkt vorne → beide
|
||||||
|
* Lautsprecher gleich laut (Phantommitte); Wasser links → links lauter.
|
||||||
*/
|
*/
|
||||||
public class OceanSoundState extends BaseAppState {
|
public class OceanSoundState extends BaseAppState {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(OceanSoundState.class);
|
private static final Logger log = LoggerFactory.getLogger(OceanSoundState.class);
|
||||||
|
|
||||||
private static final float MAX_DIST = 60f;
|
private static final float MAX_DIST = 60f;
|
||||||
private static final float REF_DIST = 8f;
|
private static final float REF_DIST = 8f;
|
||||||
private static final float WIND_THRESHOLD = 20f;
|
private static final float WIND_THRESHOLD = 20f;
|
||||||
private static final float FADE_RATE = 1f / 3f;
|
private static final float FADE_RATE = 1f / 3f;
|
||||||
private static final float SCAN_INTERVAL = 0.25f;
|
private static final float SCAN_INTERVAL = 0.25f;
|
||||||
private static final float SCAN_STEP = 5f;
|
private static final float SCAN_STEP = 5f;
|
||||||
/** Mindestverschiebung (m²) bevor die Node-Position aktualisiert wird. */
|
/**
|
||||||
private static final float POS_UPDATE_SQ = 4f * 4f; // 4 Meter
|
* Abstand Source↔Listener. Muss < refDistance (10 m JME3-Default) sein,
|
||||||
|
* damit Clamped-Inverse-Formel Gain = 1.0 liefert.
|
||||||
|
*/
|
||||||
|
private static final float PAN_DIST = 2f;
|
||||||
|
|
||||||
private static final float[] DIR_X = { 0f, 0.707f, 1f, 0.707f, 0f, -0.707f, -1f, -0.707f };
|
private static final float[] DIR_X = { 0f, 0.707f, 1f, 0.707f, 0f, -0.707f, -1f, -0.707f };
|
||||||
private static final float[] DIR_Z = { 1f, 0.707f, 0f, -0.707f, -1f, -0.707f, 0f, 0.707f };
|
private static final float[] DIR_Z = { 1f, 0.707f, 0f, -0.707f, -1f, -0.707f, 0f, 0.707f };
|
||||||
@@ -35,22 +40,22 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
private final TerrainChunkState terrain;
|
private final TerrainChunkState terrain;
|
||||||
|
|
||||||
private SimpleApplication app;
|
private SimpleApplication app;
|
||||||
private AudioNode nodeCalmSound;
|
// L/R-Paare: beide spielen dieselbe Datei, immer auf ±90° der Kamera
|
||||||
private AudioNode nodeStormySound;
|
private AudioNode nodeCalmL, nodeCalmR;
|
||||||
|
private AudioNode nodeStormyL, nodeStormyR;
|
||||||
|
|
||||||
private final Vector3f playerPos = new Vector3f();
|
private final Vector3f playerPos = new Vector3f();
|
||||||
private final Vector3f targetPos = new Vector3f();
|
private final Vector3f oceanPos = new Vector3f();
|
||||||
private final Vector3f nodePos = new Vector3f(); // zuletzt gesetzte Node-Position
|
|
||||||
|
|
||||||
/** true bis der erste gültige Scan die Nodes positioniert und abgespielt hat. */
|
private boolean firstScan = true;
|
||||||
private boolean firstScan = true;
|
private boolean playing = false;
|
||||||
private boolean playing = false;
|
private boolean oceanFound = false;
|
||||||
|
|
||||||
private float calmVol = 0f;
|
private float calmVol = 0f;
|
||||||
private float stormyVol = 0f;
|
private float stormyVol = 0f;
|
||||||
private float scanTimer = 0f;
|
private float scanTimer = 0f;
|
||||||
private boolean oceanInRange = false;
|
private boolean oceanInRange = false;
|
||||||
private float oceanDist = Float.MAX_VALUE;
|
private float oceanDist = Float.MAX_VALUE;
|
||||||
|
|
||||||
public OceanSoundState(TerrainChunkState terrain) {
|
public OceanSoundState(TerrainChunkState terrain) {
|
||||||
this.terrain = terrain;
|
this.terrain = terrain;
|
||||||
@@ -59,15 +64,18 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
@Override
|
@Override
|
||||||
protected void initialize(Application application) {
|
protected void initialize(Application application) {
|
||||||
app = (SimpleApplication) application;
|
app = (SimpleApplication) application;
|
||||||
nodeCalmSound = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm");
|
nodeCalmL = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_L");
|
||||||
nodeStormySound = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy");
|
nodeCalmR = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_R");
|
||||||
// Noch NICHT abspielen – erst wenn wir eine gültige Position haben (firstScan).
|
nodeStormyL = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_L");
|
||||||
|
nodeStormyR = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_R");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void cleanup(Application application) {
|
protected void cleanup(Application application) {
|
||||||
stop(nodeCalmSound);
|
stop(nodeCalmL);
|
||||||
stop(nodeStormySound);
|
stop(nodeCalmR);
|
||||||
|
stop(nodeStormyL);
|
||||||
|
stop(nodeStormyR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected void onEnable() {}
|
@Override protected void onEnable() {}
|
||||||
@@ -79,16 +87,24 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void update(float tpf) {
|
public void update(float tpf) {
|
||||||
if (nodeCalmSound == null && nodeStormySound == null) return;
|
if (nodeCalmL == null && nodeStormyL == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Periodisch nächste Ozean-Position berechnen
|
|
||||||
scanTimer -= tpf;
|
scanTimer -= tpf;
|
||||||
if (scanTimer <= 0f) {
|
if (scanTimer <= 0f) {
|
||||||
scanTimer = SCAN_INTERVAL;
|
scanTimer = SCAN_INTERVAL;
|
||||||
scanOceanSource();
|
scanOceanSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lautstärke
|
boolean playerInWater = terrain.getHeightAt(playerPos.x, playerPos.z) < 0f;
|
||||||
|
if (playerInWater) {
|
||||||
|
applyVolumes(0f, 0f, 0.707f, 0.707f);
|
||||||
|
calmVol = 0f;
|
||||||
|
stormyVol = 0f;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
WeatherState weather = getApplication().getStateManager().getState(WeatherState.class);
|
WeatherState weather = getApplication().getStateManager().getState(WeatherState.class);
|
||||||
float wind = weather != null ? weather.getWindSpeed() : 4f;
|
float wind = weather != null ? weather.getWindSpeed() : 4f;
|
||||||
|
|
||||||
@@ -99,17 +115,31 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
? Math.max(0f, 1f - Math.max(0f, oceanDist - REF_DIST) / (MAX_DIST - REF_DIST))
|
? Math.max(0f, 1f - Math.max(0f, oceanDist - REF_DIST) / (MAX_DIST - REF_DIST))
|
||||||
: 0f;
|
: 0f;
|
||||||
|
|
||||||
float calmTarget = (oceanInRange && wind < WIND_THRESHOLD) ? scale * distScale : 0f;
|
float calmTarget = (oceanInRange && wind < WIND_THRESHOLD) ? scale * distScale : 0f;
|
||||||
float stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale : 0f;
|
float stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale : 0f;
|
||||||
|
|
||||||
calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf);
|
calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf);
|
||||||
stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf);
|
stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf);
|
||||||
|
|
||||||
if (nodeCalmSound != null) nodeCalmSound.setVolume(calmVol);
|
float panL = 0.707f, panR = 0.707f; // default: center
|
||||||
if (nodeStormySound != null) nodeStormySound.setVolume(stormyVol);
|
if (playing && oceanFound) {
|
||||||
|
float panValue = computePan(); // -1 = links, 0 = Mitte, +1 = rechts
|
||||||
|
panL = (float) Math.sqrt((1f - panValue) / 2f);
|
||||||
|
panR = (float) Math.sqrt((1f + panValue) / 2f);
|
||||||
|
updateSourcePositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyVolumes(calmVol, stormyVol, panL, panR);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scan ────────────────────────────────────────────────────────────────
|
private void applyVolumes(float calm, float stormy, float panL, float panR) {
|
||||||
|
if (nodeCalmL != null) nodeCalmL.setVolume(calm * panL);
|
||||||
|
if (nodeCalmR != null) nodeCalmR.setVolume(calm * panR);
|
||||||
|
if (nodeStormyL != null) nodeStormyL.setVolume(stormy * panL);
|
||||||
|
if (nodeStormyR != null) nodeStormyR.setVolume(stormy * panR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scan ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void scanOceanSource() {
|
private void scanOceanSource() {
|
||||||
float px = playerPos.x;
|
float px = playerPos.x;
|
||||||
@@ -118,7 +148,6 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
if (terrain.getHeightAt(px, pz) < 0f) {
|
if (terrain.getHeightAt(px, pz) < 0f) {
|
||||||
oceanInRange = true;
|
oceanInRange = true;
|
||||||
oceanDist = 0f;
|
oceanDist = 0f;
|
||||||
applyTarget(px, pz);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,53 +180,78 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Zielposition setzen. Beim ersten Scan: Nodes positionieren und Wiedergabe starten.
|
|
||||||
* Danach: Node nur aktualisieren wenn Verschiebung > POS_UPDATE_SQ.
|
|
||||||
*/
|
|
||||||
private void applyTarget(float x, float z) {
|
private void applyTarget(float x, float z) {
|
||||||
targetPos.set(x, 0f, z);
|
oceanPos.set(x, playerPos.y, z);
|
||||||
|
oceanFound = true;
|
||||||
|
|
||||||
if (firstScan) {
|
if (firstScan) {
|
||||||
// Beim ersten gültigen Scan: Nodes korrekt platzieren, dann erst abspielen.
|
|
||||||
firstScan = false;
|
firstScan = false;
|
||||||
nodePos.set(targetPos);
|
attachAndPlay(nodeCalmL);
|
||||||
if (nodeCalmSound != null) {
|
attachAndPlay(nodeCalmR);
|
||||||
app.getRootNode().attachChild(nodeCalmSound);
|
attachAndPlay(nodeStormyL);
|
||||||
nodeCalmSound.setLocalTranslation(nodePos);
|
attachAndPlay(nodeStormyR);
|
||||||
nodeCalmSound.play();
|
|
||||||
}
|
|
||||||
if (nodeStormySound != null) {
|
|
||||||
app.getRootNode().attachChild(nodeStormySound);
|
|
||||||
nodeStormySound.setLocalTranslation(nodePos);
|
|
||||||
nodeStormySound.play();
|
|
||||||
}
|
|
||||||
playing = true;
|
playing = true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playing) return;
|
|
||||||
|
|
||||||
// Nur aktualisieren wenn sich die Zielposition signifikant geändert hat
|
|
||||||
float dxSq = (targetPos.x - nodePos.x);
|
|
||||||
float dzSq = (targetPos.z - nodePos.z);
|
|
||||||
if (dxSq * dxSq + dzSq * dzSq < POS_UPDATE_SQ) return;
|
|
||||||
|
|
||||||
nodePos.set(targetPos);
|
|
||||||
if (nodeCalmSound != null) nodeCalmSound.setLocalTranslation(nodePos);
|
|
||||||
if (nodeStormySound != null) nodeStormySound.setLocalTranslation(nodePos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hilfsmethoden ───────────────────────────────────────────────────────
|
private void attachAndPlay(AudioNode node) {
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.getRootNode().attachChild(node);
|
||||||
|
node.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panning ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pan-Wert: -1 = Ozean voll links der Kamera, 0 = Mitte, +1 = voll rechts.
|
||||||
|
* Berechnet als Dot-Produkt der horizontalen Ozean-Richtung mit dem
|
||||||
|
* Kamera-Rechtsvektor — unabhängig davon, ob Kamera zum Wasser zeigt.
|
||||||
|
*/
|
||||||
|
private float computePan() {
|
||||||
|
float dx = oceanPos.x - playerPos.x;
|
||||||
|
float dz = oceanPos.z - playerPos.z;
|
||||||
|
float len = (float) Math.sqrt(dx * dx + dz * dz);
|
||||||
|
if (len < 0.1f) {
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
dx /= len;
|
||||||
|
dz /= len;
|
||||||
|
Vector3f camLeft = app.getCamera().getLeft(); // camRight = -camLeft
|
||||||
|
float rightComp = -(dx * camLeft.x + dz * camLeft.z);
|
||||||
|
return Math.max(-1f, Math.min(1f, rightComp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält L-Quelle PAN_DIST Meter links der Kamera, R-Quelle PAN_DIST Meter rechts.
|
||||||
|
* → Immer ±90° Azimuth zum Listener, nie 0°/180° → kein HRTF-Nullpunkt.
|
||||||
|
*/
|
||||||
|
private void updateSourcePositions() {
|
||||||
|
Vector3f cam = app.getCamera().getLocation();
|
||||||
|
Vector3f camLeft = app.getCamera().getLeft();
|
||||||
|
|
||||||
|
float lx = cam.x + camLeft.x * PAN_DIST;
|
||||||
|
float lz = cam.z + camLeft.z * PAN_DIST;
|
||||||
|
float rx = cam.x - camLeft.x * PAN_DIST;
|
||||||
|
float rz = cam.z - camLeft.z * PAN_DIST;
|
||||||
|
float y = cam.y;
|
||||||
|
|
||||||
|
if (nodeCalmL != null) nodeCalmL.setLocalTranslation(lx, y, lz);
|
||||||
|
if (nodeCalmR != null) nodeCalmR.setLocalTranslation(rx, y, rz);
|
||||||
|
if (nodeStormyL != null) nodeStormyL.setLocalTranslation(lx, y, lz);
|
||||||
|
if (nodeStormyR != null) nodeStormyR.setLocalTranslation(rx, y, rz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private AudioNode loadLoop(String path, String label) {
|
private AudioNode loadLoop(String path, String label) {
|
||||||
try {
|
try {
|
||||||
AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Stream);
|
AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Buffer);
|
||||||
n.setLooping(true);
|
n.setLooping(true);
|
||||||
n.setVolume(0f);
|
n.setVolume(0f);
|
||||||
n.setPositional(true);
|
n.setPositional(true);
|
||||||
n.setRefDistance(REF_DIST);
|
// refDistance = 10 m (JME3-Default), Dist = PAN_DIST = 2 m → Gain = 1.0
|
||||||
n.setMaxDistance(MAX_DIST);
|
|
||||||
return n;
|
return n;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage());
|
log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage());
|
||||||
@@ -208,7 +262,9 @@ public class OceanSoundState extends BaseAppState {
|
|||||||
private void stop(AudioNode node) {
|
private void stop(AudioNode node) {
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
node.stop();
|
node.stop();
|
||||||
if (node.getParent() != null) app.getRootNode().detachChild(node);
|
if (node.getParent() != null) {
|
||||||
|
app.getRootNode().detachChild(node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user