Weiter gebastelt

This commit is contained in:
2026-06-09 19:25:30 +02:00
parent 5e85051716
commit edb9cfc946
54 changed files with 1088 additions and 356 deletions

View File

@@ -3523,7 +3523,12 @@ public class EditorApp extends Application {
if (pStr.endsWith(".item")) {
MenuItem placeItem = new MenuItem("📍 Platzieren");
placeItem.setOnAction(ev -> activateItemPlacement(p));
ctx.getItems().addAll(placeItem, new SeparatorMenuItem());
MenuItem editItem = new MenuItem("✏ Im Item-Editor öffnen");
editItem.setOnAction(ev -> {
String itemId = p.getFileName().toString().replace(".item", "");
switchToItemEditor(itemId);
});
ctx.getItems().addAll(placeItem, editItem, new SeparatorMenuItem());
}
MenuItem reveal = new MenuItem("Im Dateisystem anzeigen");
@@ -4535,6 +4540,14 @@ public class EditorApp extends Application {
// Asset-Tree aktualisieren
input.refreshAssets = true;
// Thumbnail generieren (JME3-Thread liest das Flag und rendert)
java.nio.file.Path finalJ3o = category.isEmpty() ? absolutePath
: ASSET_ROOT.resolve("Models")
.resolve(java.nio.file.Path.of(category.replace('/', java.io.File.separatorChar)))
.resolve(name.isEmpty() ? absolutePath.getFileName().toString()
: name.replaceAll("[\\\\/:*?\"<>|]", "_") + ".j3o");
input.modelEditorThumbnailRequest = finalJ3o;
}
private void importLodFile(int slot) {
@@ -6610,6 +6623,10 @@ public class EditorApp extends Application {
}
private void switchToItemEditor() {
switchToItemEditor(null);
}
private void switchToItemEditor(String selectItemId) {
onF5 = null;
currentTool = "itemEditor";
ToolBar tb = new ToolBar();
@@ -6619,8 +6636,11 @@ public class EditorApp extends Application {
label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;");
tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label);
topBar.getChildren().set(1, tb);
root.setCenter(new de.blight.editor.ui.ItemEditorView(ASSET_ROOT.resolve("items")));
de.blight.editor.ui.ItemEditorView view =
new de.blight.editor.ui.ItemEditorView(ASSET_ROOT.resolve("items"));
root.setCenter(view);
root.setRight(null);
if (selectItemId != null) view.selectItem(selectItemId);
}
private void switchToQuestEditor() {

View File

@@ -587,6 +587,12 @@ public class SharedInput {
/** JFX → JME: Model-Editor schließen. */
public volatile boolean modelEditorCloseRequest = false;
/**
* JFX → JME: Thumbnail für das Modell unter diesem absoluten Pfad generieren.
* Wird nach dem Speichern im ModelEditor gesetzt; JME3 rendert + speichert Sidecar + UserData.
*/
public volatile java.nio.file.Path modelEditorThumbnailRequest = null;
/** JME → JFX: true wenn das geladene Modell eingebettete LOD-Kinder hat (kein separater Pfad nötig). */
public volatile boolean modelEditorHasEmbeddedLods = false;

View File

@@ -13,13 +13,18 @@ import com.jme3.scene.shape.Box;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.PlacedItem;
import de.blight.common.PlacedItemIO;
import de.blight.common.model.Item;
import de.blight.common.model.ItemIO;
import de.blight.editor.ProjectRoot;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Verwaltet die Platzierung von Items (Pickups) auf der Karte im Editor.
@@ -36,10 +41,11 @@ public class ItemPlacementState extends BaseAppState {
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedItem> items = new ArrayList<>();
private final List<Node> nodes = new ArrayList<>();
private Node itemRoot;
private Node previewNode;
private final List<PlacedItem> items = new ArrayList<>();
private final List<Node> nodes = new ArrayList<>();
private final Map<String, Item> itemDefs = new HashMap<>();
private Node itemRoot;
private Node previewNode;
public ItemPlacementState(SharedInput input) {
this.input = input;
@@ -58,6 +64,11 @@ public class ItemPlacementState extends BaseAppState {
this.assets = app.getAssetManager();
this.rootNode = this.app.getRootNode();
java.nio.file.Path itemDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources").resolve("items");
for (Item it : ItemIO.loadAll(itemDir)) {
if (it.getItemId() != null) itemDefs.put(it.getItemId(), it);
}
itemRoot = new Node("itemRoot");
previewNode = new Node("itemPreview");
previewNode.setCullHint(Spatial.CullHint.Always);
@@ -171,12 +182,28 @@ public class ItemPlacementState extends BaseAppState {
private Node buildItemNode(String itemId) {
Node n = new Node("item_" + itemId);
n.setUserData("itemId", itemId);
Item def = itemDefs.get(itemId);
if (def != null && def.getModelRef() != null
&& def.getModelRef().getPath() != null
&& !def.getModelRef().getPath().isBlank()) {
try {
Spatial model = assets.loadModel(def.getModelRef().getPath());
model.setName("itemModel_" + itemId);
n.attachChild(model);
return n;
} catch (Exception e) {
log.warn("[ItemPlacement] Modell für '{}' nicht ladbar: {}", itemId, e.getMessage());
}
}
// Fallback: goldener Würfel
Geometry g = new Geometry("itemGeo_" + itemId, new Box(0.15f, 0.15f, 0.15f));
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.82f, 0.1f, 1f));
g.setMaterial(mat);
n.attachChild(g);
n.setUserData("itemId", itemId);
return n;
}

View File

@@ -4,6 +4,9 @@ import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.Savable;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
@@ -15,6 +18,7 @@ import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.nio.file.Path;
/**
* Modell-Editor-Modus: zeigt ein einzelnes Modell in isolierter Vorschau
@@ -148,6 +152,13 @@ public class ModelEditorState extends BaseAppState {
applyPivot(input.modelEditorPivotY);
}
// Thumbnail auf Anforderung generieren
Path thumbReq = input.modelEditorThumbnailRequest;
if (thumbReq != null && modelWrapper != null) {
input.modelEditorThumbnailRequest = null;
generateThumbnail(thumbReq);
}
// Orbit-Kamera: Maus-Delta (Middle-Button zieht Kamera, Right-Button dreht Orbit)
int[] delta = input.consumeMouseDelta();
if (delta[0] != 0 || delta[1] != 0) {
@@ -401,5 +412,35 @@ public class ModelEditorState extends BaseAppState {
if (s instanceof Node n) n.getChildren().forEach(ModelEditorState::stripControls);
}
// ── Thumbnail ─────────────────────────────────────────────────────────────
private void generateThumbnail(Path j3oPath) {
if (modelWrapper == null) return;
try {
// Klon des aktuellen Modells (inkl. angewandter Skalierung)
Spatial modelClone = modelWrapper.clone();
byte[] thumb = ThumbnailRenderer.render(modelClone, app.getRenderManager(), app.getRenderer());
if (thumb == null) return;
ThumbnailRenderer.saveSidecar(thumb, j3oPath);
embedThumbnail(thumb, j3oPath);
} catch (Exception e) {
System.err.println("[ModelEditor] Thumbnail-Fehler: " + e.getMessage());
}
}
private void embedThumbnail(byte[] pngBytes, Path j3oPath) {
try {
BinaryImporter importer = BinaryImporter.getInstance();
importer.setAssetManager(app.getAssetManager());
Savable savable = importer.load(j3oPath.toFile());
if (savable instanceof Spatial root) {
ThumbnailRenderer.embed(root, pngBytes);
BinaryExporter.getInstance().save(root, j3oPath.toFile());
}
} catch (Exception e) {
System.err.println("[ModelEditor] j3o-Embed fehlgeschlagen: " + e.getMessage());
}
}
public String getCurrentPath() { return currentPath; }
}

View File

@@ -1243,8 +1243,24 @@ public class SceneObjectState extends BaseAppState {
model.setLocalRotation(rot);
}
Files.createDirectories(req.destJ3o().getParent());
BinaryExporter.getInstance().save(model, req.destJ3o().toFile());
// Thumbnail generieren und in j3o einbetten
try {
byte[] thumb = ThumbnailRenderer.render(model, app.getRenderManager(), app.getRenderer());
if (thumb != null) {
ThumbnailRenderer.embed(model, thumb);
Files.createDirectories(req.destJ3o().getParent());
BinaryExporter.getInstance().save(model, req.destJ3o().toFile());
ThumbnailRenderer.saveSidecar(thumb, req.destJ3o());
} else {
Files.createDirectories(req.destJ3o().getParent());
BinaryExporter.getInstance().save(model, req.destJ3o().toFile());
}
} catch (Exception thumbEx) {
System.err.println("[SceneObject] Thumbnail-Fehler: " + thumbEx.getMessage());
Files.createDirectories(req.destJ3o().getParent());
BinaryExporter.getInstance().save(model, req.destJ3o().toFile());
}
if (req.srcToDelete() != null) Files.deleteIfExists(req.srcToDelete());
setStatus("Konvertiert: " + req.destJ3o().getFileName());
input.refreshAssets = true;

View File

@@ -408,15 +408,19 @@ public class TerrainEditorState extends BaseAppState {
for (byte b : splatR) { if (b != 0) { rAllZero = false; break; } }
if (rAllZero) Arrays.fill(splatR, (byte) 255);
// Gespeicherte Texturpfade in SharedInput übernehmen
System.arraycopy(loadedMapData.terrainTextures, 0,
input.terrainTexturePaths, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(loadedMapData.terrainTextures, 0,
input.terrainTexturePaths, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(loadedMapData.terrainNormalMaps, 0,
input.terrainNormalMapPaths, 0, MapData.TEXTURE_SLOTS);
// Zweite Splatmap (Slots 5-8)
upperSplatR = loadedMapData.upperSplatR.clone();
upperSplatG = loadedMapData.upperSplatG.clone();
upperSplatB = loadedMapData.upperSplatB.clone();
upperSplatA = loadedMapData.upperSplatA.clone();
System.arraycopy(loadedMapData.upperTextures, 0,
input.upperTexturePaths, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(loadedMapData.upperTextures, 0,
input.upperTexturePaths, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(loadedMapData.upperNormalMaps, 0,
input.upperNormalMapPaths, 0, MapData.TEXTURE_SLOTS);
// Alte Gebirge-Splatmap-Migration: R=255 überall war der Gebirge-Standard.
// Im neuen 1-Terrain-System bedeutet das: Slot-5-Textur deckt alles ab → auf 0 setzen.
boolean upperRAllMax = true;
@@ -837,8 +841,10 @@ public class TerrainEditorState extends BaseAppState {
final byte[] upG = upperSplatG != null ? upperSplatG.clone() : null;
final byte[] upB = upperSplatB != null ? upperSplatB.clone() : null;
final byte[] upA = upperSplatA != null ? upperSplatA.clone() : null;
final String[] texPaths = input.terrainTexturePaths.clone();
final String[] upperPaths = input.upperTexturePaths.clone();
final String[] texPaths = input.terrainTexturePaths.clone();
final String[] normalPaths = input.terrainNormalMapPaths.clone();
final String[] upperPaths = input.upperTexturePaths.clone();
final String[] upperNormPaths = input.upperNormalMapPaths.clone();
final GrassTuftIO.GrassData grassData = placedObjectState != null
? new GrassTuftIO.GrassData(placedObjectState.getSlotPaths(), placedObjectState.getAllTufts())
@@ -870,14 +876,16 @@ public class TerrainEditorState extends BaseAppState {
System.arraycopy(snapG, 0, data.splatG, 0, data.splatG.length);
System.arraycopy(snapB, 0, data.splatB, 0, data.splatB.length);
System.arraycopy(snapA, 0, data.splatA, 0, data.splatA.length);
System.arraycopy(texPaths, 0, data.terrainTextures, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(texPaths, 0, data.terrainTextures, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(normalPaths, 0, data.terrainNormalMaps, 0, MapData.TEXTURE_SLOTS);
}
if (upR != null) {
System.arraycopy(upR, 0, data.upperSplatR, 0, data.upperSplatR.length);
System.arraycopy(upG, 0, data.upperSplatG, 0, data.upperSplatG.length);
System.arraycopy(upB, 0, data.upperSplatB, 0, data.upperSplatB.length);
System.arraycopy(upA, 0, data.upperSplatA, 0, data.upperSplatA.length);
System.arraycopy(upperPaths, 0, data.upperTextures, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(upperPaths, 0, data.upperTextures, 0, MapData.TEXTURE_SLOTS);
System.arraycopy(upperNormPaths, 0, data.upperNormalMaps, 0, MapData.TEXTURE_SLOTS);
}
if (grassData != null) {

View File

@@ -0,0 +1,200 @@
package de.blight.editor.state;
import com.jme3.bounding.BoundingBox;
import com.jme3.bounding.BoundingVolume;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.Renderer;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/**
* Offscreen-Thumbnail-Rendering für j3o-Modelle (128×128 PNG, transparenter Hintergrund).
*
* Kamera: 45° von oben, 45° Yaw von der X-Achse (entspricht Orbit yaw=45°, pitch=45°).
* Zoom: Die Bounding-Box wird auf die Kamera-Bildebene projiziert; der Abstand wird so
* gewählt, dass das Modell die volle Breite oder Höhe des Thumbnails ausfüllt.
* Licht: ein Hauptlicht genau aus Blickrichtung + leichtes Umgebungslicht.
*
* Alle Methoden müssen vom JME3-Render-Thread aufgerufen werden.
*/
public final class ThumbnailRenderer {
public static final int SIZE = 128;
// Kamerawinkel: yaw = 45° (zwischen X- und Z-Achse), pitch = 45° (von oben)
private static final float YAW_RAD = FastMath.DEG_TO_RAD * 45f;
private static final float PITCH_RAD = FastMath.DEG_TO_RAD * 45f;
// Einheits-Versatzvektor Kamera→Zentrum (Orbit-Formel mit yaw=45°, pitch=45°):
// x = cos(pitch)*sin(yaw) = 0.5
// y = sin(pitch) = √2/2
// z = cos(pitch)*cos(yaw) = 0.5 → Länge = 1.0 (bereits normiert)
private static final Vector3f CAM_OFFSET = new Vector3f(
FastMath.cos(PITCH_RAD) * FastMath.sin(YAW_RAD),
FastMath.sin(PITCH_RAD),
FastMath.cos(PITCH_RAD) * FastMath.cos(YAW_RAD));
// Kamera-Basisvektoren (JME3-lookAt von CAM_OFFSET in Richtung Ursprung, up=Y):
// forward = -CAM_OFFSET = (-0.5, -0.7071, -0.5)
// left = UNIT_Y × forward / |...| = (-0.7071, 0, 0.7071)
// screen_right = -left = ( 0.7071, 0, -0.7071)
// screen_up = forward × left = (-0.5, 0.7071, -0.5)
private static final Vector3f SCREEN_RIGHT = new Vector3f( 0.7071f, 0f, -0.7071f);
private static final Vector3f SCREEN_UP = new Vector3f(-0.5f, 0.7071f, -0.5f);
// FOV des Thumbnails (45°, quadratisch → aspect = 1)
private static final float FOV_DEG = 45f;
private static final float HALF_FOV = FastMath.DEG_TO_RAD * (FOV_DEG * 0.5f);
// Rand: 2 % Abstand zum Frame-Rand (verhindert Clipping)
private static final float MARGIN = 0.98f;
private static final String USERDATA_KEY = "thumbnail";
private ThumbnailRenderer() {}
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Rendert {@code model} zu einem 128×128 ARGB-PNG mit transparentem Hintergrund.
* {@code model} darf beim Aufruf nicht in einem anderen Scene-Graph hängen.
*/
public static byte[] render(Spatial model, RenderManager rm, Renderer renderer) {
model.updateGeometricState();
BoundingVolume bv = model.getWorldBound();
Vector3f center = (bv instanceof BoundingBox bb)
? bb.getCenter().clone() : Vector3f.ZERO.clone();
float ex = (bv instanceof BoundingBox bb) ? bb.getXExtent() : 1f;
float ey = (bv instanceof BoundingBox bb) ? bb.getYExtent() : 1f;
float ez = (bv instanceof BoundingBox bb) ? bb.getZExtent() : 1f;
// Maximale Ausdehnung der Bounding-Box auf die Kamera-Bildebene projizieren
float maxR = ex * Math.abs(SCREEN_RIGHT.x) + ey * Math.abs(SCREEN_RIGHT.y) + ez * Math.abs(SCREEN_RIGHT.z);
float maxU = ex * Math.abs(SCREEN_UP.x) + ey * Math.abs(SCREEN_UP.y) + ez * Math.abs(SCREEN_UP.z);
float screenRadius = Math.max(maxR, maxU);
if (screenRadius < 1e-4f) screenRadius = 1f;
// Kameradistanz: Modell füllt Thumbnail (mit MARGIN Spielraum)
float dist = screenRadius / (MARGIN * FastMath.tan(HALF_FOV));
float near = Math.max(dist * 0.01f, 0.001f);
float far = dist * 10f;
Camera cam = new Camera(SIZE, SIZE);
cam.setFrustumPerspective(FOV_DEG, 1f, near, far);
cam.setLocation(center.add(CAM_OFFSET.mult(dist)));
cam.lookAt(center, Vector3f.UNIT_Y);
// Licht genau aus Blickrichtung (= Richtung von Kamera zum Modell)
Vector3f lightDir = CAM_OFFSET.negate(); // von Kamera → Zentrum
Node scene = new Node("_thumb_scene");
scene.addLight(new DirectionalLight(lightDir, new ColorRGBA(1.6f, 1.6f, 1.6f, 1f)));
scene.addLight(new AmbientLight(new ColorRGBA(0.25f, 0.25f, 0.25f, 1f)));
scene.attachChild(model);
scene.updateGeometricState();
return renderToFramebuffer(scene, cam, rm, renderer);
}
/**
* Setzt die UserData {@code "thumbnail"} als Base64-PNG auf dem Modell.
* Vor dem BinaryExporter.save() aufrufen.
*/
public static void embed(Spatial model, byte[] pngBytes) {
if (pngBytes == null) return;
model.setUserData(USERDATA_KEY, Base64.getEncoder().encodeToString(pngBytes));
}
public static String getUserDataKey() { return USERDATA_KEY; }
/** Speichert PNG-Bytes als {@code <j3oPath>.thumb.png}. */
public static void saveSidecar(byte[] pngBytes, Path j3oPath) {
if (pngBytes == null) return;
try {
Files.write(sidecarPath(j3oPath), pngBytes);
} catch (IOException e) {
System.err.println("[ThumbnailRenderer] Sidecar-Fehler: " + e.getMessage());
}
}
/** Pfad der Sidecar-Datei: {@code <j3oPath>.thumb.png}. */
public static Path sidecarPath(Path j3oPath) {
return j3oPath.resolveSibling(j3oPath.getFileName() + ".thumb.png");
}
// ── Intern ───────────────────────────────────────────────────────────────
private static byte[] renderToFramebuffer(Node scene, Camera cam,
RenderManager rm, Renderer renderer) {
Texture2D colorTex = new Texture2D(SIZE, SIZE, Image.Format.RGBA8);
FrameBuffer fb = new FrameBuffer(SIZE, SIZE, 1);
fb.setDepthBuffer(Image.Format.Depth);
fb.setColorTexture(colorTex);
ViewPort vp = rm.createPreView("_blight_thumb", cam);
vp.setClearFlags(true, true, true);
vp.setBackgroundColor(new ColorRGBA(0f, 0f, 0f, 0f)); // transparent
vp.setOutputFrameBuffer(fb);
vp.attachScene(scene);
try {
rm.renderViewPort(vp, 0.016f);
ByteBuffer buf = BufferUtils.createByteBuffer(SIZE * SIZE * 4);
renderer.readFrameBuffer(fb, buf);
return pngFromRgba(buf);
} catch (Exception e) {
System.err.println("[ThumbnailRenderer] Render-Fehler: " + e.getMessage());
return null;
} finally {
rm.removePreView(vp);
scene.detachAllChildren(); // model-Node löst sich aus _thumb_scene
fb.dispose();
}
}
/** Konvertiert RGBA-Rohdaten (OpenGL, Y=0 unten) in ein ARGB-PNG mit Alphakanal. */
private static byte[] pngFromRgba(ByteBuffer buf) {
buf.rewind();
byte[] raw = new byte[SIZE * SIZE * 4];
buf.get(raw);
BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < SIZE; y++) {
for (int x = 0; x < SIZE; x++) {
int srcY = SIZE - 1 - y; // Y-Flip: OpenGL ist bottom-up
int i = (srcY * SIZE + x) * 4;
int r = raw[i] & 0xFF;
int g = raw[i + 1] & 0xFF;
int b = raw[i + 2] & 0xFF;
int a = raw[i + 3] & 0xFF;
img.setRGB(x, y, (a << 24) | (r << 16) | (g << 8) | b);
}
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ImageIO.write(img, "PNG", baos);
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
}

View File

@@ -9,7 +9,6 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
@@ -21,31 +20,121 @@ import java.nio.file.Path;
import java.util.List;
/**
* Item-Verwaltung: zwei identische ItemPanel-Instanzen nebeneinander.
* Liste sortiert nach Kategorie, dann nach Name.
* Item-Manager: Liste links (volle Höhe), Bearbeitungsformular rechts.
*/
public class ItemEditorView extends BorderPane {
private final ObservableList<Item> sharedItems = FXCollections.observableArrayList();
private final ObservableList<Item> items = FXCollections.observableArrayList();
private final Path itemDir;
private final Path assetRoot;
private final SortedList<Item> sortedItems;
private final ListView<Item> listView;
private Item current = null;
// Form-Felder
private TextField idField;
private ComboBox<ItemCategory> catCombo;
private TextField nameField;
private TextField descField;
private Spinner<Integer> goldSpinner;
private TextField modelRefField;
private VBox formContainer;
private Button deleteBtn;
public ItemEditorView(Path itemDir) {
this.itemDir = itemDir;
this.itemDir = itemDir;
this.assetRoot = itemDir.getParent(); // items/ ist direkt unter assetRoot
setStyle("-fx-background-color: #1e1e2e;");
reload();
ItemPanel left = new ItemPanel("Liste 1", sharedItems, itemDir, this::reload);
ItemPanel right = new ItemPanel("Liste 2", sharedItems, itemDir, this::reload);
// ── Linke Seite: Liste ────────────────────────────────────────────────
sortedItems = new SortedList<>(items, ItemIO.SORT_ORDER);
listView = new ListView<>(sortedItems);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Item item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) { setText(null); setStyle(""); return; }
String catName = item.getCategory() != null ? item.getCategory().name() : "";
String id = item.getItemId() != null ? item.getItemId() : "";
setText(id);
String color = categoryColor(item.getCategory());
setStyle("-fx-text-fill: #dddddd;"
+ " -fx-border-color: transparent transparent transparent " + color + ";"
+ " -fx-border-width: 0 0 0 3;"
+ " -fx-padding: 3 6 3 8;");
setTooltip(new Tooltip("[" + catName + "] " + id));
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onItemSelected(old, nw));
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> reload());
Label titleLbl = new Label("Items");
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;");
HBox legend = buildLegend();
legend.setPadding(new Insets(4, 8, 4, 8));
legend.setStyle("-fx-background-color: #1a1a2a;");
Button newBtn = new Button("Neues Item");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createItem());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
VBox leftPane = new VBox(header, listView, legend, listButtons);
leftPane.setPrefWidth(440);
leftPane.setMinWidth(300);
leftPane.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
VBox.setVgrow(listView, Priority.ALWAYS);
// ── Rechte Seite: Formular ────────────────────────────────────────────
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
setLeft(leftPane);
setCenter(formScroll);
}
public void reload() {
// ── Öffentliche API ───────────────────────────────────────────────────────
public void selectItem(String itemId) {
if (itemId == null) return;
items.stream()
.filter(i -> itemId.equals(i.getItemId()))
.findFirst()
.ifPresent(listView.getSelectionModel()::select);
}
// ── Reload ────────────────────────────────────────────────────────────────
private void reload() {
String selectedId = current != null ? current.getItemId() : null;
List<Item> loaded = ItemIO.loadAll(itemDir);
sharedItems.setAll(loaded);
items.setAll(loaded);
if (selectedId != null) selectItem(selectedId);
}
// ── Category colors ───────────────────────────────────────────────────────
@@ -62,306 +151,233 @@ public class ItemEditorView extends BorderPane {
};
}
// ── Single panel ──────────────────────────────────────────────────────────
// ── Legend ────────────────────────────────────────────────────────────────
static class ItemPanel extends VBox {
private HBox buildLegend() {
HBox box = new HBox(8);
box.setAlignment(Pos.CENTER_LEFT);
for (ItemCategory cat : ItemCategory.values()) {
Label dot = new Label("");
dot.setStyle("-fx-text-fill: " + categoryColor(cat) + "; -fx-font-size: 10;");
Label lbl = new Label(cat.name());
lbl.setStyle("-fx-text-fill: #888; -fx-font-size: 10;");
box.getChildren().addAll(dot, lbl);
}
return box;
}
private final ObservableList<Item> items;
private final Path itemDir;
private final Runnable onSaved;
// ── Form construction ─────────────────────────────────────────────────────
private final SortedList<Item> sortedItems;
private final ListView<Item> listView;
private Item current = null;
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(14));
form.setStyle("-fx-background-color: #252535;");
// Form fields
private TextField idField;
private ComboBox<ItemCategory> catCombo;
private TextField nameField;
private TextField descField;
private Spinner<Integer> goldSpinner;
private TextField modelRefField;
idField = new TextField();
idField.setPromptText("eindeutige ID");
idField.focusedProperty().addListener((obs, wasFocused, isFocused) -> {
if (wasFocused && !isFocused) onIdCommitted();
});
idField.setOnAction(e -> onIdCommitted());
// Form container
private VBox formContainer;
private Button deleteBtn;
catCombo = new ComboBox<>();
catCombo.getItems().addAll(ItemCategory.values());
catCombo.setMaxWidth(Double.MAX_VALUE);
catCombo.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(ItemCategory cat, boolean empty) {
super.updateItem(cat, empty);
if (empty || cat == null) { setText(null); return; }
setText(cat.name());
setStyle("-fx-text-fill: " + categoryColor(cat) + ";");
}
});
catCombo.setButtonCell(new ListCell<>() {
@Override protected void updateItem(ItemCategory cat, boolean empty) {
super.updateItem(cat, empty);
if (empty || cat == null) { setText(null); return; }
setText(cat.name());
setStyle("-fx-text-fill: " + categoryColor(cat) + ";");
}
});
ItemPanel(String title, ObservableList<Item> items, Path itemDir, Runnable onSaved) {
this.items = items;
this.itemDir = itemDir;
this.onSaved = onSaved;
nameField = new TextField();
nameField.setPromptText("TextReference-Schlüssel");
descField = new TextField();
descField.setPromptText("TextReference-Schlüssel");
goldSpinner = new Spinner<>(0, 999999, 0);
goldSpinner.setEditable(true);
goldSpinner.setMaxWidth(Double.MAX_VALUE);
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
setSpacing(0);
modelRefField = new TextField();
modelRefField.setPromptText("Modell-Pfad (z.B. Models/Items/sword.j3o)");
modelRefField.setEditable(false);
modelRefField.setStyle("-fx-background-color: #1e1e2e; -fx-text-fill: #ccc;");
// ── Header ────────────────────────────────────────────────────────
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;"
+ " -fx-border-width: 0 0 1 0;");
Button pickModelBtn = new Button("");
pickModelBtn.setTooltip(new Tooltip("Modell auswählen"));
pickModelBtn.setOnAction(e -> pickModel());
// ── Item list (sorted) ─────────────────────────────────────────────
sortedItems = new SortedList<>(items, ItemIO.SORT_ORDER);
listView = new ListView<>(sortedItems);
listView.setPrefHeight(200);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Item item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) { setText(null); setStyle(""); return; }
HBox modelRow = new HBox(4, modelRefField, pickModelBtn);
HBox.setHgrow(modelRefField, Priority.ALWAYS);
modelRow.setAlignment(Pos.CENTER_LEFT);
String catName = item.getCategory() != null ? item.getCategory().name() : "";
String name = item.getName() != null ? item.getName().id()
: (item.getItemId() != null ? item.getItemId() : "");
setText(name);
String color = categoryColor(item.getCategory());
setStyle("-fx-text-fill: #dddddd;"
+ " -fx-border-color: transparent transparent transparent " + color + ";"
+ " -fx-border-width: 0 0 0 3;"
+ " -fx-padding: 3 6 3 8;");
setTooltip(new Tooltip("[" + catName + "] " + item.getItemId()));
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onItemSelected(old, nw));
Button clearModelBtn = new Button("");
clearModelBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
clearModelBtn.setTooltip(new Tooltip("Modell-Referenz entfernen"));
clearModelBtn.setOnAction(e -> modelRefField.clear());
// Category legend
HBox legend = buildLegend();
legend.setPadding(new Insets(4, 8, 4, 8));
legend.setStyle("-fx-background-color: #1a1a2a;");
HBox modelFullRow = new HBox(4, modelRefField, pickModelBtn, clearModelBtn);
HBox.setHgrow(modelRefField, Priority.ALWAYS);
modelFullRow.setAlignment(Pos.CENTER_LEFT);
Button newBtn = new Button("Neues Item");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createItem());
Button saveBtn = new Button("Item speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentItem());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
form.getChildren().addAll(
sectionTitle("Item"),
new Separator(),
row("Item-ID:", idField),
row("Kategorie:", catCombo),
new Separator(),
sectionTitle("Texte"),
row("Name:", nameField),
row("Beschreibung:", descField),
new Separator(),
sectionTitle("Werte"),
row("Wert (Gold):", goldSpinner),
row("Modell:", modelFullRow),
new Separator(),
saveBtn
);
return form;
}
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
// ── Modell-Auswahl ────────────────────────────────────────────────────────
VBox listSection = new VBox(listView, legend, listButtons);
listSection.setStyle("-fx-background-color: #1a1a2a;");
private void pickModel() {
ModelChooser chooser = new ModelChooser(assetRoot);
chooser.showAndWait().ifPresent(path -> modelRefField.setText(path));
}
// ── Form ──────────────────────────────────────────────────────────
formContainer = buildForm();
private void onIdCommitted() {
String id = idField.getText().trim();
if (id.isBlank()) return;
if (nameField.getText().isBlank()) nameField.setText(id + ".name");
if (descField.getText().isBlank()) descField.setText(id + ".description");
}
// ── Form load / save ──────────────────────────────────────────────────────
private void onItemSelected(Item old, Item nw) {
if (old != null) saveFormToItem(old);
current = nw;
deleteBtn.setDisable(nw == null);
if (nw != null) {
formContainer.setDisable(false);
loadFormFromItem(nw);
} else {
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
// ── Legend ────────────────────────────────────────────────────────────
private HBox buildLegend() {
HBox box = new HBox(10);
box.setAlignment(Pos.CENTER_LEFT);
for (ItemCategory cat : ItemCategory.values()) {
Label dot = new Label("");
dot.setStyle("-fx-text-fill: " + categoryColor(cat) + "; -fx-font-size: 10;");
Label lbl = new Label(cat.name());
lbl.setStyle("-fx-text-fill: #888; -fx-font-size: 10;");
box.getChildren().addAll(dot, lbl);
}
return box;
}
// ── Form construction ─────────────────────────────────────────────────
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
idField = new TextField();
idField.setPromptText("eindeutige ID");
catCombo = new ComboBox<>();
catCombo.getItems().addAll(ItemCategory.values());
catCombo.setMaxWidth(Double.MAX_VALUE);
catCombo.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(ItemCategory cat, boolean empty) {
super.updateItem(cat, empty);
if (empty || cat == null) { setText(null); return; }
setText(cat.name());
setStyle("-fx-text-fill: " + categoryColor(cat) + ";");
}
});
catCombo.setButtonCell(new ListCell<>() {
@Override protected void updateItem(ItemCategory cat, boolean empty) {
super.updateItem(cat, empty);
if (empty || cat == null) { setText(null); return; }
setText(cat.name());
setStyle("-fx-text-fill: " + categoryColor(cat) + ";");
}
});
nameField = new TextField();
nameField.setPromptText("TextReference-Schlüssel");
descField = new TextField();
descField.setPromptText("TextReference-Schlüssel");
goldSpinner = new Spinner<>(0, 999999, 0);
goldSpinner.setEditable(true);
goldSpinner.setMaxWidth(Double.MAX_VALUE);
modelRefField = new TextField();
modelRefField.setPromptText("Modell-Pfad (z.B. Models/Items/sword.j3o)");
Button saveBtn = new Button("Item speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentItem());
form.getChildren().addAll(
sectionTitle("Item"),
new Separator(),
row("Item-ID:", idField),
row("Kategorie:", catCombo),
new Separator(),
sectionTitle("Texte"),
row("Name:", nameField),
row("Beschreibung:", descField),
new Separator(),
sectionTitle("Werte"),
row("Wert (Gold):", goldSpinner),
row("Modell:", modelRefField),
new Separator(),
saveBtn
);
return form;
}
// ── Form load / save ──────────────────────────────────────────────────
private void onItemSelected(Item old, Item nw) {
if (old != null) saveFormToItem(old);
current = nw;
deleteBtn.setDisable(nw == null);
if (nw != null) {
formContainer.setDisable(false);
loadFormFromItem(nw);
} else {
formContainer.setDisable(true);
clearForm();
}
}
private void loadFormFromItem(Item item) {
idField.setText(safe(item.getItemId()));
catCombo.setValue(item.getCategory());
nameField.setText(item.getName() != null ? item.getName().id() : "");
descField.setText(item.getDescription() != null ? item.getDescription().id() : "");
goldSpinner.getValueFactory().setValue(item.getWorthGold());
ObjectReference ref = item.getModelRef();
modelRefField.setText(ref != null && ref.getPath() != null ? ref.getPath() : "");
}
private void saveFormToItem(Item item) {
item.setItemId(idField.getText().trim());
item.setCategory(catCombo.getValue());
item.setName(ref(nameField));
item.setDescription(ref(descField));
item.setWorthGold(goldSpinner.getValue());
String mr = modelRefField.getText().trim();
item.setModelRef(mr.isBlank() ? null : new ObjectReference(mr));
}
private void clearForm() {
idField.clear();
catCombo.setValue(null);
nameField.clear();
descField.clear();
goldSpinner.getValueFactory().setValue(0);
modelRefField.clear();
}
// ── List operations ───────────────────────────────────────────────────
private void createItem() {
Item item = new Item();
item.setItemId("neues_item_" + System.currentTimeMillis());
item.setCategory(ItemCategory.MISC);
items.add(item);
// Select the new item in the sorted view
listView.getSelectionModel().select(item);
}
private void deleteSelected() {
Item sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
String iId = sel.getItemId();
items.remove(sel);
if (iId != null && !iId.isBlank()) {
try { ItemIO.delete(iId, itemDir); }
catch (IOException e) { /* ignore */ }
}
current = null;
clearForm();
formContainer.setDisable(true);
deleteBtn.setDisable(true);
onSaved.run();
}
private void saveCurrentItem() {
if (current == null) return;
saveFormToItem(current);
if (current.getItemId() == null || current.getItemId().isBlank()) {
new Alert(Alert.AlertType.ERROR, "Item-ID darf nicht leer sein.", ButtonType.OK).showAndWait();
return;
}
try {
ItemIO.save(current, itemDir);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
onSaved.run();
// Re-select after reload (list re-sorts)
String savedId = current.getItemId();
items.stream()
.filter(i -> savedId.equals(i.getItemId()))
.findFirst()
.ifPresent(listView.getSelectionModel()::select);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(120);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static String safe(String s) { return s != null ? s : ""; }
private static TextReference ref(TextField f) {
String s = f.getText().trim();
return s.isBlank() ? null : new TextReference(s);
}
}
private void loadFormFromItem(Item item) {
idField.setText(safe(item.getItemId()));
catCombo.setValue(item.getCategory());
nameField.setText(item.getName() != null ? item.getName().id() : "");
descField.setText(item.getDescription() != null ? item.getDescription().id() : "");
goldSpinner.getValueFactory().setValue(item.getWorthGold());
ObjectReference ref = item.getModelRef();
modelRefField.setText(ref != null && ref.getPath() != null ? ref.getPath() : "");
}
private void saveFormToItem(Item item) {
item.setItemId(idField.getText().trim());
item.setCategory(catCombo.getValue());
item.setName(ref(nameField));
item.setDescription(ref(descField));
item.setWorthGold(goldSpinner.getValue());
String mr = modelRefField.getText().trim();
item.setModelRef(mr.isBlank() ? null : new ObjectReference(mr));
}
private void clearForm() {
idField.clear();
catCombo.setValue(null);
nameField.clear();
descField.clear();
goldSpinner.getValueFactory().setValue(0);
modelRefField.clear();
}
// ── List operations ───────────────────────────────────────────────────────
private void createItem() {
Item item = new Item();
item.setItemId("neues_item_" + System.currentTimeMillis());
item.setCategory(ItemCategory.MISC);
items.add(item);
listView.getSelectionModel().select(item);
}
private void deleteSelected() {
Item sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
String iId = sel.getItemId();
items.remove(sel);
if (iId != null && !iId.isBlank()) {
try { ItemIO.delete(iId, itemDir); }
catch (IOException e) { /* ignore */ }
}
current = null;
clearForm();
formContainer.setDisable(true);
deleteBtn.setDisable(true);
reload();
}
private void saveCurrentItem() {
if (current == null) return;
saveFormToItem(current);
if (current.getItemId() == null || current.getItemId().isBlank()) {
new Alert(Alert.AlertType.ERROR, "Item-ID darf nicht leer sein.", ButtonType.OK).showAndWait();
return;
}
try {
ItemIO.save(current, itemDir);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
String savedId = current.getItemId();
reload();
selectItem(savedId);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(100);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static String safe(String s) { return s != null ? s : ""; }
private static TextReference ref(TextField f) {
String s = f.getText().trim();
return s.isBlank() ? null : new TextReference(s);
}
}

View File

@@ -0,0 +1,196 @@
package de.blight.editor.ui;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* Dialog zum Auswählen eines .j3o-Modells.
* Zeigt Karten mit 128×128-Thumbnail wenn eine {@code .j3o.thumb.png} Sidecar-Datei vorhanden ist.
* Gibt den relativen Asset-Pfad (z.B. "Models/Items/sword.j3o") zurück oder {@code null} bei Abbruch.
*/
public class ModelChooser extends Dialog<String> {
private static final int THUMB_SIZE = 96;
private static final int CARD_W = THUMB_SIZE + 16;
private static final int CARD_H = THUMB_SIZE + 38;
private final ToggleGroup toggleGroup = new ToggleGroup();
private final List<ToggleButton> allCards = new ArrayList<>();
private final VBox contentBox = new VBox(16);
private final TextField filterField = new TextField();
private final Path assetRoot;
private String selectedPath;
public ModelChooser(Path assetRoot) {
this.assetRoot = assetRoot;
setTitle("Modell auswählen");
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
contentBox.setPadding(new Insets(4));
Path modelsDir = assetRoot.resolve("Models");
if (Files.isDirectory(modelsDir)) {
// Modelle nach Unterordner gruppieren
buildCards(modelsDir);
}
filterField.setPromptText("Filtern…");
filterField.textProperty().addListener(
(obs, o, n) -> applyFilter(n == null ? "" : n.toLowerCase()));
ScrollPane scroll = new ScrollPane(contentBox);
scroll.setFitToWidth(true);
scroll.setPrefSize(720, 500);
scroll.setStyle("-fx-background-color: transparent;");
VBox root = new VBox(8, filterField, scroll);
root.setPadding(new Insets(10));
getDialogPane().setContent(root);
getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
okBtn.setText("Auswählen");
okBtn.setDisable(true);
toggleGroup.selectedToggleProperty().addListener((obs, o, n) -> {
okBtn.setDisable(n == null);
selectedPath = (n instanceof ToggleButton tb) ? (String) tb.getUserData() : null;
});
setResultConverter(btn -> btn == ButtonType.OK ? selectedPath : null);
}
// ── Karten bauen ─────────────────────────────────────────────────────────
private void buildCards(Path modelsDir) {
// Alle .j3o-Dateien sammeln, nach Verzeichnis gruppiert
List<Path> j3oFiles = new ArrayList<>();
try (Stream<Path> walk = Files.walk(modelsDir)) {
walk.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".j3o"))
.sorted()
.forEach(j3oFiles::add);
} catch (IOException ignored) {}
if (j3oFiles.isEmpty()) return;
// Nach Unterordner gruppieren
String currentGroup = null;
FlowPane currentPane = null;
for (Path j3o : j3oFiles) {
String relToModels = modelsDir.relativize(j3o.getParent()).toString().replace('\\', '/');
if (relToModels.isEmpty()) relToModels = "(Wurzel)";
if (!relToModels.equals(currentGroup)) {
currentGroup = relToModels;
currentPane = newFlowPane();
contentBox.getChildren().add(section(currentGroup, currentPane));
}
String assetPath = assetRoot.relativize(j3o).toString().replace('\\', '/');
Image thumb = loadThumb(j3o);
ToggleButton card = buildCard(j3o.getFileName().toString(), assetPath, thumb);
currentPane.getChildren().add(card);
allCards.add(card);
}
}
private Image loadThumb(Path j3o) {
// Sidecar: <model.j3o>.thumb.png
Path sidecar = j3o.resolveSibling(j3o.getFileName() + ".thumb.png");
if (Files.isRegularFile(sidecar)) {
try (InputStream is = Files.newInputStream(sidecar)) {
return new Image(is, THUMB_SIZE, THUMB_SIZE, true, true);
} catch (IOException ignored) {}
}
return null;
}
// ── Karte ────────────────────────────────────────────────────────────────
private ToggleButton buildCard(String displayName, String assetPath, Image thumb) {
ImageView iv;
if (thumb != null) {
iv = new ImageView(thumb);
} else {
// Platzhalter: Gitterbox-Symbol
iv = new ImageView();
iv.setStyle("-fx-background-color: #2a2a3a;");
}
iv.setFitWidth(THUMB_SIZE);
iv.setFitHeight(THUMB_SIZE);
iv.setPreserveRatio(true);
String short_ = truncate(displayName.replace(".j3o", ""), 14);
Label lbl = new Label(short_);
lbl.setMaxWidth(CARD_W - 4);
lbl.setAlignment(Pos.CENTER);
lbl.setStyle("-fx-font-size: 10;");
VBox graphic = new VBox(4, iv, lbl);
graphic.setAlignment(Pos.TOP_CENTER);
ToggleButton btn = new ToggleButton();
btn.setGraphic(graphic);
btn.setUserData(assetPath);
btn.setToggleGroup(toggleGroup);
btn.setPrefSize(CARD_W, CARD_H);
btn.setTooltip(new Tooltip(assetPath));
btn.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
selectedPath = assetPath;
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (okBtn != null) okBtn.fire();
}
});
return btn;
}
// ── Filter ────────────────────────────────────────────────────────────────
private void applyFilter(String lower) {
for (ToggleButton card : allCards) {
String path = (String) card.getUserData();
boolean visible = lower.isBlank() || path.toLowerCase().contains(lower);
card.setVisible(visible);
card.setManaged(visible);
}
}
// ── Layout-Helfer ─────────────────────────────────────────────────────────
private static FlowPane newFlowPane() {
FlowPane fp = new FlowPane(8, 8);
fp.setPadding(new Insets(4, 4, 4, 4));
return fp;
}
private static VBox section(String title, FlowPane pane) {
Label lbl = new Label(title);
lbl.setStyle("-fx-font-weight: bold; -fx-font-size: 11;");
return new VBox(4, lbl, new Separator(), pane);
}
private static String truncate(String s, int max) {
if (s.length() <= max) return s;
return s.substring(0, max - 1) + "";
}
}