add survival mode support: camera, film system, and dynamic crafting recipes for photos
This commit is contained in:
@@ -26,6 +26,13 @@ public final class Main extends JavaPlugin {
|
||||
|
||||
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
|
||||
|
||||
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
|
||||
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
|
||||
/** Loaded film count stored on a camera (INTEGER, 0..{@code CameraItems.MAX_FILM}). */
|
||||
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
|
||||
/** Marks a {@code PLAYER_HEAD} as a film roll (BYTE 1). */
|
||||
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
instance = this;
|
||||
@@ -33,6 +40,11 @@ public final class Main extends JavaPlugin {
|
||||
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
|
||||
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(new eu.mhsl.minecraft.pixelpics.survival.CameraListener(), this);
|
||||
Bukkit.getPluginManager().registerEvents(new eu.mhsl.minecraft.pixelpics.survival.CraftingListener(), this);
|
||||
Bukkit.getPluginManager().registerEvents(new eu.mhsl.minecraft.pixelpics.survival.JoinListener(), this);
|
||||
eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes.register();
|
||||
|
||||
initRenderer();
|
||||
}
|
||||
|
||||
@@ -81,6 +93,7 @@ public final class Main extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes.unregister();
|
||||
if (resourcePack != null) {
|
||||
resourcePack.close();
|
||||
resourcePack = null;
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
package eu.mhsl.minecraft.pixelpics.commands;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.MapView;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class PixelPicsCommand implements CommandExecutor {
|
||||
|
||||
@@ -44,57 +31,14 @@ public class PixelPicsCommand implements CommandExecutor {
|
||||
}
|
||||
if (args.length > 0) return false;
|
||||
|
||||
DefaultScreenRenderer renderer = Main.getInstance().getScreenRenderer();
|
||||
if (renderer == null) {
|
||||
player.sendMessage(Component.text("PixelPics ist nicht einsatzbereit: es wurde kein Resource-Pack geladen.",
|
||||
// Debug shortcut: render a photo from the player's view without needing a camera or film.
|
||||
if (!player.hasPermission("pixelpic.admin")) {
|
||||
player.sendActionBar(Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.",
|
||||
NamedTextColor.RED));
|
||||
return true;
|
||||
}
|
||||
|
||||
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
|
||||
|
||||
// Capture the world snapshot on the main thread.
|
||||
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
|
||||
|
||||
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
|
||||
ImageMapRenderer mapRenderer = new ImageMapRenderer();
|
||||
MapView mapView = Bukkit.createMap(player.getWorld());
|
||||
int id = mapView.getId();
|
||||
MapManager.attachView(mapView, mapRenderer);
|
||||
|
||||
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
|
||||
MapMeta meta = (MapMeta) map.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag,
|
||||
PersistentDataType.STRING, UUID.randomUUID().toString());
|
||||
meta.setMapView(mapView);
|
||||
map.setItemMeta(meta);
|
||||
player.getInventory().addItem(map);
|
||||
player.sendMessage(Component.text("📸 Aufnahme wird entwickelt …", NamedTextColor.GRAY));
|
||||
|
||||
// Trace + dither off-thread, then start the developing animation on the main thread.
|
||||
Main plugin = Main.getInstance();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
BufferedImage image;
|
||||
byte[] indices;
|
||||
try {
|
||||
image = renderer.execute(job);
|
||||
indices = MapImageDither.dither(image);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Render failed: " + e.getMessage());
|
||||
Bukkit.getScheduler().runTask(plugin, () ->
|
||||
player.sendMessage(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
|
||||
return;
|
||||
}
|
||||
BufferedImage finalImage = image;
|
||||
byte[] finalIndices = indices;
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
MapManager.saveImage(finalImage, id);
|
||||
MapManager.saveIndices(finalIndices, id);
|
||||
mapRenderer.develop(finalIndices);
|
||||
player.sendMessage(Component.text("✅ Aufnahme erstellt!", NamedTextColor.GREEN));
|
||||
});
|
||||
});
|
||||
|
||||
PhotoService.takePhoto(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,5 +19,24 @@ public record EntityState(
|
||||
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
|
||||
boolean saddle, // horse/donkey/mule is saddled
|
||||
boolean chest, // donkey/mule/llama is carrying a chest
|
||||
String bodyEquip // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
|
||||
) {}
|
||||
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
|
||||
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
|
||||
boolean invisible // entity is invisible -> render only its equipment (like vanilla), not the body
|
||||
) {
|
||||
|
||||
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
|
||||
public record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
|
||||
public boolean isEmpty() {
|
||||
return head == null && chest == null && legs == null && feet == null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One worn armor piece. */
|
||||
public record EquipPiece(
|
||||
String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute"
|
||||
int dyeColor, // ARGB tint for dyeable (leather) armor; 0 = undyed/not dyeable
|
||||
String trimMaterial, // armor-trim material key (e.g. "diamond"); null = no trim
|
||||
String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim
|
||||
boolean glint // item is enchanted -> render the enchantment glint
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,63 @@ public final class TextureOps {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
|
||||
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
|
||||
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
|
||||
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
|
||||
*/
|
||||
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
|
||||
int n = Math.min(from.length, to.length);
|
||||
if (n == 0) return;
|
||||
for (int[] row : tex) {
|
||||
for (int x = 0; x < row.length; x++) {
|
||||
int p = row[x];
|
||||
int a = (p >>> 24) & 0xFF;
|
||||
if (a == 0) continue;
|
||||
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
|
||||
int best = 0, bestD = Integer.MAX_VALUE;
|
||||
for (int i = 0; i < n; i++) {
|
||||
int c = from[i];
|
||||
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
|
||||
int d = dr * dr + dg * dg + db * db;
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
}
|
||||
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
|
||||
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
|
||||
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
|
||||
* transparent background). Not animated; just a frozen purple shimmer.
|
||||
*/
|
||||
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
|
||||
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
|
||||
int gh = glint.length, gw = glint[0].length;
|
||||
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
|
||||
for (int y = 0; y < tex.length; y++) {
|
||||
for (int x = 0; x < tex[y].length; x++) {
|
||||
int p = tex[y][x];
|
||||
int a = (p >>> 24) & 0xFF;
|
||||
if (a == 0) continue; // only sheen actual armor pixels
|
||||
int gp = glint[y % gh][x % gw];
|
||||
int ga = (gp >>> 24) & 0xFF;
|
||||
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
|
||||
double inten = strength * (ga / 255.0) * (gl / 255.0);
|
||||
if (inten <= 0) continue;
|
||||
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
|
||||
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
|
||||
int b = clamp((p & 0xFF) + (int) (cb * inten));
|
||||
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int clamp(int v) {
|
||||
return v < 0 ? 0 : (Math.min(v, 255));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
|
||||
tex = compositeHorse(s, tex); // coat markings + horse armor
|
||||
} else if (cem.equals("llama")) {
|
||||
tex = compositeLlama(s, tex); // dyed/trader carpet decor
|
||||
} else if (cem.equals("nautilus")) {
|
||||
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
|
||||
}
|
||||
CemModelLoader.CemModel model = models.get(cem);
|
||||
if (model == null || tex == null) return fallbackBox(s, tex);
|
||||
// A visible entity needs its body model+texture; an invisible one renders only its equipment
|
||||
// (vanilla hides the body but still draws worn armor).
|
||||
boolean invisible = s.invisible();
|
||||
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
|
||||
|
||||
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
|
||||
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
|
||||
@@ -68,31 +73,44 @@ public final class CemBaker implements EntityBaker<EntityState> {
|
||||
if (cem.equals("donkey")) { hidden.add("left_chest"); hidden.add("right_chest"); }
|
||||
else if (cem.equals("llama")) { hidden.add("chest_left"); hidden.add("chest_right"); }
|
||||
}
|
||||
List<CemGeometry.Baked> baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden));
|
||||
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
|
||||
if (s.typeKey().equals("sheep")) {
|
||||
CemModelLoader.CemModel wool = models.get("sheep_wool");
|
||||
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
|
||||
if (wool != null && woolTex != null) {
|
||||
int[][] t = TextureOps.deepCopy(woolTex);
|
||||
if (s.tint() != 0) TextureOps.tint(t, s.tint());
|
||||
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
|
||||
}
|
||||
}
|
||||
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
|
||||
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
|
||||
if (cem.equals("guardian")) {
|
||||
double[] org = {-8, 2, -6}, size = {2, 12, 12};
|
||||
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
|
||||
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
|
||||
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
|
||||
}
|
||||
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
|
||||
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
|
||||
if (baked.isEmpty()) return fallbackBox(s, tex);
|
||||
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference
|
||||
// so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
|
||||
List<CemGeometry.Baked> body = (model != null && tex != null)
|
||||
? CemGeometry.bakeModel(model, tex, pre, hidden) : java.util.List.of();
|
||||
|
||||
List<CemGeometry.Baked> baked = new ArrayList<>();
|
||||
if (!invisible) {
|
||||
baked.addAll(body);
|
||||
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
|
||||
if (s.typeKey().equals("sheep")) {
|
||||
CemModelLoader.CemModel wool = models.get("sheep_wool");
|
||||
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
|
||||
if (wool != null && woolTex != null) {
|
||||
int[][] t = TextureOps.deepCopy(woolTex);
|
||||
if (s.tint() != 0) TextureOps.tint(t, s.tint());
|
||||
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
|
||||
}
|
||||
}
|
||||
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
|
||||
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
|
||||
if (cem.equals("guardian")) {
|
||||
double[] org = {-8, 2, -6}, size = {2, 12, 12};
|
||||
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
|
||||
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
|
||||
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
|
||||
}
|
||||
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
|
||||
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
|
||||
}
|
||||
// Humanoid armor (players, armor stands, zombies, …): extra inflated armor_layer models. Rendered
|
||||
// even when invisible, so an invisible armored entity shows floating armor like in vanilla.
|
||||
if (s.equipment() != null) addArmorLayers(s, pre, baked);
|
||||
if (baked.isEmpty()) return invisible ? null : fallbackBox(s, tex);
|
||||
|
||||
// Ground-snap from the body extent (preserves visible behaviour); fall back to the drawn geometry.
|
||||
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
|
||||
double minY = Double.MAX_VALUE;
|
||||
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
|
||||
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
|
||||
|
||||
Affine place = Affine.translation(s.x(), s.y(), s.z())
|
||||
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
|
||||
@@ -164,6 +182,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Nautilus body armor + saddle: same 128² UV as the nautilus texture, so composited as overlays. */
|
||||
private int[][] compositeNautilus(EntityState s, int[][] base) {
|
||||
if (base == null || (s.bodyEquip() == null && !s.saddle())) return base;
|
||||
int[][] out = TextureOps.deepCopy(base);
|
||||
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/nautilus_body/" + s.bodyEquip());
|
||||
if (s.saddle()) overlayIfPresent(out, "entity/equipment/nautilus_saddle/saddle");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */
|
||||
private void addSaddleLayer(EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
|
||||
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
|
||||
@@ -178,6 +205,103 @@ public final class CemBaker implements EntityBaker<EntityState> {
|
||||
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
|
||||
}
|
||||
|
||||
// --- humanoid armor ---------------------------------------------------------------------------
|
||||
// Vanilla splits worn armor across two inflated layer models that overlay the standard humanoid body:
|
||||
// armor_layer_1 (texture entity/equipment/humanoid/<mat>): head=helmet, body+arms=chestplate,
|
||||
// shoes=boots;
|
||||
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings.
|
||||
// Each slot may use a different material, so each is baked separately, showing only its parts.
|
||||
private static final java.util.Set<String> ARMOR1_HEAD = java.util.Set.of("head");
|
||||
private static final java.util.Set<String> ARMOR1_CHEST = java.util.Set.of("body", "left_arm", "right_arm");
|
||||
private static final java.util.Set<String> ARMOR1_FEET = java.util.Set.of("left_shoe", "right_shoe");
|
||||
private static final java.util.Set<String> ARMOR2_LEGS = java.util.Set.of("waist", "left_leg", "right_leg");
|
||||
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
|
||||
|
||||
private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) {
|
||||
EntityState.Equipment eq = s.equipment();
|
||||
bakeArmorPiece(eq.head(), "armor_layer_1", ARMOR1_HEAD, "humanoid", pre, baked);
|
||||
// Chest slot: an elytra renders its own wing model instead of the chestplate layer.
|
||||
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
|
||||
bakeElytra(eq.chest(), pre, baked);
|
||||
} else {
|
||||
bakeArmorPiece(eq.chest(), "armor_layer_1", ARMOR1_CHEST, "humanoid", pre, baked);
|
||||
}
|
||||
bakeArmorPiece(eq.legs(), "armor_layer_2", ARMOR2_LEGS, "humanoid_leggings", pre, baked);
|
||||
bakeArmorPiece(eq.feet(), "armor_layer_1", ARMOR1_FEET, "humanoid", pre, baked);
|
||||
}
|
||||
|
||||
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */
|
||||
private void bakeArmorPiece(EntityState.EquipPiece piece, String modelName, java.util.Set<String> show,
|
||||
String layerFolder, Affine pre, List<CemGeometry.Baked> baked) {
|
||||
if (piece == null) return;
|
||||
CemModelLoader.CemModel model = models.get(modelName);
|
||||
if (model == null) return;
|
||||
int[][] tex = buildArmorTexture(piece, layerFolder);
|
||||
if (tex == null) return;
|
||||
java.util.Set<String> hidden = new java.util.HashSet<>();
|
||||
for (CemModelLoader.CemPart p : model.parts()) if (!show.contains(p.name())) hidden.add(p.name());
|
||||
baked.addAll(CemGeometry.bakeModel(model, tex, pre, hidden));
|
||||
}
|
||||
|
||||
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
|
||||
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
|
||||
String asset = piece.asset();
|
||||
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset)).orElse(null);
|
||||
if (base == null) return null;
|
||||
int[][] out = TextureOps.deepCopy(base);
|
||||
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
|
||||
if (asset.equals("leather")) {
|
||||
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
|
||||
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
|
||||
}
|
||||
if (piece.trimMaterial() != null && piece.trimPattern() != null) {
|
||||
applyTrim(out, layerFolder, piece.trimPattern(), piece.trimMaterial(), asset);
|
||||
}
|
||||
if (piece.glint()) applyGlint(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
|
||||
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
|
||||
int[][] mask = textures.get(ResourceLocation.parse("trims/entity/" + layerFolder + "/" + pattern)).orElse(null);
|
||||
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
|
||||
int[] from = palette8("trims/color_palettes/trim_palette");
|
||||
// Vanilla uses the darker palette variant when the trim material matches the armor material.
|
||||
String palette = material;
|
||||
if (material.equals(armorAsset)
|
||||
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker")).isPresent()) {
|
||||
palette = material + "_darker";
|
||||
}
|
||||
int[] to = palette8("trims/color_palettes/" + palette);
|
||||
if (from == null || to == null) return;
|
||||
int[][] colored = TextureOps.deepCopy(mask);
|
||||
TextureOps.paletteSwap(colored, from, to);
|
||||
TextureOps.overlay(armorTex, colored);
|
||||
}
|
||||
|
||||
/** First row of an 8×1 trim colour-palette texture; null if missing. */
|
||||
private int[] palette8(String path) {
|
||||
int[][] p = textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||
return (p == null || p.length == 0) ? null : p[0];
|
||||
}
|
||||
|
||||
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
|
||||
private void applyGlint(int[][] tex) {
|
||||
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
|
||||
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6));
|
||||
}
|
||||
|
||||
/** Elytra in the chest slot: bake the wing model with the default elytra texture (no chestplate). */
|
||||
private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
|
||||
CemModelLoader.CemModel model = models.get("elytra");
|
||||
if (model == null) return;
|
||||
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra")).orElse(null);
|
||||
if (tex == null) return;
|
||||
int[][] out = TextureOps.deepCopy(tex);
|
||||
if (piece.glint()) applyGlint(out);
|
||||
baked.addAll(CemGeometry.bakeModel(model, out, pre, java.util.Set.of()));
|
||||
}
|
||||
|
||||
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
|
||||
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
|
||||
double[] from = {-w / 2, 0, -w / 2};
|
||||
|
||||
+88
-2
@@ -33,6 +33,15 @@ public final class EntitySnapshotBuilder {
|
||||
"potion", "ender_pearl", "tnt", "falling_block", "item"
|
||||
);
|
||||
|
||||
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held
|
||||
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align.
|
||||
private static final java.util.Set<String> HUMANOID_ARMOR_WEARERS = java.util.Set.of(
|
||||
"player", "mannequin", "armor_stand", "giant",
|
||||
"zombie", "husk", "drowned", "zombie_villager", "zombified_piglin",
|
||||
"skeleton", "stray", "wither_skeleton", "bogged",
|
||||
"piglin", "piglin_brute"
|
||||
);
|
||||
|
||||
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
|
||||
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
|
||||
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
|
||||
@@ -58,7 +67,13 @@ public final class EntitySnapshotBuilder {
|
||||
}
|
||||
|
||||
boolean baby = (e instanceof Ageable a && !a.isAdult())
|
||||
|| (e instanceof org.bukkit.entity.Zombie z && z.isBaby());
|
||||
|| (e instanceof org.bukkit.entity.Zombie z && z.isAdult());
|
||||
|
||||
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
|
||||
// invisibility potion effect, or an explicitly-hidden armor stand.
|
||||
boolean invisible = e.isInvisible()
|
||||
|| (e instanceof org.bukkit.entity.ArmorStand as && !as.isVisible())
|
||||
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(org.bukkit.potion.PotionEffectType.INVISIBILITY));
|
||||
|
||||
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
|
||||
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
|
||||
@@ -114,6 +129,14 @@ public final class EntitySnapshotBuilder {
|
||||
} else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
|
||||
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
|
||||
saddle = isSaddled(ah);
|
||||
} else if (e instanceof org.bukkit.entity.AbstractNautilus && e instanceof LivingEntity nl) {
|
||||
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
|
||||
org.bukkit.inventory.EntityEquipment eq = nl.getEquipment();
|
||||
if (eq != null) {
|
||||
bodyEquip = equipAsset(eq.getItem(org.bukkit.inventory.EquipmentSlot.BODY));
|
||||
org.bukkit.inventory.ItemStack sd = eq.getItem(org.bukkit.inventory.EquipmentSlot.SADDLE);
|
||||
saddle = sd != null && !sd.getType().isAir();
|
||||
}
|
||||
} else if (e instanceof org.bukkit.entity.Fox f) {
|
||||
variant = keyOf(f.getFoxType());
|
||||
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
|
||||
@@ -143,10 +166,62 @@ public final class EntitySnapshotBuilder {
|
||||
// Unsupported on this server version — fall back to the base texture.
|
||||
}
|
||||
|
||||
EntityState.Equipment equipment = null;
|
||||
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
|
||||
equipment = captureEquipment(wearer);
|
||||
}
|
||||
|
||||
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
|
||||
bodyYaw, baby, width, height,
|
||||
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel,
|
||||
markings, saddle, chest, bodyEquip);
|
||||
markings, saddle, chest, bodyEquip, equipment, invisible);
|
||||
}
|
||||
|
||||
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
|
||||
private static EntityState.Equipment captureEquipment(LivingEntity le) {
|
||||
try {
|
||||
org.bukkit.inventory.EntityEquipment eq = le.getEquipment();
|
||||
if (eq == null) return null;
|
||||
EntityState.Equipment equip = new EntityState.Equipment(
|
||||
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
|
||||
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
|
||||
return equip.isEmpty() ? null : equip;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */
|
||||
private static EntityState.EquipPiece armorPiece(org.bukkit.inventory.ItemStack it) {
|
||||
if (it == null || it.getType().isAir()) return null;
|
||||
String asset = armorAsset(it.getType());
|
||||
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
|
||||
int dye = 0;
|
||||
String trimMat = null, trimPat = null;
|
||||
boolean glint = false;
|
||||
if (it.hasItemMeta()) {
|
||||
org.bukkit.inventory.meta.ItemMeta meta = it.getItemMeta();
|
||||
glint = meta.hasEnchants();
|
||||
if (meta instanceof org.bukkit.inventory.meta.LeatherArmorMeta lam) dye = lam.getColor().asARGB();
|
||||
if (meta instanceof org.bukkit.inventory.meta.ArmorMeta am && am.getTrim() != null) {
|
||||
trimMat = keyOf(am.getTrim().getMaterial());
|
||||
trimPat = keyOf(am.getTrim().getPattern());
|
||||
}
|
||||
}
|
||||
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
|
||||
}
|
||||
|
||||
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
|
||||
private static String armorAsset(org.bukkit.Material m) {
|
||||
String key = m.getKey().getKey();
|
||||
if (key.equals("elytra")) return "elytra";
|
||||
if (key.equals("turtle_helmet")) return "turtle_scute";
|
||||
String base = null;
|
||||
for (String suf : new String[]{"_helmet", "_chestplate", "_leggings", "_boots"}) {
|
||||
if (key.endsWith(suf)) { base = key.substring(0, key.length() - suf.length()); break; }
|
||||
}
|
||||
if (base == null) return null;
|
||||
return base.equals("golden") ? "gold" : base;
|
||||
}
|
||||
|
||||
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
|
||||
@@ -171,6 +246,17 @@ public final class EntitySnapshotBuilder {
|
||||
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
|
||||
}
|
||||
|
||||
/** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
|
||||
private static String equipAsset(org.bukkit.inventory.ItemStack it) {
|
||||
if (it == null || it.getType().isAir()) return null;
|
||||
try {
|
||||
var comp = it.getData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE);
|
||||
if (comp != null && comp.assetId() != null) return comp.assetId().value();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
|
||||
private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) {
|
||||
org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package eu.mhsl.minecraft.pixelpics.survival;
|
||||
|
||||
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||
import com.destroystokyo.paper.profile.ProfileProperty;
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
import org.bukkit.inventory.meta.components.EquippableComponent;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned
|
||||
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
|
||||
* existing {@code FILLED_MAP}. Identity is carried by PDC markers (robust against renames), and the
|
||||
* camera's loaded film count lives in PDC as well.
|
||||
*/
|
||||
public final class CameraItems {
|
||||
|
||||
/** Maximum film rolls a camera can hold; one roll yields one photo. */
|
||||
public static final int MAX_FILM = 8;
|
||||
|
||||
private static final int CAMERA_MODEL_DATA = 1001;
|
||||
private static final int FILM_MODEL_DATA = 1002;
|
||||
|
||||
static final String CAMERA_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
|
||||
static final String FILM_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
|
||||
|
||||
private CameraItems() {}
|
||||
|
||||
// --- factories ---
|
||||
|
||||
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
|
||||
public static ItemStack createCamera(int filmCount) {
|
||||
int count = Math.clamp(filmCount, 0, MAX_FILM);
|
||||
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
|
||||
SkullMeta meta = (SkullMeta) item.getItemMeta();
|
||||
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
|
||||
meta.displayName(Component.text("Kamera", NamedTextColor.AQUA)
|
||||
.decoration(TextDecoration.ITALIC, false));
|
||||
meta.setCustomModelData(CAMERA_MODEL_DATA);
|
||||
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
|
||||
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
|
||||
applyCameraLore(meta, count);
|
||||
makeUnwearable(meta);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** A single film roll. */
|
||||
public static ItemStack createFilm() {
|
||||
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
|
||||
SkullMeta meta = (SkullMeta) item.getItemMeta();
|
||||
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
|
||||
meta.displayName(Component.text("Filmrolle", NamedTextColor.GREEN)
|
||||
.decoration(TextDecoration.ITALIC, false));
|
||||
meta.setCustomModelData(FILM_MODEL_DATA);
|
||||
meta.lore(List.of(Component.text("Lädt eine Kamera auf.", NamedTextColor.GRAY)
|
||||
.decoration(TextDecoration.ITALIC, false)));
|
||||
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
|
||||
makeUnwearable(meta);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */
|
||||
public static ItemStack withFilmCount(ItemStack camera, int newCount) {
|
||||
int count = Math.max(0, Math.min(MAX_FILM, newCount));
|
||||
ItemStack copy = camera.clone();
|
||||
SkullMeta meta = (SkullMeta) copy.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
|
||||
applyCameraLore(meta, count);
|
||||
copy.setItemMeta(meta);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// --- identity ---
|
||||
|
||||
public static boolean isCamera(ItemStack item) {
|
||||
return hasMarker(item, Main.getInstance().cameraMarker);
|
||||
}
|
||||
|
||||
public static boolean isFilm(ItemStack item) {
|
||||
return hasMarker(item, Main.getInstance().filmMarker);
|
||||
}
|
||||
|
||||
/** A photo is a filled map carrying our picture id. */
|
||||
public static boolean isPhoto(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
|
||||
return item.getItemMeta().getPersistentDataContainer()
|
||||
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
|
||||
}
|
||||
|
||||
/** Loaded film on a camera, or 0 if the item is not a camera. */
|
||||
public static int getFilmCount(ItemStack item) {
|
||||
if (!isCamera(item)) return 0;
|
||||
Integer v = item.getItemMeta().getPersistentDataContainer()
|
||||
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
|
||||
return v == null ? 0 : v;
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
private static boolean hasMarker(ItemStack item, org.bukkit.NamespacedKey key) {
|
||||
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false;
|
||||
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer();
|
||||
return pdc.has(key, PersistentDataType.BYTE);
|
||||
}
|
||||
|
||||
private static void applyCameraLore(ItemMeta meta, int count) {
|
||||
meta.lore(List.of(
|
||||
Component.text("Film: " + count + " / " + MAX_FILM,
|
||||
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED).decoration(TextDecoration.ITALIC, false),
|
||||
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
|
||||
.decoration(TextDecoration.ITALIC, false)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the {@code PLAYER_HEAD}'s default head-equippable component so the item cannot be worn:
|
||||
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it —
|
||||
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
|
||||
*/
|
||||
private static void makeUnwearable(ItemMeta meta) {
|
||||
EquippableComponent eq = meta.getEquippable();
|
||||
eq.setSlot(EquipmentSlot.SADDLE);
|
||||
eq.setSwappable(false);
|
||||
meta.setEquippable(eq);
|
||||
}
|
||||
|
||||
private static void applyHead(SkullMeta meta, String seed, String textureB64) {
|
||||
if (textureB64 == null || textureB64.isBlank()) return;
|
||||
UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
|
||||
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
|
||||
profile.setProperty(new ProfileProperty("textures", textureB64));
|
||||
meta.setPlayerProfile(profile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package eu.mhsl.minecraft.pixelpics.survival;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot;
|
||||
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
|
||||
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
|
||||
*/
|
||||
public class CameraListener implements Listener {
|
||||
|
||||
private static final long COOLDOWN_MILLIS = 500;
|
||||
private final Map<UUID, Long> lastUse = new HashMap<>();
|
||||
|
||||
@EventHandler
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
if (event.getHand() != EquipmentSlot.HAND) return;
|
||||
Action action = event.getAction();
|
||||
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;
|
||||
|
||||
ItemStack inHand = event.getItem();
|
||||
if (!CameraItems.isCamera(inHand)) return;
|
||||
|
||||
Player player = event.getPlayer();
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
lastUse.put(player.getUniqueId(), now);
|
||||
|
||||
event.setCancelled(true);
|
||||
|
||||
int film = CameraItems.getFilmCount(inHand);
|
||||
if (film <= 0) {
|
||||
// Dry "empty shutter" click — no film loaded.
|
||||
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
|
||||
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume one film and update the in-hand camera (count + lore) before the shot.
|
||||
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
|
||||
PhotoService.takePhoto(player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package eu.mhsl.minecraft.pixelpics.survival;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em>
|
||||
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
|
||||
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose
|
||||
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually
|
||||
* (the vanilla machinery cannot, given the variable-NBT ingredients) and blocks shift-click and any
|
||||
* attempt to leak our custom items into other recipes.
|
||||
*/
|
||||
public class CraftingListener implements Listener {
|
||||
|
||||
private enum Kind { LOAD, COPY, NONE }
|
||||
|
||||
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */
|
||||
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
|
||||
|
||||
@EventHandler
|
||||
public void onPrepare(PrepareItemCraftEvent event) {
|
||||
CraftingInventory inv = event.getInventory();
|
||||
Scan scan = scan(inv.getMatrix());
|
||||
|
||||
switch (scan.kind()) {
|
||||
case LOAD -> {
|
||||
int count = CameraItems.getFilmCount(scan.camera());
|
||||
inv.setResult(count >= CameraItems.MAX_FILM
|
||||
? null
|
||||
: CameraItems.withFilmCount(scan.camera(), count + 1));
|
||||
}
|
||||
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
|
||||
case NONE -> {
|
||||
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
|
||||
// from being consumed by any unrelated (vanilla) recipe.
|
||||
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
|
||||
inv.setResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
CraftingInventory inv = event.getInventory();
|
||||
Scan scan = scan(inv.getMatrix());
|
||||
|
||||
if (scan.kind() == Kind.NONE) {
|
||||
// Defensive: never let our items be consumed by another recipe.
|
||||
if (scan.hasOurItems()) event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
event.setCancelled(true);
|
||||
Player player = (Player) event.getWhoClicked();
|
||||
|
||||
if (event.isShiftClick()) {
|
||||
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
|
||||
ItemStack result;
|
||||
ItemStack[] matrix = inv.getMatrix();
|
||||
if (scan.kind() == Kind.LOAD) {
|
||||
int count = CameraItems.getFilmCount(scan.camera());
|
||||
if (count >= CameraItems.MAX_FILM) return;
|
||||
result = CameraItems.withFilmCount(scan.camera(), count + 1);
|
||||
consumeFirst(matrix, CameraItems::isCamera);
|
||||
consumeFirst(matrix, CameraItems::isFilm);
|
||||
} else { // COPY
|
||||
result = buildPhotoCopy(scan.photo());
|
||||
if (result == null) return;
|
||||
consumeFirst(matrix, CameraItems::isFilm); // original photo stays
|
||||
}
|
||||
|
||||
// Apply on the next tick so the click settles before we mutate the grid and hand over the item.
|
||||
ItemStack finalResult = result;
|
||||
Bukkit.getScheduler().runTask(Main.getInstance(), () -> {
|
||||
inv.setMatrix(matrix);
|
||||
give(player, finalResult);
|
||||
player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
|
||||
player.updateInventory();
|
||||
});
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static Scan scan(ItemStack[] matrix) {
|
||||
int cameras = 0, films = 0, photos = 0, others = 0;
|
||||
ItemStack camera = null, film = null, photo = null;
|
||||
for (ItemStack it : matrix) {
|
||||
if (it == null || it.getType().isAir()) continue;
|
||||
if (CameraItems.isCamera(it)) { cameras++; camera = it; }
|
||||
else if (CameraItems.isFilm(it)) { films++; film = it; }
|
||||
else if (CameraItems.isPhoto(it)) { photos++; photo = it; }
|
||||
else others++;
|
||||
}
|
||||
boolean ours = cameras + films + photos > 0;
|
||||
Kind kind = Kind.NONE;
|
||||
if (others == 0) {
|
||||
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD;
|
||||
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY;
|
||||
}
|
||||
return new Scan(kind, camera, film, photo, ours);
|
||||
}
|
||||
|
||||
private static boolean isDynamicRecipe(Recipe recipe) {
|
||||
if (!(recipe instanceof Keyed keyed)) return false;
|
||||
NamespacedKey key = keyed.getKey();
|
||||
return SurvivalRecipes.LOAD.equals(key) || SurvivalRecipes.COPY.equals(key);
|
||||
}
|
||||
|
||||
/** A second photo referencing the same {@link MapView} and picture id as {@code photo}. */
|
||||
private static ItemStack buildPhotoCopy(ItemStack photo) {
|
||||
MapMeta src = (MapMeta) photo.getItemMeta();
|
||||
if (!src.hasMapView() || src.getMapView() == null) return null;
|
||||
ItemStack copy = new ItemStack(Material.FILLED_MAP);
|
||||
MapMeta dst = (MapMeta) copy.getItemMeta();
|
||||
dst.setMapView(src.getMapView());
|
||||
String pid = src.getPersistentDataContainer()
|
||||
.get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
|
||||
if (pid != null) {
|
||||
dst.getPersistentDataContainer()
|
||||
.set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
|
||||
}
|
||||
copy.setItemMeta(dst);
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
|
||||
for (int i = 0; i < matrix.length; i++) {
|
||||
ItemStack it = matrix[i];
|
||||
if (it != null && !it.getType().isAir() && pred.test(it)) {
|
||||
int amt = it.getAmount();
|
||||
if (amt <= 1) matrix[i] = null;
|
||||
else { it.setAmount(amt - 1); matrix[i] = it; }
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void give(Player player, ItemStack item) {
|
||||
ItemStack cursor = player.getItemOnCursor();
|
||||
if (cursor.getType().isAir()) {
|
||||
player.setItemOnCursor(item);
|
||||
} else {
|
||||
player.getInventory().addItem(item).values()
|
||||
.forEach(left -> player.getWorld().dropItemNaturally(player.getLocation(), left));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package eu.mhsl.minecraft.pixelpics.survival;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Unlocks the survival recipes in each player's recipe book on join (idempotent). */
|
||||
public class JoinListener implements Listener {
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
event.getPlayer().discoverRecipes(SurvivalRecipes.allKeys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package eu.mhsl.minecraft.pixelpics.survival;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.MapView;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
|
||||
* with action-bar feedback, a shutter sound + flash particles on capture, and a chime when the
|
||||
* picture finishes developing. Shared by the camera item (survival) and the {@code /pixelPic}
|
||||
* debug command. Film accounting is the caller's responsibility — this method never touches film.
|
||||
*/
|
||||
public final class PhotoService {
|
||||
|
||||
private PhotoService() {}
|
||||
|
||||
/** Captures and delivers a photo. Returns {@code false} if the renderer is unavailable. */
|
||||
public static boolean takePhoto(Player player) {
|
||||
Main plugin = Main.getInstance();
|
||||
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
|
||||
if (renderer == null) {
|
||||
player.sendActionBar(Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).",
|
||||
NamedTextColor.RED));
|
||||
return false;
|
||||
}
|
||||
|
||||
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
|
||||
|
||||
// Capture the world snapshot on the main thread.
|
||||
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
|
||||
|
||||
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
|
||||
ImageMapRenderer mapRenderer = new ImageMapRenderer();
|
||||
MapView mapView = Bukkit.createMap(player.getWorld());
|
||||
int id = mapView.getId();
|
||||
MapManager.attachView(mapView, mapRenderer);
|
||||
|
||||
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
|
||||
MapMeta meta = (MapMeta) map.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(plugin.pictureIdFlag,
|
||||
PersistentDataType.STRING, UUID.randomUUID().toString());
|
||||
meta.setMapView(mapView);
|
||||
map.setItemMeta(meta);
|
||||
player.getInventory().addItem(map);
|
||||
|
||||
// Feedback is sound + particles only; no action-bar text on a normal shot.
|
||||
playShutter(player);
|
||||
|
||||
// Trace + dither off-thread, then start the developing animation on the main thread.
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
BufferedImage image;
|
||||
byte[] indices;
|
||||
try {
|
||||
image = renderer.execute(job);
|
||||
indices = MapImageDither.dither(image);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Render failed: " + e.getMessage());
|
||||
Bukkit.getScheduler().runTask(plugin, () ->
|
||||
player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
|
||||
return;
|
||||
}
|
||||
BufferedImage finalImage = image;
|
||||
byte[] finalIndices = indices;
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
MapManager.saveImage(finalImage, id);
|
||||
MapManager.saveIndices(finalIndices, id);
|
||||
mapRenderer.develop(finalIndices);
|
||||
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void playShutter(Player player) {
|
||||
player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f);
|
||||
Location eye = player.getEyeLocation();
|
||||
Vector forward = eye.getDirection().normalize().multiply(1.0);
|
||||
Location flash = eye.clone().add(forward);
|
||||
player.getWorld().spawnParticle(Particle.END_ROD, flash, 12, 0.2, 0.2, 0.2, 0.01);
|
||||
player.getWorld().spawnParticle(Particle.FIREWORK, flash, 6, 0.1, 0.1, 0.1, 0.02);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package eu.mhsl.minecraft.pixelpics.survival;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and
|
||||
* film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
|
||||
* inputs carry variable NBT) and their real results are computed dynamically in {@link CraftingListener}
|
||||
* — the registered result here is only the book/preview icon.
|
||||
*/
|
||||
public final class SurvivalRecipes {
|
||||
|
||||
public static final NamespacedKey CAMERA = key("camera_recipe");
|
||||
public static final NamespacedKey FILM = key("film_recipe");
|
||||
public static final NamespacedKey LOAD = key("load_recipe");
|
||||
public static final NamespacedKey COPY = key("copy_recipe");
|
||||
|
||||
private SurvivalRecipes() {}
|
||||
|
||||
private static NamespacedKey key(String name) {
|
||||
return new NamespacedKey(Main.getInstance(), name);
|
||||
}
|
||||
|
||||
public static List<NamespacedKey> allKeys() {
|
||||
return List.of(CAMERA, FILM, LOAD, COPY);
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
unregister();
|
||||
|
||||
// Camera
|
||||
ShapedRecipe camera = new ShapedRecipe(CAMERA, CameraItems.createCamera(0));
|
||||
camera.shape("IDI", "GLG", "IRI");
|
||||
camera.setIngredient('I', Material.IRON_INGOT);
|
||||
camera.setIngredient('D', Material.DIAMOND);
|
||||
camera.setIngredient('G', Material.GLASS_PANE);
|
||||
camera.setIngredient('L', Material.ENDER_EYE);
|
||||
camera.setIngredient('R', Material.REDSTONE);
|
||||
Bukkit.addRecipe(camera, false);
|
||||
|
||||
// Film roll
|
||||
ShapedRecipe film = new ShapedRecipe(FILM, CameraItems.createFilm());
|
||||
film.shape(" P ", "PIP", " P ");
|
||||
film.setIngredient('P', Material.PAPER);
|
||||
film.setIngredient('I', Material.INK_SAC);
|
||||
Bukkit.addRecipe(film, false);
|
||||
|
||||
// Load film: camera + film
|
||||
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
|
||||
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
|
||||
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
|
||||
Bukkit.addRecipe(load, false);
|
||||
|
||||
// Copy photo: photo + film
|
||||
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
|
||||
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
|
||||
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
|
||||
Bukkit.addRecipe(copy, false);
|
||||
|
||||
// Cover /reload while players are online; fresh joins are handled by JoinListener.
|
||||
Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys()));
|
||||
}
|
||||
|
||||
public static void unregister() {
|
||||
for (NamespacedKey k : allKeys()) {
|
||||
Bukkit.removeRecipe(k, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ commands:
|
||||
usage: "/pixelPic [cleanup [confirm] [days]]"
|
||||
permissions:
|
||||
pixelpic.use:
|
||||
description: "Allows taking PixelPics camera screenshots"
|
||||
description: "Allows running /pixelPic (the render branch itself requires pixelpic.admin)"
|
||||
default: true
|
||||
pixelpic.admin:
|
||||
description: "Allows managing PixelPics (e.g. cleanup)"
|
||||
description: "Allows the /pixelPic debug render (no camera/film) and cleanup management"
|
||||
default: op
|
||||
|
||||
@@ -62,12 +62,41 @@ public class EntityTestRender {
|
||||
|
||||
// Horse/llama/donkey equipment for the standalone render.
|
||||
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings
|
||||
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule");
|
||||
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule", "nautilus");
|
||||
static final java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
|
||||
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet
|
||||
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama")
|
||||
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama"),
|
||||
Map.entry("nautilus", "diamond")
|
||||
);
|
||||
|
||||
// Humanoid armor test cases (worn equipment, trims, glint, elytra).
|
||||
static EntityState.EquipPiece P(String asset) { return new EntityState.EquipPiece(asset, 0, null, null, false); }
|
||||
static EntityState.EquipPiece P(String asset, int dye, String trimMat, String trimPat, boolean glint) {
|
||||
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
|
||||
}
|
||||
|
||||
static final Map<String, EntityState.Equipment> ARMOR = new HashMap<>();
|
||||
static {
|
||||
// Full diamond armor.
|
||||
ARMOR.put("skeleton", new EntityState.Equipment(P("diamond"), P("diamond"), P("diamond"), P("diamond")));
|
||||
// Dyed (orange) leather armor.
|
||||
EntityState.EquipPiece le = P("leather", 0xFFFF8000, null, null, false);
|
||||
ARMOR.put("zombie", new EntityState.Equipment(le, le, le, le));
|
||||
// Iron armor with a coast/diamond trim.
|
||||
EntityState.EquipPiece ir = P("iron", 0, "diamond", "coast", false);
|
||||
ARMOR.put("armor_stand", new EntityState.Equipment(ir, ir, ir, ir));
|
||||
// Enchanted netherite armor (glint).
|
||||
EntityState.EquipPiece ne = P("netherite", 0, null, null, true);
|
||||
ARMOR.put("wither_skeleton", new EntityState.Equipment(ne, ne, ne, ne));
|
||||
// Enchanted diamond helmet + elytra in the chest slot.
|
||||
ARMOR.put("player", new EntityState.Equipment(P("diamond", 0, null, null, true), P("elytra"), null, null));
|
||||
// Gold helmet.
|
||||
ARMOR.put("piglin", new EntityState.Equipment(P("gold"), null, null, null));
|
||||
}
|
||||
|
||||
// Invisible test cases: armor_stand -> only its (trimmed iron) armor floats; creeper -> nothing renders.
|
||||
static final java.util.Set<String> INVISIBLE = java.util.Set.of("armor_stand", "creeper");
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Logger log = Logger.getLogger("test");
|
||||
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
|
||||
@@ -80,7 +109,9 @@ public class EntityTestRender {
|
||||
log.info("Loaded " + n + " geometries");
|
||||
SkinCache skins = new SkinCache();
|
||||
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins);
|
||||
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins);
|
||||
eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font =
|
||||
eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log);
|
||||
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins, font);
|
||||
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
|
||||
|
||||
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
|
||||
@@ -144,8 +175,12 @@ public class EntityTestRender {
|
||||
boolean isPlayer = key.equals("player");
|
||||
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0,
|
||||
isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
|
||||
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key));
|
||||
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key), ARMOR.get(key),
|
||||
INVISIBLE.contains(key));
|
||||
RenderedEntity re = baker.bake(s);
|
||||
if (re == null || re.cubes.isEmpty()) { // invisible with no equipment -> nothing renders
|
||||
return new BufferedImage(TW, TH, BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
|
||||
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;
|
||||
double cz = (re.aabbMin[2] + re.aabbMax[2]) / 2;
|
||||
|
||||
Reference in New Issue
Block a user