model adjustments
parent
87a78136ec
commit
532c3a0da8
113
README.md
113
README.md
|
|
@ -106,6 +106,13 @@ every attribute rather than only this mod's, and reads this mod's own attributes
|
||||||
back off the equipment as well, so the answer is the same whether or not the map
|
back off the equipment as well, so the answer is the same whether or not the map
|
||||||
ever lost them.
|
ever lost them.
|
||||||
|
|
||||||
|
**Counting gem cases.** `ApothicSockets.java` makes each Apothic gem case worth
|
||||||
|
an Apotheosis socket, which data cannot do at all - every route through MIAPI's
|
||||||
|
component properties is either dead or crashes the crafting screen, see below.
|
||||||
|
It is the one place this mod reaches into another's internals, and it does it
|
||||||
|
reflectively so that neither Apotheosis nor Truly Modular becomes something to
|
||||||
|
build against.
|
||||||
|
|
||||||
**One item.** The arcana core, which is the only thing here that is not a
|
**One item.** The arcana core, which is the only thing here that is not a
|
||||||
material or a module - see below.
|
material or a module - see below.
|
||||||
|
|
||||||
|
|
@ -142,15 +149,40 @@ right, each with the same `gem_armor_medium` slot the rest of the set uses and
|
||||||
the same price, 5% of the piece's armour.
|
the same price, 5% of the piece's armour.
|
||||||
|
|
||||||
They inherit Armory's heavy pieces whole and change two things: the slot, and
|
They inherit Armory's heavy pieces whole and change two things: the slot, and
|
||||||
where the gem sits in it. Armory's own gems are placed on the body - the chest's
|
where the gem sits in it.
|
||||||
sits on the sternum, from `origin: body` - so these do the same with the limb as
|
|
||||||
the origin. On an arm the gem is turned to face outwards and set at the top of
|
Where the gem sits is the unfinished part of these four, and the honest summary
|
||||||
the shoulder, in the pauldron; on a leg it faces forward, halfway down, in the
|
is that it is placed by trial rather than by arithmetic. MIAPI draws a worn piece
|
||||||
knee. The numbers come from Armory's own: a sprite lies proud of the side of an
|
once per body part; which part a module is drawn under comes from its own model's
|
||||||
arm at 2.15 and of the front of a body at 2.35, which is what its banner patches
|
`origin`, and where it sits comes from the matching entry of its slot's transform
|
||||||
use, so the gems use it too. They are eyeball values on someone else's model
|
stack. Armory's gemstone declares no origin, so a gem is drawn once, in the
|
||||||
and the four transforms are the only thing to touch if a gem sits proud or
|
`body` pass, from the `body` entry - which means a gem slot is placed in body
|
||||||
sinks in.
|
space no matter which limb it is cut into, and is pinned to the torso rather than
|
||||||
|
to the limb it belongs to.
|
||||||
|
|
||||||
|
The numbers are body-space pixels from the neck: +x to the wearer's left, +y
|
||||||
|
down, -z forward. A pauldron gem is turned a quarter turn about y to face out of
|
||||||
|
the shoulder; a knee gem faces forward already. Right-hand pieces are the same
|
||||||
|
numbers with x and the turn negated.
|
||||||
|
|
||||||
|
They are not right yet, and two attempts to make them right are worth recording
|
||||||
|
so that they are not repeated. Naming the limb as the gem slot's `origin` - so
|
||||||
|
the offset lands in the entry the limb pass reads and the gem follows the arm -
|
||||||
|
composes correctly on paper and in game puts the gems several blocks away from
|
||||||
|
the wearer, floating. And `tools/preview_armour.py`, which applies the same chain
|
||||||
|
MIAPI does and draws the result from any angle, agrees with the paper rather than
|
||||||
|
with the game: given the configuration that in practice left gems at the neck, it
|
||||||
|
puts them on the pauldron. The tool is worth keeping for reading Armory's own
|
||||||
|
geometry - where the plate bands are, which face is which, how far the chain
|
||||||
|
scales a limb - and it should not be trusted to predict a gem's final position
|
||||||
|
until it can reproduce a symptom somebody has actually seen.
|
||||||
|
|
||||||
|
One thing does not survive any of this and is not fixable from here. A slot
|
||||||
|
transform that names an origin has nothing left for the inventory icon, which
|
||||||
|
reads a different entry of the same stack, so the gem sits at the centre of the
|
||||||
|
icon - the collar of a chestplate, which is where Armory leaves its own, and the
|
||||||
|
crotch of a pair of pants, which is where this one leaves ours. The two
|
||||||
|
placements come from one transform and cannot both be aimed.
|
||||||
|
|
||||||
The pieces take any medium armour gemstone, which includes the Apothic gem case
|
The pieces take any medium armour gemstone, which includes the Apothic gem case
|
||||||
below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular
|
below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular
|
||||||
|
|
@ -161,11 +193,39 @@ for an Apotheosis one. Apotheosis gems are not modules and never can be - what
|
||||||
makes one worth having is its own rarity and purity data, which a MIAPI
|
makes one worth having is its own rarity and purity data, which a MIAPI
|
||||||
material cannot carry - so the two systems cannot share a slot. What they can
|
material cannot carry - so the two systems cannot share a slot. What they can
|
||||||
share is the item. Apotheosis stores how many gems a thing holds in a plain
|
share is the item. Apotheosis stores how many gems a thing holds in a plain
|
||||||
data component, `apotheosis:sockets`, and MIAPI's `component` property sets any
|
data component, `apotheosis:sockets`, and MIAPI can set any component a module
|
||||||
component a module likes, so the case is one line of data: put it in an armour
|
likes: put a case in an armour piece's `gem_armor_medium` slot instead of a
|
||||||
piece's `gem_armor_medium` slot instead of a gemstone, and the piece reports one
|
gemstone, and the piece reports an Apotheosis socket. The gem itself goes in at
|
||||||
Apotheosis socket. The gem itself goes in at a smithing table, through
|
a smithing table, through Apotheosis' own socketing recipe, not at the workbench.
|
||||||
Apotheosis' own socketing recipe, not at the workbench.
|
|
||||||
|
One socket a case, and none of it is in the module JSON, because every route
|
||||||
|
through data is closed. MIAPI's `components` property sets a component rather
|
||||||
|
than adding to it, so two cases both writing `1` leave the piece with one socket
|
||||||
|
between them. The property that could resolve a count instead of a constant,
|
||||||
|
`advanced_components`, is written but never registered in MIAPI 2.3.8 -
|
||||||
|
`PropertyRegistry` wires up `components` and `material_component_property` and
|
||||||
|
never mentions it - so that key resolves to nothing. And the one that does work
|
||||||
|
turned out to be worse than useless: `ComponentProperty.preview` dereferences the
|
||||||
|
workbench without a null check, MIAPI passes it null while hovering an option in
|
||||||
|
the replace list, and `shouldExecuteOnCraft` only calls it for a module that
|
||||||
|
carries the property - so a gem case with a `components` line crashed the
|
||||||
|
crafting screen, and only a gem case did.
|
||||||
|
|
||||||
|
So `ApothicSockets.java` answers it instead, and Apotheosis makes that easy by
|
||||||
|
asking the question out loud: `SocketHelper` reads the component and then fires
|
||||||
|
`GetItemSocketsEvent` so anything may revise the answer. This counts the cases on
|
||||||
|
the stack and adds one for each. Nothing writes the component now, which is
|
||||||
|
better than the data version managed: a socket cut by a Sigil of Socketing
|
||||||
|
survives and the cases stack on top, where the component used to overwrite it.
|
||||||
|
|
||||||
|
It reaches both mods reflectively rather than compiling against them. Neither is
|
||||||
|
a build dependency here and one socket is not worth making them one, so the seven
|
||||||
|
members it needs - three off MIAPI's module tree, three off the event, and the
|
||||||
|
event class itself - are resolved once at startup and cached. Missing classes
|
||||||
|
mean the mods are not installed, which is ordinary and silent; a missing method
|
||||||
|
means something was renamed, which is logged, because it is a thing to fix rather
|
||||||
|
than a thing to expect. Either way the count falls back to what the component
|
||||||
|
says on its own.
|
||||||
|
|
||||||
That leaves the two systems where they each work best. Apotheosis reads the
|
That leaves the two systems where they each work best. Apotheosis reads the
|
||||||
socket count through `SocketHelper`, which classifies the item with
|
socket count through `SocketHelper`, which classifies the item with
|
||||||
|
|
@ -175,16 +235,23 @@ arrive as attribute modifiers on the stack, through `ItemAttributeModifierEvent`
|
||||||
which is where MIAPI is not looking; MIAPI writes its own numbers into the
|
which is where MIAPI is not looking; MIAPI writes its own numbers into the
|
||||||
`ATTRIBUTE_MODIFIERS` component, and the two add up rather than overwrite.
|
`ATTRIBUTE_MODIFIERS` component, and the two add up rather than overwrite.
|
||||||
|
|
||||||
It wears Armory's own medium gemstone model rather than art of its own, which
|
It is drawn here rather than borrowed. It used to wear Armory's medium gemstone
|
||||||
is both the least work and the right picture: a case is a setting cut for a
|
model, which was the least work and the wrong picture: a case is the setting,
|
||||||
gem, in whatever material it was built from, and it sits exactly where a
|
not the stone, and one that looks like a gemstone is indistinguishable from the
|
||||||
gemstone would. Nothing is copied to do it - the module points at Armory's
|
gemstone it is an alternative to. So `items/gem_case.png` is a four-by-four
|
||||||
model path, and Armory is required for the pack to load at all.
|
bezel around an empty two-by-two socket - a gemstone's own footprint, with the
|
||||||
|
mount drawn around it - lit from the top left like the pommel sockets, and on
|
||||||
|
the same greys the materials' palettes map, so it comes out in whatever it was
|
||||||
|
built from. The socket stays empty-looking whatever is in it, which is honest:
|
||||||
|
an Apotheosis gem lives in a component and MIAPI has nothing to render for it.
|
||||||
|
|
||||||
One socket a case, which is the one-for-one trade the module is. Two things
|
The bezel is twice a gemstone across, which would put a case at twice a
|
||||||
worth knowing: the component is the module's while the module is in, so sockets
|
gemstone's size in a slot sized for one, so the module's own model transform
|
||||||
added with a Sigil of Socketing are overwritten by it and come back when the
|
carries a scale of 0.5 and hands the slot back a gemstone's footprint. That is
|
||||||
case comes out, and a gem sitting in a case stops counting if the case is
|
the module's business rather than the slot's: a socket that holds either should
|
||||||
|
not have to be scaled differently depending on which went in.
|
||||||
|
|
||||||
|
One thing worth knowing: a gem sitting in a case stops counting if the case is
|
||||||
removed - it is not destroyed, it is just in a socket that no longer exists.
|
removed - it is not destroyed, it is just in a socket that no longer exists.
|
||||||
|
|
||||||
**The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum,
|
**The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
package eu.abdelbaki.cmmodular;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.neoforged.bus.api.Event;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes each Apothic gem case worth one Apotheosis socket instead of the piece
|
||||||
|
* as a whole being worth one.
|
||||||
|
*
|
||||||
|
* <p>None of this is in the module JSON, and it cannot be. MIAPI's
|
||||||
|
* {@code components} property sets a component rather than adding to it, so two
|
||||||
|
* cases both writing 1 would leave the piece with one socket between them; the
|
||||||
|
* property that could resolve a count instead, {@code advanced_components}, is
|
||||||
|
* written but never registered in MIAPI 2.3.8, so that key is dead. And the one
|
||||||
|
* that does work is worse than useless here: {@code ComponentProperty.preview}
|
||||||
|
* dereferences the workbench without a null check, and MIAPI hands it null while
|
||||||
|
* hovering an option in the replace list, so merely carrying a {@code components}
|
||||||
|
* property crashes the crafting screen on a server.
|
||||||
|
*
|
||||||
|
* <p>Apotheosis asks the question itself. {@code SocketHelper.getSockets} reads
|
||||||
|
* the component and then fires {@code GetItemSocketsEvent} so anything can
|
||||||
|
* revise the answer, which is exactly what is wanted, so this counts the cases
|
||||||
|
* and revises it.
|
||||||
|
*
|
||||||
|
* <p>It does that without compiling against either mod. This mod deliberately
|
||||||
|
* has no dependencies to build on - Truly Modular and Apotheosis are runtime
|
||||||
|
* mods it talks to through data - and one socket is not worth giving that up.
|
||||||
|
* So both sides are reached reflectively and resolved once at startup. Failing
|
||||||
|
* costs nothing: the count reverts to what the component alone says, which is
|
||||||
|
* the behaviour without this class at all. Absence is silent, since a pack
|
||||||
|
* without those mods is the ordinary case, but a rename is logged, because
|
||||||
|
* that one is a thing to fix rather than a thing to expect.
|
||||||
|
*/
|
||||||
|
public final class ApothicSockets {
|
||||||
|
/** The one place this mod logs: a silent reflective failure is unfindable. */
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(CMModular.MOD_ID);
|
||||||
|
|
||||||
|
private static final String EVENT = "dev.shadowsoffire.apotheosis.event.GetItemSocketsEvent";
|
||||||
|
private static final String MIAPI_MODULE = "smartin.miapi.modules.ItemModule";
|
||||||
|
|
||||||
|
/** Module ids that are a gem case, generic and one per limb socket. */
|
||||||
|
private static final List<String> CASES = List.of(
|
||||||
|
"cmmodular:gem/apothic_case",
|
||||||
|
"cmmodular:gem/limb/case_arm_left",
|
||||||
|
"cmmodular:gem/limb/case_arm_right",
|
||||||
|
"cmmodular:gem/limb/case_leg_left",
|
||||||
|
"cmmodular:gem/limb/case_leg_right");
|
||||||
|
|
||||||
|
/** MIAPI's module tree, reached without naming its types at compile time. */
|
||||||
|
private static Method getModules;
|
||||||
|
private static Method getFlatList;
|
||||||
|
private static Method getModule;
|
||||||
|
private static Method moduleId;
|
||||||
|
|
||||||
|
/** Apotheosis' event, likewise. */
|
||||||
|
private static Method getStack;
|
||||||
|
private static Method getSockets;
|
||||||
|
private static Method setSockets;
|
||||||
|
|
||||||
|
private ApothicSockets() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static void register() {
|
||||||
|
Class<?> itemModule;
|
||||||
|
Class<?> moduleInstance;
|
||||||
|
Class<?> event;
|
||||||
|
try {
|
||||||
|
itemModule = Class.forName(MIAPI_MODULE);
|
||||||
|
moduleInstance = Class.forName("smartin.miapi.modules.ModuleInstance");
|
||||||
|
event = Class.forName(EVENT);
|
||||||
|
} catch (ClassNotFoundException absent) {
|
||||||
|
// One of the two mods is not installed, which is the ordinary case
|
||||||
|
// for a pack without them and nothing to say anything about. The
|
||||||
|
// classes answer that better than ModList does, which is not
|
||||||
|
// reliably populated this early.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
getModules = itemModule.getMethod("getModules", ItemStack.class);
|
||||||
|
getFlatList = moduleInstance.getMethod("getFlatList");
|
||||||
|
getModule = moduleInstance.getMethod("getModule");
|
||||||
|
moduleId = itemModule.getMethod("id");
|
||||||
|
getStack = event.getMethod("getStack");
|
||||||
|
getSockets = event.getMethod("getSockets");
|
||||||
|
setSockets = event.getMethod("setSockets", int.class);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Class<Event> type = (Class<Event>) event;
|
||||||
|
NeoForge.EVENT_BUS.addListener(type, (Consumer<Event>) ApothicSockets::onGetSockets);
|
||||||
|
} catch (ReflectiveOperationException | ClassCastException moved) {
|
||||||
|
// Both mods are here but something has been renamed, which is worth
|
||||||
|
// saying out loud: the cases still work, they just stop counting.
|
||||||
|
LOGGER.warn("Apothic gem cases will hold one socket a piece rather than one"
|
||||||
|
+ " each - Apotheosis or MIAPI has moved something: {}", moved.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers with a socket a case, added to whatever the stack already had.
|
||||||
|
* Nothing else here writes that component now, so a socket cut by a Sigil of
|
||||||
|
* Socketing survives and the cases stack on top of it, which is a better
|
||||||
|
* trade than the component's own answer of overwriting it.
|
||||||
|
*/
|
||||||
|
private static void onGetSockets(Event event) {
|
||||||
|
try {
|
||||||
|
int cases = countCases((ItemStack) getStack.invoke(event));
|
||||||
|
if (cases > 0) {
|
||||||
|
setSockets.invoke(event, (int) getSockets.invoke(event) + cases);
|
||||||
|
}
|
||||||
|
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||||
|
// Answering wrongly is worse than not answering: leave the count be.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countCases(ItemStack stack) throws ReflectiveOperationException {
|
||||||
|
Object root = getModules.invoke(null, stack);
|
||||||
|
if (root == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int found = 0;
|
||||||
|
for (Object module : (List<?>) getFlatList.invoke(root)) {
|
||||||
|
Object id = moduleId.invoke(getModule.invoke(module));
|
||||||
|
if (id != null && CASES.contains(id.toString())) {
|
||||||
|
found++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,7 @@ public class CMModular {
|
||||||
SandPhasing.register(modBus);
|
SandPhasing.register(modBus);
|
||||||
EquipmentAttributes.register(modBus);
|
EquipmentAttributes.register(modBus);
|
||||||
ArcanaCore.register(modBus);
|
ArcanaCore.register(modBus);
|
||||||
|
ApothicSockets.register();
|
||||||
modBus.addListener(this::addMaterialPacks);
|
modBus.addListener(this::addMaterialPacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"parent": "item/generated",
|
||||||
|
"textures": {
|
||||||
|
"layer0": "cmmodular:items/gem_case"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 142 B |
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"display_name": "miapi.module.cmmodular.gem.apothic_case.name",
|
"display_name": "miapi.module.cmmodular.gem.apothic_case.name",
|
||||||
"model": {
|
"model": {
|
||||||
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
|
"path": "cmmodular:models/item/armor/gems/case/[material.texture].json",
|
||||||
"transform": {
|
"transform": {
|
||||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||||
"translation": {"x": 0, "y": 0, "z": 0},
|
"translation": {"x": 0, "y": 0, "z": 0},
|
||||||
"scale": {"x": 1, "y": 1, "z": 1}
|
"scale": {"x": 0.5, "y": 0.5, "z": 0.5}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowed_in_slots": [
|
"allowed_in_slots": [
|
||||||
|
|
@ -17,9 +17,6 @@
|
||||||
"gem_armor_generic",
|
"gem_armor_generic",
|
||||||
"gem_armor_medium_generic"
|
"gem_armor_medium_generic"
|
||||||
],
|
],
|
||||||
"component": {
|
|
||||||
"apotheosis:sockets": 1
|
|
||||||
},
|
|
||||||
"material_property": [
|
"material_property": [
|
||||||
"default"
|
"default"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,9 @@
|
||||||
"translationKey": "miapi.slot.gemstone.medium",
|
"translationKey": "miapi.slot.gemstone.medium",
|
||||||
"transform": {
|
"transform": {
|
||||||
"rotation": {"x": 0, "y": 90, "z": 0},
|
"rotation": {"x": 0, "y": 90, "z": 0},
|
||||||
"translation": {"x": -2.15, "y": -2, "z": 0},
|
"translation": {"x": 8.6, "y": 1.2, "z": 0},
|
||||||
"scale": {"x": 1, "y": 1, "z": 1},
|
"scale": {"x": 1.4, "y": 1.4, "z": 1.4},
|
||||||
"origin": "left_arm"
|
"origin": "body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,9 @@
|
||||||
"translationKey": "miapi.slot.gemstone.medium",
|
"translationKey": "miapi.slot.gemstone.medium",
|
||||||
"transform": {
|
"transform": {
|
||||||
"rotation": {"x": 0, "y": -90, "z": 0},
|
"rotation": {"x": 0, "y": -90, "z": 0},
|
||||||
"translation": {"x": 2.15, "y": -2, "z": 0},
|
"translation": {"x": -8.6, "y": 1.2, "z": 0},
|
||||||
"scale": {"x": 1, "y": 1, "z": 1},
|
"scale": {"x": 1.4, "y": 1.4, "z": 1.4},
|
||||||
"origin": "right_arm"
|
"origin": "body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@
|
||||||
"translationKey": "miapi.slot.gemstone.medium",
|
"translationKey": "miapi.slot.gemstone.medium",
|
||||||
"transform": {
|
"transform": {
|
||||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||||
"translation": {"x": 0, "y": 5, "z": -2.35},
|
"translation": {"x": 1.9, "y": 18.5, "z": -2.8},
|
||||||
"scale": {"x": 1, "y": 1, "z": 1},
|
"scale": {"x": 1.4, "y": 1.4, "z": 1.4},
|
||||||
"origin": "left_leg"
|
"origin": "body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@
|
||||||
"translationKey": "miapi.slot.gemstone.medium",
|
"translationKey": "miapi.slot.gemstone.medium",
|
||||||
"transform": {
|
"transform": {
|
||||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||||
"translation": {"x": 0, "y": 5, "z": -2.35},
|
"translation": {"x": -1.9, "y": 18.5, "z": -2.8},
|
||||||
"scale": {"x": 1, "y": 1, "z": 1},
|
"scale": {"x": 1.4, "y": 1.4, "z": 1.4},
|
||||||
"origin": "right_leg"
|
"origin": "body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render where a gem lands on Armory's worn armour, without starting the game.
|
||||||
|
|
||||||
|
MIAPI places a module by composing its slot's transform with the ones above it
|
||||||
|
and then with the model part it names, so the only way to know whether a gem sits
|
||||||
|
on a pauldron or in mid air is to do that composition and look. This does it: it
|
||||||
|
reads Armory's own models out of its jar, applies the same chain MIAPI does, and
|
||||||
|
draws the result from as many angles as asked for.
|
||||||
|
|
||||||
|
python3 tools/preview_armour.py <armory.jar> [out.png]
|
||||||
|
|
||||||
|
Two things it does not simplify away, because both change the answer:
|
||||||
|
|
||||||
|
*Perspective.* An orthographic view flatters a placement - a gem floating a pixel
|
||||||
|
off a plate looks welded to it head on. Views are taken from several yaws and
|
||||||
|
pitches with a real camera so the gap shows.
|
||||||
|
|
||||||
|
*MIAPI's arithmetic.* `Transform.merge` does not compose matrices; it multiplies
|
||||||
|
them and then decomposes the product back into translation, Euler angles and
|
||||||
|
scale. That is lossy the moment a rotation meets a non-uniform scale, which is
|
||||||
|
exactly what Armory's limb slots are, so the shear is dropped and the gem comes
|
||||||
|
out turned and stretched differently from what the matrix says. `--exact` renders
|
||||||
|
the composition MIAPI does not do, for comparison.
|
||||||
|
|
||||||
|
Everything is in model pixels, the units the module JSON is written in: +x is the
|
||||||
|
wearer's left, +y is down, -z is forward, and the origin is the base of the neck.
|
||||||
|
"""
|
||||||
|
import argparse, json, math, zipfile
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- linear algebra
|
||||||
|
|
||||||
|
def ident():
|
||||||
|
return [[1.0 if r == c else 0.0 for c in range(4)] for r in range(4)]
|
||||||
|
|
||||||
|
def matmul(a, b):
|
||||||
|
return [[sum(a[r][k] * b[k][c] for k in range(4)) for c in range(4)] for r in range(4)]
|
||||||
|
|
||||||
|
def apply(m, p):
|
||||||
|
v = list(p) + [1.0]
|
||||||
|
return tuple(sum(m[r][c] * v[c] for c in range(4)) for r in range(3))
|
||||||
|
|
||||||
|
def translate(t):
|
||||||
|
m = ident()
|
||||||
|
for i in range(3):
|
||||||
|
m[i][3] = t[i]
|
||||||
|
return m
|
||||||
|
|
||||||
|
def scale(s):
|
||||||
|
m = ident()
|
||||||
|
for i in range(3):
|
||||||
|
m[i][i] = s[i]
|
||||||
|
return m
|
||||||
|
|
||||||
|
def rot(axis, deg):
|
||||||
|
a = math.radians(deg)
|
||||||
|
c, s = math.cos(a), math.sin(a)
|
||||||
|
m = ident()
|
||||||
|
if axis == 'x':
|
||||||
|
m[1][1], m[1][2], m[2][1], m[2][2] = c, -s, s, c
|
||||||
|
elif axis == 'y':
|
||||||
|
m[0][0], m[0][2], m[2][0], m[2][2] = c, s, -s, c
|
||||||
|
else:
|
||||||
|
m[0][0], m[0][1], m[1][0], m[1][1] = c, -s, s, c
|
||||||
|
return m
|
||||||
|
|
||||||
|
def transform_matrix(tr):
|
||||||
|
"""MIAPI's Transform.toMatrix: T * Rx * Ry * Rz * S, translation in pixels."""
|
||||||
|
g = lambda d, k, dflt: float(d.get(k, dflt)) if isinstance(d, dict) else dflt
|
||||||
|
t, r, s = tr.get('translation', {}), tr.get('rotation', {}), tr.get('scale', {})
|
||||||
|
m = translate((g(t, 'x', 0), g(t, 'y', 0), g(t, 'z', 0)))
|
||||||
|
for axis in 'xyz':
|
||||||
|
m = matmul(m, rot(axis, g(r, axis, 0)))
|
||||||
|
return matmul(m, scale((g(s, 'x', 1), g(s, 'y', 1), g(s, 'z', 1))))
|
||||||
|
|
||||||
|
# ------------------------------------------------- MIAPI's lossy merge, reproduced
|
||||||
|
|
||||||
|
def decompose(m):
|
||||||
|
"""MIAPI's Transform.fromMatrix: translation, XYZ Euler, per-column scale.
|
||||||
|
|
||||||
|
Any shear in the product is silently discarded, which is the whole reason
|
||||||
|
this function is here rather than a plain matrix multiply.
|
||||||
|
"""
|
||||||
|
t = (m[0][3], m[1][3], m[2][3])
|
||||||
|
cols = [[m[r][c] for r in range(3)] for c in range(3)]
|
||||||
|
s = [math.sqrt(sum(v * v for v in col)) or 1.0 for col in cols]
|
||||||
|
r = [[cols[c][row] / s[c] for c in range(3)] for row in range(3)]
|
||||||
|
# R = Rx*Ry*Rz, so sin(y) is r[0][2] and the other two follow from it.
|
||||||
|
y = math.asin(max(-1.0, min(1.0, r[0][2])))
|
||||||
|
x = math.atan2(-r[1][2], r[2][2])
|
||||||
|
z = math.atan2(-r[0][1], r[0][0])
|
||||||
|
return {'translation': {'x': t[0], 'y': t[1], 'z': t[2]},
|
||||||
|
'rotation': {'x': math.degrees(x), 'y': math.degrees(y), 'z': math.degrees(z)},
|
||||||
|
'scale': {'x': s[0], 'y': s[1], 'z': s[2]}}
|
||||||
|
|
||||||
|
def merge(parent, child, exact=False):
|
||||||
|
"""MIAPI's Transform.merge: the parent applies first, then the product is
|
||||||
|
decomposed and rebuilt. `exact` keeps the product instead."""
|
||||||
|
product = matmul(child, parent)
|
||||||
|
return product if exact else transform_matrix(decompose(product))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- the figure
|
||||||
|
|
||||||
|
# HumanoidModel part pivots, the frame each origin resolves against. Checked
|
||||||
|
# against HumanoidModel.createMesh rather than remembered.
|
||||||
|
PIVOTS = {
|
||||||
|
'head': (0, 0, 0), 'hat': (0, 0, 0), 'body': (0, 0, 0),
|
||||||
|
'left_arm': (5, 2, 0), 'right_arm': (-5, 2, 0),
|
||||||
|
'left_leg': (1.9, 12, 0), 'right_leg': (-1.9, 12, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
ARMORY = 'assets/miapi/models/item/armor/model'
|
||||||
|
|
||||||
|
def boxes_from_model(jar, path):
|
||||||
|
with zipfile.ZipFile(jar) as z:
|
||||||
|
data = json.loads(z.read(path))
|
||||||
|
return [(tuple(e['from']), tuple(e['to'])) for e in data.get('elements', [])]
|
||||||
|
|
||||||
|
def place(boxes, chain, pivot):
|
||||||
|
"""Local boxes through the chain and into the part's frame, as 8 corners."""
|
||||||
|
out = []
|
||||||
|
for lo, hi in boxes:
|
||||||
|
pts = []
|
||||||
|
for i in range(8):
|
||||||
|
corner = (hi[0] if i & 1 else lo[0], hi[1] if i & 2 else lo[1], hi[2] if i & 4 else lo[2])
|
||||||
|
x, y, z = apply(chain, corner)
|
||||||
|
pts.append((x + pivot[0], y + pivot[1], z + pivot[2]))
|
||||||
|
out.append(pts)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def gem_box(half_xy, half_z=0.5):
|
||||||
|
"""A gem sprite as a slab centred on its own origin, the way MIAPI bakes it."""
|
||||||
|
return [((-half_xy, -half_xy, -half_z), (half_xy, half_xy, half_z))]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- drawing
|
||||||
|
|
||||||
|
# The 6 faces of a box as indices into the 8 corners produced by place().
|
||||||
|
FACES = [(0, 1, 3, 2), (4, 5, 7, 6), (0, 1, 5, 4), (2, 3, 7, 6), (0, 2, 6, 4), (1, 3, 7, 5)]
|
||||||
|
|
||||||
|
def camera(yaw, pitch, dist, target=(0, 8, 0)):
|
||||||
|
"""World -> eye, looking at the figure's middle from yaw/pitch degrees out.
|
||||||
|
|
||||||
|
Yaw 0 is the wearer's front. Minecraft faces a model down -z, so the camera
|
||||||
|
starts on that side rather than on +z, which would label the back "front" and
|
||||||
|
is exactly the sort of quiet inversion this tool exists to avoid.
|
||||||
|
"""
|
||||||
|
m = translate((0, 0, -dist))
|
||||||
|
m = matmul(m, rot('x', pitch))
|
||||||
|
m = matmul(m, rot('y', yaw + 180))
|
||||||
|
return matmul(m, translate((-target[0], -target[1], -target[2])))
|
||||||
|
|
||||||
|
def render(groups, out_path, views=None, size=300, fov=38.0):
|
||||||
|
"""One panel per view. `groups` is (fill, outline, label, [boxes])."""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
views = views or [('front', 0, 0), ('front-left', 35, 10), ('left', 90, 0),
|
||||||
|
('above-left', 45, 35), ('back', 180, 0), ('below-left', 40, -30)]
|
||||||
|
cols = min(3, len(views))
|
||||||
|
rows = (len(views) + cols - 1) // cols
|
||||||
|
img = Image.new('RGB', (size * cols, size * rows), (26, 26, 30))
|
||||||
|
d = ImageDraw.Draw(img, 'RGBA')
|
||||||
|
f = (size / 2) / math.tan(math.radians(fov) / 2)
|
||||||
|
|
||||||
|
for i, (label, yaw, pitch) in enumerate(views):
|
||||||
|
ox, oy = (i % cols) * size, (i // cols) * size
|
||||||
|
view = camera(yaw, pitch, 46.0)
|
||||||
|
|
||||||
|
def project(p):
|
||||||
|
x, y, z = apply(view, p)
|
||||||
|
# +y is down in model space, and the camera looks down -z.
|
||||||
|
depth = max(0.1, -z)
|
||||||
|
return (ox + size / 2 + x * f / depth, oy + size / 2 + y * f / depth), depth
|
||||||
|
|
||||||
|
polys = []
|
||||||
|
for fill, outline, _, boxes in groups:
|
||||||
|
for pts in boxes:
|
||||||
|
eye = [apply(view, p) for p in pts]
|
||||||
|
if all(e[2] > -0.1 for e in eye):
|
||||||
|
continue # entirely behind the camera
|
||||||
|
for face in FACES:
|
||||||
|
quad = [project(pts[k])[0] for k in face]
|
||||||
|
z = sum(project(pts[k])[1] for k in face) / 4
|
||||||
|
polys.append((z, quad, fill, outline))
|
||||||
|
for _, quad, fill, outline in sorted(polys, key=lambda t: -t[0]):
|
||||||
|
d.polygon(quad, fill=fill, outline=outline)
|
||||||
|
d.text((ox + 8, oy + 6), f"{label} yaw {yaw} pitch {pitch}", fill=(190, 190, 200))
|
||||||
|
img.save(out_path)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- the scene
|
||||||
|
|
||||||
|
def scene(jar, repo, exact=False):
|
||||||
|
"""Armory's heavy pieces plus a gem in each of this mod's four sockets."""
|
||||||
|
def socket(name):
|
||||||
|
with open(f'{repo}/src/main/resources/packs/armory/data/cmmodular/miapi'
|
||||||
|
f'/modules/armor/socket/{name}.json') as fh:
|
||||||
|
return json.load(fh)['data']['replace']['slots']['gem']['transform']
|
||||||
|
|
||||||
|
with zipfile.ZipFile(jar) as z:
|
||||||
|
chest = json.loads(z.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
|
||||||
|
pants = json.loads(z.read('data/tm_armory/miapi/modules/armor/pants.json'))['slots']
|
||||||
|
|
||||||
|
plate, gems = [], []
|
||||||
|
for part, chain_json, model, sock in (
|
||||||
|
('left_arm', chest['arm_left'], 'arm_left/heavy', 'arm_left'),
|
||||||
|
('right_arm', chest['arm_right'], 'arm_right/heavy', 'arm_right'),
|
||||||
|
('left_leg', pants['leg_left'], 'leg_left/heavy', 'leg_left'),
|
||||||
|
('right_leg', pants['leg_right'], 'leg_right/heavy', 'leg_right'),
|
||||||
|
):
|
||||||
|
limb = transform_matrix(chain_json['transform'])
|
||||||
|
plate += place(boxes_from_model(jar, f'{ARMORY}/{model}/default.json'), limb, PIVOTS[part])
|
||||||
|
gems += place(gem_box(1.0), merge(transform_matrix(socket(sock)), limb, exact), PIVOTS[part])
|
||||||
|
|
||||||
|
# Armory's own socketed chest, as a control: its gem is known to sit on the
|
||||||
|
# sternum, so if this lands there the chain above is being modelled right.
|
||||||
|
body = transform_matrix(chest['chest_front']['transform'])
|
||||||
|
plate += place(boxes_from_model(jar, f'{ARMORY}/chest_front/socket/default.json'), body, PIVOTS['body'])
|
||||||
|
gems += place(gem_box(1.0), merge(transform_matrix(
|
||||||
|
{'translation': {'y': -1}, 'scale': {'x': 1.1, 'y': 1.1, 'z': 1.1}}), body, exact), PIVOTS['body'])
|
||||||
|
return plate, gems
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument('jar')
|
||||||
|
ap.add_argument('out', nargs='?', default='armour-preview.png')
|
||||||
|
ap.add_argument('--exact', action='store_true',
|
||||||
|
help="compose matrices properly instead of reproducing MIAPI's lossy merge")
|
||||||
|
ap.add_argument('--repo', default='.')
|
||||||
|
args = ap.parse_args()
|
||||||
|
plate, gems = scene(args.jar, args.repo, args.exact)
|
||||||
|
print(render([((78, 62, 104, 255), (120, 104, 150, 255), 'plate', plate),
|
||||||
|
((86, 216, 122, 255), (235, 255, 240, 255), 'gem', gems)], args.out))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue