Soundsystem weiter ausgebaut
This commit is contained in:
@@ -9,6 +9,7 @@ import com.jme3.system.AppSettings;
|
||||
import de.blight.common.BlightHome;
|
||||
import de.blight.common.SaveGameIO;
|
||||
import de.blight.game.config.*;
|
||||
import de.blight.game.state.AudioSettingsState;
|
||||
import de.blight.game.state.SaveGameState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,11 +32,13 @@ public class BlightGame extends SimpleApplication {
|
||||
|
||||
private KeyBindings keyBindings;
|
||||
private GraphicsSettings graphicsSettings;
|
||||
private AudioSettings audioSettings;
|
||||
private ScreenshotAppState screenshotState;
|
||||
private Path screenshotDir;
|
||||
private WorldScene worldScene;
|
||||
private ConfigScreen configScreen;
|
||||
private GraphicsScreen graphicsScreen;
|
||||
private AudioScreen audioScreen;
|
||||
private PauseMenu pauseMenu;
|
||||
private MainMenuState mainMenuState;
|
||||
|
||||
@@ -135,6 +138,8 @@ public class BlightGame extends SimpleApplication {
|
||||
|
||||
keyBindings = KeyBindingStore.load();
|
||||
graphicsSettings = GraphicsStore.load();
|
||||
audioSettings = AudioSettingsStore.load();
|
||||
stateManager.attach(new AudioSettingsState(audioSettings));
|
||||
|
||||
status("Lade Spielstand...");
|
||||
stateManager.attach(new SaveGameState());
|
||||
@@ -164,6 +169,13 @@ public class BlightGame extends SimpleApplication {
|
||||
stateManager.attach(graphicsScreen);
|
||||
graphicsScreen.setEnabled(false);
|
||||
|
||||
audioScreen = new AudioScreen(audioSettings, () -> {
|
||||
audioScreen.setEnabled(false);
|
||||
screenCloseTarget.run();
|
||||
});
|
||||
stateManager.attach(audioScreen);
|
||||
audioScreen.setEnabled(false);
|
||||
|
||||
SaveGameState saveState = stateManager.getState(SaveGameState.class);
|
||||
|
||||
pauseMenu = new PauseMenu(
|
||||
@@ -173,6 +185,11 @@ public class BlightGame extends SimpleApplication {
|
||||
pauseMenu.setEnabled(false);
|
||||
graphicsScreen.setEnabled(true);
|
||||
},
|
||||
() -> {
|
||||
screenCloseTarget = () -> pauseMenu.setEnabled(true);
|
||||
pauseMenu.setEnabled(false);
|
||||
audioScreen.setEnabled(true);
|
||||
},
|
||||
() -> {
|
||||
screenCloseTarget = () -> pauseMenu.setEnabled(true);
|
||||
pauseMenu.setEnabled(false);
|
||||
@@ -237,6 +254,7 @@ public class BlightGame extends SimpleApplication {
|
||||
if (mainMenuState != null && mainMenuState.isEnabled()) return;
|
||||
|
||||
if (graphicsScreen.isEnabled()) return;
|
||||
if (audioScreen.isEnabled()) return;
|
||||
|
||||
if (configScreen.isEnabled()) {
|
||||
if (configScreen.isWaiting()) {
|
||||
|
||||
@@ -28,6 +28,15 @@ public class AnimSet {
|
||||
private String previewModelPath = null;
|
||||
/** Manueller Positions-/Rotations-Versatz pro Clip-Name. */
|
||||
private Map<String, AnimOffset> animOffsets = new LinkedHashMap<>();
|
||||
/** Sub-Clip-Definitionen: Name → Zeitfenster aus einem kombinierten Quell-Clip. */
|
||||
private Map<String, SubClipDef> subClips = new LinkedHashMap<>();
|
||||
|
||||
/** Beschreibt einen Sub-Clip: Zeitfenster [start, end] innerhalb eines kombinierten Quell-Clips. */
|
||||
public static class SubClipDef {
|
||||
public String source;
|
||||
public float start;
|
||||
public float end;
|
||||
}
|
||||
|
||||
public List<String> getClips() { return clips; }
|
||||
public void setClips(List<String> clips) { this.clips = clips; }
|
||||
@@ -41,6 +50,10 @@ public class AnimSet {
|
||||
public void setAnimOffsets(Map<String, AnimOffset> animOffsets) {
|
||||
this.animOffsets = animOffsets;
|
||||
}
|
||||
public Map<String, SubClipDef> getSubClips() {
|
||||
return subClips != null ? subClips : new LinkedHashMap<>();
|
||||
}
|
||||
public void setSubClips(Map<String, SubClipDef> subClips) { this.subClips = subClips; }
|
||||
|
||||
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
|
||||
public void save(Path setDir, String setName) throws IOException {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.jme3.scene.Spatial;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -220,6 +221,71 @@ public class AnimationLibrary extends BaseAppState {
|
||||
} else {
|
||||
log.info("[AnimLib] {} Clips geladen: {}", clips.size(), clips.keySet());
|
||||
}
|
||||
|
||||
Path setDir = findAssetRoot().resolve("animations").resolve("sets");
|
||||
try {
|
||||
AnimSet animSet = AnimSet.load(setDir, "human");
|
||||
createSubClips(animSet);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AnimLib] Sub-Clips konnten nicht erstellt werden: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void createSubClips(AnimSet animSet) {
|
||||
for (Map.Entry<String, AnimSet.SubClipDef> entry : animSet.getSubClips().entrySet()) {
|
||||
String subName = entry.getKey();
|
||||
AnimSet.SubClipDef def = entry.getValue();
|
||||
AnimClip source = clips.get(def.source);
|
||||
Armature arm = armatures.get(def.source);
|
||||
if (source == null) {
|
||||
log.warn("[AnimLib] SubClip '{}': Quelle '{}' nicht gefunden – übersprungen", subName, def.source);
|
||||
continue;
|
||||
}
|
||||
AnimClip sub = extractSubClip(source, subName, def.start, def.end);
|
||||
clips.put(subName, sub);
|
||||
if (arm != null) {
|
||||
armatures.put(subName, arm);
|
||||
}
|
||||
log.info("[AnimLib] SubClip '{}' extrahiert [{}-{}s] aus '{}'",
|
||||
subName, def.start, def.end, def.source);
|
||||
}
|
||||
}
|
||||
|
||||
private static AnimClip extractSubClip(AnimClip source, String name, float startSec, float endSec) {
|
||||
List<AnimTrack<?>> newTracks = new ArrayList<>();
|
||||
for (AnimTrack<?> track : source.getTracks()) {
|
||||
if (!(track instanceof TransformTrack tt)) {
|
||||
newTracks.add(track);
|
||||
continue;
|
||||
}
|
||||
float[] times = tt.getTimes();
|
||||
int first = 0;
|
||||
while (first < times.length - 1 && times[first] < startSec - 1e-5f) {
|
||||
first++;
|
||||
}
|
||||
int last = times.length - 1;
|
||||
while (last > 0 && times[last] > endSec + 1e-5f) {
|
||||
last--;
|
||||
}
|
||||
int count = last - first + 1;
|
||||
if (count <= 0) {
|
||||
continue;
|
||||
}
|
||||
float[] newTimes = new float[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
newTimes[i] = times[first + i] - startSec;
|
||||
}
|
||||
Vector3f[] trans = tt.getTranslations();
|
||||
Quaternion[] rots = tt.getRotations();
|
||||
Vector3f[] scales = tt.getScales();
|
||||
Vector3f[] newTrans = trans != null ? Arrays.copyOfRange(trans, first, last + 1) : null;
|
||||
Quaternion[] newRots = rots != null ? Arrays.copyOfRange(rots, first, last + 1) : null;
|
||||
Vector3f[] newScales = scales != null ? Arrays.copyOfRange(scales, first, last + 1) : null;
|
||||
newTracks.add(new TransformTrack(tt.getTarget(), newTimes, newTrans, newRots, newScales));
|
||||
}
|
||||
AnimClip sub = new AnimClip(name);
|
||||
sub.setTracks(newTracks.toArray(new AnimTrack[0]));
|
||||
return sub;
|
||||
}
|
||||
|
||||
private void loadClipFromFile(Path file) {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package de.blight.game.audio;
|
||||
|
||||
import com.jme3.asset.AssetManager;
|
||||
import com.jme3.audio.AudioData;
|
||||
import com.jme3.audio.AudioNode;
|
||||
import com.jme3.scene.Node;
|
||||
import de.blight.game.state.AudioSettingsState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Dreistufige Fallback-Logik:
|
||||
* 1. audio/footsteps/{surface}/{gait}/*.ogg
|
||||
* 2. audio/footsteps/{surface}/*.ogg (für alle Gangarten, Lautstärke gait-abhängig)
|
||||
* 3. audio/footsteps/*.ogg (Wurzel-Fallback, gleiche Lautstärke-Logik)
|
||||
*
|
||||
* Auf jeder Stufe werden auch Alias-Oberflächen geprüft:
|
||||
* grass ↔ leaves | pavement ↔ rock | dirt ↔ gravel
|
||||
*
|
||||
* Lautstärke nach Gangart: sprinting=100 %, running=80 %, walking=50 % von BASE_VOLUME.
|
||||
*/
|
||||
public class FootstepSystem {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FootstepSystem.class);
|
||||
|
||||
private static final float BASE_VOLUME = 0.70f;
|
||||
private static final float PITCH_CENTER = 1.00f;
|
||||
private static final float PITCH_RANGE = 0.10f;
|
||||
|
||||
private static final String[] GAITS = {"walking", "running", "sprinting"};
|
||||
|
||||
// surface-name → alias surface-names
|
||||
private static final Map<String, List<String>> ALIASES;
|
||||
static {
|
||||
ALIASES = new HashMap<>();
|
||||
ALIASES.put("grass", List.of("leaves"));
|
||||
ALIASES.put("leaves", List.of("grass"));
|
||||
ALIASES.put("pavement", List.of("rock"));
|
||||
ALIASES.put("rock", List.of("pavement"));
|
||||
ALIASES.put("dirt", List.of("gravel"));
|
||||
ALIASES.put("gravel", List.of("dirt"));
|
||||
}
|
||||
|
||||
private final AudioSettingsState audioSettings;
|
||||
private final Random random = new Random();
|
||||
|
||||
/** Stufe 1: surface → gait → AudioNodes */
|
||||
private final Map<String, Map<String, List<AudioNode>>> gaitPools = new HashMap<>();
|
||||
/** Stufe 2: surface → AudioNodes (flach, kein Gait-Unterordner) */
|
||||
private final Map<String, List<AudioNode>> flatPools = new HashMap<>();
|
||||
/** Stufe 3: Dateien direkt in audio/footsteps/ */
|
||||
private final List<AudioNode> rootPool = new ArrayList<>();
|
||||
|
||||
public FootstepSystem(AssetManager am, Node parentNode, AudioSettingsState audioSettings,
|
||||
Path assetRoot) {
|
||||
this.audioSettings = audioSettings;
|
||||
|
||||
String rootDir = "audio/footsteps/";
|
||||
|
||||
for (SurfaceType st : SurfaceType.values()) {
|
||||
String key = st.name().toLowerCase();
|
||||
|
||||
// Stufe 1: gait-Unterordner
|
||||
Map<String, List<AudioNode>> gaitMap = new HashMap<>();
|
||||
for (String gait : GAITS) {
|
||||
List<AudioNode> nodes = scanDir(am, parentNode, assetRoot,
|
||||
rootDir + key + "/" + gait + "/");
|
||||
if (!nodes.isEmpty()) {
|
||||
gaitMap.put(gait, nodes);
|
||||
}
|
||||
}
|
||||
if (!gaitMap.isEmpty()) {
|
||||
gaitPools.put(key, gaitMap);
|
||||
}
|
||||
|
||||
// Stufe 2: Oberflächen-Ordner flach
|
||||
List<AudioNode> flat = scanDir(am, parentNode, assetRoot, rootDir + key + "/");
|
||||
if (!flat.isEmpty()) {
|
||||
flatPools.put(key, flat);
|
||||
}
|
||||
}
|
||||
|
||||
// Stufe 3: Wurzel-Ordner flach
|
||||
rootPool.addAll(scanDir(am, parentNode, assetRoot, rootDir));
|
||||
|
||||
int total = gaitPools.values().stream()
|
||||
.mapToInt(m -> m.values().stream().mapToInt(List::size).sum()).sum()
|
||||
+ flatPools.values().stream().mapToInt(List::size).sum()
|
||||
+ rootPool.size();
|
||||
log.info("[Footstep] Geladen – Gait-Pools: {}, Flat-Pools: {}, Root: {}, Gesamt: {} AudioNodes",
|
||||
gaitPools.size(), flatPools.size(), rootPool.size(), total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt Schrittsounds für alle Oberflächen parallel, deren Anteil ≥ MIN_WEIGHT ist.
|
||||
* Jeder Sound wird mit vol = base_vol × fraction abgespielt.
|
||||
*
|
||||
* @param surfaceWeights Normierte Anteile (0..1) pro SurfaceType.ordinal()
|
||||
* @param gait "walking" / "running" / "sprinting"
|
||||
*/
|
||||
public void play(float[] surfaceWeights, String gait) {
|
||||
float baseVol = BASE_VOLUME * gaitVolumeFactor(gait)
|
||||
* (audioSettings != null ? audioSettings.effectiveEffects() : 1f);
|
||||
float pitch = PITCH_CENTER + (random.nextFloat() - 0.5f) * PITCH_RANGE;
|
||||
|
||||
SurfaceType[] types = SurfaceType.values();
|
||||
for (int i = 0; i < surfaceWeights.length && i < types.length; i++) {
|
||||
float fraction = surfaceWeights[i];
|
||||
if (fraction < MIN_WEIGHT) continue;
|
||||
|
||||
SurfaceType surface = types[i];
|
||||
if (surface == SurfaceType.UNKNOWN) continue;
|
||||
|
||||
String key = surface.name().toLowerCase();
|
||||
List<AudioNode> nodes = resolve(key, gait);
|
||||
if (nodes == null || nodes.isEmpty()) continue;
|
||||
|
||||
AudioNode node = nodes.get(random.nextInt(nodes.size()));
|
||||
node.setVolume(baseVol * fraction);
|
||||
node.setPitch(pitch);
|
||||
node.playInstance();
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimaler Anteil (0..1) damit eine Oberfläche einen eigenen Sound bekommt. */
|
||||
private static final float MIN_WEIGHT = 0.10f;
|
||||
|
||||
// ── Auflösung ──────────────────────────────────────────────────────────────
|
||||
|
||||
private List<AudioNode> resolve(String key, String gait) {
|
||||
// Stufe 1a: exakt surface/gait
|
||||
List<AudioNode> r = fromGaitPool(key, gait);
|
||||
if (r != null) return r;
|
||||
|
||||
// Stufe 1b: Alias/gait
|
||||
for (String alias : aliases(key)) {
|
||||
r = fromGaitPool(alias, gait);
|
||||
if (r != null) return r;
|
||||
}
|
||||
|
||||
// Stufe 2a: surface flach
|
||||
r = fromFlatPool(key);
|
||||
if (r != null) return r;
|
||||
|
||||
// Stufe 2b: Alias flach
|
||||
for (String alias : aliases(key)) {
|
||||
r = fromFlatPool(alias);
|
||||
if (r != null) return r;
|
||||
}
|
||||
|
||||
// Stufe 3: Wurzel
|
||||
if (!rootPool.isEmpty()) return rootPool;
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private List<AudioNode> fromGaitPool(String key, String gait) {
|
||||
Map<String, List<AudioNode>> m = gaitPools.get(key);
|
||||
if (m == null) return null;
|
||||
List<AudioNode> l = m.get(gait);
|
||||
return (l != null && !l.isEmpty()) ? l : null;
|
||||
}
|
||||
|
||||
private List<AudioNode> fromFlatPool(String key) {
|
||||
List<AudioNode> l = flatPools.get(key);
|
||||
return (l != null && !l.isEmpty()) ? l : null;
|
||||
}
|
||||
|
||||
private List<String> aliases(String key) {
|
||||
return ALIASES.getOrDefault(key, Collections.emptyList());
|
||||
}
|
||||
|
||||
// ── Lautstärke ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static float gaitVolumeFactor(String gait) {
|
||||
return switch (gait) {
|
||||
case "sprinting" -> 1.00f;
|
||||
case "running" -> 0.80f;
|
||||
default -> 0.50f; // walking
|
||||
};
|
||||
}
|
||||
|
||||
// ── Laden ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liest alle .ogg-Dateien direkt (nicht rekursiv) aus relDir.
|
||||
* Unterordner werden ignoriert (die werden separat gescannt).
|
||||
*/
|
||||
private List<AudioNode> scanDir(AssetManager am, Node parentNode,
|
||||
Path assetRoot, String relDir) {
|
||||
Path absDir = assetRoot.resolve(relDir);
|
||||
if (!Files.isDirectory(absDir)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Path> oggFiles;
|
||||
try (var stream = Files.list(absDir)) {
|
||||
oggFiles = stream
|
||||
.filter(p -> Files.isRegularFile(p)
|
||||
&& p.getFileName().toString().toLowerCase().endsWith(".ogg"))
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
} catch (IOException e) {
|
||||
log.warn("[Footstep] Fehler beim Scannen von {}: {}", absDir, e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<AudioNode> nodes = new ArrayList<>(oggFiles.size());
|
||||
for (Path file : oggFiles) {
|
||||
String assetPath = relDir + file.getFileName().toString();
|
||||
try {
|
||||
AudioNode node = new AudioNode(am, assetPath, AudioData.DataType.Buffer);
|
||||
node.setPositional(false);
|
||||
node.setLooping(false);
|
||||
node.setVolume(BASE_VOLUME);
|
||||
parentNode.attachChild(node);
|
||||
nodes.add(node);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Footstep] Nicht ladbar '{}': {}", assetPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodes.isEmpty()) {
|
||||
log.debug("[Footstep] {}: {} Sounds", relDir, nodes.size());
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.blight.game.audio;
|
||||
|
||||
public enum SurfaceType {
|
||||
GRASS, DIRT, SAND, ROCK, GRAVEL, LEAVES, PAVEMENT, WOOD, UNKNOWN;
|
||||
|
||||
public static SurfaceType fromTexturePath(String path) {
|
||||
if (path == null || path.isEmpty()) return UNKNOWN;
|
||||
String p = path.toLowerCase();
|
||||
if (p.contains("gras")) return GRASS;
|
||||
if (p.contains("dirt")) return DIRT;
|
||||
if (p.contains("sand")) return SAND;
|
||||
if (p.contains("rock")) return ROCK;
|
||||
if (p.contains("gravel")) return GRAVEL;
|
||||
if (p.contains("leaves")) return LEAVES;
|
||||
if (p.contains("pavement")) return PAVEMENT;
|
||||
if (p.contains("wood")) return WOOD;
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
public static SurfaceType fromName(String name) {
|
||||
if (name == null || name.isEmpty()) return UNKNOWN;
|
||||
try {
|
||||
return SurfaceType.valueOf(name.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,309 +1,275 @@
|
||||
package de.blight.game.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.KeyInput;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.RawInputListener;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.input.event.JoyAxisEvent;
|
||||
import com.jme3.input.event.JoyButtonEvent;
|
||||
import com.jme3.input.event.KeyInputEvent;
|
||||
import com.jme3.input.event.MouseButtonEvent;
|
||||
import com.jme3.input.event.MouseMotionEvent;
|
||||
import com.jme3.input.event.TouchEvent;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
|
||||
/**
|
||||
* Overlay-AppState der die Tastenbelegungs-Maske anzeigt.
|
||||
*
|
||||
* ESC → Schließen (ohne Speichern)
|
||||
* Klick auf Row → wartet auf neue Taste
|
||||
* ESC während Warten → bricht nur die Zuweisung ab
|
||||
* Speichern → schreibt JSON, ruft onSave-Callback
|
||||
*/
|
||||
public class ConfigScreen extends BaseAppState implements RawInputListener {
|
||||
|
||||
// Farben
|
||||
private static final ColorRGBA COL_BG = new ColorRGBA(0.05f, 0.05f, 0.08f, 0.88f);
|
||||
private static final ColorRGBA COL_PANEL = new ColorRGBA(0.10f, 0.10f, 0.16f, 1.00f);
|
||||
private static final ColorRGBA COL_ROW = new ColorRGBA(0.18f, 0.18f, 0.28f, 1.00f);
|
||||
private static final ColorRGBA COL_ROW_HOVER = new ColorRGBA(0.25f, 0.25f, 0.40f, 1.00f);
|
||||
private static final ColorRGBA COL_ROW_WAIT = new ColorRGBA(0.50f, 0.30f, 0.10f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN_SAVE = new ColorRGBA(0.15f, 0.40f, 0.15f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN_CANCEL = new ColorRGBA(0.40f, 0.15f, 0.15f, 1.00f);
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
private static final ColorRGBA COL_TEXT_KEY = new ColorRGBA(0.85f, 0.85f, 0.50f, 1.00f);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private BitmapFont font;
|
||||
|
||||
private KeyBindings liveBindings; // geteilt mit der ganzen App
|
||||
private KeyBindings editCopy; // wird beim Öffnen geklont
|
||||
|
||||
private Runnable onSave; // Callback → PlayerInputControl.reloadBindings
|
||||
private Runnable onClose; // Callback → PauseMenu wiederherstellen
|
||||
|
||||
private Node panel;
|
||||
private List<Row> rows = new ArrayList<>();
|
||||
private int waitingRow = -1; // -1 = keine Zuweisung aktiv
|
||||
|
||||
// UI-Elemente für Buttons (Bounds in Screen-Koordinaten)
|
||||
private float saveBtnX, saveBtnY, saveBtnW, saveBtnH;
|
||||
private float cancelBtnX, cancelBtnY;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static class Row {
|
||||
String field;
|
||||
String label;
|
||||
BitmapText keyText;
|
||||
Geometry bg;
|
||||
float x, y, w, h; // Button-Bounds
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ConfigScreen(KeyBindings liveBindings, Runnable onSave) {
|
||||
this.liveBindings = liveBindings;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
public boolean isWaiting() { return waitingRow >= 0; }
|
||||
|
||||
public void setOnClose(Runnable onClose) { this.onClose = onClose; }
|
||||
|
||||
public void cancelWaiting() {
|
||||
if (waitingRow >= 0) { resetRowColor(waitingRow); waitingRow = -1; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
editCopy = liveBindings.copy();
|
||||
waitingRow = -1;
|
||||
buildUI();
|
||||
app.getInputManager().setCursorVisible(true);
|
||||
app.getInputManager().addRawInputListener(this);
|
||||
app.getInputManager().addMapping("_CfgClick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
app.getInputManager().addListener(clickListener, "_CfgClick");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
if (panel != null) { guiNode.detachChild(panel); panel = null; }
|
||||
rows.clear();
|
||||
waitingRow = -1;
|
||||
app.getInputManager().removeRawInputListener(this);
|
||||
app.getInputManager().deleteMapping("_CfgClick");
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// UI aufbauen
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void buildUI() {
|
||||
float sw = app.getCamera().getWidth();
|
||||
float sh = app.getCamera().getHeight();
|
||||
|
||||
panel = new Node("cfg-panel");
|
||||
|
||||
// Halbdurchsichtiger Overlay über dem Spiel
|
||||
addQuad(panel, 0, 0, sw, sh, COL_BG, -2);
|
||||
|
||||
float pw = 720, ph = 440;
|
||||
float px = (sw - pw) / 2f, py = (sh - ph) / 2f;
|
||||
addQuad(panel, px, py, pw, ph, COL_PANEL, -1);
|
||||
|
||||
// Titel
|
||||
BitmapText title = text("TASTENBELEGUNG", 20, COL_TEXT);
|
||||
centerText(title, px, py + ph - 40, pw);
|
||||
panel.attachChild(title);
|
||||
|
||||
BitmapText hint = text("Klicke eine Taste um sie neu zu belegen", 14, new ColorRGBA(0.7f, 0.7f, 0.7f, 1f));
|
||||
centerText(hint, px, py + ph - 70, pw);
|
||||
panel.attachChild(hint);
|
||||
|
||||
// Reihen
|
||||
float rowX = px + 30;
|
||||
float keyX = px + pw - 220;
|
||||
float rowW = 180;
|
||||
float rowH = 36;
|
||||
float startY = py + ph - 110;
|
||||
float stepY = 48;
|
||||
|
||||
for (int i = 0; i < KeyBindings.ENTRIES.length; i++) {
|
||||
String[] entry = KeyBindings.ENTRIES[i];
|
||||
float ry = startY - i * stepY;
|
||||
|
||||
BitmapText lbl = text(entry[1], 16, COL_TEXT);
|
||||
lbl.setLocalTranslation(rowX, ry + rowH - 8, 0);
|
||||
panel.attachChild(lbl);
|
||||
|
||||
Geometry bg = addQuad(panel, keyX, ry, rowW, rowH, COL_ROW, 0);
|
||||
|
||||
BitmapText kt = text(KeyNames.of(editCopy.get(entry[0])), 16, COL_TEXT_KEY);
|
||||
kt.setLocalTranslation(keyX + 10, ry + rowH - 8, 1);
|
||||
panel.attachChild(kt);
|
||||
|
||||
Row row = new Row();
|
||||
row.field = entry[0];
|
||||
row.label = entry[1];
|
||||
row.keyText = kt;
|
||||
row.bg = bg;
|
||||
row.x = keyX; row.y = ry; row.w = rowW; row.h = rowH;
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
float btnW = 160, btnH = 42;
|
||||
float btnY = py + 25;
|
||||
saveBtnX = px + pw / 2f - btnW - 15;
|
||||
saveBtnY = btnY;
|
||||
saveBtnW = btnW;
|
||||
saveBtnH = btnH;
|
||||
cancelBtnX = px + pw / 2f + 15;
|
||||
cancelBtnY = btnY;
|
||||
|
||||
addQuad(panel, saveBtnX, saveBtnY, btnW, btnH, COL_BTN_SAVE, 0);
|
||||
BitmapText saveLabel = text("Speichern", 16, COL_TEXT);
|
||||
centerText(saveLabel, saveBtnX, saveBtnY + btnH - 10, btnW);
|
||||
panel.attachChild(saveLabel);
|
||||
|
||||
addQuad(panel, cancelBtnX, cancelBtnY, btnW, btnH, COL_BTN_CANCEL, 0);
|
||||
BitmapText cancelLabel = text("Abbrechen", 16, COL_TEXT);
|
||||
centerText(cancelLabel, cancelBtnX, cancelBtnY + btnH - 10, btnW);
|
||||
panel.attachChild(cancelLabel);
|
||||
|
||||
guiNode.attachChild(panel);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mausklick
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private final ActionListener clickListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
Vector2f cursor = app.getInputManager().getCursorPosition();
|
||||
|
||||
// Reihen prüfen
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Row r = rows.get(i);
|
||||
if (hits(cursor, r.x, r.y, r.w, r.h)) {
|
||||
waitingRow = i;
|
||||
r.bg.getMaterial().setColor("Color", COL_ROW_WAIT);
|
||||
r.keyText.setText("...");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Speichern
|
||||
if (hits(cursor, saveBtnX, saveBtnY, saveBtnW, saveBtnH)) {
|
||||
liveBindings.copyFrom(editCopy);
|
||||
KeyBindingStore.save(liveBindings);
|
||||
if (onSave != null) onSave.run();
|
||||
setEnabled(false);
|
||||
if (onClose != null) onClose.run();
|
||||
return;
|
||||
}
|
||||
|
||||
// Abbrechen
|
||||
if (hits(cursor, cancelBtnX, cancelBtnY, saveBtnW, saveBtnH)) {
|
||||
setEnabled(false);
|
||||
if (onClose != null) onClose.run();
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tastendruck beim Warten auf Zuweisung (RawInputListener)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyInputEvent evt) {
|
||||
if (!evt.isPressed() || waitingRow < 0) return;
|
||||
if (evt.getKeyCode() == KeyInput.KEY_ESCAPE) return; // cancelWaiting() wird von BlightApp aufgerufen
|
||||
|
||||
Row r = rows.get(waitingRow);
|
||||
editCopy.set(r.field, evt.getKeyCode());
|
||||
r.keyText.setText(KeyNames.of(evt.getKeyCode()));
|
||||
resetRowColor(waitingRow);
|
||||
waitingRow = -1;
|
||||
}
|
||||
|
||||
private void resetRowColor(int idx) {
|
||||
rows.get(idx).bg.getMaterial().setColor("Color", COL_ROW);
|
||||
}
|
||||
|
||||
// RawInputListener-Pflichtmethoden
|
||||
@Override public void beginInput() {}
|
||||
@Override public void endInput() {}
|
||||
@Override public void onMouseMotionEvent(MouseMotionEvent evt) {}
|
||||
@Override public void onMouseButtonEvent(MouseButtonEvent evt) {}
|
||||
@Override public void onJoyAxisEvent(JoyAxisEvent evt) {}
|
||||
@Override public void onJoyButtonEvent(JoyButtonEvent evt) {}
|
||||
@Override public void onTouchEvent(TouchEvent evt) {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hilfsmethoden
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Geometry addQuad(Node parent, float x, float y, float w, float h, ColorRGBA color, float z) {
|
||||
Geometry geo = new Geometry("q", new Quad(w, h));
|
||||
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", color.clone());
|
||||
if (color.a < 1f) {
|
||||
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
|
||||
}
|
||||
geo.setMaterial(mat);
|
||||
geo.setLocalTranslation(x, y, z);
|
||||
parent.attachChild(geo);
|
||||
return geo;
|
||||
}
|
||||
|
||||
private BitmapText text(String content, int size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size);
|
||||
t.setColor(color);
|
||||
t.setText(content);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void centerText(BitmapText t, float x, float y, float width) {
|
||||
t.setLocalTranslation(x + (width - t.getLineWidth()) / 2f, y, 1);
|
||||
}
|
||||
|
||||
private boolean hits(Vector2f p, float x, float y, float w, float h) {
|
||||
return p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h;
|
||||
}
|
||||
}
|
||||
package de.blight.game.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.KeyInput;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.RawInputListener;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.input.event.JoyAxisEvent;
|
||||
import com.jme3.input.event.JoyButtonEvent;
|
||||
import com.jme3.input.event.KeyInputEvent;
|
||||
import com.jme3.input.event.MouseButtonEvent;
|
||||
import com.jme3.input.event.MouseMotionEvent;
|
||||
import com.jme3.input.event.TouchEvent;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import de.blight.lang.TextResolver;
|
||||
|
||||
public class ConfigScreen extends BaseAppState implements RawInputListener {
|
||||
|
||||
private static final ColorRGBA COL_ROW = new ColorRGBA(0.18f, 0.18f, 0.28f, 1.00f);
|
||||
private static final ColorRGBA COL_ROW_WAIT = new ColorRGBA(0.50f, 0.30f, 0.10f, 1.00f);
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
private static final ColorRGBA COL_TEXT_KEY = new ColorRGBA(0.85f, 0.85f, 0.50f, 1.00f);
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private BitmapFont font;
|
||||
|
||||
private KeyBindings liveBindings;
|
||||
private KeyBindings editCopy;
|
||||
|
||||
private Runnable onSave;
|
||||
private Runnable onClose;
|
||||
|
||||
private Node panel;
|
||||
private Node bgLayer;
|
||||
private Node canvasNode;
|
||||
private List<Row> rows = new ArrayList<>();
|
||||
private int waitingRow = -1;
|
||||
|
||||
private float saveBtnX, saveBtnY, saveBtnW, saveBtnH;
|
||||
private float cancelBtnX, cancelBtnY;
|
||||
|
||||
private static class Row {
|
||||
String field;
|
||||
BitmapText keyText;
|
||||
Geometry bg;
|
||||
float x, y, w, h;
|
||||
}
|
||||
|
||||
public ConfigScreen(KeyBindings liveBindings, Runnable onSave) {
|
||||
this.liveBindings = liveBindings;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
public boolean isWaiting() { return waitingRow >= 0; }
|
||||
public void setOnClose(Runnable onClose) { this.onClose = onClose; }
|
||||
|
||||
public void cancelWaiting() {
|
||||
if (waitingRow >= 0) { resetRowColor(waitingRow); waitingRow = -1; }
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
editCopy = liveBindings.copy();
|
||||
waitingRow = -1;
|
||||
buildUI();
|
||||
app.getInputManager().setCursorVisible(true);
|
||||
app.getInputManager().addRawInputListener(this);
|
||||
app.getInputManager().addMapping("_CfgClick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
app.getInputManager().addListener(clickListener, "_CfgClick");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
if (bgLayer != null) { guiNode.detachChild(bgLayer); bgLayer = null; }
|
||||
if (canvasNode != null) { guiNode.detachChild(canvasNode); canvasNode = null; }
|
||||
panel = null;
|
||||
rows.clear();
|
||||
waitingRow = -1;
|
||||
app.getInputManager().removeRawInputListener(this);
|
||||
app.getInputManager().deleteMapping("_CfgClick");
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
private void buildUI() {
|
||||
float sw = MenuCanvas.REF_W;
|
||||
float sh = MenuCanvas.REF_H;
|
||||
|
||||
bgLayer = MenuCanvas.createBgLayer(app.getAssetManager(), app.getCamera());
|
||||
canvasNode = MenuCanvas.createCanvas(app.getCamera());
|
||||
guiNode.attachChild(bgLayer);
|
||||
guiNode.attachChild(canvasNode);
|
||||
|
||||
panel = new Node("cfg-panel");
|
||||
|
||||
float pw = 720, ph = 440;
|
||||
float px = (sw - pw) / 2f, py = (sh - ph) / 2f;
|
||||
panel.attachChild(NinePatch.panel(app.getAssetManager()).build(px, py, pw, ph, -1));
|
||||
|
||||
BitmapText title = text(t("menu.controls.title"), 20, COL_TEXT);
|
||||
centerText(title, px, py + ph - 40, pw);
|
||||
panel.attachChild(title);
|
||||
|
||||
BitmapText hint = text(t("menu.controls.hint"), 14, new ColorRGBA(0.7f, 0.7f, 0.7f, 1f));
|
||||
centerText(hint, px, py + ph - 70, pw);
|
||||
panel.attachChild(hint);
|
||||
|
||||
float rowX = px + 30;
|
||||
float keyX = px + pw - 220;
|
||||
float rowW = 180;
|
||||
float rowH = 36;
|
||||
float startY = py + ph - 110;
|
||||
float stepY = 48;
|
||||
|
||||
for (int i = 0; i < KeyBindings.ENTRIES.length; i++) {
|
||||
String[] entry = KeyBindings.ENTRIES[i];
|
||||
float ry = startY - i * stepY;
|
||||
|
||||
BitmapText lbl = text(t("key." + entry[0]), 16, COL_TEXT);
|
||||
lbl.setLocalTranslation(rowX, ry + rowH - 8, 0);
|
||||
panel.attachChild(lbl);
|
||||
|
||||
Geometry bg = addQuad(panel, keyX, ry, rowW, rowH, COL_ROW, 0);
|
||||
|
||||
BitmapText kt = text(KeyNames.of(editCopy.get(entry[0])), 16, COL_TEXT_KEY);
|
||||
kt.setLocalTranslation(keyX + 10, ry + rowH - 8, 1);
|
||||
panel.attachChild(kt);
|
||||
|
||||
Row row = new Row();
|
||||
row.field = entry[0];
|
||||
row.keyText = kt;
|
||||
row.bg = bg;
|
||||
row.x = keyX; row.y = ry; row.w = rowW; row.h = rowH;
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
float btnW = 160, btnH = 42;
|
||||
float btnY = py + 25;
|
||||
saveBtnX = px + pw / 2f - btnW - 15;
|
||||
saveBtnY = btnY;
|
||||
saveBtnW = btnW;
|
||||
saveBtnH = btnH;
|
||||
cancelBtnX = px + pw / 2f + 15;
|
||||
cancelBtnY = btnY;
|
||||
|
||||
panel.attachChild(NinePatch.buttonSave(app.getAssetManager()).build(saveBtnX, saveBtnY, btnW, btnH, 0));
|
||||
BitmapText saveLabel = text(t("menu.controls.btn.save"), 16, COL_TEXT);
|
||||
centerText(saveLabel, saveBtnX, saveBtnY + btnH - 10, btnW);
|
||||
panel.attachChild(saveLabel);
|
||||
|
||||
panel.attachChild(NinePatch.buttonQuit(app.getAssetManager()).build(cancelBtnX, cancelBtnY, btnW, btnH, 0));
|
||||
BitmapText cancelLabel = text(t("menu.controls.btn.cancel"), 16, COL_TEXT);
|
||||
centerText(cancelLabel, cancelBtnX, cancelBtnY + btnH - 10, btnW);
|
||||
panel.attachChild(cancelLabel);
|
||||
|
||||
canvasNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private final ActionListener clickListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
float[] v = toVirtual(app.getInputManager().getCursorPosition());
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Row r = rows.get(i);
|
||||
if (hits(v, r.x, r.y, r.w, r.h)) {
|
||||
waitingRow = i;
|
||||
r.bg.getMaterial().setColor("Color", COL_ROW_WAIT);
|
||||
r.keyText.setText("...");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hits(v, saveBtnX, saveBtnY, saveBtnW, saveBtnH)) {
|
||||
liveBindings.copyFrom(editCopy);
|
||||
KeyBindingStore.save(liveBindings);
|
||||
if (onSave != null) onSave.run();
|
||||
setEnabled(false);
|
||||
if (onClose != null) onClose.run();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hits(v, cancelBtnX, cancelBtnY, saveBtnW, saveBtnH)) {
|
||||
setEnabled(false);
|
||||
if (onClose != null) onClose.run();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyInputEvent evt) {
|
||||
if (!evt.isPressed() || waitingRow < 0) return;
|
||||
if (evt.getKeyCode() == KeyInput.KEY_ESCAPE) return;
|
||||
|
||||
Row r = rows.get(waitingRow);
|
||||
editCopy.set(r.field, evt.getKeyCode());
|
||||
r.keyText.setText(KeyNames.of(evt.getKeyCode()));
|
||||
resetRowColor(waitingRow);
|
||||
waitingRow = -1;
|
||||
}
|
||||
|
||||
private void resetRowColor(int idx) {
|
||||
rows.get(idx).bg.getMaterial().setColor("Color", COL_ROW);
|
||||
}
|
||||
|
||||
private float[] toVirtual(Vector2f screen) {
|
||||
float scale = Math.min(app.getCamera().getWidth() / MenuCanvas.REF_W,
|
||||
app.getCamera().getHeight() / MenuCanvas.REF_H);
|
||||
float ox = (app.getCamera().getWidth() - MenuCanvas.REF_W * scale) / 2f;
|
||||
float oy = (app.getCamera().getHeight() - MenuCanvas.REF_H * scale) / 2f;
|
||||
return new float[]{ (screen.x - ox) / scale, (screen.y - oy) / scale };
|
||||
}
|
||||
|
||||
private static String t(String id) { return TextResolver.get().resolveId(id); }
|
||||
|
||||
@Override public void beginInput() {}
|
||||
@Override public void endInput() {}
|
||||
@Override public void onMouseMotionEvent(MouseMotionEvent evt) {}
|
||||
@Override public void onMouseButtonEvent(MouseButtonEvent evt) {}
|
||||
@Override public void onJoyAxisEvent(JoyAxisEvent evt) {}
|
||||
@Override public void onJoyButtonEvent(JoyButtonEvent evt) {}
|
||||
@Override public void onTouchEvent(TouchEvent evt) {}
|
||||
|
||||
private Geometry addQuad(Node parent, float x, float y, float w, float h, ColorRGBA color, float z) {
|
||||
Geometry geo = new Geometry("q", new Quad(w, h));
|
||||
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", color.clone());
|
||||
if (color.a < 1f) {
|
||||
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
|
||||
}
|
||||
geo.setMaterial(mat);
|
||||
geo.setLocalTranslation(x, y, z);
|
||||
parent.attachChild(geo);
|
||||
return geo;
|
||||
}
|
||||
|
||||
private BitmapText text(String content, int size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size);
|
||||
t.setColor(color);
|
||||
t.setText(content);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void centerText(BitmapText t, float x, float y, float width) {
|
||||
t.setLocalTranslation(x + (width - t.getLineWidth()) / 2f, y, 1);
|
||||
}
|
||||
|
||||
private boolean hits(float[] v, float x, float y, float w, float h) {
|
||||
return v[0] >= x && v[0] <= x + w && v[1] >= y && v[1] <= y + h;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,290 +1,291 @@
|
||||
package de.blight.game.config;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import com.jme3.system.AppSettings;
|
||||
|
||||
public class GraphicsScreen extends BaseAppState {
|
||||
|
||||
private static final ColorRGBA COL_BG = new ColorRGBA(0.05f, 0.05f, 0.08f, 0.88f);
|
||||
private static final ColorRGBA COL_PANEL = new ColorRGBA(0.10f, 0.10f, 0.16f, 1.00f);
|
||||
private static final ColorRGBA COL_ROW = new ColorRGBA(0.18f, 0.18f, 0.28f, 1.00f);
|
||||
private static final ColorRGBA COL_ARROW = new ColorRGBA(0.28f, 0.28f, 0.44f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN_OK = new ColorRGBA(0.15f, 0.40f, 0.15f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN_CANCEL = new ColorRGBA(0.40f, 0.15f, 0.15f, 1.00f);
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
private static final ColorRGBA COL_TEXT_VAL = new ColorRGBA(0.85f, 0.85f, 0.50f, 1.00f);
|
||||
|
||||
private static final int[][] RESOLUTIONS = {
|
||||
{1280, 720}, {1600, 900}, {1920, 1080}, {2560, 1440}, {3840, 2160}
|
||||
};
|
||||
private static final int[] SAMPLES = {0, 2, 4, 8};
|
||||
|
||||
private static final int ROW_RES = 0;
|
||||
private static final int ROW_FULL = 1;
|
||||
private static final int ROW_VSYNC = 2;
|
||||
private static final int ROW_AA = 3;
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private BitmapFont font;
|
||||
private Node panel;
|
||||
|
||||
private final GraphicsSettings live;
|
||||
private GraphicsSettings edit;
|
||||
private final Runnable onClose;
|
||||
|
||||
private int resIdx;
|
||||
private int samplesIdx;
|
||||
|
||||
// Per-row layout (indexed by ROW_*)
|
||||
private final float[] cellX = new float[4];
|
||||
private final float[] cellY = new float[4];
|
||||
private final float[] cellW = new float[4];
|
||||
private final float cellH = 36;
|
||||
private final float arrW = 30;
|
||||
private final float[] leftX = new float[4];
|
||||
private final float[] rightX = new float[4];
|
||||
private final BitmapText[] valTexts = new BitmapText[4];
|
||||
|
||||
private float okX, okY, okW, okH;
|
||||
private float cancelX, cancelY;
|
||||
|
||||
public GraphicsScreen(GraphicsSettings live, Runnable onClose) {
|
||||
this.live = live;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
edit = new GraphicsSettings();
|
||||
edit.width = live.width; edit.height = live.height;
|
||||
edit.fullscreen = live.fullscreen;
|
||||
edit.vsync = live.vsync;
|
||||
edit.samples = live.samples;
|
||||
|
||||
resIdx = 0;
|
||||
for (int i = 0; i < RESOLUTIONS.length; i++) {
|
||||
if (RESOLUTIONS[i][0] == edit.width && RESOLUTIONS[i][1] == edit.height) {
|
||||
resIdx = i; break;
|
||||
}
|
||||
}
|
||||
samplesIdx = 0;
|
||||
for (int i = 0; i < SAMPLES.length; i++) {
|
||||
if (SAMPLES[i] == edit.samples) { samplesIdx = i; break; }
|
||||
}
|
||||
|
||||
buildUI();
|
||||
app.getInputManager().setCursorVisible(true);
|
||||
app.getInputManager().addMapping("_GfxClick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
app.getInputManager().addListener(clickListener, "_GfxClick");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
if (panel != null) { guiNode.detachChild(panel); panel = null; }
|
||||
app.getInputManager().deleteMapping("_GfxClick");
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
private void buildUI() {
|
||||
float sw = app.getCamera().getWidth();
|
||||
float sh = app.getCamera().getHeight();
|
||||
panel = new Node("gfx-panel");
|
||||
addQuad(panel, 0, 0, sw, sh, COL_BG, -2);
|
||||
|
||||
float pw = 640, ph = 400;
|
||||
float px = (sw - pw) / 2f, py = (sh - ph) / 2f;
|
||||
addQuad(panel, px, py, pw, ph, COL_PANEL, -1);
|
||||
|
||||
BitmapText title = txt("GRAFIKEINSTELLUNGEN", 20, COL_TEXT);
|
||||
centerText(title, px, py + ph - 42, pw);
|
||||
panel.attachChild(title);
|
||||
|
||||
String[] labels = {"Auflösung", "Vollbild", "VSync", "Kantenglättung"};
|
||||
float lblX = px + 30;
|
||||
float vx = px + pw - 270;
|
||||
float vw = 190;
|
||||
float startY = py + ph - 100;
|
||||
float step = 60;
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float ry = startY - i * step;
|
||||
|
||||
BitmapText lbl = txt(labels[i], 16, COL_TEXT);
|
||||
lbl.setLocalTranslation(lblX, ry + cellH - 8, 0);
|
||||
panel.attachChild(lbl);
|
||||
|
||||
// Left arrow
|
||||
addQuad(panel, vx - arrW - 6, ry, arrW, cellH, COL_ARROW, 0);
|
||||
BitmapText lt = txt("<", 16, COL_TEXT);
|
||||
lt.setLocalTranslation(vx - arrW - 6 + (arrW - lt.getLineWidth()) / 2f, ry + cellH - 8, 1);
|
||||
panel.attachChild(lt);
|
||||
|
||||
// Value cell
|
||||
addQuad(panel, vx, ry, vw, cellH, COL_ROW, 0);
|
||||
|
||||
// Right arrow
|
||||
addQuad(panel, vx + vw + 6, ry, arrW, cellH, COL_ARROW, 0);
|
||||
BitmapText rt = txt(">", 16, COL_TEXT);
|
||||
rt.setLocalTranslation(vx + vw + 6 + (arrW - rt.getLineWidth()) / 2f, ry + cellH - 8, 1);
|
||||
panel.attachChild(rt);
|
||||
|
||||
BitmapText vt = txt("", 16, COL_TEXT_VAL);
|
||||
panel.attachChild(vt);
|
||||
valTexts[i] = vt;
|
||||
|
||||
cellX[i] = vx; cellY[i] = ry; cellW[i] = vw;
|
||||
leftX[i] = vx - arrW - 6;
|
||||
rightX[i] = vx + vw + 6;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 4; i++) refreshText(i);
|
||||
|
||||
float bw = 160, bh = 42;
|
||||
okW = bw; okH = bh;
|
||||
okX = px + pw / 2f - bw - 10;
|
||||
okY = py + 22;
|
||||
cancelX = px + pw / 2f + 10;
|
||||
cancelY = py + 22;
|
||||
|
||||
addQuad(panel, okX, okY, bw, bh, COL_BTN_OK, 0);
|
||||
BitmapText okLbl = txt("Übernehmen", 16, COL_TEXT);
|
||||
centerText(okLbl, okX, okY + bh - 10, bw);
|
||||
panel.attachChild(okLbl);
|
||||
|
||||
addQuad(panel, cancelX, cancelY, bw, bh, COL_BTN_CANCEL, 0);
|
||||
BitmapText cancelLbl = txt("Abbrechen", 16, COL_TEXT);
|
||||
centerText(cancelLbl, cancelX, cancelY + bh - 10, bw);
|
||||
panel.attachChild(cancelLbl);
|
||||
|
||||
guiNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private void refreshText(int row) {
|
||||
String val = switch (row) {
|
||||
case ROW_RES -> RESOLUTIONS[resIdx][0] + "x" + RESOLUTIONS[resIdx][1];
|
||||
case ROW_FULL -> edit.fullscreen ? "An" : "Aus";
|
||||
case ROW_VSYNC -> edit.vsync ? "An" : "Aus";
|
||||
case ROW_AA -> SAMPLES[samplesIdx] == 0 ? "Aus" : SAMPLES[samplesIdx] + "x MSAA";
|
||||
default -> "";
|
||||
};
|
||||
BitmapText vt = valTexts[row];
|
||||
vt.setText(val);
|
||||
vt.setLocalTranslation(
|
||||
cellX[row] + (cellW[row] - vt.getLineWidth()) / 2f,
|
||||
cellY[row] + cellH - 8,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private final ActionListener clickListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
Vector2f c = app.getInputManager().getCursorPosition();
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (hits(c, leftX[i], cellY[i], arrW, cellH)) { cycleRow(i, -1); return; }
|
||||
if (hits(c, rightX[i], cellY[i], arrW, cellH)) { cycleRow(i, +1); return; }
|
||||
}
|
||||
if (hits(c, okX, okY, okW, okH)) { applyAndSave(); return; }
|
||||
if (hits(c, cancelX, cancelY, okW, okH)) { close(); }
|
||||
};
|
||||
|
||||
private void cycleRow(int row, int dir) {
|
||||
switch (row) {
|
||||
case ROW_RES:
|
||||
resIdx = (resIdx + dir + RESOLUTIONS.length) % RESOLUTIONS.length;
|
||||
edit.width = RESOLUTIONS[resIdx][0];
|
||||
edit.height = RESOLUTIONS[resIdx][1];
|
||||
break;
|
||||
case ROW_FULL:
|
||||
edit.fullscreen = !edit.fullscreen;
|
||||
break;
|
||||
case ROW_VSYNC:
|
||||
edit.vsync = !edit.vsync;
|
||||
break;
|
||||
case ROW_AA:
|
||||
samplesIdx = (samplesIdx + dir + SAMPLES.length) % SAMPLES.length;
|
||||
edit.samples = SAMPLES[samplesIdx];
|
||||
break;
|
||||
}
|
||||
refreshText(row);
|
||||
}
|
||||
|
||||
private void applyAndSave() {
|
||||
live.width = edit.width; live.height = edit.height;
|
||||
live.fullscreen = edit.fullscreen;
|
||||
live.vsync = edit.vsync;
|
||||
live.samples = edit.samples;
|
||||
|
||||
GraphicsStore.save(live);
|
||||
|
||||
AppSettings s = app.getContext().getSettings();
|
||||
s.setResolution(live.width, live.height);
|
||||
s.setFullscreen(live.fullscreen);
|
||||
s.setVSync(live.vsync);
|
||||
s.setSamples(live.samples);
|
||||
app.setSettings(s);
|
||||
|
||||
close();
|
||||
app.restart();
|
||||
}
|
||||
|
||||
private void close() {
|
||||
setEnabled(false);
|
||||
if (onClose != null) onClose.run();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Geometry addQuad(Node parent, float x, float y, float w, float h, ColorRGBA color, float z) {
|
||||
Geometry geo = new Geometry("q", new Quad(w, h));
|
||||
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", color.clone());
|
||||
if (color.a < 1f) {
|
||||
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
|
||||
}
|
||||
geo.setMaterial(mat);
|
||||
geo.setLocalTranslation(x, y, z);
|
||||
parent.attachChild(geo);
|
||||
return geo;
|
||||
}
|
||||
|
||||
private BitmapText txt(String s, int size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size); t.setColor(color); t.setText(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void centerText(BitmapText t, float x, float y, float width) {
|
||||
t.setLocalTranslation(x + (width - t.getLineWidth()) / 2f, y, 1);
|
||||
}
|
||||
|
||||
private boolean hits(Vector2f p, float x, float y, float w, float h) {
|
||||
return p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h;
|
||||
}
|
||||
}
|
||||
package de.blight.game.config;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import com.jme3.system.AppSettings;
|
||||
import de.blight.lang.TextResolver;
|
||||
|
||||
public class GraphicsScreen extends BaseAppState {
|
||||
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
private static final ColorRGBA COL_TEXT_VAL = new ColorRGBA(0.85f, 0.85f, 0.50f, 1.00f);
|
||||
|
||||
private static final int[][] RESOLUTIONS = {
|
||||
{1280, 720}, {1600, 900}, {1920, 1080}, {2560, 1440}, {3840, 2160}
|
||||
};
|
||||
private static final int[] SAMPLES = {0, 2, 4, 8};
|
||||
|
||||
private static final int ROW_RES = 0;
|
||||
private static final int ROW_FULL = 1;
|
||||
private static final int ROW_VSYNC = 2;
|
||||
private static final int ROW_AA = 3;
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private BitmapFont font;
|
||||
private Node panel;
|
||||
private Node bgLayer;
|
||||
private Node canvasNode;
|
||||
|
||||
private final GraphicsSettings live;
|
||||
private GraphicsSettings edit;
|
||||
private final Runnable onClose;
|
||||
|
||||
private int resIdx;
|
||||
private int samplesIdx;
|
||||
|
||||
private final float[] cellX = new float[4];
|
||||
private final float[] cellY = new float[4];
|
||||
private final float[] cellW = new float[4];
|
||||
private final float cellH = 36;
|
||||
private final float arrW = 30;
|
||||
private final float[] leftX = new float[4];
|
||||
private final float[] rightX = new float[4];
|
||||
private final BitmapText[] valTexts = new BitmapText[4];
|
||||
|
||||
private float okX, okY, okW, okH;
|
||||
private float cancelX, cancelY;
|
||||
|
||||
public GraphicsScreen(GraphicsSettings live, Runnable onClose) {
|
||||
this.live = live;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
edit = new GraphicsSettings();
|
||||
edit.width = live.width; edit.height = live.height;
|
||||
edit.fullscreen = live.fullscreen;
|
||||
edit.vsync = live.vsync;
|
||||
edit.samples = live.samples;
|
||||
|
||||
resIdx = 0;
|
||||
for (int i = 0; i < RESOLUTIONS.length; i++) {
|
||||
if (RESOLUTIONS[i][0] == edit.width && RESOLUTIONS[i][1] == edit.height) {
|
||||
resIdx = i; break;
|
||||
}
|
||||
}
|
||||
samplesIdx = 0;
|
||||
for (int i = 0; i < SAMPLES.length; i++) {
|
||||
if (SAMPLES[i] == edit.samples) { samplesIdx = i; break; }
|
||||
}
|
||||
|
||||
buildUI();
|
||||
app.getInputManager().setCursorVisible(true);
|
||||
app.getInputManager().addMapping("_GfxClick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
app.getInputManager().addListener(clickListener, "_GfxClick");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
if (bgLayer != null) { guiNode.detachChild(bgLayer); bgLayer = null; }
|
||||
if (canvasNode != null) { guiNode.detachChild(canvasNode); canvasNode = null; }
|
||||
panel = null;
|
||||
app.getInputManager().deleteMapping("_GfxClick");
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
private void buildUI() {
|
||||
float sw = MenuCanvas.REF_W;
|
||||
float sh = MenuCanvas.REF_H;
|
||||
|
||||
bgLayer = MenuCanvas.createBgLayer(app.getAssetManager(), app.getCamera());
|
||||
canvasNode = MenuCanvas.createCanvas(app.getCamera());
|
||||
guiNode.attachChild(bgLayer);
|
||||
guiNode.attachChild(canvasNode);
|
||||
|
||||
panel = new Node("gfx-panel");
|
||||
|
||||
float pw = 640, ph = 400;
|
||||
float px = (sw - pw) / 2f, py = (sh - ph) / 2f;
|
||||
panel.attachChild(NinePatch.panel(app.getAssetManager()).build(px, py, pw, ph, -1));
|
||||
|
||||
BitmapText title = txt(t("menu.graphics.title"), 20, COL_TEXT);
|
||||
centerText(title, px, py + ph - 42, pw);
|
||||
panel.attachChild(title);
|
||||
|
||||
String[] labelKeys = {
|
||||
"menu.graphics.row.resolution",
|
||||
"menu.graphics.row.fullscreen",
|
||||
"menu.graphics.row.vsync",
|
||||
"menu.graphics.row.aa"
|
||||
};
|
||||
float lblX = px + 30;
|
||||
float vx = px + pw - 270;
|
||||
float vw = 190;
|
||||
float startY = py + ph - 100;
|
||||
float step = 60;
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float ry = startY - i * step;
|
||||
|
||||
BitmapText lbl = txt(t(labelKeys[i]), 16, COL_TEXT);
|
||||
lbl.setLocalTranslation(lblX, ry + cellH - 8, 0);
|
||||
panel.attachChild(lbl);
|
||||
|
||||
panel.attachChild(NinePatch.buttonArrow(app.getAssetManager()).build(vx - arrW - 6, ry, arrW, cellH, 0));
|
||||
BitmapText lt = txt("<", 16, COL_TEXT);
|
||||
lt.setLocalTranslation(vx - arrW - 6 + (arrW - lt.getLineWidth()) / 2f, ry + cellH - 8, 1);
|
||||
panel.attachChild(lt);
|
||||
|
||||
panel.attachChild(NinePatch.button(app.getAssetManager()).build(vx, ry, vw, cellH, 0));
|
||||
|
||||
panel.attachChild(NinePatch.buttonArrow(app.getAssetManager()).build(vx + vw + 6, ry, arrW, cellH, 0));
|
||||
BitmapText rt = txt(">", 16, COL_TEXT);
|
||||
rt.setLocalTranslation(vx + vw + 6 + (arrW - rt.getLineWidth()) / 2f, ry + cellH - 8, 1);
|
||||
panel.attachChild(rt);
|
||||
|
||||
BitmapText vt = txt("", 16, COL_TEXT_VAL);
|
||||
panel.attachChild(vt);
|
||||
valTexts[i] = vt;
|
||||
|
||||
cellX[i] = vx; cellY[i] = ry; cellW[i] = vw;
|
||||
leftX[i] = vx - arrW - 6;
|
||||
rightX[i] = vx + vw + 6;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 4; i++) refreshText(i);
|
||||
|
||||
float bw = 160, bh = 42;
|
||||
okW = bw; okH = bh;
|
||||
okX = px + pw / 2f - bw - 10;
|
||||
okY = py + 22;
|
||||
cancelX = px + pw / 2f + 10;
|
||||
cancelY = py + 22;
|
||||
|
||||
panel.attachChild(NinePatch.buttonSave(app.getAssetManager()).build(okX, okY, bw, bh, 0));
|
||||
BitmapText okLbl = txt(t("menu.graphics.btn.apply"), 16, COL_TEXT);
|
||||
centerText(okLbl, okX, okY + bh - 10, bw);
|
||||
panel.attachChild(okLbl);
|
||||
|
||||
panel.attachChild(NinePatch.buttonQuit(app.getAssetManager()).build(cancelX, cancelY, bw, bh, 0));
|
||||
BitmapText cancelLbl = txt(t("menu.graphics.btn.cancel"), 16, COL_TEXT);
|
||||
centerText(cancelLbl, cancelX, cancelY + bh - 10, bw);
|
||||
panel.attachChild(cancelLbl);
|
||||
|
||||
canvasNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private void refreshText(int row) {
|
||||
String val = switch (row) {
|
||||
case ROW_RES -> RESOLUTIONS[resIdx][0] + "x" + RESOLUTIONS[resIdx][1];
|
||||
case ROW_FULL -> edit.fullscreen ? t("menu.graphics.val.on") : t("menu.graphics.val.off");
|
||||
case ROW_VSYNC -> edit.vsync ? t("menu.graphics.val.on") : t("menu.graphics.val.off");
|
||||
case ROW_AA -> SAMPLES[samplesIdx] == 0
|
||||
? t("menu.graphics.val.off")
|
||||
: SAMPLES[samplesIdx] + "x MSAA";
|
||||
default -> "";
|
||||
};
|
||||
BitmapText vt = valTexts[row];
|
||||
vt.setText(val);
|
||||
vt.setLocalTranslation(
|
||||
cellX[row] + (cellW[row] - vt.getLineWidth()) / 2f,
|
||||
cellY[row] + cellH - 8,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private final ActionListener clickListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
float[] v = toVirtual(app.getInputManager().getCursorPosition());
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (hits(v, leftX[i], cellY[i], arrW, cellH)) { cycleRow(i, -1); return; }
|
||||
if (hits(v, rightX[i], cellY[i], arrW, cellH)) { cycleRow(i, +1); return; }
|
||||
}
|
||||
if (hits(v, okX, okY, okW, okH)) { applyAndSave(); return; }
|
||||
if (hits(v, cancelX, cancelY, okW, okH)) { close(); }
|
||||
};
|
||||
|
||||
private void cycleRow(int row, int dir) {
|
||||
switch (row) {
|
||||
case ROW_RES:
|
||||
resIdx = (resIdx + dir + RESOLUTIONS.length) % RESOLUTIONS.length;
|
||||
edit.width = RESOLUTIONS[resIdx][0];
|
||||
edit.height = RESOLUTIONS[resIdx][1];
|
||||
break;
|
||||
case ROW_FULL:
|
||||
edit.fullscreen = !edit.fullscreen;
|
||||
break;
|
||||
case ROW_VSYNC:
|
||||
edit.vsync = !edit.vsync;
|
||||
break;
|
||||
case ROW_AA:
|
||||
samplesIdx = (samplesIdx + dir + SAMPLES.length) % SAMPLES.length;
|
||||
edit.samples = SAMPLES[samplesIdx];
|
||||
break;
|
||||
}
|
||||
refreshText(row);
|
||||
}
|
||||
|
||||
private void applyAndSave() {
|
||||
live.width = edit.width; live.height = edit.height;
|
||||
live.fullscreen = edit.fullscreen;
|
||||
live.vsync = edit.vsync;
|
||||
live.samples = edit.samples;
|
||||
|
||||
GraphicsStore.save(live);
|
||||
|
||||
AppSettings s = app.getContext().getSettings();
|
||||
s.setResolution(live.width, live.height);
|
||||
s.setFullscreen(live.fullscreen);
|
||||
s.setVSync(live.vsync);
|
||||
s.setSamples(live.samples);
|
||||
app.setSettings(s);
|
||||
|
||||
close();
|
||||
app.restart();
|
||||
}
|
||||
|
||||
private void close() {
|
||||
setEnabled(false);
|
||||
if (onClose != null) onClose.run();
|
||||
}
|
||||
|
||||
private float[] toVirtual(Vector2f screen) {
|
||||
float scale = Math.min(app.getCamera().getWidth() / MenuCanvas.REF_W,
|
||||
app.getCamera().getHeight() / MenuCanvas.REF_H);
|
||||
float ox = (app.getCamera().getWidth() - MenuCanvas.REF_W * scale) / 2f;
|
||||
float oy = (app.getCamera().getHeight() - MenuCanvas.REF_H * scale) / 2f;
|
||||
return new float[]{ (screen.x - ox) / scale, (screen.y - oy) / scale };
|
||||
}
|
||||
|
||||
private static String t(String id) { return TextResolver.get().resolveId(id); }
|
||||
|
||||
private BitmapText txt(String s, int size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size); t.setColor(color); t.setText(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void centerText(BitmapText t, float x, float y, float width) {
|
||||
t.setLocalTranslation(x + (width - t.getLineWidth()) / 2f, y, 1);
|
||||
}
|
||||
|
||||
private boolean hits(float[] v, float x, float y, float w, float h) {
|
||||
return v[0] >= x && v[0] <= x + w && v[1] >= y && v[1] <= y + h;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +1,193 @@
|
||||
package de.blight.game.config;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
|
||||
public class PauseMenu extends BaseAppState {
|
||||
|
||||
private static final ColorRGBA COL_BG = new ColorRGBA(0.05f, 0.05f, 0.08f, 0.88f);
|
||||
private static final ColorRGBA COL_PANEL = new ColorRGBA(0.10f, 0.10f, 0.16f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN = new ColorRGBA(0.18f, 0.18f, 0.28f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN_DIS = new ColorRGBA(0.12f, 0.12f, 0.18f, 1.00f);
|
||||
private static final ColorRGBA COL_BTN_QUIT = new ColorRGBA(0.38f, 0.10f, 0.10f, 1.00f);
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
private static final ColorRGBA COL_TEXT_DIS = new ColorRGBA(0.40f, 0.40f, 0.40f, 1.00f);
|
||||
private static final ColorRGBA COL_TEXT_SUB = new ColorRGBA(0.35f, 0.35f, 0.35f, 1.00f);
|
||||
|
||||
private static final int BTN_GRAFIK = 0;
|
||||
private static final int BTN_AUDIO = 1;
|
||||
private static final int BTN_STEUERUNG = 2;
|
||||
private static final int BTN_SPEICHERN = 3;
|
||||
private static final int BTN_BEENDEN = 4;
|
||||
|
||||
private static final ColorRGBA COL_BTN_SAVE = new ColorRGBA(0.12f, 0.28f, 0.14f, 1.00f);
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private BitmapFont font;
|
||||
private Node panel;
|
||||
|
||||
private Runnable onGraphics;
|
||||
private Runnable onControls;
|
||||
private Runnable onSave;
|
||||
|
||||
// [x, y, w, h] per button
|
||||
private final float[][] btnBounds = new float[5][4];
|
||||
|
||||
public PauseMenu(Runnable onSave, Runnable onGraphics, Runnable onControls) {
|
||||
this.onSave = onSave;
|
||||
this.onGraphics = onGraphics;
|
||||
this.onControls = onControls;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
buildUI();
|
||||
app.getInputManager().setCursorVisible(true);
|
||||
app.getInputManager().addMapping("_PauseClick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
app.getInputManager().addListener(clickListener, "_PauseClick");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
if (panel != null) { guiNode.detachChild(panel); panel = null; }
|
||||
app.getInputManager().deleteMapping("_PauseClick");
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
private void buildUI() {
|
||||
float sw = app.getCamera().getWidth();
|
||||
float sh = app.getCamera().getHeight();
|
||||
panel = new Node("pause-panel");
|
||||
addQuad(panel, 0, 0, sw, sh, COL_BG, -2);
|
||||
|
||||
float pw = 320, ph = 430;
|
||||
float px = (sw - pw) / 2f, py = (sh - ph) / 2f;
|
||||
addQuad(panel, px, py, pw, ph, COL_PANEL, -1);
|
||||
|
||||
BitmapText title = txt("PAUSE", 26, COL_TEXT);
|
||||
centerText(title, px, py + ph - 48, pw);
|
||||
panel.attachChild(title);
|
||||
|
||||
String[] labels = {"Grafik", "Audio", "Steuerung", "Speichern", "Beenden"};
|
||||
boolean[] enabled = {true, false, true, true, true};
|
||||
ColorRGBA[] bgCols = {COL_BTN, COL_BTN_DIS, COL_BTN, COL_BTN_SAVE, COL_BTN_QUIT};
|
||||
|
||||
float bw = 260, bh = 52;
|
||||
float bx = px + (pw - bw) / 2f;
|
||||
float startY = py + ph - 112;
|
||||
float step = 62;
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
float by = startY - i * step;
|
||||
ColorRGBA txCol = enabled[i] ? COL_TEXT : COL_TEXT_DIS;
|
||||
|
||||
addQuad(panel, bx, by, bw, bh, bgCols[i], 0);
|
||||
|
||||
BitmapText lbl = txt(labels[i], 18, txCol);
|
||||
if (!enabled[i]) {
|
||||
lbl.setLocalTranslation(bx + (bw - lbl.getLineWidth()) / 2f, by + bh - 12, 1);
|
||||
BitmapText hint = txt("Bald verfügbar", 12, COL_TEXT_SUB);
|
||||
hint.setLocalTranslation(bx + (bw - hint.getLineWidth()) / 2f, by + 14, 1);
|
||||
panel.attachChild(hint);
|
||||
} else {
|
||||
centerText(lbl, bx, by + bh - 16, bw);
|
||||
}
|
||||
panel.attachChild(lbl);
|
||||
|
||||
btnBounds[i][0] = bx; btnBounds[i][1] = by;
|
||||
btnBounds[i][2] = bw; btnBounds[i][3] = bh;
|
||||
}
|
||||
|
||||
guiNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private final ActionListener clickListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
Vector2f c = app.getInputManager().getCursorPosition();
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
if (!hits(c, btnBounds[i][0], btnBounds[i][1], btnBounds[i][2], btnBounds[i][3])) continue;
|
||||
switch (i) {
|
||||
case BTN_GRAFIK -> { if (onGraphics != null) onGraphics.run(); }
|
||||
case BTN_AUDIO -> { /* Bald verfügbar */ }
|
||||
case BTN_STEUERUNG -> { if (onControls != null) onControls.run(); }
|
||||
case BTN_SPEICHERN -> { if (onSave != null) onSave.run(); }
|
||||
case BTN_BEENDEN -> app.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Geometry addQuad(Node parent, float x, float y, float w, float h, ColorRGBA color, float z) {
|
||||
Geometry geo = new Geometry("q", new Quad(w, h));
|
||||
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", color.clone());
|
||||
if (color.a < 1f) {
|
||||
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
|
||||
}
|
||||
geo.setMaterial(mat);
|
||||
geo.setLocalTranslation(x, y, z);
|
||||
parent.attachChild(geo);
|
||||
return geo;
|
||||
}
|
||||
|
||||
private BitmapText txt(String s, int size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size); t.setColor(color); t.setText(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void centerText(BitmapText t, float x, float y, float width) {
|
||||
t.setLocalTranslation(x + (width - t.getLineWidth()) / 2f, y, 1);
|
||||
}
|
||||
|
||||
private boolean hits(Vector2f p, float x, float y, float w, float h) {
|
||||
return p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h;
|
||||
}
|
||||
}
|
||||
package de.blight.game.config;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.post.FilterPostProcessor;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import de.blight.game.post.GaussianBlurFilter;
|
||||
import de.blight.game.scene.WorldScene;
|
||||
import de.blight.lang.TextResolver;
|
||||
|
||||
public class PauseMenu extends BaseAppState {
|
||||
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
|
||||
private static final int BTN_GRAFIK = 0;
|
||||
private static final int BTN_AUDIO = 1;
|
||||
private static final int BTN_STEUERUNG = 2;
|
||||
private static final int BTN_SPEICHERN = 3;
|
||||
private static final int BTN_BEENDEN = 4;
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private BitmapFont font;
|
||||
private Node panel;
|
||||
private Node bgLayer;
|
||||
private Node canvasNode;
|
||||
|
||||
private Runnable onSave;
|
||||
private Runnable onGraphics;
|
||||
private Runnable onAudio;
|
||||
private Runnable onControls;
|
||||
|
||||
private GaussianBlurFilter blurFilter;
|
||||
|
||||
private final float[][] btnBounds = new float[5][4];
|
||||
|
||||
public PauseMenu(Runnable onSave, Runnable onGraphics, Runnable onAudio, Runnable onControls) {
|
||||
this.onSave = onSave;
|
||||
this.onGraphics = onGraphics;
|
||||
this.onAudio = onAudio;
|
||||
this.onControls = onControls;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
buildUI();
|
||||
app.getInputManager().setCursorVisible(true);
|
||||
app.getInputManager().addMapping("_PauseClick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
app.getInputManager().addListener(clickListener, "_PauseClick");
|
||||
addBlur();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
removeBlur();
|
||||
if (bgLayer != null) { guiNode.detachChild(bgLayer); bgLayer = null; }
|
||||
if (canvasNode != null) { guiNode.detachChild(canvasNode); canvasNode = null; }
|
||||
panel = null;
|
||||
app.getInputManager().deleteMapping("_PauseClick");
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
private void addBlur() {
|
||||
WorldScene ws = getApplication().getStateManager().getState(WorldScene.class);
|
||||
if (ws == null) return;
|
||||
FilterPostProcessor fpp = ws.getSharedFPP();
|
||||
if (fpp == null) return;
|
||||
blurFilter = new GaussianBlurFilter(6f);
|
||||
fpp.addFilter(blurFilter);
|
||||
}
|
||||
|
||||
private void removeBlur() {
|
||||
if (blurFilter == null) return;
|
||||
WorldScene ws = getApplication().getStateManager().getState(WorldScene.class);
|
||||
if (ws != null) {
|
||||
FilterPostProcessor fpp = ws.getSharedFPP();
|
||||
if (fpp != null) fpp.removeFilter(blurFilter);
|
||||
}
|
||||
blurFilter = null;
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
private void buildUI() {
|
||||
float sw = MenuCanvas.REF_W;
|
||||
float sh = MenuCanvas.REF_H;
|
||||
|
||||
bgLayer = MenuCanvas.createPauseBgLayer(app.getAssetManager(), app.getCamera());
|
||||
canvasNode = MenuCanvas.createFixedCanvas(app.getCamera());
|
||||
guiNode.attachChild(bgLayer);
|
||||
guiNode.attachChild(canvasNode);
|
||||
|
||||
panel = new Node("pause-panel");
|
||||
|
||||
float pw = 320, ph = 430;
|
||||
float px = (sw - pw) / 2f, py = (sh - ph) / 2f;
|
||||
panel.attachChild(NinePatch.panel(app.getAssetManager()).build(px, py, pw, ph, -1));
|
||||
|
||||
BitmapText title = txt(t("menu.pause.title"), 26, COL_TEXT);
|
||||
centerText(title, px, py + ph - 48, pw);
|
||||
panel.attachChild(title);
|
||||
|
||||
String[] labelKeys = {
|
||||
"menu.pause.btn.graphics",
|
||||
"menu.pause.btn.audio",
|
||||
"menu.pause.btn.controls",
|
||||
"menu.pause.btn.save",
|
||||
"menu.pause.btn.quit"
|
||||
};
|
||||
|
||||
float bw = 260, bh = 52;
|
||||
float bx = px + (pw - bw) / 2f;
|
||||
float startY = py + ph - 112;
|
||||
float step = 62;
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
float by = startY - i * step;
|
||||
NinePatch btnPatch = switch (i) {
|
||||
case 3 -> NinePatch.buttonSave(app.getAssetManager());
|
||||
case 4 -> NinePatch.buttonQuit(app.getAssetManager());
|
||||
default -> NinePatch.button(app.getAssetManager());
|
||||
};
|
||||
panel.attachChild(btnPatch.build(bx, by, bw, bh, 0));
|
||||
|
||||
BitmapText lbl = txt(t(labelKeys[i]), 18, COL_TEXT);
|
||||
centerText(lbl, bx, by + bh - 16, bw);
|
||||
panel.attachChild(lbl);
|
||||
|
||||
btnBounds[i][0] = bx; btnBounds[i][1] = by;
|
||||
btnBounds[i][2] = bw; btnBounds[i][3] = bh;
|
||||
}
|
||||
|
||||
canvasNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private final ActionListener clickListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
Vector2f c = app.getInputManager().getCursorPosition();
|
||||
// Kein Scaling – einfach den Zentrumversatz abziehen
|
||||
float ox = (app.getCamera().getWidth() - MenuCanvas.REF_W) / 2f;
|
||||
float oy = (app.getCamera().getHeight() - MenuCanvas.REF_H) / 2f;
|
||||
float vx = c.x - ox;
|
||||
float vy = c.y - oy;
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
if (!hits(vx, vy, btnBounds[i][0], btnBounds[i][1], btnBounds[i][2], btnBounds[i][3])) continue;
|
||||
switch (i) {
|
||||
case BTN_GRAFIK -> { if (onGraphics != null) onGraphics.run(); }
|
||||
case BTN_AUDIO -> { if (onAudio != null) onAudio.run(); }
|
||||
case BTN_STEUERUNG -> { if (onControls != null) onControls.run(); }
|
||||
case BTN_SPEICHERN -> { if (onSave != null) onSave.run(); }
|
||||
case BTN_BEENDEN -> app.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
private static String t(String id) { return TextResolver.get().resolveId(id); }
|
||||
|
||||
private BitmapText txt(String s, int size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size); t.setColor(color); t.setText(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void centerText(BitmapText t, float x, float y, float width) {
|
||||
t.setLocalTranslation(x + (width - t.getLineWidth()) / 2f, y, 1);
|
||||
}
|
||||
|
||||
private boolean hits(float px, float py, float x, float y, float w, float h) {
|
||||
return px >= x && px <= x + w && py >= y && py <= y + h;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ public class PlayerInputControl {
|
||||
private String runningClip;
|
||||
|
||||
private com.jme3.anim.SkinningControl skinningControl = null;
|
||||
private com.jme3.anim.Armature armature = null;
|
||||
|
||||
// ── Fußgeräusche ─────────────────────────────────────────────────────────
|
||||
// Bone-Namen nach Phase-0-Bone-Log eintragen:
|
||||
private static final String BONE_LEFT_FOOT = "mixamorig:LeftFoot";
|
||||
private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot";
|
||||
// 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 de.blight.game.audio.FootstepSystem footstepSystem;
|
||||
private java.util.function.BiFunction<Float, Float, float[]> surfaceQuery;
|
||||
private float lastLeftFootY = Float.MAX_VALUE;
|
||||
private float lastRightFootY = Float.MAX_VALUE;
|
||||
|
||||
// ── Anim-Offsets ─────────────────────────────────────────────────────────
|
||||
private Map<String, AnimOffset> animOffsets = new LinkedHashMap<>();
|
||||
@@ -141,6 +154,10 @@ public class PlayerInputControl {
|
||||
log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null);
|
||||
skinningControl = findSkinningControl(visual);
|
||||
log.info("[AnimCtx] SkinningControl gefunden: {}", skinningControl != null);
|
||||
if (skinningControl != null) {
|
||||
armature = skinningControl.getArmature();
|
||||
logJointNames(armature);
|
||||
}
|
||||
if (visual != null) {
|
||||
visualBaseTranslation = visual.getLocalTranslation().clone();
|
||||
}
|
||||
@@ -281,6 +298,76 @@ public class PlayerInputControl {
|
||||
|
||||
public boolean isLockedInPlace() { return lockedInPlace; }
|
||||
|
||||
// ── Neues-Spiel-Intro ────────────────────────────────────────────────────
|
||||
|
||||
private boolean inputsBlocked = false;
|
||||
private com.jme3.anim.tween.action.Action frozenReviveAction = null;
|
||||
|
||||
/** Setzt Blickrichtung (Yaw in Grad, 0=+Z, 90=+X, Uhrzeigersinn von oben). */
|
||||
public void setInitialFacing(float yawDegrees) {
|
||||
if (visual == null) return;
|
||||
float rad = -yawDegrees * com.jme3.math.FastMath.DEG_TO_RAD;
|
||||
visual.setLocalRotation(new Quaternion().fromAngles(0f, rad, 0f));
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockiert alle Eingaben für das Neues-Spiel-Intro (ohne Animation zu wechseln).
|
||||
* Wird in NewGameIntroState.initialize() aufgerufen, damit der Spieler
|
||||
* während der BLACK-Phase nicht steuern kann.
|
||||
*/
|
||||
public void blockForIntro() {
|
||||
lockInPlace();
|
||||
inputsBlocked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die REVIVE-Animation und friert sie bei Frame 0 ein (speed=0).
|
||||
* Wird unmittelbar vor dem Einblenden aufgerufen (BLACK→FADING_IN).
|
||||
*/
|
||||
public void startFrozenRevive(String reviveClip) {
|
||||
lockInPlace();
|
||||
inputsBlocked = true;
|
||||
if (animComposer != null) {
|
||||
if (animLib != null && visual != null) animLib.ensureApplied(reviveClip, visual);
|
||||
// transitionLength=0: verhindert den 0.4 s Überblend-Effekt bei speed=0.
|
||||
// Ohne diesen Fix wäre transitionWeight=time/0.4=0 → Charakter zeigt Idle-Pose.
|
||||
com.jme3.anim.tween.action.Action reviveAction = animComposer.action(reviveClip);
|
||||
if (reviveAction instanceof com.jme3.anim.tween.action.BlendableAction ba) {
|
||||
ba.setTransitionLength(0.0);
|
||||
}
|
||||
frozenReviveAction = animComposer.setCurrentAction(reviveClip);
|
||||
if (frozenReviveAction != null) {
|
||||
frozenReviveAction.setSpeed(0f);
|
||||
log.info("[Intro] REVIVE '{}' eingefroren (frame 0)", reviveClip);
|
||||
} else {
|
||||
log.warn("[Intro] setCurrentAction('{}') → null – REVIVE nicht eingefroren", reviveClip);
|
||||
}
|
||||
} else {
|
||||
log.warn("[Intro] animComposer null – REVIVE kann nicht eingefroren werden");
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt die REVIVE-Animation mit normaler Geschwindigkeit frei. */
|
||||
public void unfreezeRevive() {
|
||||
if (frozenReviveAction != null) {
|
||||
frozenReviveAction.setSpeed(1f);
|
||||
frozenReviveAction = null;
|
||||
log.info("[Intro] REVIVE freigegeben – Animation läuft");
|
||||
}
|
||||
}
|
||||
|
||||
/** Hebt die Input-Blockade auf und gibt Bewegungseingaben wieder frei. */
|
||||
public void unblockInputs() {
|
||||
inputsBlocked = false;
|
||||
unlockFromPlace();
|
||||
currentAnim = null; // erzwingt Neubewertung in update() (z.B. IDLE nach REVIVE)
|
||||
}
|
||||
|
||||
/** Liefert die Clipdauer der REVIVE-Animation in Sekunden, oder 3 s als Fallback. */
|
||||
public float getReviveClipLength() {
|
||||
return resolveClipLength(AnimationAction.REVIVE, 3f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet die Navigation zum angegebenen Welt-Punkt.
|
||||
* Während der Navigation werden WASD-Eingaben ignoriert.
|
||||
@@ -363,6 +450,8 @@ public class PlayerInputControl {
|
||||
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(animOffsetCurrent));
|
||||
}
|
||||
|
||||
if (inputsBlocked) return;
|
||||
|
||||
if (paused) {
|
||||
if (autopilotDir != null) {
|
||||
autopilotDir = null;
|
||||
@@ -502,6 +591,8 @@ public class PlayerInputControl {
|
||||
playAction(target);
|
||||
currentAnim = target;
|
||||
}
|
||||
|
||||
pollFootsteps();
|
||||
}
|
||||
|
||||
private void playAction(AnimationAction action) {
|
||||
@@ -521,6 +612,69 @@ public class PlayerInputControl {
|
||||
}
|
||||
}
|
||||
|
||||
public void setFootstepSystem(de.blight.game.audio.FootstepSystem fs,
|
||||
java.util.function.BiFunction<Float, Float, float[]> query) {
|
||||
this.footstepSystem = fs;
|
||||
this.surfaceQuery = query;
|
||||
}
|
||||
|
||||
private void logJointNames(com.jme3.anim.Armature arm) {
|
||||
if (arm == null) return;
|
||||
StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):");
|
||||
for (int i = 0; i < arm.getJointCount(); i++) {
|
||||
sb.append("\n [").append(i).append("] ").append(arm.getJoint(i).getName());
|
||||
}
|
||||
log.info("{}", sb);
|
||||
}
|
||||
|
||||
private void pollFootsteps() {
|
||||
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
|
||||
if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) {
|
||||
lastLeftFootY = Float.MAX_VALUE;
|
||||
lastRightFootY = Float.MAX_VALUE;
|
||||
return;
|
||||
}
|
||||
float speed = physicsChar.getWalkDirection().length();
|
||||
if (speed < 0.001f) {
|
||||
lastLeftFootY = Float.MAX_VALUE;
|
||||
lastRightFootY = Float.MAX_VALUE;
|
||||
return;
|
||||
}
|
||||
String gait = currentAnimToGait(currentAnim);
|
||||
if (gait == null) {
|
||||
return;
|
||||
}
|
||||
com.jme3.anim.Joint lf = armature.getJoint(BONE_LEFT_FOOT);
|
||||
com.jme3.anim.Joint rf = armature.getJoint(BONE_RIGHT_FOOT);
|
||||
if (lf != null) {
|
||||
float y = lf.getModelTransform().getTranslation().y;
|
||||
if (y < FOOT_GROUND_Y && lastLeftFootY >= FOOT_GROUND_Y) {
|
||||
triggerStep(gait);
|
||||
}
|
||||
lastLeftFootY = y;
|
||||
}
|
||||
if (rf != null) {
|
||||
float y = rf.getModelTransform().getTranslation().y;
|
||||
if (y < FOOT_GROUND_Y && lastRightFootY >= FOOT_GROUND_Y) {
|
||||
triggerStep(gait);
|
||||
}
|
||||
lastRightFootY = y;
|
||||
}
|
||||
}
|
||||
|
||||
private String currentAnimToGait(AnimationAction anim) {
|
||||
if (anim == AnimationAction.WALK) return "walking";
|
||||
if (anim == AnimationAction.RUN) return "running";
|
||||
if (anim == AnimationAction.SPRINT) return "sprinting";
|
||||
return null;
|
||||
}
|
||||
|
||||
private void triggerStep(String gait) {
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
float[] weights = surfaceQuery.apply(pos.x, pos.z);
|
||||
footstepSystem.play(weights, gait);
|
||||
}
|
||||
|
||||
/** Durchsucht den Szenegraphen rekursiv nach dem ersten SkinningControl. */
|
||||
private com.jme3.anim.SkinningControl findSkinningControl(Spatial s) {
|
||||
if (s == null) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.blight.game.post;
|
||||
|
||||
import com.jme3.asset.AssetManager;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.post.Filter;
|
||||
import com.jme3.renderer.RenderManager;
|
||||
import com.jme3.renderer.ViewPort;
|
||||
|
||||
/** Einfacher 5×5-Gauß-Unschärfe-Filter für das Pause-/Inventar-Menü. */
|
||||
public class GaussianBlurFilter extends Filter {
|
||||
|
||||
private final float blurScale;
|
||||
|
||||
public GaussianBlurFilter(float blurScale) {
|
||||
super("GaussianBlur");
|
||||
this.blurScale = blurScale;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initFilter(AssetManager manager, RenderManager renderManager,
|
||||
ViewPort vp, int w, int h) {
|
||||
material = new Material(manager, "MatDefs/GaussianBlur.j3md");
|
||||
material.setFloat("BlurScale", blurScale);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Material getMaterial() {
|
||||
return material;
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,12 @@ import de.blight.game.state.SculptedMeshState;
|
||||
import de.blight.game.state.WaterBodyState;
|
||||
import de.blight.game.state.DayNightState;
|
||||
import de.blight.game.state.WeatherState;
|
||||
import de.blight.game.state.DialogHudState;
|
||||
import de.blight.game.state.InteractionHudState;
|
||||
import de.blight.game.state.InventoryState;
|
||||
import de.blight.game.state.WorldInteractableState;
|
||||
import de.blight.game.state.WorldItemsState;
|
||||
import de.blight.game.state.WorldNpcsState;
|
||||
import de.blight.game.state.StoneWorldState;
|
||||
import de.blight.game.state.WorldObjectsState;
|
||||
import de.blight.game.state.WorldLightState;
|
||||
@@ -83,15 +85,89 @@ public class WorldScene extends BaseAppState {
|
||||
private CharacterControl physicsChar;
|
||||
private boolean animContextReady = false;
|
||||
private boolean physicsCharPending = false;
|
||||
private float spawnX = 0f;
|
||||
private float spawnY = 5f;
|
||||
private float spawnZ = 0f;
|
||||
private float spawnX = 0f;
|
||||
private float spawnY = 5f;
|
||||
private float spawnZ = 0f;
|
||||
private float spawnYaw = 0f;
|
||||
private de.blight.game.state.OceanSoundState oceanSound;
|
||||
private de.blight.game.state.AmbientSoundSystem ambientSounds;
|
||||
private de.blight.game.audio.FootstepSystem footstepSystem;
|
||||
|
||||
public WorldScene(KeyBindings keyBindings) {
|
||||
this.keyBindings = keyBindings;
|
||||
}
|
||||
|
||||
public InventoryState getInventoryState() { return inventoryState; }
|
||||
public com.jme3.post.FilterPostProcessor getSharedFPP() { return sharedFPP; }
|
||||
|
||||
/**
|
||||
* Gibt für jede SurfaceType einen normierten Blend-Anteil (0..1) zurück,
|
||||
* proportional zu den Splatmap-Gewichten an dieser Position.
|
||||
* Indiziert über SurfaceType.ordinal().
|
||||
*/
|
||||
public float[] querySurfaceWeights(float worldX, float worldZ) {
|
||||
float[] result = new float[de.blight.game.audio.SurfaceType.values().length];
|
||||
if (loadedMapData == null) return result;
|
||||
|
||||
int size = de.blight.common.MapData.SPLAT_SIZE;
|
||||
int px = Math.max(0, Math.min(size - 1, Math.round((worldX + 2048f) / 2f)));
|
||||
// Z ist im Splatmap gespiegelt (Editor: pz = (size-1) - round((z+2048)/2))
|
||||
int pz = Math.max(0, Math.min(size - 1, (size - 1) - Math.round((worldZ + 2048f) / 2f)));
|
||||
int idx = pz * size + px;
|
||||
|
||||
// Upper/Third-Splatmap unterdrücken die Basistextur visuell (Overlay-Modell).
|
||||
int maxOverlay = Math.max(
|
||||
Math.max(loadedMapData.upperSplatR[idx] & 0xFF, loadedMapData.upperSplatG[idx] & 0xFF),
|
||||
Math.max(
|
||||
Math.max(loadedMapData.upperSplatB[idx] & 0xFF, loadedMapData.upperSplatA[idx] & 0xFF),
|
||||
Math.max(
|
||||
Math.max(loadedMapData.thirdSplatR[idx] & 0xFF, loadedMapData.thirdSplatG[idx] & 0xFF),
|
||||
Math.max(loadedMapData.thirdSplatB[idx] & 0xFF, loadedMapData.thirdSplatA[idx] & 0xFF)
|
||||
)
|
||||
)
|
||||
);
|
||||
float baseScale = Math.max(0f, (255 - maxOverlay) / 255f);
|
||||
|
||||
int splatR = loadedMapData.splatR[idx] & 0xFF;
|
||||
if (splatR == 0) splatR = 255; // Alte Maps: wie im Shader auf 255 normieren
|
||||
|
||||
int[] slotW = {
|
||||
(int)(splatR * baseScale), // slot 0: gras1
|
||||
(int)((loadedMapData.splatG[idx] & 0xFF) * baseScale), // slot 1
|
||||
(int)((loadedMapData.splatB[idx] & 0xFF) * baseScale), // slot 2
|
||||
(int)((loadedMapData.splatA[idx] & 0xFF) * baseScale), // slot 3
|
||||
loadedMapData.upperSplatR[idx] & 0xFF, // slot 4
|
||||
loadedMapData.upperSplatG[idx] & 0xFF, // slot 5
|
||||
loadedMapData.upperSplatB[idx] & 0xFF, // slot 6
|
||||
loadedMapData.upperSplatA[idx] & 0xFF, // slot 7
|
||||
loadedMapData.thirdSplatR[idx] & 0xFF, // slot 8
|
||||
loadedMapData.thirdSplatG[idx] & 0xFF, // slot 9
|
||||
loadedMapData.thirdSplatB[idx] & 0xFF, // slot 10
|
||||
loadedMapData.thirdSplatA[idx] & 0xFF, // slot 11
|
||||
};
|
||||
|
||||
// Gewichte auf SurfaceTypes akkumulieren
|
||||
int total = 0;
|
||||
for (int i = 0; i < slotW.length; i++) {
|
||||
if (slotW[i] <= 0) continue;
|
||||
String path;
|
||||
if (i < 4) {
|
||||
path = (loadedMapData.terrainTextures.length > i) ? loadedMapData.terrainTextures[i] : "";
|
||||
} else if (i < 8) {
|
||||
path = (loadedMapData.upperTextures.length > i - 4) ? loadedMapData.upperTextures[i - 4] : "";
|
||||
} else {
|
||||
path = (loadedMapData.thirdTextures.length > i - 8) ? loadedMapData.thirdTextures[i - 8] : "";
|
||||
}
|
||||
de.blight.game.audio.SurfaceType st = de.blight.game.audio.SurfaceType.fromTexturePath(path);
|
||||
result[st.ordinal()] += slotW[i];
|
||||
total += slotW[i];
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
for (int i = 0; i < result.length; i++) result[i] /= total;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Wird von ConfigScreen nach dem Speichern aufgerufen. */
|
||||
public void reloadBindings(KeyBindings kb) {
|
||||
@@ -173,6 +249,12 @@ public class WorldScene extends BaseAppState {
|
||||
playerInput.setPhysicsCharacter(physicsChar);
|
||||
playerInput.setVisual(characterVisual != null ? characterVisual : character);
|
||||
|
||||
de.blight.game.state.AudioSettingsState audioSettings =
|
||||
app.getStateManager().getState(de.blight.game.state.AudioSettingsState.class);
|
||||
footstepSystem = new de.blight.game.audio.FootstepSystem(assetManager, rootNode, audioSettings,
|
||||
AnimationLibrary.findAssetRoot());
|
||||
playerInput.setFootstepSystem(footstepSystem, this::querySurfaceWeights);
|
||||
|
||||
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
|
||||
try {
|
||||
de.blight.game.navigation.PathFinder pf = de.blight.game.navigation.PathFinder.load();
|
||||
@@ -197,6 +279,9 @@ public class WorldScene extends BaseAppState {
|
||||
new WorldItemsState(keyBindings, physicsChar, mc, playerInput));
|
||||
app.getStateManager().attach(
|
||||
new WorldInteractableState(keyBindings, physicsChar, playerInput));
|
||||
app.getStateManager().attach(new DialogHudState());
|
||||
app.getStateManager().attach(
|
||||
new WorldNpcsState(keyBindings, physicsChar, playerInput, mc));
|
||||
app.getStateManager().attach(new InteractionHudState());
|
||||
inventoryState = new InventoryState(mc, keyBindings);
|
||||
inventoryState.setEnabled(false);
|
||||
@@ -225,12 +310,26 @@ public class WorldScene extends BaseAppState {
|
||||
}
|
||||
|
||||
if (!animContextReady && animLib != null && animLib.isInitialized()) {
|
||||
setupAnimationContext();
|
||||
animContextReady = true;
|
||||
animContextReady = true; // zuerst setzen – verhindert endlose Wiederholung bei Exception
|
||||
try {
|
||||
setupAnimationContext();
|
||||
} catch (Exception e) {
|
||||
log.error("[WorldScene] setupAnimationContext() fehlgeschlagen – Character trotzdem eingeblendet", e);
|
||||
if (characterVisual != null) characterVisual.setCullHint(Spatial.CullHint.Inherit);
|
||||
}
|
||||
}
|
||||
playerInput.update(tpf);
|
||||
thirdPersonCam.update(tpf);
|
||||
|
||||
if (physicsChar != null) {
|
||||
com.jme3.math.Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
// Audio-Listener folgt dem Spieler (Ohr-Position + Kamera-Orientierung)
|
||||
app.getListener().setLocation(pos);
|
||||
app.getListener().setRotation(app.getCamera().getRotation());
|
||||
if (oceanSound != null) oceanSound.setPlayerPosition(pos);
|
||||
if (ambientSounds != null) ambientSounds.setPlayerPosition(pos);
|
||||
}
|
||||
|
||||
// Terrain-Shader mit DayNightState-Licht synchronisieren (Richtung + Farben)
|
||||
if (terrainMaterial != null && dayNight != null
|
||||
&& dayNight.getSunLight() != null) {
|
||||
@@ -319,6 +418,16 @@ public class WorldScene extends BaseAppState {
|
||||
if (characterVisual != null) {
|
||||
characterVisual.setCullHint(Spatial.CullHint.Inherit);
|
||||
}
|
||||
|
||||
playerInput.setInitialFacing(spawnYaw);
|
||||
|
||||
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(
|
||||
new de.blight.game.state.NewGameIntroState(playerInput, reviveClip, reviveLength));
|
||||
}
|
||||
}
|
||||
|
||||
// CharacterControl setzt den Spatial auf den Kapsel-Mittelpunkt: radius=0.4, halfCyl=0.5 → 0.9m über dem Boden.
|
||||
@@ -500,22 +609,28 @@ public class WorldScene extends BaseAppState {
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn-Priorität: 1) Editor-Property 2) Spielstand 3) Karten-Default
|
||||
String propX = System.getProperty("blight.temp.spawn.x");
|
||||
String propZ = System.getProperty("blight.temp.spawn.z");
|
||||
// Spawn-Priorität: 1) Temp-Editor-Property 2) Spielstand (außer Neues Spiel) 3) Karten-Default
|
||||
String propX = System.getProperty("blight.temp.spawn.x");
|
||||
String propZ = System.getProperty("blight.temp.spawn.z");
|
||||
String propYaw = System.getProperty("blight.temp.spawn.yaw");
|
||||
boolean isNewGame = "true".equals(System.getProperty("blight.new.game"));
|
||||
|
||||
if (propX != null) {
|
||||
spawnX = Float.parseFloat(propX);
|
||||
spawnZ = propZ != null ? Float.parseFloat(propZ) : (loadedMapData != null ? loadedMapData.spawnZ : 0f);
|
||||
spawnX = Float.parseFloat(propX);
|
||||
spawnZ = propZ != null ? Float.parseFloat(propZ) : (loadedMapData != null ? loadedMapData.spawnZ : 0f);
|
||||
spawnYaw = propYaw != null ? Float.parseFloat(propYaw) : 0f;
|
||||
} else {
|
||||
de.blight.game.state.SaveGameState saveState =
|
||||
de.blight.game.state.SaveGameState saveState = isNewGame ? null :
|
||||
app.getStateManager().getState(de.blight.game.state.SaveGameState.class);
|
||||
if (saveState != null && saveState.getSave().character.positionSaved) {
|
||||
spawnX = saveState.getSave().character.x;
|
||||
spawnY = saveState.getSave().character.y;
|
||||
spawnZ = saveState.getSave().character.z;
|
||||
spawnX = saveState.getSave().character.x;
|
||||
spawnY = saveState.getSave().character.y;
|
||||
spawnZ = saveState.getSave().character.z;
|
||||
spawnYaw = loadedMapData != null ? loadedMapData.spawnYaw : 0f;
|
||||
} else if (loadedMapData != null) {
|
||||
spawnX = loadedMapData.spawnX;
|
||||
spawnZ = loadedMapData.spawnZ;
|
||||
spawnX = loadedMapData.spawnX;
|
||||
spawnZ = loadedMapData.spawnZ;
|
||||
spawnYaw = loadedMapData.spawnYaw;
|
||||
}
|
||||
}
|
||||
log.info("[WorldScene] SpawnXZ: X={} Z={}", spawnX, spawnZ);
|
||||
@@ -545,6 +660,12 @@ public class WorldScene extends BaseAppState {
|
||||
terrainChunkState.setSpawnHint(spawnX, spawnZ);
|
||||
|
||||
app.getStateManager().attach(new SculptedMeshState(bulletAppState, loadedMapData));
|
||||
|
||||
oceanSound = new de.blight.game.state.OceanSoundState(terrainChunkState);
|
||||
app.getStateManager().attach(oceanSound);
|
||||
|
||||
ambientSounds = new de.blight.game.state.AmbientSoundSystem();
|
||||
app.getStateManager().attach(ambientSounds);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -816,7 +937,11 @@ public class WorldScene extends BaseAppState {
|
||||
for (int i = 0; i < 4; i++) diffFb[4+i] = new ColorRGBA(0.45f, 0.32f, 0.25f, 1f);
|
||||
for (int i = 0; i < 4; i++) diffFb[8+i] = new ColorRGBA(0.45f, 0.32f, 0.25f, 1f);
|
||||
debugSlot0Path = diffPaths[0];
|
||||
log.info("[Terrain] Slot-0 Textur = '{}' Slot-1 = '{}'", diffPaths[0], diffPaths[1]);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
if (diffPaths[i] != null && !diffPaths[i].isEmpty()) {
|
||||
log.info("[Terrain] Slot-{} = '{}'", i, diffPaths[i]);
|
||||
}
|
||||
}
|
||||
mat.setParam("DiffuseArray", com.jme3.shader.VarType.TextureArray,
|
||||
buildTextureArray(diffPaths, diffFb, assetManager));
|
||||
|
||||
|
||||
@@ -18,23 +18,29 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Distanzbasierter Ambient-Sound pro Polygon-Bereich.
|
||||
* Innerhalb des Polygons: volle Lautstärke.
|
||||
* Außerhalb: lineares Fade bis zur Reichweite (volume * CROSSFADE_SCALE Einheiten).
|
||||
* Distanzbasierter Ambient-Sound pro Polygon-Bereich, jetzt mit Stereo-Panning:
|
||||
* - Innerhalb des Polygons: nicht-positional (Rundum-Klang).
|
||||
* - Außerhalb: AudioNode am nächsten Polygon-Rand → OpenAL panst aus der richtigen Richtung.
|
||||
* Lautstärke-Fading bleibt manuell; OpenAL's Distanz-Dämpfung wird mit großem
|
||||
* refDistance deaktiviert, damit es keine Doppeldämpfung gibt.
|
||||
*/
|
||||
public class AmbientSoundSystem extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AmbientSoundSystem.class);
|
||||
private static final float CROSSFADE_SCALE = 30f; // Einheiten Reichweite außerhalb bei volume=1.0
|
||||
private static final float FADE_DURATION = 2f; // Sekunden für vollen Lautstärke-Hub
|
||||
private static final float CROSSFADE_SCALE = 30f;
|
||||
private static final float FADE_DURATION = 2f;
|
||||
/** RefDistance > jede denkbare Spieler-AudioNode-Distanz → OpenAL dämpft nicht. */
|
||||
private static final float NO_ATTN_REF = 1000f;
|
||||
private static final float NO_ATTN_MAX = 2000f;
|
||||
|
||||
private SimpleApplication app;
|
||||
private AssetManager assets;
|
||||
private Node rootNode;
|
||||
|
||||
private final List<PlacedSoundArea> data = new ArrayList<>();
|
||||
private final List<AudioNode> sounds = new ArrayList<>();
|
||||
private final List<Boolean> attached = new ArrayList<>();
|
||||
private final List<PlacedSoundArea> data = new ArrayList<>();
|
||||
private final List<AudioNode> sounds = new ArrayList<>();
|
||||
private final List<Boolean> attached = new ArrayList<>();
|
||||
private final List<Boolean> wasInside = new ArrayList<>();
|
||||
|
||||
private final Vector3f playerPos = new Vector3f();
|
||||
|
||||
@@ -51,10 +57,14 @@ public class AmbientSoundSystem extends BaseAppState {
|
||||
AudioNode node = new AudioNode(assets, area.soundPath(), AudioData.DataType.Stream);
|
||||
node.setLooping(true);
|
||||
node.setVolume(0f);
|
||||
node.setPositional(false);
|
||||
// Positional-Modus: refDistance sehr groß → OpenAL dämpft nicht selbst
|
||||
node.setPositional(true);
|
||||
node.setRefDistance(NO_ATTN_REF);
|
||||
node.setMaxDistance(NO_ATTN_MAX);
|
||||
data.add(area);
|
||||
sounds.add(node);
|
||||
attached.add(false);
|
||||
wasInside.add(false);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AmbientSoundSystem] Sound nicht ladbar '{}': {}", area.soundPath(), e.getMessage());
|
||||
}
|
||||
@@ -77,6 +87,7 @@ public class AmbientSoundSystem extends BaseAppState {
|
||||
data.clear();
|
||||
sounds.clear();
|
||||
attached.clear();
|
||||
wasInside.clear();
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@@ -95,9 +106,8 @@ public class AmbientSoundSystem extends BaseAppState {
|
||||
AudioNode node = sounds.get(i);
|
||||
float target = computeTarget(area);
|
||||
float cur = node.getVolume();
|
||||
boolean wasOn = attached.get(i);
|
||||
|
||||
if (target > 0f && !wasOn) {
|
||||
if (target > 0f && !attached.get(i)) {
|
||||
node.setVolume(0f);
|
||||
rootNode.attachChild(node);
|
||||
node.play();
|
||||
@@ -106,6 +116,9 @@ public class AmbientSoundSystem extends BaseAppState {
|
||||
}
|
||||
|
||||
if (attached.get(i)) {
|
||||
// Stereo-Panning: AudioNode zur richtigen Richtung verschieben
|
||||
updateNodePosition(node, area, i);
|
||||
|
||||
float step = area.volume() * tpf / FADE_DURATION;
|
||||
float nv = target > cur
|
||||
? Math.min(cur + step, target)
|
||||
@@ -122,11 +135,33 @@ public class AmbientSoundSystem extends BaseAppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zielvolumen basierend auf signiertem Abstand zur Polygongrenze.
|
||||
* Innen (≥0): volle Lautstärke.
|
||||
* Außen: linear von voll (Grenze) auf null (hearRange = volume * CROSSFADE_SCALE).
|
||||
*/
|
||||
// ── AudioNode-Position für Stereo-Panning ────────────────────────────────
|
||||
|
||||
private void updateNodePosition(AudioNode node, PlacedSoundArea area, int i) {
|
||||
float px = playerPos.x, pz = playerPos.z;
|
||||
boolean inside = pointInPolygon(px, pz, area.pointsX(), area.pointsZ());
|
||||
|
||||
if (inside) {
|
||||
// Innerhalb: nicht-positional → Rundum-Klang
|
||||
if (!wasInside.get(i)) {
|
||||
node.setPositional(false);
|
||||
wasInside.set(i, true);
|
||||
}
|
||||
} else {
|
||||
// Außerhalb: positional am nächsten Rand-Punkt
|
||||
if (wasInside.get(i)) {
|
||||
node.setPositional(true);
|
||||
node.setRefDistance(NO_ATTN_REF);
|
||||
node.setMaxDistance(NO_ATTN_MAX);
|
||||
wasInside.set(i, false);
|
||||
}
|
||||
float[] nearest = nearestPointOnPolygon(px, pz, area.pointsX(), area.pointsZ());
|
||||
node.setLocalTranslation(nearest[0], playerPos.y, nearest[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Polygon-Berechnungen ─────────────────────────────────────────────────
|
||||
|
||||
private float computeTarget(PlacedSoundArea area) {
|
||||
float signedDist = signedDistToPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ());
|
||||
if (signedDist >= 0f) return area.volume();
|
||||
@@ -135,18 +170,38 @@ public class AmbientSoundSystem extends BaseAppState {
|
||||
return area.volume() * (1f + signedDist / hearRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positiv = Spieler ist innen (Distanz zur nächsten Kante).
|
||||
* Negativ = Spieler ist außen (negierte Distanz zur nächsten Kante).
|
||||
*/
|
||||
/** Positiv = Spieler ist innen, Negativ = Spieler ist außen. */
|
||||
private static float signedDistToPolygon(float px, float pz, float[] xs, float[] zs) {
|
||||
float edgeDist = minDistToPolygonEdge(px, pz, xs, zs);
|
||||
return pointInPolygon(px, pz, xs, zs) ? edgeDist : -edgeDist;
|
||||
}
|
||||
|
||||
private static float minDistToPolygonEdge(float px, float pz, float[] xs, float[] zs) {
|
||||
/** Nächster Punkt auf einer Polygon-Kante (x, z) als float[2]. */
|
||||
private static float[] nearestPointOnPolygon(float px, float pz, float[] xs, float[] zs) {
|
||||
int n = xs.length;
|
||||
float minD2 = Float.MAX_VALUE;
|
||||
float bestX = xs[0], bestZ = zs[0];
|
||||
for (int i = 0, j = n - 1; i < n; j = i++) {
|
||||
float ax = xs[j], az = zs[j];
|
||||
float bx = xs[i], bz = zs[i];
|
||||
float dx = bx - ax, dz = bz - az;
|
||||
float lenSq = dx * dx + dz * dz;
|
||||
float t = lenSq == 0f ? 0f : Math.max(0f, Math.min(1f, ((px - ax) * dx + (pz - az) * dz) / lenSq));
|
||||
float cx = ax + t * dx;
|
||||
float cz = az + t * dz;
|
||||
float d2 = (cx - px) * (cx - px) + (cz - pz) * (cz - pz);
|
||||
if (d2 < minD2) {
|
||||
minD2 = d2;
|
||||
bestX = cx;
|
||||
bestZ = cz;
|
||||
}
|
||||
}
|
||||
return new float[]{bestX, bestZ};
|
||||
}
|
||||
|
||||
private static float minDistToPolygonEdge(float px, float pz, float[] xs, float[] zs) {
|
||||
int n = xs.length;
|
||||
float minD2 = Float.MAX_VALUE;
|
||||
for (int i = 0, j = n - 1; i < n; j = i++) {
|
||||
float d2 = pointToSegmentDist2(px, pz, xs[j], zs[j], xs[i], zs[i]);
|
||||
if (d2 < minD2) minD2 = d2;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import de.blight.game.config.AudioSettings;
|
||||
|
||||
/**
|
||||
* Hält die Audio-Einstellungen zur Laufzeit. Sound-Systeme lesen daraus
|
||||
* ihre effektiven Lautstärken (master × kategorie).
|
||||
*/
|
||||
public class AudioSettingsState extends BaseAppState {
|
||||
|
||||
private final AudioSettings settings;
|
||||
|
||||
public AudioSettingsState(AudioSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override protected void initialize(Application application) {}
|
||||
@Override protected void cleanup(Application application) {}
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
public AudioSettings getSettings() { return settings; }
|
||||
|
||||
public float getMaster() { return clamp(settings.master); }
|
||||
public float getMusic() { return clamp(settings.music); }
|
||||
public float getSpeech() { return clamp(settings.speech); }
|
||||
public float getEffects() { return clamp(settings.effects); }
|
||||
public float getAmbient() { return clamp(settings.ambient); }
|
||||
|
||||
public float effectiveAmbient() { return getMaster() * getAmbient(); }
|
||||
public float effectiveMusic() { return getMaster() * getMusic(); }
|
||||
public float effectiveEffects() { return getMaster() * getEffects(); }
|
||||
public float effectiveSpeech() { return getMaster() * getSpeech(); }
|
||||
|
||||
private static float clamp(float v) { return Math.max(0f, Math.min(1f, v)); }
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.KeyInput;
|
||||
import com.jme3.input.MouseInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector2f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.Spatial;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import de.blight.common.model.DialogOption;
|
||||
import de.blight.common.model.MainCharacter;
|
||||
import de.blight.common.model.NPC;
|
||||
import de.blight.common.model.TextReference;
|
||||
import de.blight.game.config.MenuCanvas;
|
||||
import de.blight.game.config.NinePatch;
|
||||
import de.blight.lang.TextResolver;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dialog-HUD: zeigt am unteren Bildschirmrand Gesprächstexte und wählbare
|
||||
* Antwortoptionen im Stil des Pausemenüs an.
|
||||
*
|
||||
* <h2>Phasen</h2>
|
||||
* <pre>
|
||||
* HIDDEN
|
||||
* → TEXT_NPC : Begrüßungstext des NPC (Default-Message) oder NPC-Antwort
|
||||
* → TEXT_HERO : Was der Spieler sagt (textHero der gewählten Option)
|
||||
* → OPTIONS : Auswahl der verfügbaren Dialog-Optionen
|
||||
* → HIDDEN : nach "Verlassen"
|
||||
* </pre>
|
||||
*
|
||||
* Navigation: Pfeiltasten hoch/runter + Enter ODER Mausklick.
|
||||
* Rechtsklick: Text überspringen / zur nächsten Seite.
|
||||
*/
|
||||
public class DialogHudState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DialogHudState.class);
|
||||
|
||||
// ── Layout-Konstanten (virtuelle Koordinaten 1376 × 768) ─────────────────
|
||||
|
||||
private static final float PNL_X = 63f;
|
||||
private static final float PNL_Y = 20f;
|
||||
private static final float PNL_W = 1250f;
|
||||
private static final float PNL_H = 255f;
|
||||
|
||||
private static final float MARGIN_X = 20f;
|
||||
private static final float FONT_NAME = 18f;
|
||||
private static final float FONT_TEXT = 16f;
|
||||
private static final float FONT_OPT = 15f;
|
||||
private static final float LINE_H_TXT = 26f;
|
||||
private static final float LINE_H_OPT = 26f;
|
||||
|
||||
private static final int MAX_TEXT_LINES = 3;
|
||||
private static final int MAX_CHARS_LINE = 82;
|
||||
private static final int MAX_OPTIONS = 5;
|
||||
|
||||
private static final ColorRGBA COL_NAME = new ColorRGBA(1.00f, 0.90f, 0.55f, 1f);
|
||||
private static final ColorRGBA COL_TEXT = ColorRGBA.White;
|
||||
private static final ColorRGBA COL_OPT = new ColorRGBA(0.80f, 0.80f, 0.80f, 1f);
|
||||
private static final ColorRGBA COL_OPT_SEL = new ColorRGBA(1.00f, 0.90f, 0.45f, 1f);
|
||||
private static final ColorRGBA COL_HINT = new ColorRGBA(0.55f, 0.55f, 0.55f, 1f);
|
||||
|
||||
private static final String EXIT_KEY = "dialog.exit";
|
||||
|
||||
// ── Input-Action-Namen ────────────────────────────────────────────────────
|
||||
|
||||
private static final String ACT_UP = "_DlgUp";
|
||||
private static final String ACT_DOWN = "_DlgDown";
|
||||
private static final String ACT_CONFIRM = "_DlgConfirm";
|
||||
private static final String ACT_SKIP = "_DlgSkip";
|
||||
private static final String ACT_CLICK = "_DlgClick";
|
||||
|
||||
// ── Zustände ─────────────────────────────────────────────────────────────
|
||||
|
||||
private enum Phase { HIDDEN, TEXT_NPC, TEXT_HERO, OPTIONS }
|
||||
|
||||
private Phase phase = Phase.HIDDEN;
|
||||
|
||||
// ── Dialog-Daten ─────────────────────────────────────────────────────────
|
||||
|
||||
private NPC currentNpc;
|
||||
private MainCharacter mainChar;
|
||||
private Runnable onClose;
|
||||
|
||||
/** Alle Seiten des aktuellen Textes. */
|
||||
private List<List<String>> textPages = new ArrayList<>();
|
||||
private int pageIdx = 0;
|
||||
|
||||
/** Aktuelle Option, die nach dem Text gezeigt werden soll (für Option-Text). */
|
||||
private DialogOption pendingOption;
|
||||
|
||||
/** Verfügbare Optionen (inkl. Exit). */
|
||||
private List<DisplayOption> displayOptions = new ArrayList<>();
|
||||
private int selectedOpt = 0;
|
||||
|
||||
/** Panel-Bounds für Maus-Hit-Tests. */
|
||||
private float[][] optBounds = new float[MAX_OPTIONS][4]; // x,y,w,h
|
||||
|
||||
// ── JME3 UI-Knoten ────────────────────────────────────────────────────────
|
||||
|
||||
private SimpleApplication app;
|
||||
private BitmapFont font;
|
||||
private Node guiNode;
|
||||
private Node canvasNode;
|
||||
private Node panel;
|
||||
|
||||
private BitmapText nameText;
|
||||
private BitmapText[] lineTexts = new BitmapText[MAX_TEXT_LINES];
|
||||
private BitmapText hintText;
|
||||
private BitmapText[] optTexts = new BitmapText[MAX_OPTIONS];
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
if (phase != Phase.HIDDEN) closePanel();
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Startet den Dialog mit dem gegebenen NPC.
|
||||
*
|
||||
* @param npc der angesprochene NPC
|
||||
* @param mc der Hauptcharakter
|
||||
* @param onCloseCallback wird aufgerufen wenn der Dialog endet
|
||||
*/
|
||||
public void startDialog(NPC npc, MainCharacter mc, Runnable onCloseCallback) {
|
||||
this.currentNpc = npc;
|
||||
this.mainChar = mc;
|
||||
this.onClose = onCloseCallback;
|
||||
|
||||
buildPanel();
|
||||
registerInput();
|
||||
|
||||
// Erst: Default-Message anzeigen (falls vorhanden), dann Optionen
|
||||
TextReference greeting = npc.getDefaultMessage();
|
||||
String greetText = greeting != null ? TextResolver.get().resolve(greeting) : null;
|
||||
|
||||
List<DialogOption> available = resolveOptions(npc, mc);
|
||||
|
||||
if (greetText != null && !greetText.isBlank()) {
|
||||
showText(Phase.TEXT_NPC, resolveNpcName(npc), greetText, () -> {
|
||||
if (available.isEmpty()) {
|
||||
closeDialog();
|
||||
} else {
|
||||
showOptions(available);
|
||||
}
|
||||
});
|
||||
} else if (!available.isEmpty()) {
|
||||
showOptions(available);
|
||||
} else {
|
||||
// Keine Nachricht, keine Optionen → sofort schließen
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return phase != Phase.HIDDEN;
|
||||
}
|
||||
|
||||
// ── Text-Anzeige ─────────────────────────────────────────────────────────
|
||||
|
||||
private Runnable afterText;
|
||||
|
||||
private void showText(Phase textPhase, String speaker, String rawText, Runnable after) {
|
||||
this.phase = textPhase;
|
||||
this.afterText = after;
|
||||
this.textPages = paginate(wrapText(rawText, MAX_CHARS_LINE), MAX_TEXT_LINES);
|
||||
this.pageIdx = 0;
|
||||
renderCurrentTextPage(speaker);
|
||||
}
|
||||
|
||||
private void renderCurrentTextPage(String speaker) {
|
||||
nameText.setText(speaker);
|
||||
nameText.setCullHint(Spatial.CullHint.Inherit);
|
||||
|
||||
List<String> page = (pageIdx < textPages.size()) ? textPages.get(pageIdx) : List.of();
|
||||
for (int i = 0; i < MAX_TEXT_LINES; i++) {
|
||||
lineTexts[i].setText(i < page.size() ? page.get(i) : "");
|
||||
lineTexts[i].setCullHint(Spatial.CullHint.Inherit);
|
||||
}
|
||||
|
||||
boolean more = (pageIdx + 1) < textPages.size();
|
||||
hintText.setText(more
|
||||
? t("dialog.hint.advance")
|
||||
: t("dialog.hint.continue"));
|
||||
hintText.setCullHint(Spatial.CullHint.Inherit);
|
||||
|
||||
for (BitmapText o : optTexts) o.setCullHint(Spatial.CullHint.Always);
|
||||
}
|
||||
|
||||
// ── Optionen-Anzeige ─────────────────────────────────────────────────────
|
||||
|
||||
private void showOptions(List<DialogOption> options) {
|
||||
phase = Phase.OPTIONS;
|
||||
displayOptions.clear();
|
||||
selectedOpt = 0;
|
||||
|
||||
for (DialogOption opt : options) {
|
||||
String label = TextResolver.get().resolveId(opt.getLabel() != null ? opt.getLabel() : "");
|
||||
if (label.isBlank()) label = opt.getId() != null
|
||||
? opt.getId().substring(0, Math.min(opt.getId().length(), 20)) : "?";
|
||||
displayOptions.add(new DisplayOption(label, opt));
|
||||
}
|
||||
displayOptions.add(new DisplayOption(t(EXIT_KEY), null));
|
||||
|
||||
nameText.setText(resolveNpcName(currentNpc));
|
||||
nameText.setCullHint(Spatial.CullHint.Inherit);
|
||||
for (BitmapText l : lineTexts) l.setCullHint(Spatial.CullHint.Always);
|
||||
hintText.setCullHint(Spatial.CullHint.Always);
|
||||
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
private void renderOptions() {
|
||||
float baseY = PNL_Y + PNL_H - 80f;
|
||||
|
||||
for (int i = 0; i < MAX_OPTIONS; i++) {
|
||||
if (i < displayOptions.size()) {
|
||||
DisplayOption do_ = displayOptions.get(i);
|
||||
boolean sel = (i == selectedOpt);
|
||||
String prefix = sel ? "► " : " ";
|
||||
optTexts[i].setText(prefix + (i + 1) + ". " + do_.label());
|
||||
optTexts[i].setColor(sel ? COL_OPT_SEL : COL_OPT);
|
||||
optTexts[i].setCullHint(Spatial.CullHint.Inherit);
|
||||
|
||||
float ty = baseY - i * LINE_H_OPT;
|
||||
optTexts[i].setLocalTranslation(PNL_X + MARGIN_X, ty, 2f);
|
||||
|
||||
optBounds[i][0] = PNL_X + MARGIN_X;
|
||||
optBounds[i][1] = ty - FONT_OPT;
|
||||
optBounds[i][2] = PNL_W - MARGIN_X * 2;
|
||||
optBounds[i][3] = FONT_OPT + 4;
|
||||
} else {
|
||||
optTexts[i].setCullHint(Spatial.CullHint.Always);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input-Handler ─────────────────────────────────────────────────────────
|
||||
|
||||
private void onUp() {
|
||||
if (phase != Phase.OPTIONS) return;
|
||||
selectedOpt = Math.max(0, selectedOpt - 1);
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
private void onDown() {
|
||||
if (phase != Phase.OPTIONS) return;
|
||||
selectedOpt = Math.min(displayOptions.size() - 1, selectedOpt + 1);
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
private void onConfirm() {
|
||||
if (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO) {
|
||||
advanceText();
|
||||
} else if (phase == Phase.OPTIONS) {
|
||||
confirmOption(selectedOpt);
|
||||
}
|
||||
}
|
||||
|
||||
private void onSkip() {
|
||||
if (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO) {
|
||||
// Alle verbleibenden Seiten überspringen
|
||||
pageIdx = textPages.size();
|
||||
if (afterText != null) { Runnable cb = afterText; afterText = null; cb.run(); }
|
||||
} else if (phase == Phase.OPTIONS) {
|
||||
confirmOption(selectedOpt);
|
||||
}
|
||||
}
|
||||
|
||||
private void onMouseClick(Vector2f cursor) {
|
||||
if (phase != Phase.OPTIONS) { onConfirm(); return; }
|
||||
|
||||
// Kursorenanpassung auf virtuelle Koordinaten
|
||||
float ox = (app.getCamera().getWidth() - MenuCanvas.REF_W) / 2f;
|
||||
float oy = (app.getCamera().getHeight() - MenuCanvas.REF_H) / 2f;
|
||||
float scale = Math.min(
|
||||
app.getCamera().getWidth() / MenuCanvas.REF_W,
|
||||
app.getCamera().getHeight() / MenuCanvas.REF_H);
|
||||
|
||||
float ox2 = (app.getCamera().getWidth() - MenuCanvas.REF_W * scale) / 2f;
|
||||
float oy2 = (app.getCamera().getHeight() - MenuCanvas.REF_H * scale) / 2f;
|
||||
float vx = (cursor.x - ox2) / scale;
|
||||
float vy = (cursor.y - oy2) / scale;
|
||||
|
||||
for (int i = 0; i < displayOptions.size() && i < MAX_OPTIONS; i++) {
|
||||
float bx = optBounds[i][0], by = optBounds[i][1];
|
||||
float bw = optBounds[i][2], bh = optBounds[i][3];
|
||||
if (vx >= bx && vx <= bx + bw && vy >= by && vy <= by + bh) {
|
||||
selectedOpt = i;
|
||||
renderOptions();
|
||||
confirmOption(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void advanceText() {
|
||||
if (pageIdx + 1 < textPages.size()) {
|
||||
pageIdx++;
|
||||
renderCurrentTextPage(nameText.getText());
|
||||
} else if (afterText != null) {
|
||||
Runnable cb = afterText;
|
||||
afterText = null;
|
||||
cb.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void confirmOption(int idx) {
|
||||
if (idx < 0 || idx >= displayOptions.size()) return;
|
||||
DisplayOption selected = displayOptions.get(idx);
|
||||
|
||||
if (selected.option() == null) {
|
||||
// Exit
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
DialogOption opt = selected.option();
|
||||
|
||||
// Held-Text
|
||||
String heroText = opt.getTextHero() != null
|
||||
? TextResolver.get().resolve(opt.getTextHero()) : null;
|
||||
// NPC-Text
|
||||
String npcText = opt.getTextNpc() != null
|
||||
? TextResolver.get().resolve(opt.getTextNpc()) : null;
|
||||
|
||||
// Option-Effekte anwenden (Optionen aktualisieren, Quest, etc.)
|
||||
applyOption(opt);
|
||||
|
||||
List<DialogOption> nextOpts = resolveOptions(currentNpc, mainChar);
|
||||
|
||||
if (heroText != null && !heroText.isBlank()) {
|
||||
showText(Phase.TEXT_HERO, t("dialog.speaker.player"), heroText, () -> {
|
||||
if (npcText != null && !npcText.isBlank()) {
|
||||
showText(Phase.TEXT_NPC, resolveNpcName(currentNpc), npcText, () -> {
|
||||
if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts);
|
||||
});
|
||||
} else {
|
||||
if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts);
|
||||
}
|
||||
});
|
||||
} else if (npcText != null && !npcText.isBlank()) {
|
||||
showText(Phase.TEXT_NPC, resolveNpcName(currentNpc), npcText, () -> {
|
||||
if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts);
|
||||
});
|
||||
} else {
|
||||
if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Optionen-Auflösung ────────────────────────────────────────────────────
|
||||
|
||||
private List<DialogOption> resolveOptions(NPC npc, MainCharacter mc) {
|
||||
if (npc.getCurrentOptions() == null || mc == null) return List.of();
|
||||
try {
|
||||
return npc.getAvailableOptions(mc);
|
||||
} catch (Exception e) {
|
||||
log.warn("[DialogHud] Optionen-Auflösung fehlgeschlagen: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wendet die Dialog-Option an (Optionen-Listen aktualisieren, Quests etc.). */
|
||||
private void applyOption(DialogOption opt) {
|
||||
if (currentNpc.getCurrentOptions() == null) return;
|
||||
currentNpc.getCurrentOptions().remove(opt);
|
||||
if (opt.getDisablesOptions() != null)
|
||||
currentNpc.getCurrentOptions().removeAll(opt.getDisablesOptions());
|
||||
if (opt.getNextOptions() != null)
|
||||
currentNpc.getCurrentOptions().addAll(opt.getNextOptions());
|
||||
if (opt.isEnablesTrade()) currentNpc.setTrader(true);
|
||||
if (mainChar != null) mainChar.handleDialogOption(opt);
|
||||
}
|
||||
|
||||
// ── Dialog beenden ────────────────────────────────────────────────────────
|
||||
|
||||
private void closeDialog() {
|
||||
closePanel();
|
||||
if (onClose != null) { Runnable cb = onClose; onClose = null; cb.run(); }
|
||||
}
|
||||
|
||||
// ── UI-Aufbau / -Abbau ────────────────────────────────────────────────────
|
||||
|
||||
private void buildPanel() {
|
||||
canvasNode = MenuCanvas.createFixedCanvas(app.getCamera());
|
||||
guiNode.attachChild(canvasNode);
|
||||
|
||||
panel = new Node("dialog-panel");
|
||||
|
||||
// Hintergrundpanel
|
||||
panel.attachChild(NinePatch.panel(app.getAssetManager())
|
||||
.build(PNL_X, PNL_Y, PNL_W, PNL_H, -1f));
|
||||
|
||||
// NPC-Name
|
||||
nameText = makeTxt("", FONT_NAME, COL_NAME);
|
||||
nameText.setLocalTranslation(PNL_X + MARGIN_X, PNL_Y + PNL_H - 22f, 2f);
|
||||
nameText.setCullHint(Spatial.CullHint.Always);
|
||||
panel.attachChild(nameText);
|
||||
|
||||
// Trennlinie (dünnes Quad)
|
||||
panel.attachChild(makeSeparator(PNL_X + MARGIN_X, PNL_Y + PNL_H - 46f, PNL_W - MARGIN_X * 2));
|
||||
|
||||
// Dialog-Textzeilen
|
||||
float textStartY = PNL_Y + PNL_H - 72f;
|
||||
for (int i = 0; i < MAX_TEXT_LINES; i++) {
|
||||
lineTexts[i] = makeTxt("", FONT_TEXT, COL_TEXT);
|
||||
lineTexts[i].setLocalTranslation(PNL_X + MARGIN_X, textStartY - i * LINE_H_TXT, 2f);
|
||||
lineTexts[i].setCullHint(Spatial.CullHint.Always);
|
||||
panel.attachChild(lineTexts[i]);
|
||||
}
|
||||
|
||||
// Hint-Text (Rechtsklick-Weiter)
|
||||
hintText = makeTxt("", FONT_TEXT - 2f, COL_HINT);
|
||||
hintText.setLocalTranslation(PNL_X + PNL_W - MARGIN_X - 300f, PNL_Y + 28f, 2f);
|
||||
hintText.setCullHint(Spatial.CullHint.Always);
|
||||
panel.attachChild(hintText);
|
||||
|
||||
// Optionszeilen
|
||||
for (int i = 0; i < MAX_OPTIONS; i++) {
|
||||
optTexts[i] = makeTxt("", FONT_OPT, COL_OPT);
|
||||
optTexts[i].setCullHint(Spatial.CullHint.Always);
|
||||
panel.attachChild(optTexts[i]);
|
||||
}
|
||||
|
||||
canvasNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private void closePanel() {
|
||||
phase = Phase.HIDDEN;
|
||||
unregisterInput();
|
||||
if (canvasNode != null) {
|
||||
guiNode.detachChild(canvasNode);
|
||||
canvasNode = null;
|
||||
panel = null;
|
||||
}
|
||||
textPages.clear();
|
||||
displayOptions.clear();
|
||||
pendingOption = null;
|
||||
afterText = null;
|
||||
currentNpc = null;
|
||||
mainChar = null;
|
||||
}
|
||||
|
||||
// ── Input-Registrierung ───────────────────────────────────────────────────
|
||||
|
||||
private void registerInput() {
|
||||
var im = app.getInputManager();
|
||||
im.addMapping(ACT_UP, new KeyTrigger(KeyInput.KEY_UP));
|
||||
im.addMapping(ACT_DOWN, new KeyTrigger(KeyInput.KEY_DOWN));
|
||||
im.addMapping(ACT_CONFIRM, new KeyTrigger(KeyInput.KEY_RETURN),
|
||||
new KeyTrigger(KeyInput.KEY_NUMPADENTER));
|
||||
im.addMapping(ACT_SKIP, new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
|
||||
im.addMapping(ACT_CLICK, new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
|
||||
im.addListener(inputListener, ACT_UP, ACT_DOWN, ACT_CONFIRM, ACT_SKIP, ACT_CLICK);
|
||||
im.setCursorVisible(true);
|
||||
}
|
||||
|
||||
private void unregisterInput() {
|
||||
var im = app.getInputManager();
|
||||
try { im.removeListener(inputListener); } catch (Exception ignored) {}
|
||||
for (String a : new String[]{ACT_UP, ACT_DOWN, ACT_CONFIRM, ACT_SKIP, ACT_CLICK}) {
|
||||
try { im.deleteMapping(a); } catch (Exception ignored) {}
|
||||
}
|
||||
im.setCursorVisible(false);
|
||||
}
|
||||
|
||||
private final ActionListener inputListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
switch (name) {
|
||||
case ACT_UP -> onUp();
|
||||
case ACT_DOWN -> onDown();
|
||||
case ACT_CONFIRM -> onConfirm();
|
||||
case ACT_SKIP -> onSkip();
|
||||
case ACT_CLICK -> onMouseClick(app.getInputManager().getCursorPosition());
|
||||
}
|
||||
};
|
||||
|
||||
// ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
|
||||
private BitmapText makeTxt(String s, float size, ColorRGBA color) {
|
||||
BitmapText t = new BitmapText(font);
|
||||
t.setSize(size);
|
||||
t.setColor(color);
|
||||
t.setText(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
private Geometry makeSeparator(float x, float y, float w) {
|
||||
Geometry g = new Geometry("sep", new Quad(w, 1f));
|
||||
Material m = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
m.setColor("Color", new ColorRGBA(0.4f, 0.4f, 0.4f, 0.8f));
|
||||
m.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
g.setMaterial(m);
|
||||
g.setQueueBucket(RenderQueue.Bucket.Transparent);
|
||||
g.setLocalTranslation(x, y, 1f);
|
||||
return g;
|
||||
}
|
||||
|
||||
private String resolveNpcName(NPC npc) {
|
||||
if (npc == null) return "?";
|
||||
String lk = npc.getLabelKey();
|
||||
if (!lk.isBlank()) {
|
||||
String resolved = TextResolver.get().resolveId(lk);
|
||||
if (!resolved.startsWith("[")) return resolved;
|
||||
}
|
||||
return npc.getCharacterId() != null ? npc.getCharacterId() : "NPC";
|
||||
}
|
||||
|
||||
private static String t(String id) { return TextResolver.get().resolveId(id); }
|
||||
|
||||
/** Bricht Text an Wortgrenzen auf. */
|
||||
private static List<String> wrapText(String text, int maxChars) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (text == null || text.isBlank()) return lines;
|
||||
// Zuerst explizite Zeilenumbrüche beachten
|
||||
for (String paragraph : text.split("\n")) {
|
||||
String[] words = paragraph.split("\\s+");
|
||||
StringBuilder cur = new StringBuilder();
|
||||
for (String w : words) {
|
||||
if (w.isBlank()) continue;
|
||||
if (cur.length() > 0 && cur.length() + 1 + w.length() > maxChars) {
|
||||
lines.add(cur.toString());
|
||||
cur = new StringBuilder(w);
|
||||
} else {
|
||||
if (cur.length() > 0) cur.append(' ');
|
||||
cur.append(w);
|
||||
}
|
||||
}
|
||||
if (cur.length() > 0) lines.add(cur.toString());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Gruppiert Zeilen in Seiten der Größe {@code linesPerPage}. */
|
||||
private static List<List<String>> paginate(List<String> lines, int linesPerPage) {
|
||||
List<List<String>> pages = new ArrayList<>();
|
||||
for (int i = 0; i < lines.size(); i += linesPerPage) {
|
||||
pages.add(lines.subList(i, Math.min(i + linesPerPage, lines.size())));
|
||||
}
|
||||
if (pages.isEmpty()) pages.add(List.of());
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ── Hilfsrecord ──────────────────────────────────────────────────────────
|
||||
|
||||
private record DisplayOption(String label, DialogOption option) {}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.Spatial;
|
||||
import de.blight.lang.TextResolver;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -23,9 +24,12 @@ public class InteractionHudState extends BaseAppState {
|
||||
|
||||
private static final float Y_OFFSET = 0.6f;
|
||||
|
||||
private Camera cam;
|
||||
private Node guiNode;
|
||||
private WorldItemsState worldItems;
|
||||
private Camera cam;
|
||||
private Node guiNode;
|
||||
private WorldItemsState worldItems;
|
||||
private WorldInteractableState worldInteractables;
|
||||
private WorldNpcsState worldNpcs;
|
||||
private DialogHudState dialogHud;
|
||||
|
||||
private BitmapText labelText;
|
||||
|
||||
@@ -47,9 +51,14 @@ public class InteractionHudState extends BaseAppState {
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
worldItems = getStateManager().getState(WorldItemsState.class);
|
||||
worldItems = getStateManager().getState(WorldItemsState.class);
|
||||
worldInteractables = getStateManager().getState(WorldInteractableState.class);
|
||||
worldNpcs = getStateManager().getState(WorldNpcsState.class);
|
||||
dialogHud = getStateManager().getState(DialogHudState.class);
|
||||
if (worldItems == null)
|
||||
log.warn("[InteractionHud] WorldItemsState nicht gefunden.");
|
||||
if (worldInteractables == null)
|
||||
log.warn("[InteractionHud] WorldInteractableState nicht gefunden.");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,40 +75,54 @@ public class InteractionHudState extends BaseAppState {
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (worldItems == null) {
|
||||
// Label ausblenden wenn Dialog aktiv
|
||||
if (dialogHud != null && dialogHud.isActive()) {
|
||||
labelText.setCullHint(Spatial.CullHint.Always);
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = worldItems.getHoveredIdx();
|
||||
if (idx < 0) {
|
||||
String labelKey = null;
|
||||
Vector3f worldPos = null;
|
||||
|
||||
// NPC hat höchste Priorität, dann Interactable, dann Item
|
||||
if (worldNpcs != null) {
|
||||
String key = worldNpcs.getHoveredLabelKey();
|
||||
if (key != null && !key.isBlank()) {
|
||||
labelKey = key;
|
||||
worldPos = worldNpcs.getHoveredWorldPos();
|
||||
}
|
||||
}
|
||||
|
||||
// Interactable hat Vorrang vor Item
|
||||
if (labelKey == null && worldInteractables != null) {
|
||||
String key = worldInteractables.getHoveredLabelKey();
|
||||
if (key != null && !key.isBlank()) {
|
||||
labelKey = key;
|
||||
worldPos = worldInteractables.getHoveredWorldPos();
|
||||
}
|
||||
}
|
||||
|
||||
if (labelKey == null && worldItems != null && worldItems.getHoveredIdx() >= 0) {
|
||||
String key = worldItems.getHoveredLabelKey();
|
||||
if (key != null && !key.isBlank()) {
|
||||
labelKey = key;
|
||||
worldPos = worldItems.getHoveredWorldPos();
|
||||
}
|
||||
}
|
||||
|
||||
if (labelKey == null || worldPos == null) {
|
||||
labelText.setCullHint(Spatial.CullHint.Always);
|
||||
return;
|
||||
}
|
||||
|
||||
String name = worldItems.getHoveredItemName();
|
||||
if (name == null) {
|
||||
labelText.setCullHint(Spatial.CullHint.Always);
|
||||
return;
|
||||
}
|
||||
|
||||
// Weltposition des Items → Bildschirmkoordinate
|
||||
Node itemsRoot = worldItems.getItemsRoot();
|
||||
if (idx >= itemsRoot.getQuantity()) {
|
||||
labelText.setCullHint(Spatial.CullHint.Always);
|
||||
return;
|
||||
}
|
||||
Spatial target = itemsRoot.getChild(idx);
|
||||
Vector3f worldPos = target.getWorldTranslation().add(0f, Y_OFFSET, 0f);
|
||||
Vector3f screenV = cam.getScreenCoordinates(worldPos);
|
||||
|
||||
// Hinter der Kamera → nicht anzeigen
|
||||
Vector3f screenV = cam.getScreenCoordinates(worldPos.add(0f, Y_OFFSET, 0f));
|
||||
if (screenV.z >= 1f) {
|
||||
labelText.setCullHint(Spatial.CullHint.Always);
|
||||
return;
|
||||
}
|
||||
|
||||
labelText.setText(name);
|
||||
String text = TextResolver.get().resolveId(labelKey);
|
||||
labelText.setText(text);
|
||||
float textW = labelText.getLineWidth();
|
||||
labelText.setLocalTranslation(screenV.x - textW * 0.5f, screenV.y, 1f);
|
||||
labelText.setCullHint(Spatial.CullHint.Inherit);
|
||||
|
||||
@@ -20,9 +20,13 @@ import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import com.jme3.post.FilterPostProcessor;
|
||||
import com.jme3.texture.Texture;
|
||||
import de.blight.common.model.*;
|
||||
import de.blight.game.config.KeyBindings;
|
||||
import de.blight.game.config.MenuCanvas;
|
||||
import de.blight.game.config.NinePatch;
|
||||
import de.blight.game.post.GaussianBlurFilter;
|
||||
import de.blight.game.scene.WorldScene;
|
||||
|
||||
import java.util.*;
|
||||
@@ -82,6 +86,9 @@ public class InventoryState extends BaseAppState {
|
||||
private Node guiNode;
|
||||
private Node panel;
|
||||
private Node gridNode;
|
||||
private Node bgLayer;
|
||||
private Node canvasNode;
|
||||
private GaussianBlurFilter blurFilter;
|
||||
|
||||
// ── Daten ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,7 +144,14 @@ public class InventoryState extends BaseAppState {
|
||||
app.getInputManager().addListener(scrollListener, MAP_SCROLL_UP, MAP_SCROLL_DN);
|
||||
app.getInputManager().addListener(clickListener, MAP_CLICK);
|
||||
WorldScene ws = app.getStateManager().getState(WorldScene.class);
|
||||
if (ws != null) ws.setPaused(true);
|
||||
if (ws != null) {
|
||||
ws.setPaused(true);
|
||||
FilterPostProcessor fpp = ws.getSharedFPP();
|
||||
if (fpp != null) {
|
||||
blurFilter = new GaussianBlurFilter(6f);
|
||||
fpp.addFilter(blurFilter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -147,7 +161,14 @@ public class InventoryState extends BaseAppState {
|
||||
app.getInputManager().removeListener(clickListener);
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
WorldScene ws = app.getStateManager().getState(WorldScene.class);
|
||||
if (ws != null) ws.setPaused(false);
|
||||
if (ws != null) {
|
||||
ws.setPaused(false);
|
||||
if (blurFilter != null) {
|
||||
FilterPostProcessor fpp = ws.getSharedFPP();
|
||||
if (fpp != null) fpp.removeFilter(blurFilter);
|
||||
blurFilter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -173,10 +194,10 @@ public class InventoryState extends BaseAppState {
|
||||
|
||||
private final ActionListener clickListener = (name, pressed, tpf) -> {
|
||||
if (!pressed || panel == null || tabBounds == null) return;
|
||||
Vector2f c = app.getInputManager().getCursorPosition();
|
||||
float[] v = toVirtual(app.getInputManager().getCursorPosition());
|
||||
for (int i = 0; i < tabBounds.length; i++) {
|
||||
float[] b = tabBounds[i];
|
||||
if (c.x >= b[0] && c.x <= b[0] + b[2] && c.y >= b[1] && c.y <= b[1] + b[3]) {
|
||||
if (v[0] >= b[0] && v[0] <= b[0] + b[2] && v[1] >= b[1] && v[1] <= b[1] + b[3]) {
|
||||
switchTab(tabOrder[i]);
|
||||
return;
|
||||
}
|
||||
@@ -186,8 +207,13 @@ public class InventoryState extends BaseAppState {
|
||||
// ── Haupt-Panel aufbauen ──────────────────────────────────────────────────
|
||||
|
||||
private void buildPanel() {
|
||||
float sw = app.getCamera().getWidth();
|
||||
float sh = app.getCamera().getHeight();
|
||||
float sw = MenuCanvas.REF_W;
|
||||
float sh = MenuCanvas.REF_H;
|
||||
|
||||
bgLayer = MenuCanvas.createBgLayer(assetManager, app.getCamera());
|
||||
canvasNode = MenuCanvas.createCanvas(app.getCamera());
|
||||
guiNode.attachChild(bgLayer);
|
||||
guiNode.attachChild(canvasNode);
|
||||
|
||||
// Panelgröße: 5 Spalten + Ränder
|
||||
float pw = COLS * (CELL_W + CELL_GAP) + CELL_GAP + 2 * PAD;
|
||||
@@ -196,8 +222,7 @@ public class InventoryState extends BaseAppState {
|
||||
float py = (sh - ph) / 2f;
|
||||
|
||||
panel = new Node("inv-panel");
|
||||
quad(panel, 0, 0, sw, sh, COL_OVERLAY, -20); // Verdunkelung
|
||||
quad(panel, px, py, pw, ph, COL_PANEL, -19); // Hauptpanel
|
||||
panel.attachChild(NinePatch.panel(assetManager).build(px, py, pw, ph, -19));
|
||||
quad(panel, px, py + ph - HDR_H, pw, HDR_H, COL_HDR, -18); // Header-Balken
|
||||
|
||||
// Titel
|
||||
@@ -213,7 +238,7 @@ public class InventoryState extends BaseAppState {
|
||||
// Tabs aufbauen
|
||||
buildTabs(px, py, pw, ph);
|
||||
|
||||
guiNode.attachChild(panel);
|
||||
canvasNode.attachChild(panel);
|
||||
}
|
||||
|
||||
private void buildTabs(float px, float py, float pw, float ph) {
|
||||
@@ -229,7 +254,8 @@ public class InventoryState extends BaseAppState {
|
||||
for (int i = 0; i < tabOrder.length; i++) {
|
||||
boolean active = tabOrder[i] == activeTab;
|
||||
float tx = tabX0 + i * tabW;
|
||||
quad(panel, tx, tabY, tabW - 4, TAB_H, active ? COL_TAB_ON : COL_TAB_OFF, -18);
|
||||
NinePatch tabPatch = active ? NinePatch.tabActive(assetManager) : NinePatch.tabInactive(assetManager);
|
||||
panel.attachChild(tabPatch.build(tx, tabY, tabW - 4, TAB_H, -18));
|
||||
BitmapText lbl = txt(catLabel(tabOrder[i]), 13, active ? COL_WHITE : COL_MUTED);
|
||||
lbl.setLocalTranslation(tx + (tabW - 4 - lbl.getLineWidth()) / 2f, tabY + TAB_H - 8, -17);
|
||||
panel.attachChild(lbl);
|
||||
@@ -341,9 +367,7 @@ public class InventoryState extends BaseAppState {
|
||||
// ── Einzel-Zelle ──────────────────────────────────────────────────────────
|
||||
|
||||
private void buildCell(Node parent, Item item, int count, float x, float y) {
|
||||
// Zell-Hintergrund + Rand
|
||||
quad(parent, x, y, CELL_W, CELL_H, COL_CELL_FRAME, -18);
|
||||
quad(parent, x + 1, y + 1, CELL_W - 2, CELL_H - 2, COL_CELL, -17);
|
||||
parent.attachChild(NinePatch.itemCell(assetManager).build(x, y, CELL_W, CELL_H, -18));
|
||||
|
||||
// Thumbnail
|
||||
float thumbX = x + (CELL_W - THUMB_SZ) / 2f;
|
||||
@@ -415,8 +439,18 @@ public class InventoryState extends BaseAppState {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private float[] toVirtual(Vector2f screen) {
|
||||
float scale = Math.min(app.getCamera().getWidth() / MenuCanvas.REF_W,
|
||||
app.getCamera().getHeight() / MenuCanvas.REF_H);
|
||||
float ox = (app.getCamera().getWidth() - MenuCanvas.REF_W * scale) / 2f;
|
||||
float oy = (app.getCamera().getHeight() - MenuCanvas.REF_H * scale) / 2f;
|
||||
return new float[]{ (screen.x - ox) / scale, (screen.y - oy) / scale };
|
||||
}
|
||||
|
||||
private void destroyPanel() {
|
||||
if (panel != null) { guiNode.detachChild(panel); panel = null; gridNode = null; }
|
||||
if (bgLayer != null) { guiNode.detachChild(bgLayer); bgLayer = null; }
|
||||
if (canvasNode != null) { guiNode.detachChild(canvasNode); canvasNode = null; }
|
||||
panel = null; gridNode = null;
|
||||
}
|
||||
|
||||
private Geometry quad(Node parent, float x, float y, float w, float h, ColorRGBA col, float z) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import de.blight.game.control.PlayerInputControl;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Neues-Spiel-Intro: Schwarzer Bildschirm → Einblenden (Ton + Bild) → REVIVE-Animation → IDLE.
|
||||
*
|
||||
* Phasen:
|
||||
* BLACK (3 s) – voller schwarzer Schirm, keine Eingaben (IDLE läuft, nicht sichtbar)
|
||||
* FADING_IN (3 s) – Alpha und Listener-Lautstärke 0→1
|
||||
* REVIVE_PLAYING – schwarzer Schirm weg, Animation läuft durch
|
||||
* DONE – Eingaben freigegeben, State entfernt sich selbst
|
||||
*/
|
||||
public class NewGameIntroState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NewGameIntroState.class);
|
||||
|
||||
private static final float BLACK_DURATION = 3f;
|
||||
private static final float FADE_DURATION = 3f;
|
||||
|
||||
private enum Phase { BLACK, FADING_IN, REVIVE_PLAYING, DONE }
|
||||
|
||||
private final PlayerInputControl playerInput;
|
||||
private final String reviveClip;
|
||||
private final float reviveLength;
|
||||
|
||||
private SimpleApplication app;
|
||||
private Geometry overlay;
|
||||
private Material overlayMat;
|
||||
|
||||
private Phase phase;
|
||||
private float timer;
|
||||
|
||||
public NewGameIntroState(PlayerInputControl playerInput, String reviveClip, float reviveLength) {
|
||||
this.playerInput = playerInput;
|
||||
this.reviveClip = reviveClip;
|
||||
this.reviveLength = reviveLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application application) {
|
||||
app = (SimpleApplication) application;
|
||||
|
||||
float w = app.getCamera().getWidth();
|
||||
float h = app.getCamera().getHeight();
|
||||
Quad quad = new Quad(w, h);
|
||||
overlay = new Geometry("intro_overlay", quad);
|
||||
overlayMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
overlayMat.setColor("Color", new ColorRGBA(0f, 0f, 0f, 1f));
|
||||
overlayMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
overlay.setMaterial(overlayMat);
|
||||
overlay.setQueueBucket(RenderQueue.Bucket.Gui);
|
||||
app.getGuiNode().attachChild(overlay);
|
||||
|
||||
app.getListener().setVolume(0f);
|
||||
|
||||
if (reviveClip != null) {
|
||||
playerInput.blockForIntro();
|
||||
phase = Phase.BLACK;
|
||||
timer = BLACK_DURATION;
|
||||
log.info("[NewGameIntro] Gestartet – REVIVE='{}' ({} s), Inputs blockiert", reviveClip, reviveLength);
|
||||
} else {
|
||||
log.warn("[NewGameIntro] Kein REVIVE-Clip – Intro übersprungen.");
|
||||
app.getGuiNode().detachChild(overlay);
|
||||
app.getListener().setVolume(1f);
|
||||
playerInput.unblockInputs();
|
||||
phase = Phase.DONE;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
switch (phase) {
|
||||
case BLACK -> {
|
||||
timer -= tpf;
|
||||
if (timer <= 0f) {
|
||||
// REVIVE unmittelbar vor dem Einblenden einfrieren
|
||||
playerInput.startFrozenRevive(reviveClip);
|
||||
phase = Phase.FADING_IN;
|
||||
timer = FADE_DURATION;
|
||||
log.info("[NewGameIntro] BLACK fertig – REVIVE eingefroren, starte FADING_IN ({} s)", FADE_DURATION);
|
||||
}
|
||||
}
|
||||
case FADING_IN -> {
|
||||
timer -= tpf;
|
||||
float alpha = Math.max(0f, timer / FADE_DURATION);
|
||||
overlayMat.setColor("Color", new ColorRGBA(0f, 0f, 0f, alpha));
|
||||
app.getListener().setVolume(1f - alpha);
|
||||
|
||||
if (timer <= 0f) {
|
||||
app.getGuiNode().detachChild(overlay);
|
||||
overlay = null;
|
||||
app.getListener().setVolume(1f);
|
||||
playerInput.unfreezeRevive();
|
||||
timer = reviveLength;
|
||||
phase = Phase.REVIVE_PLAYING;
|
||||
log.info("[NewGameIntro] Eingeblendet – REVIVE läuft ({} s)", reviveLength);
|
||||
}
|
||||
}
|
||||
case REVIVE_PLAYING -> {
|
||||
timer -= tpf;
|
||||
if (timer <= 0f) {
|
||||
playerInput.unblockInputs();
|
||||
phase = Phase.DONE;
|
||||
log.info("[NewGameIntro] REVIVE abgeschlossen – Eingaben freigegeben");
|
||||
}
|
||||
}
|
||||
case DONE -> getApplication().getStateManager().detach(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application application) {
|
||||
if (overlay != null && overlay.getParent() != null) {
|
||||
app.getGuiNode().detachChild(overlay);
|
||||
}
|
||||
app.getListener().setVolume(1f);
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.audio.AudioData;
|
||||
import com.jme3.audio.AudioNode;
|
||||
import com.jme3.math.Vector3f;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Spielt Wellensounds positional ab. Die AudioNodes werden EINMALIG nach dem
|
||||
* ersten gültigen Scan positioniert (vermeidet den initialen Sprung von (0,0,0)
|
||||
* zur Ozean-Kante, der OpenAL-Knistern verursacht). Danach wird die Position
|
||||
* nur noch alle SCAN_INTERVAL Sekunden aktualisiert, wenn sich der Ozean
|
||||
* signifikant verschoben hat.
|
||||
*/
|
||||
public class OceanSoundState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OceanSoundState.class);
|
||||
|
||||
private static final float MAX_DIST = 60f;
|
||||
private static final float REF_DIST = 8f;
|
||||
private static final float WIND_THRESHOLD = 20f;
|
||||
private static final float FADE_RATE = 1f / 3f;
|
||||
private static final float SCAN_INTERVAL = 0.25f;
|
||||
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
|
||||
|
||||
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 final TerrainChunkState terrain;
|
||||
|
||||
private SimpleApplication app;
|
||||
private AudioNode nodeCalmSound;
|
||||
private AudioNode nodeStormySound;
|
||||
|
||||
private final Vector3f playerPos = new Vector3f();
|
||||
private final Vector3f targetPos = 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 playing = false;
|
||||
|
||||
private float calmVol = 0f;
|
||||
private float stormyVol = 0f;
|
||||
private float scanTimer = 0f;
|
||||
private boolean oceanInRange = false;
|
||||
private float oceanDist = Float.MAX_VALUE;
|
||||
|
||||
public OceanSoundState(TerrainChunkState terrain) {
|
||||
this.terrain = terrain;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initialize(Application application) {
|
||||
app = (SimpleApplication) application;
|
||||
nodeCalmSound = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm");
|
||||
nodeStormySound = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy");
|
||||
// Noch NICHT abspielen – erst wenn wir eine gültige Position haben (firstScan).
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application application) {
|
||||
stop(nodeCalmSound);
|
||||
stop(nodeStormySound);
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
public void setPlayerPosition(Vector3f pos) {
|
||||
playerPos.set(pos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (nodeCalmSound == null && nodeStormySound == null) return;
|
||||
|
||||
// Periodisch nächste Ozean-Position berechnen
|
||||
scanTimer -= tpf;
|
||||
if (scanTimer <= 0f) {
|
||||
scanTimer = SCAN_INTERVAL;
|
||||
scanOceanSource();
|
||||
}
|
||||
|
||||
// Lautstärke
|
||||
WeatherState weather = getApplication().getStateManager().getState(WeatherState.class);
|
||||
float wind = weather != null ? weather.getWindSpeed() : 4f;
|
||||
|
||||
AudioSettingsState audioSettings = getApplication().getStateManager().getState(AudioSettingsState.class);
|
||||
float scale = audioSettings != null ? audioSettings.effectiveAmbient() : 0.5f;
|
||||
|
||||
float distScale = oceanInRange
|
||||
? Math.max(0f, 1f - Math.max(0f, oceanDist - REF_DIST) / (MAX_DIST - REF_DIST))
|
||||
: 0f;
|
||||
|
||||
float calmTarget = (oceanInRange && wind < WIND_THRESHOLD) ? scale * distScale : 0f;
|
||||
float stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale : 0f;
|
||||
|
||||
calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf);
|
||||
stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf);
|
||||
|
||||
if (nodeCalmSound != null) nodeCalmSound.setVolume(calmVol);
|
||||
if (nodeStormySound != null) nodeStormySound.setVolume(stormyVol);
|
||||
}
|
||||
|
||||
// ── Scan ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void scanOceanSource() {
|
||||
float px = playerPos.x;
|
||||
float pz = playerPos.z;
|
||||
|
||||
if (terrain.getHeightAt(px, pz) < 0f) {
|
||||
oceanInRange = true;
|
||||
oceanDist = 0f;
|
||||
applyTarget(px, pz);
|
||||
return;
|
||||
}
|
||||
|
||||
float minDist = Float.MAX_VALUE;
|
||||
float bestX = px, bestZ = pz;
|
||||
|
||||
for (int d = 0; d < 8; d++) {
|
||||
float dx = DIR_X[d];
|
||||
float dz = DIR_Z[d];
|
||||
for (float r = SCAN_STEP; r <= MAX_DIST + SCAN_STEP; r += SCAN_STEP) {
|
||||
if (terrain.getHeightAt(px + dx * r, pz + dz * r) < 0f) {
|
||||
float dist = r - SCAN_STEP;
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
bestX = px + dx * r;
|
||||
bestZ = pz + dz * r;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minDist >= MAX_DIST) {
|
||||
oceanInRange = false;
|
||||
oceanDist = Float.MAX_VALUE;
|
||||
} else {
|
||||
oceanInRange = true;
|
||||
oceanDist = minDist;
|
||||
applyTarget(bestX, bestZ);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
targetPos.set(x, 0f, z);
|
||||
|
||||
if (firstScan) {
|
||||
// Beim ersten gültigen Scan: Nodes korrekt platzieren, dann erst abspielen.
|
||||
firstScan = false;
|
||||
nodePos.set(targetPos);
|
||||
if (nodeCalmSound != null) {
|
||||
app.getRootNode().attachChild(nodeCalmSound);
|
||||
nodeCalmSound.setLocalTranslation(nodePos);
|
||||
nodeCalmSound.play();
|
||||
}
|
||||
if (nodeStormySound != null) {
|
||||
app.getRootNode().attachChild(nodeStormySound);
|
||||
nodeStormySound.setLocalTranslation(nodePos);
|
||||
nodeStormySound.play();
|
||||
}
|
||||
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 AudioNode loadLoop(String path, String label) {
|
||||
try {
|
||||
AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Stream);
|
||||
n.setLooping(true);
|
||||
n.setVolume(0f);
|
||||
n.setPositional(true);
|
||||
n.setRefDistance(REF_DIST);
|
||||
n.setMaxDistance(MAX_DIST);
|
||||
return n;
|
||||
} catch (Exception e) {
|
||||
log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void stop(AudioNode node) {
|
||||
if (node != null) {
|
||||
node.stop();
|
||||
if (node.getParent() != null) app.getRootNode().detachChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
private static float approach(float cur, float target, float maxStep) {
|
||||
float d = target - cur;
|
||||
return Math.abs(d) <= maxStep ? target : cur + Math.signum(d) * maxStep;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.input.controls.MouseButtonTrigger;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
import com.jme3.scene.Spatial;
|
||||
import de.blight.common.PlacedModel;
|
||||
import de.blight.common.PlacedModelIO;
|
||||
@@ -82,7 +83,8 @@ public class WorldInteractableState extends BaseAppState {
|
||||
private record InteractableEntry(
|
||||
float worldX, float worldY, float worldZ,
|
||||
InteractableType type,
|
||||
String interactableId
|
||||
String interactableId,
|
||||
String labelKey
|
||||
) {}
|
||||
|
||||
private final List<InteractableEntry> entries = new ArrayList<>();
|
||||
@@ -98,6 +100,9 @@ public class WorldInteractableState extends BaseAppState {
|
||||
WALKING_BACK
|
||||
}
|
||||
|
||||
private Camera cam;
|
||||
private int hoveredIdx = -1;
|
||||
|
||||
private Phase phase = Phase.IDLE;
|
||||
private int targetIdx = -1;
|
||||
private float walkTimer = 0f;
|
||||
@@ -122,13 +127,20 @@ public class WorldInteractableState extends BaseAppState {
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.inputManager = app.getInputManager();
|
||||
this.cam = app.getCamera();
|
||||
try {
|
||||
List<PlacedModel> models = PlacedModelIO.load();
|
||||
for (PlacedModel m : models) {
|
||||
if (m.interactableType() == null || m.interactableType().isBlank()) continue;
|
||||
InteractableType t = InteractableType.fromString(m.interactableType());
|
||||
if (t == InteractableType.BED || t == InteractableType.BENCH) {
|
||||
entries.add(new InteractableEntry(m.x(), m.y(), m.z(), t, m.interactableId()));
|
||||
if (t == InteractableType.BENCH) {
|
||||
Bench b = BenchIO.load(m.interactableId()).orElse(null);
|
||||
String lk = b != null ? b.getLabelKey() : "interactable.bench.name";
|
||||
entries.add(new InteractableEntry(m.x(), m.y(), m.z(), t, m.interactableId(), lk));
|
||||
} else if (t == InteractableType.BED) {
|
||||
Bed b = BedIO.load(m.interactableId()).orElse(null);
|
||||
String lk = b != null ? b.getLabelKey() : "interactable.bed.name";
|
||||
entries.add(new InteractableEntry(m.x(), m.y(), m.z(), t, m.interactableId(), lk));
|
||||
}
|
||||
}
|
||||
log.info("[WorldInteractable] {} Interactables geladen.", entries.size());
|
||||
@@ -158,6 +170,7 @@ public class WorldInteractableState extends BaseAppState {
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
updateHover();
|
||||
if (phase == Phase.WALKING_BACK) walkTimer += tpf;
|
||||
if (benchPendingId != null) {
|
||||
benchPendingTimer += tpf;
|
||||
@@ -183,6 +196,58 @@ public class WorldInteractableState extends BaseAppState {
|
||||
startGetUp();
|
||||
};
|
||||
|
||||
// ── Hover-Erkennung ───────────────────────────────────────────────────────
|
||||
|
||||
private void updateHover() {
|
||||
if (phase != Phase.IDLE || cam == null) {
|
||||
hoveredIdx = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3f charPos = physicsChar.getPhysicsLocation();
|
||||
float centerX = cam.getWidth() * 0.5f;
|
||||
float hMargin = cam.getWidth() * 0.10f;
|
||||
float screenH = cam.getHeight();
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestDist = Float.MAX_VALUE;
|
||||
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
InteractableEntry e = entries.get(i);
|
||||
float dx = e.worldX() - charPos.x;
|
||||
float dz = e.worldZ() - charPos.z;
|
||||
float d = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
float range = (e.type() == InteractableType.BENCH) ? BENCH_RANGE : BED_RANGE;
|
||||
if (d > range) continue;
|
||||
|
||||
Vector3f worldPos = new Vector3f(e.worldX(), e.worldY(), e.worldZ());
|
||||
Vector3f camToEntry = worldPos.subtract(cam.getLocation());
|
||||
if (camToEntry.dot(cam.getDirection()) <= 0f) continue;
|
||||
|
||||
Vector3f screen = cam.getScreenCoordinates(worldPos);
|
||||
if (Math.abs(screen.x - centerX) > hMargin) continue;
|
||||
if (screen.y < 0f || screen.y > screenH) continue;
|
||||
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
hoveredIdx = bestIdx;
|
||||
}
|
||||
|
||||
public String getHoveredLabelKey() {
|
||||
if (hoveredIdx < 0 || hoveredIdx >= entries.size()) return null;
|
||||
return entries.get(hoveredIdx).labelKey();
|
||||
}
|
||||
|
||||
public Vector3f getHoveredWorldPos() {
|
||||
if (hoveredIdx < 0 || hoveredIdx >= entries.size()) return null;
|
||||
InteractableEntry e = entries.get(hoveredIdx);
|
||||
return new Vector3f(e.worldX(), e.worldY(), e.worldZ());
|
||||
}
|
||||
|
||||
// ── Suche nächstes Interactable ───────────────────────────────────────────
|
||||
|
||||
private int findNearestInRange() {
|
||||
|
||||
@@ -342,6 +342,19 @@ public class WorldItemsState extends BaseAppState {
|
||||
return def != null ? def.getDisplayText() : pi.itemId();
|
||||
}
|
||||
|
||||
public String getHoveredLabelKey() {
|
||||
if (hoveredIdx < 0 || hoveredIdx >= items.size()) return null;
|
||||
PlacedItem pi = items.get(hoveredIdx);
|
||||
Item def = itemDefs.get(pi.itemId());
|
||||
return def != null ? def.getLabelKey() : (pi.itemId() != null ? "item." + pi.itemId() + ".name" : "");
|
||||
}
|
||||
|
||||
public Vector3f getHoveredWorldPos() {
|
||||
if (hoveredIdx < 0 || hoveredIdx >= items.size()) return null;
|
||||
PlacedItem pi = items.get(hoveredIdx);
|
||||
return new Vector3f(pi.x(), pi.y() + 0.25f, pi.z());
|
||||
}
|
||||
|
||||
// ── Pickup-Sequenz ────────────────────────────────────────────────────────
|
||||
|
||||
private void updateWalking(float tpf) {
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.asset.AssetManager;
|
||||
import com.jme3.asset.plugins.FileLocator;
|
||||
import com.jme3.bullet.control.CharacterControl;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.math.*;
|
||||
import com.jme3.renderer.Camera;
|
||||
import com.jme3.scene.*;
|
||||
import com.jme3.scene.shape.Box;
|
||||
import de.blight.common.PlacedModel;
|
||||
import de.blight.common.PlacedModelIO;
|
||||
import de.blight.common.model.*;
|
||||
import de.blight.game.animation.AnimationLibrary;
|
||||
import de.blight.game.config.KeyBindings;
|
||||
import de.blight.game.control.PlayerInputControl;
|
||||
import de.blight.game.state.TerrainChunkState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Platziert NPCs in der Spielwelt anhand ihrer Tagesablauf-Routinen.
|
||||
*
|
||||
* Jeder NPC hat eine aktive Routine mit zeitgebundenen Aktivitäten.
|
||||
* Der State ermittelt fortlaufend die Soll-Position jedes NPCs für die aktuelle
|
||||
* Spielstunde und lädt/entlädt den visuellen Repräsentanten je nach Spieler-Nähe.
|
||||
*/
|
||||
public class WorldNpcsState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WorldNpcsState.class);
|
||||
|
||||
private static final float LOAD_RADIUS = 80f;
|
||||
private static final float UNLOAD_RADIUS = 100f;
|
||||
private static final float CHECK_INTERVAL = 2f;
|
||||
private static final float INTERACT_RANGE = 4f;
|
||||
private static final String INTERACT_ACTION = "InteractNpc";
|
||||
|
||||
// ── Abhängigkeiten ────────────────────────────────────────────────────────
|
||||
|
||||
private final KeyBindings keyBindings;
|
||||
private final CharacterControl physicsChar;
|
||||
private final PlayerInputControl playerInput;
|
||||
private final MainCharacter mainCharacter;
|
||||
|
||||
private SimpleApplication app;
|
||||
private AssetManager assets;
|
||||
private Camera cam;
|
||||
private Node rootNode;
|
||||
private Node npcsRoot;
|
||||
private DayNightState dayNight;
|
||||
private TerrainChunkState terrainChunks;
|
||||
|
||||
// ── NPC-Daten ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Alle NPCs die im Spiel existieren (geladen aus .character-Dateien). */
|
||||
private final List<NPC> allNpcs = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Position-Lookup für objekt-gebundene Aktivitäten (WORK, SLEEP, SIT@Interactable).
|
||||
* Schlüssel: interactableId des PlacedModel (= UUID der Bank/Bett/etc.).
|
||||
*/
|
||||
private final Map<String, Vector3f> interactablePositions = new HashMap<>();
|
||||
|
||||
/** Aktuell in der Welt gespawnte NPCs. */
|
||||
private final List<SpawnedNpc> spawned = new ArrayList<>();
|
||||
|
||||
private record SpawnedNpc(NPC npc, Spatial visual, float worldX, float worldZ) {}
|
||||
|
||||
// ── Periodischer Check ────────────────────────────────────────────────────
|
||||
|
||||
private float checkTimer = CHECK_INTERVAL; // sofortiger erster Check
|
||||
private int lastHour = -1;
|
||||
|
||||
// ── Hover + Dialog ────────────────────────────────────────────────────────
|
||||
|
||||
private int hoveredIdx = -1;
|
||||
private boolean dialogActive = false;
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
public WorldNpcsState(KeyBindings keyBindings, CharacterControl physicsChar,
|
||||
PlayerInputControl playerInput, MainCharacter mainCharacter) {
|
||||
this.keyBindings = keyBindings;
|
||||
this.physicsChar = physicsChar;
|
||||
this.playerInput = playerInput;
|
||||
this.mainCharacter = mainCharacter;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.assets = app.getAssetManager();
|
||||
this.cam = app.getCamera();
|
||||
this.rootNode = this.app.getRootNode();
|
||||
this.npcsRoot = new Node("npcsRoot");
|
||||
|
||||
try {
|
||||
assets.registerLocator(
|
||||
AnimationLibrary.findAssetRoot().toAbsolutePath().toString(),
|
||||
FileLocator.class);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
dayNight = getStateManager().getState(DayNightState.class);
|
||||
terrainChunks = getStateManager().getState(TerrainChunkState.class);
|
||||
|
||||
loadAllNpcs();
|
||||
buildInteractablePositions();
|
||||
|
||||
rootNode.attachChild(npcsRoot);
|
||||
|
||||
app.getInputManager().addMapping(INTERACT_ACTION, new KeyTrigger(keyBindings.interact));
|
||||
app.getInputManager().addListener(interactListener, INTERACT_ACTION);
|
||||
|
||||
checkTimer = CHECK_INTERVAL; // sofortiger erster Check im nächsten Frame
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDisable() {
|
||||
allNpcs.clear();
|
||||
interactablePositions.clear();
|
||||
spawned.clear();
|
||||
npcsRoot.detachAllChildren();
|
||||
npcsRoot.removeFromParent();
|
||||
hoveredIdx = -1;
|
||||
dialogActive = false;
|
||||
try { app.getInputManager().removeListener(interactListener); } catch (Exception ignored) {}
|
||||
try { app.getInputManager().deleteMapping(INTERACT_ACTION); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application app) {}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
checkTimer += tpf;
|
||||
|
||||
int currentHour = dayNight != null ? dayNight.getDayTime().getHour() : 12;
|
||||
boolean hourChanged = currentHour != lastHour;
|
||||
|
||||
if (checkTimer >= CHECK_INTERVAL || hourChanged) {
|
||||
checkTimer = 0f;
|
||||
lastHour = currentHour;
|
||||
updateSpawnedNpcs(currentHour);
|
||||
}
|
||||
|
||||
if (!dialogActive) {
|
||||
updateHover();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spawn-Logik ───────────────────────────────────────────────────────────
|
||||
|
||||
private void updateSpawnedNpcs(int currentHour) {
|
||||
Vector3f playerPos = physicsChar.getPhysicsLocation();
|
||||
|
||||
// Bestehende NPCs prüfen: Position aktualisieren oder entladen
|
||||
List<SpawnedNpc> toRemove = new ArrayList<>();
|
||||
for (SpawnedNpc s : spawned) {
|
||||
Vector3f pos = resolveRoutinePosition(s.npc(), currentHour);
|
||||
float dist = pos != null ? dist2d(playerPos, pos) : Float.MAX_VALUE;
|
||||
|
||||
if (pos == null || dist > UNLOAD_RADIUS) {
|
||||
s.visual().removeFromParent();
|
||||
toRemove.add(s);
|
||||
} else {
|
||||
// Position aktualisieren wenn sich die Stunde geändert hat
|
||||
s.visual().setLocalTranslation(pos);
|
||||
}
|
||||
}
|
||||
spawned.removeAll(toRemove);
|
||||
|
||||
// IDs der bereits gespawnten NPCs für schnelle Prüfung
|
||||
Set<String> spawnedIds = new HashSet<>();
|
||||
for (SpawnedNpc s : spawned) {
|
||||
spawnedIds.add(s.npc().getCharacterId());
|
||||
}
|
||||
|
||||
// Neue NPCs einladen wenn in Reichweite
|
||||
for (NPC npc : allNpcs) {
|
||||
if (spawnedIds.contains(npc.getCharacterId())) continue;
|
||||
Vector3f pos = resolveRoutinePosition(npc, currentHour);
|
||||
if (pos == null) continue;
|
||||
if (dist2d(playerPos, pos) > LOAD_RADIUS) continue;
|
||||
|
||||
Spatial vis = buildVisual(npc);
|
||||
vis.setLocalTranslation(pos);
|
||||
npcsRoot.attachChild(vis);
|
||||
spawned.add(new SpawnedNpc(npc, vis, pos.x, pos.z));
|
||||
log.debug("[WorldNpcs] NPC '{}' gespawnt bei ({}, {})", npc.getCharacterId(), pos.x, pos.z);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt die Weltposition eines NPCs für die angegebene Stunde
|
||||
* anhand seiner aktiven Routine.
|
||||
* Gibt null zurück wenn keine Position ermittelbar (keine Routine, keine Aktivität,
|
||||
* oder Aktivitäts-Objekt nicht gefunden).
|
||||
*/
|
||||
private Vector3f resolveRoutinePosition(NPC npc, int hour) {
|
||||
NpcRoutine routine = npc.getActiveRoutine();
|
||||
if (routine == null || routine.getBlocks() == null) return null;
|
||||
|
||||
RoutineBlock block = null;
|
||||
for (RoutineBlock b : routine.getBlocks()) {
|
||||
if (b.covers(hour)) { block = b; break; }
|
||||
}
|
||||
if (block == null || block.getActivity() == null) return null;
|
||||
|
||||
RoutineActivity act = block.getActivity();
|
||||
return switch (act.getType()) {
|
||||
case STAND, TALK -> worldPointToVec(act.getPosition());
|
||||
case SIT -> {
|
||||
if (act.getObjectUuid() != null && !act.getObjectUuid().isBlank()) {
|
||||
yield interactablePositions.get(act.getObjectUuid());
|
||||
}
|
||||
yield worldPointToVec(act.getPosition());
|
||||
}
|
||||
case PATROL -> {
|
||||
List<WorldPoint> wps = act.getWaypoints();
|
||||
if (wps == null || wps.isEmpty()) yield null;
|
||||
// Einfache Näherung: ersten Wegpunkt nehmen
|
||||
// TODO: NPC entlang der Wegpunkte animieren
|
||||
yield worldPointToVec(wps.get(0));
|
||||
}
|
||||
case WORK, SLEEP -> {
|
||||
if (act.getObjectUuid() == null || act.getObjectUuid().isBlank()) yield null;
|
||||
yield interactablePositions.get(act.getObjectUuid());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hover-Erkennung ───────────────────────────────────────────────────────
|
||||
|
||||
private void updateHover() {
|
||||
if (cam == null) { hoveredIdx = -1; return; }
|
||||
|
||||
Vector3f charPos = physicsChar.getPhysicsLocation();
|
||||
float centerX = cam.getWidth() * 0.5f;
|
||||
float hMargin = cam.getWidth() * 0.10f;
|
||||
float screenH = cam.getHeight();
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestDist = Float.MAX_VALUE;
|
||||
|
||||
for (int i = 0; i < spawned.size(); i++) {
|
||||
SpawnedNpc s = spawned.get(i);
|
||||
Vector3f pos = s.visual().getLocalTranslation();
|
||||
|
||||
float dx = pos.x - charPos.x;
|
||||
float dz = pos.z - charPos.z;
|
||||
float d = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (d > INTERACT_RANGE) continue;
|
||||
|
||||
Vector3f headPos = pos.add(0f, 1.5f, 0f);
|
||||
Vector3f camToHead = headPos.subtract(cam.getLocation());
|
||||
if (camToHead.dot(cam.getDirection()) <= 0f) continue;
|
||||
|
||||
Vector3f screen = cam.getScreenCoordinates(headPos);
|
||||
if (Math.abs(screen.x - centerX) > hMargin) continue;
|
||||
if (screen.y < 0f || screen.y > screenH) continue;
|
||||
|
||||
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||
}
|
||||
|
||||
hoveredIdx = bestIdx;
|
||||
}
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────────
|
||||
|
||||
private final ActionListener interactListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed || dialogActive || hoveredIdx < 0) return;
|
||||
startDialog(hoveredIdx);
|
||||
};
|
||||
|
||||
private void startDialog(int idx) {
|
||||
SpawnedNpc entry = spawned.get(idx);
|
||||
DialogHudState dialog = getApplication().getStateManager().getState(DialogHudState.class);
|
||||
if (dialog == null) return;
|
||||
|
||||
dialogActive = true;
|
||||
playerInput.lockInPlace();
|
||||
|
||||
dialog.startDialog(entry.npc(), mainCharacter, () -> {
|
||||
dialogActive = false;
|
||||
playerInput.unlockFromPlace();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Accessor für InteractionHudState ──────────────────────────────────────
|
||||
|
||||
public String getHoveredLabelKey() {
|
||||
if (hoveredIdx < 0 || hoveredIdx >= spawned.size()) return null;
|
||||
return spawned.get(hoveredIdx).npc().getLabelKey();
|
||||
}
|
||||
|
||||
public Vector3f getHoveredWorldPos() {
|
||||
if (hoveredIdx < 0 || hoveredIdx >= spawned.size()) return null;
|
||||
Vector3f pos = spawned.get(hoveredIdx).visual().getLocalTranslation();
|
||||
return pos.add(0f, 1.5f, 0f);
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
|
||||
private void loadAllNpcs() {
|
||||
allNpcs.clear();
|
||||
try {
|
||||
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
|
||||
for (GameCharacter gc : CharacterIO.loadAll(charDir)) {
|
||||
if (gc instanceof NPC npc) {
|
||||
allNpcs.add(npc);
|
||||
}
|
||||
}
|
||||
log.info("[WorldNpcs] {} NPCs mit Routinen geladen.", allNpcs.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("[WorldNpcs] Fehler beim Laden der NPCs: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void buildInteractablePositions() {
|
||||
interactablePositions.clear();
|
||||
try {
|
||||
List<PlacedModel> models = PlacedModelIO.load();
|
||||
for (PlacedModel m : models) {
|
||||
String id = m.interactableId();
|
||||
if (id != null && !id.isBlank()) {
|
||||
interactablePositions.put(id, new Vector3f(m.x(), m.y(), m.z()));
|
||||
}
|
||||
}
|
||||
log.debug("[WorldNpcs] {} Interactable-Positionen indexiert.", interactablePositions.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("[WorldNpcs] Fehler beim Laden der Objekt-Positionen: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3f worldPointToVec(WorldPoint p) {
|
||||
if (p == null) return null;
|
||||
float y = p.y;
|
||||
// y == 0 bedeutet "Terrain-Höhe" → per TerrainChunkState auflösen
|
||||
if (y == 0f && terrainChunks != null) {
|
||||
y = terrainChunks.getHeightAt(p.x, p.z);
|
||||
}
|
||||
return new Vector3f(p.x, y, p.z);
|
||||
}
|
||||
|
||||
private static float dist2d(Vector3f a, Vector3f b) {
|
||||
float dx = a.x - b.x;
|
||||
float dz = a.z - b.z;
|
||||
return (float) Math.sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
private Spatial buildVisual(NPC npc) {
|
||||
String modelPath = npc.getModelPath();
|
||||
if (modelPath != null && !modelPath.isBlank()) {
|
||||
try {
|
||||
Spatial model = assets.loadModel(modelPath);
|
||||
model.setName("npc_" + npc.getCharacterId());
|
||||
return model;
|
||||
} catch (Exception e) {
|
||||
log.warn("[WorldNpcs] Modell '{}' nicht ladbar – Platzhalter.", modelPath);
|
||||
}
|
||||
}
|
||||
// Platzhalter: Körper + Kopf
|
||||
Node ph = new Node("npc_" + npc.getCharacterId());
|
||||
Material bodyMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
bodyMat.setColor("Color", new ColorRGBA(0.5f, 0.7f, 1.0f, 1f));
|
||||
Geometry body = new Geometry("body", new Box(0.25f, 0.6f, 0.25f));
|
||||
body.setMaterial(bodyMat);
|
||||
body.setLocalTranslation(0f, 0.85f, 0f);
|
||||
Material headMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
headMat.setColor("Color", new ColorRGBA(1.0f, 0.85f, 0.7f, 1f));
|
||||
Geometry head = new Geometry("head", new Box(0.2f, 0.2f, 0.2f));
|
||||
head.setMaterial(headMat);
|
||||
head.setLocalTranslation(0f, 1.7f, 0f);
|
||||
ph.attachChild(body);
|
||||
ph.attachChild(head);
|
||||
return ph;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user