Volver al índice
src/main/java/ar/com/companeros/horror/client/ParasiteRig.java
package ar.com.companeros.horror.client;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Anatomía original compartida por Minecraft y el visor: 16 unidades por bloque, suelo Y=24. */
public final class ParasiteRig {
public static final int ATLAS_SIZE = 128;
private static final float PI = (float) Math.PI, DEG = PI / 180F;
private static final List<Bone> HORROR_BONES = buildHorrorBones();
private static final List<Bone> FRIENDLY_BONES = friendlyBones();
private static final Map<String, String> PARENTS = parents();
public record Box(int u, int v, float x, float y, float z, float w, float h, float d, boolean mirror) {}
public record Bone(String name, String parent, float x, float y, float z, List<Box> boxes) {}
public record State(int stage, float limbSwing, float limbAmount, float age,
float yaw, float pitch, boolean pursuing, boolean climbing,
boolean ambushing, boolean crawling, int encounterTicks, int attackTicks,
float compactProgress, float leftFootOffset, float rightFootOffset) {
public State(int stage, float limbSwing, float limbAmount, float age,
float yaw, float pitch, boolean pursuing, boolean climbing,
boolean ambushing, boolean crawling, int encounterTicks, int attackTicks,
float compactProgress) {
this(stage, limbSwing, limbAmount, age, yaw, pitch, pursuing, climbing,
ambushing, crawling, encounterTicks, attackTicks, compactProgress, 0, 0);
}
public State(int stage, float limbSwing, float limbAmount, float age,
float yaw, float pitch, boolean pursuing, boolean climbing,
boolean ambushing, boolean crawling, int encounterTicks, int attackTicks) {
this(stage, limbSwing, limbAmount, age, yaw, pitch, pursuing, climbing,
ambushing, crawling, encounterTicks, attackTicks, 0, 0, 0);
}
}
public record Bounds(float minX, float minY, float minZ, float maxX, float maxY, float maxZ) {
public float width() { return maxX - minX; }
public float height() { return maxY - minY; }
public float depth() { return maxZ - minZ; }
}
public static final class Pose {
public float x, y, z, xRot, yRot, zRot;
public float xScale = 1, yScale = 1, zScale = 1;
public boolean visible = true;
private Pose(Bone bone) { x = bone.x(); y = bone.y(); z = bone.z(); }
}
private ParasiteRig() {}
// ANATOMÍA: rostro y ropa conservan los UV de la skin del huésped, sin máscara negra fija.
public static List<Bone> bones(boolean friendly) { return friendly ? FRIENDLY_BONES : HORROR_BONES; }
private static List<Bone> buildHorrorBones() {
List<Bone> bones = new ArrayList<>();
add(bones, "world", "", 0, 0, 0);
add(bones, "body", "world", 0, 0, 0);
add(bones, "spine_lower", "body", 0, 0, 0);
add(bones, "spine_lower_mesh", "spine_lower", 0, 0, 0, box(16, 24, -3.7F, -4, -2, 7.4F, 4, 4));
add(bones, "spine_mid", "spine_lower", 0, -4, 0);
add(bones, "spine_mid_mesh", "spine_mid", 0, 0, 0, box(16, 20, -3.9F, -4, -2, 7.8F, 4, 4));
add(bones, "spine_upper", "spine_mid", 0, -4, 0);
add(bones, "chest", "spine_upper", 0, 0, 0, box(16, 16, -4, -4, -2, 8, 4, 4));
for (int i = 0; i < 3; i++) {
String neck = "neck" + i;
add(bones, neck, i == 0 ? "spine_upper" : "neck" + (i - 1), 0, i == 0 ? -4 : -3, 0);
add(bones, neck + "_mesh", neck, 0, 0, 0,
box(64, 12, -1.25F + i * .075F, -3, -1.25F + i * .075F, 2.5F - i * .15F, 3, 2.5F - i * .15F));
if (i > 0) {
float width = 2.48F - (i - 1) * .15F;
add(bones, neck + "_skin_joint", neck, 0, 0, 0,
box(64, 12, -width * .5F, -.41F, -width * .5F, width, .82F, width));
}
}
// La mandíbula es la franja inferior real de la cara, articulada sólo al hablar/morder.
add(bones, "head", "neck2", 0, -3, 0, box(0, 0, -4, -8, -4, 8, 6, 8));
add(bones, "jaw", "head", 0, -2, 0, box(0, 6, -4, 0, -4, 8, 2, 8));
add(bones, "mouth", "head", 0, -2, -4.015F, box(96, 96, -1.15F, -.25F, 0, 2.3F, .5F, .04F));
add(bones, "jaw_cavity", "jaw", 0, 0, -4.02F, box(96, 96, -1.12F, -.25F, 0, 2.24F, .5F, .04F));
add(bones, "upper_teeth", "head", 0, -2, -4.06F,
box(112, 96, -.8F, -.08F, 0, .28F, .22F, .06F), box(112, 96, .5F, -.08F, 0, .26F, .21F, .06F));
add(bones, "lower_teeth", "jaw", 0, 0, -4.06F, box(112, 96, -.12F, -.12F, 0, .25F, .2F, .06F));
// Los laterales de la cara se estiran con la mandíbula; no son una máscara permanente.
add(bones, "cheek_l", "head", 3.4F, -2, -3.55F, box(8, 10, -.28F, 0, -.2F, .56F, 1.15F, .4F));
add(bones, "cheek_r", "head", -3.4F, -2, -3.55F, box(8, 10, -.28F, 0, -.2F, .56F, 1.15F, .4F));
limb(bones, "arm_l", 3.2F, 1, 0, 32, 48, 32, 54, true);
limb(bones, "arm_r", -3.2F, 1, 0, 40, 16, 40, 22, true);
limb(bones, "leg_l", 1.7F, 12, 0, 16, 48, 16, 54, false);
limb(bones, "leg_r", -1.7F, 12, 0, 0, 16, 0, 22, false);
// Prefabs propios: cada pieza sigue su hueso y no sustituye la skin del huésped.
for (int style = 1; style <= 4; style++) {
int u = style == 1 || style == 2 || style == 4 ? 64 : 96;
int v = style == 1 ? 64 : style == 2 ? 96 : style == 3 ? 64 : 32;
add(bones, "plate_" + style + "_chest", "chest", 0, 0, 0,
box(u, v, -4.12F, -3.9F, -2.22F, 8.24F, 3.7F, .5F));
for (String limb : new String[]{"arm_l", "arm_r", "leg_l", "leg_r"}) {
add(bones, "plate_" + style + "_" + limb, limb + "_lower", 0, 0, 0,
box(u, v, -2.15F, 1, -2.18F, 4.3F, 4, .5F));
add(bones, "joint_" + style + "_" + limb, limb + "_joint", 0, 0, 0,
box(u, v, -1.8F, -.65F, -1.9F, 3.6F, 1.3F, 3.8F));
}
}
return List.copyOf(bones);
}
private static void limb(List<Bone> bones, String name, float x, float y, float z,
int upperU, int upperV, int lowerU, int lowerV, boolean arm) {
add(bones, name, arm ? "spine_upper" : "body", x, arm ? -3.5F : 0, z);
add(bones, name + "_upper", name, 0, 0, 0, box(upperU, upperV, -2, 0, -2, 4, 6, 4));
add(bones, name + "_joint", name, 0, 6, 0);
// La tibia toma sólo pantalón; la franja final de la skin queda reservada al zapato.
int fleshU = arm ? (name.endsWith("_l") ? 80 : 64) : lowerU;
int fleshV = arm ? 0 : lowerV;
add(bones, name + "_lower", name + "_joint", 0, 0, 0, box(fleshU, fleshV, -2, 0, -2, 4, arm ? 6 : 2, 4));
// El pliegue exterior une ambos extremos de piel; los huesos mantienen su longitud.
add(bones, name + "_skin_fold", name + "_joint", 0, 0, 0,
box(fleshU, fleshV, -2, -.5F, -.07F, 4, 1, .14F));
add(bones, name + "_hand", name + "_joint", 0, 6, 0,
box(lowerU, lowerV + (arm ? 3 : 4), -2, 0, -2, 4, arm ? 2.6F : 2, 4));
if (arm) for (int digit = 0; digit < 4; digit++) {
String finger = name + "_finger" + digit;
// Tres falanges continuas: nudillo ancho, medio más fino y punta de piel.
float length = new float[]{2.45F, 3.05F, 2.85F, 2.15F}[digit];
float proximal = length * .45F, middle = length * .3F, distal = length * .25F;
add(bones, finger, name + "_hand", -1.28F + digit * .86F, 2.32F, -.25F,
box(lowerU, lowerV + 3, -.38F, 0, -.35F, .76F, proximal, .7F));
add(bones, finger + "_middle", finger, 0, proximal - .04F, 0,
box(lowerU, lowerV + 3, -.345F, 0, -.32F, .69F, middle, .64F));
add(bones, finger + "_tip", finger + "_middle", 0, middle - .04F, 0,
box(lowerU, lowerV + 3, -.315F, 0, -.29F, .63F, distal, .58F));
}
if (arm) {
// El pulgar nace del lateral de la palma y se opone a los cuatro dedos.
add(bones, name + "_thumb", name + "_hand", x > 0 ? -1.73F : 1.73F, .95F, -.15F,
box(lowerU, lowerV + 3, -.41F, 0, -.36F, .82F, 1.02F, .72F));
add(bones, name + "_thumb_tip", name + "_thumb", 0, .97F, 0,
box(lowerU, lowerV + 3, -.34F, 0, -.3F, .68F, .74F, .6F));
}
}
// ASPECTO SENSIBLE: conserva el modelo amable, sin articulaciones ni expresiones de terror.
private static List<Bone> friendlyBones() {
List<Bone> bones = new ArrayList<>();
add(bones, "body", "", 0, 0, 0, box(0, 0, -4, 0, -2, 8, 12, 4));
add(bones, "neck", "", 0, 0, 0, box(40, 0, -1.5F, -3, -1.5F, 3, 3, 3));
add(bones, "head", "", 0, -3, 0, box(0, 24, -3, -6, -3, 6, 6, 6));
add(bones, "eyes", "head", 0, 0, 0,
box(96, 0, -2, -4, -3.15F, 1.1F, 1.5F, .25F), box(96, 0, .9F, -4, -3.15F, 1.1F, 1.5F, .25F));
add(bones, "smile", "head", 0, 0, 0, box(100, 0, -1.2F, -1.6F, -3.13F, 2.4F, .35F, .2F));
for (boolean left : new boolean[]{true, false}) {
String arm = left ? "arm_l" : "arm_r";
add(bones, arm, "", left ? 4 : -4, 1, 0,
new Box(32, 20, left ? 0 : -2, 0, -1, 2, 8, 2, !left));
add(bones, arm + "_joint", arm, left ? 1 : -1, 7, 0,
new Box(48, 20, -1, 0, -1, 2, 8, 2, !left));
}
add(bones, "leg_l", "", 2, 12, 0, box(0, 48, -1.5F, 0, -1.5F, 3, 12, 3));
add(bones, "leg_r", "", -2, 12, 0, box(16, 48, -1.5F, 0, -1.5F, 3, 12, 3));
return List.copyOf(bones);
}
// POSTURA: se reconstruye cada frame. El ciclo de los apoyos depende de distancia, no de edad.
public static Map<String, Pose> poses(State s, boolean friendly) {
return poses(s, friendly, 0);
}
public static Map<String, Pose> poses(State s, boolean friendly, int appearance) {
Map<String, Pose> p = new LinkedHashMap<>();
for (Bone bone : bones(friendly)) p.put(bone.name(), new Pose(bone));
if (friendly) { friendlyPose(p, s); return p; }
for (String name : p.keySet()) if (name.startsWith("plate_") || name.startsWith("joint_")) {
int style = name.charAt(6) - '0';
int slot = name.endsWith("chest") ? 0 : name.contains("arm_") ? 1 : 2;
p.get(name).visible = style == ((appearance >> (slot * 3)) & 7);
}
float mutation = clamp(s.stage(), 0, 4) / 4F;
float compact = clamp(s.compactProgress(), 0, 1);
float low = smooth(0, 1, compact);
float motion = clamp(s.limbAmount(), 0, 1), cycle = s.limbSwing() * (s.pursuing() ? .72F : .47F);
float torso = mix(11.25F, 14.4F, mutation);
float upperLeg = mix(6F, 16F, mutation), lowerLeg = mix(6F, 16F, mutation);
float upperArm = mix(5.15F, 15F, mutation), lowerArm = mix(5.15F, 16F, mutation);
Pose body = p.get("body");
body.xRot = mix(.025F, .08F, low);
body.y = mix(22.05F - upperLeg - lowerLeg + 1.9F + (s.pursuing() ? 2.5F : 0), 20.5F, low);
float breathing = sin(s.age() * .045F) * .025F * mutation;
p.get("spine_lower").xRot = mix(.12F + (s.pursuing() ? .25F : 0), 1.45F, low);
// A tres bloques flexiona la espalda: bajar el collider no debe comprimir su anatomía.
p.get("spine_lower").xRot += .46F * smooth(0, .27272F, compact) * (1 - smooth(.27272F, .63636F, compact));
p.get("spine_mid").xRot = mix(.08F + breathing, .07F, low);
p.get("spine_upper").xRot = mix(-.08F - breathing * .5F, -.03F, low);
for (String spine : new String[]{"spine_lower_mesh", "spine_mid_mesh", "chest"}) p.get(spine).yScale = torso / 12;
p.get("spine_mid").y = p.get("spine_upper").y = -torso / 3;
p.get("chest").xScale = .82F;
p.get("chest").zScale = .92F + sin(s.age() * .055F) * .012F * mutation;
float headSize = mix(.91F, .84F, mutation);
p.get("head").xScale = p.get("head").yScale = p.get("head").zScale = headSize;
// Cuello continuo: escalar su caja conserva independientes las posiciones de los hijos.
float segment = mix(.8F, 7.5F, mutation) / 3;
for (int i = 0; i < 3; i++) {
// El primer segmento nace en la vértebra superior; sólo los dos
// siguientes se encadenan hacia abajo. Mantener este pivote local
// evita hundir la cabeza varios píxeles al estar de pie.
p.get("neck" + i).y = i == 0 ? 0 : -segment;
p.get("neck" + i + "_mesh").yScale = segment / 3;
p.get("neck" + i).xRot = mutation * (i == 0 ? .15F : i == 1 ? -.11F : -.06F);
}
p.get("head").y = -segment;
p.get("head").yRot = clamp(s.yaw() * DEG, -1.12F, 1.12F);
p.get("head").xRot = clamp(s.pitch() * DEG, -.8F, .8F) - spinePitch(p);
p.get("head").zRot = mutation * .025F + sin(s.age() * .035F) * mutation * .012F;
for (String face : new String[]{"mouth", "jaw_cavity", "upper_teeth", "lower_teeth", "cheek_l", "cheek_r"}) p.get(face).visible = false;
float legUpper = upperLeg, legLower = lowerLeg;
float armUpper = upperArm, armLower = lowerArm;
for (int side : new int[]{1, -1}) {
String leg = side > 0 ? "leg_l" : "leg_r", arm = side > 0 ? "arm_l" : "arm_r";
configureLimb(p, leg, legUpper, legLower, .7F, false, mutation, compact);
configureLimb(p, arm, armUpper, armLower, .64F, true, mutation, compact);
p.get(leg).y = 0;
p.get(leg).x = side * mix(1.7F, 1.35F, compact);
// Separar el hombro del volumen dorsal evita que el brazo atraviese
// la vértebra media cuando el torso gira durante el encuentro.
p.get(arm).x = side * mix(5.5F, 3.05F, smooth(0, .2F, compact));
p.get(arm).y = -torso / 3 + 1.2F;
float phase = cycle + (side < 0 ? PI : 0);
float[] step = footStep(phase, motion, s.pursuing(), low);
float groundOffset = clamp(side > 0 ? s.leftFootOffset() : s.rightFootOffset(), -.4F, .5F) * 16;
solveWorldTarget(p, leg, 22.15F - step[1] - groundOffset, mix(step[0] + .9F, 3F + step[0] * .22F, low), legUpper, legLower, -1);
p.get(leg + "_hand").xRot = -body.xRot - p.get(leg).xRot - p.get(leg + "_joint").xRot;
// Contrapeso de brazos suave. Correr no convierte el cuerpo en cuatro resortes.
float swing = sin(phase) * motion * (s.pursuing() ? .5F : .2F);
p.get(arm).xRot = -.16F - mutation * .08F - swing - spinePitch(p);
p.get(arm + "_joint").xRot = -.28F - Math.max(0, swing) * .35F;
p.get(arm + "_hand").xRot = -.12F;
}
if (low > .01F) crawlPose(p, s, low, armUpper, armLower, legUpper, legLower);
if (!s.climbing() && low < .05F) {
float counter = sin(cycle) * motion * (s.pursuing() ? .11F : .04F);
p.get("spine_lower").yRot = -counter;
p.get("spine_upper").yRot = counter * 1.4F;
p.get("body").x = sin(cycle) * motion * .18F;
}
if (s.ambushing() && motion < .04F) {
p.get("head").xRot -= mutation * .075F;
p.get("arm_l_joint").xRot += mutation * .12F;
}
if (s.climbing()) climbPose(p, s);
// ACECHO: el peso se desvanece antes de caminar o plegarse; no hay un interruptor
// en .08 de movimiento que teletransporte cuello y brazos a otra pose.
if (!s.pursuing() && !s.climbing() && !s.crawling() && s.encounterTicks() < 0 && mutation >= .5F) {
float idleWeight = 1 - smooth(.03F, .14F, motion);
float uprightWeight = 1 - smooth(.55F, .82F, compact);
stalkPose(p, s, mutation, idleWeight * uprightWeight);
}
if (s.encounterTicks() >= 0) encounterPose(p, s, mutation);
if (s.attackTicks() > 0) attackPose(p, s, mutation);
bridgeSkinJoints(p);
constrainBody(p, s, compact);
return p;
}
private static void configureLimb(Map<String, Pose> p, String name, float upper, float lower,
float thickness, boolean arm, float mutation, float compact) {
Pose upperBox = p.get(name + "_upper"), lowerBox = p.get(name + "_lower");
upperBox.yScale = upper / 6; lowerBox.yScale = lower / (arm ? 6 : 2);
upperBox.xScale = upperBox.zScale = lowerBox.xScale = lowerBox.zScale = thickness;
p.get(name + "_joint").y = upper; p.get(name + "_hand").y = lower;
// La bisagra queda en el borde de la piel: al plegar no mete un segmento dentro del otro.
float hinge = 2F * thickness * (arm ? 1 : -1);
p.get(name + "_joint").z = hinge;
p.get(name + "_lower").z = p.get(name + "_hand").z = -hinge;
p.get(name + "_hand").xScale = p.get(name + "_hand").zScale = thickness;
if (arm) p.get(name + "_hand").yScale = thickness;
if (arm) for (int digit = 0; digit < 4; digit++) {
Pose finger = p.get(name + "_finger" + digit);
finger.visible = mutation >= .25F;
finger.xScale = finger.yScale = finger.zScale = mix(.55F, .8F, mutation);
finger.xRot = .1F + digit * .025F;
p.get(name + "_finger" + digit + "_middle").xRot = .18F + digit * .015F;
p.get(name + "_finger" + digit + "_tip").xRot = .2F;
}
if (arm) {
p.get(name + "_thumb").visible = mutation >= .25F;
p.get(name + "_thumb").zRot = name.endsWith("_l") ? .55F : -.55F;
p.get(name + "_thumb").yRot = name.endsWith("_l") ? .28F : -.28F;
p.get(name + "_thumb").xRot = .28F;
p.get(name + "_thumb_tip").xRot = .26F;
}
}
// PIEL CONTINUA: une las dos caras externas de cada codo o rodilla, sin trasladar la bisagra.
private static void bridgeSkinJoints(Map<String, Pose> p) {
for (String limb : new String[]{"arm_l", "arm_r", "leg_l", "leg_r"}) {
Pose joint = p.get(limb + "_joint"), skin = p.get(limb + "_skin_fold");
float angle = joint.xRot, hinge = joint.z;
skin.y = -hinge * sin(angle);
skin.z = -hinge * (1 + cos(angle));
skin.xRot = -.5F * angle;
skin.xScale = skin.zScale = abs(hinge) * .5F;
skin.yScale = Math.max(.12F, 4 * abs(hinge) * abs(sin(angle * .5F)) + .06F);
}
for (int i = 1; i < 3; i++) {
Pose joint = p.get("neck" + i), skin = p.get("neck" + i + "_skin_joint");
skin.xRot = -.5F * joint.xRot;
skin.yRot = -.5F * joint.yRot;
skin.zRot = -.5F * joint.zRot;
}
}
// APOYO E IK: 62% del ciclo con el pie plantado; sólo se eleva al volver hacia delante.
public static float[] footStep(float phase, float motion, boolean running, float low) {
float cycle = (phase / (2 * PI)) % 1;
if (cycle < 0) cycle++;
float reach = (running ? 8.5F : 3.9F) * motion, z, lift;
if (cycle < .62F) { z = mix(-reach, reach, cycle / .62F); lift = 0; }
else {
float travel = (cycle - .62F) / .38F;
z = mix(reach, -reach, smooth(0, 1, travel));
lift = sin(travel * PI) * (running ? 2F : 1.2F) * motion * (1 - low * .55F);
}
return new float[]{z, lift};
}
private static void solveWorldTarget(Map<String, Pose> p, String name, float targetY, float targetZ,
float upperLength, float lowerLength, int bend) {
Pose upper = p.get(name), joint = p.get(name + "_joint");
String parent = parent(p, name);
float[] origin = transform(p, parent, upper.x, upper.y, upper.z);
float[] axisY = transform(p, parent, upper.x, upper.y + 1, upper.z);
float[] axisZ = transform(p, parent, upper.x, upper.y, upper.z + 1);
float dy = targetY - origin[1], dz = targetZ - origin[2];
float localY = dy * (axisY[1] - origin[1]) + dz * (axisY[2] - origin[2]);
float localZ = dy * (axisZ[1] - origin[1]) + dz * (axisZ[2] - origin[2]);
float hinge = joint.z;
float upperAxisLength = (float)Math.hypot(upperLength, hinge), lowerAxisLength = (float)Math.hypot(lowerLength, hinge);
float distance = clamp((float)Math.sqrt(localY * localY + localZ * localZ),
Math.abs(upperAxisLength - lowerAxisLength) + .05F, upperAxisLength + lowerAxisLength - .025F);
float first = acos((upperAxisLength * upperAxisLength + distance * distance - lowerAxisLength * lowerAxisLength) / (2 * upperAxisLength * distance));
float knee = acos((distance * distance - upperAxisLength * upperAxisLength - lowerAxisLength * lowerAxisLength) / (2 * upperAxisLength * lowerAxisLength));
upper.xRot = (float)Math.atan2(localZ, localY) - bend * first - (float)Math.atan2(hinge, upperLength);
joint.xRot = bend * knee + (float)Math.atan2(hinge, upperLength) + (float)Math.atan2(hinge, lowerLength);
}
private static float spinePitch(Map<String, Pose> p) {
return p.get("body").xRot + p.get("spine_lower").xRot + p.get("spine_mid").xRot + p.get("spine_upper").xRot;
}
// PASO BAJO: las rodillas cambian de dirección, pero los pies siguen teniendo un destino real.
// Mezclar ángulos de una pierna de pie y otra plegada hacía flotar el paso de dos bloques.
private static void crawlPose(Map<String, Pose> p, State s, float blend, float armUpper, float armLower, float legUpper, float legLower) {
float phase = s.limbSwing() * (s.pursuing() ? .82F : .32F);
float moving = clamp(s.limbAmount() * (s.pursuing() ? 1.15F : 1), 0, 1);
float support = smooth(.06F, .4F, blend);
// Al bajar al suelo recoge las rodillas antes de terminar el descenso, sin un latigazo final.
// La altura interpolada guía el descenso. Cambiar el booleano crawling
// durante el paso no puede elegir de golpe otra postura de brazos.
float prone = smooth(.72F, 1, blend);
// Dentro de un bloque tumba el tórax de costado; conserva todos los huesos y su grosor.
p.get("body").xRot = mix(p.get("body").xRot, 0, prone);
p.get("spine_lower").xRot = mix(p.get("spine_lower").xRot, PI * .5F, prone);
p.get("spine_lower").zRot = PI * .5F * prone;
p.get("spine_mid").xRot *= 1 - prone;
p.get("spine_upper").xRot *= 1 - prone;
p.get("neck0").x = .2F * prone;
p.get("spine_mid").zRot = sin(phase) * .035F * moving * blend;
for (int side : new int[]{1, -1}) {
String arm = side > 0 ? "arm_l" : "arm_r", leg = side > 0 ? "leg_l" : "leg_r";
float offset = side < 0 ? PI : 0;
float[] hand = footStep(phase + offset, moving, false, 1);
float[] foot = footStep(phase + offset + PI, moving, false, 1);
float beforeUpper = p.get(arm).xRot, beforeLower = p.get(arm + "_joint").xRot;
// En cuatro apoyos las manos van delante del pecho, no debajo de la pelvis.
float shoulderZ = transform(p, "spine_upper", p.get(arm).x, p.get(arm).y, p.get(arm).z)[2];
float handReach = armUpper * .55F;
solveWorldTarget(p, arm, 22.4F - hand[1] * support,
shoulderZ - handReach + hand[0], armUpper, armLower, 1);
p.get(arm).xRot = mixAngle(beforeUpper, p.get(arm).xRot, support);
p.get(arm + "_joint").xRot = mixAngle(beforeLower, p.get(arm + "_joint").xRot, support);
blendHandToFloor(p, arm, support);
// CUATRO APOYOS: al bajar recoge los pies bajo la pelvis y mantiene cerradas las rodillas.
float backwardReach = mix(1.2F, 10F, blend);
float groundOffset = clamp(side > 0 ? s.leftFootOffset() : s.rightFootOffset(), -.4F, .5F) * 16;
solveWorldTarget(p, leg, 22F - foot[1] * (1 - prone * .7F) - groundOffset,
mix(backwardReach + foot[0] * .4F, .5F + foot[0] * .1F, prone), legUpper, legLower, -1);
p.get(leg + "_hand").xRot = -p.get("body").xRot - p.get(leg).xRot - p.get(leg + "_joint").xRot;
if (prone > 0) {
// Los codos se arquean sobre los costados; las palmas quedan junto al pecho.
// No extiende dos brazos rectos por delante como una figura que vuela.
p.get(arm).x = side * mix(3.05F, 0, prone);
p.get(arm).z = side * 3.3F * prone;
p.get(arm).y -= 3.4F * prone;
float[] origin = transform(p, "spine_upper", p.get(arm).x, p.get(arm).y, p.get(arm).z);
supportArm(p, arm, new float[]{side * 3.3F, 25.34F - hand[1] * .25F, origin[2] + 1F + hand[0] * .008F},
armUpper, armLower, prone, 0);
// La palma también entra en la postura baja. Cambiar su orientación
// completa al primer milímetro del descenso invertía la muñeca.
blendHandToFloor(p, arm, prone);
p.get(leg).x = side * mix(abs(p.get(leg).x), 1.45F, prone);
solveWorldTarget(p, leg, mix(22F - foot[1] * (1 - prone * .7F), 25F - foot[1] * .3F, prone) - groundOffset,
mix(backwardReach + foot[0] * .4F, 1.55F + foot[0] * .015F, prone), legUpper, legLower, -1);
p.get(leg + "_hand").xRot = -p.get("body").xRot - p.get(leg).xRot - p.get(leg + "_joint").xRot;
}
for (int digit = 0; digit < 4; digit++) {
p.get(arm + "_finger" + digit).xRot = mix(.1F, 1.65F, support);
p.get(arm + "_finger" + digit + "_tip").xRot = mix(.35F, .12F, support);
}
}
p.get("head").xRot = -spinePitch(p) - .14F;
if (prone > 0) {
Pose head = p.get("head");
float beforeX = head.xRot, beforeY = head.yRot, beforeZ = head.zRot;
orientHeadToWitness(p, s, 0);
float targetX = head.xRot, targetY = head.yRot, targetZ = head.zRot;
head.xRot = beforeX; head.yRot = beforeY; head.zRot = beforeZ;
blendRotation(head, targetX, targetY, targetZ, prone);
}
}
// POSTURA REUTILIZABLE: permite un encuentro en cuatro apoyos con los mismos puntos de apoyo.
private static void prepareCrawlTorso(Map<String, Pose> p, State s, float compact) {
float low = smooth(0, 1, compact), mutation = clamp(s.stage(), 0, 4) / 4F;
float legLength = mix(12, 32, mutation);
p.get("body").xRot = mix(.025F, .08F, low);
p.get("body").y = mix(23.95F - legLength + (s.pursuing() ? 2.5F : 0), 20.5F, low);
p.get("spine_lower").xRot = mix(.12F + (s.pursuing() ? .25F : 0), 1.45F, low);
p.get("spine_mid").xRot = mix(.08F, .07F, low);
p.get("spine_upper").xRot = mix(-.08F, -.03F, low);
}
// TREPADA: manos y zapatos se plantan en una pared vertical, sin acortar huesos.
// limbSwing representa aquí distancia vertical en unidades de modelo (16 por bloque).
private static void climbPose(Map<String, Pose> p, State s) {
float mutation = clamp(s.stage(), 0, 4) / 4F;
float armUpper = mix(5.15F, 15F, mutation), armLower = mix(5.15F, 16F, mutation);
float legUpper = mix(6F, 16F, mutation), legLower = mix(6F, 16F, mutation);
float stride = mix(5F, 12F, mutation), wallZ = -8F;
p.get("body").xRot = .04F;
p.get("body").z = 0;
p.get("spine_lower").xRot = .07F;
p.get("spine_mid").xRot = -.04F;
p.get("spine_upper").xRot = -.02F;
p.get("head").xRot = -.16F - spinePitch(p);
float movement = clamp(s.limbAmount(), 0, 1);
for (int side : new int[]{1, -1}) {
String arm = side > 0 ? "arm_l" : "arm_r", leg = side > 0 ? "leg_l" : "leg_r";
float offset = side > 0 ? 0 : .5F;
float[] handStep = climbWallStep(s.limbSwing(), stride, offset);
float[] footStep = climbWallStep(s.limbSwing(), stride, offset + .5F);
float[] shoulder = transform(p, "spine_upper", p.get(arm).x, p.get(arm).y, p.get(arm).z);
float handY = shoulder[1] - (armUpper + armLower) * .7F + handStep[0];
// Dedos hacia arriba y ligeramente cerrados hacia la superficie, no hacia el jugador.
for (int digit = 0; digit < 4; digit++) {
p.get(arm + "_finger" + digit).xRot = -.12F;
p.get(arm + "_finger" + digit + "_tip").xRot = -.1F;
}
p.get(arm + "_thumb").xRot = -.08F;
float away = handStep[1] * 2.4F * movement;
float[] target = new float[]{shoulder[0], handY, wallZ + 1.8F + away};
supportArm(p, arm, target, armUpper, armLower, 1, new float[]{side * .15F, .12F, 1});
climbOrientContact(p, arm, 0, PI);
// El espesor real de palma/dedos fija el contacto; no se dibujan agarres ficticios.
target[2] = wallZ + climbContactInset(p, arm) + away;
supportArm(p, arm, target, armUpper, armLower, 1, new float[]{side * .15F, .12F, 1});
climbOrientContact(p, arm, 0, PI);
float footY = p.get("body").y + (legUpper + legLower) * .45F + footStep[0];
float footZ = wallZ + 2F + footStep[1] * 2F * movement;
solveWorldTarget(p, leg, footY, footZ, legUpper, legLower, -1);
// La suela apunta a la pared y la punta del zapato hacia arriba.
climbOrientContact(p, leg, -PI * .5F, 0);
}
}
// CICLO VERTICAL: durante 72% del ciclo la coordenada relativa compensa
// exactamente la subida del cuerpo. Sólo la vuelta separa el apoyo de la pared.
private static float[] climbWallStep(float distance, float stride, float offset) {
float cycle = ((distance / stride + offset) % 1 + 1) % 1;
if (cycle < .72F) return new float[]{cycle * stride, 0};
float travel = (cycle - .72F) / .28F;
return new float[]{mix(.72F * stride, 0, smooth(0, 1, travel)), sin(travel * PI)};
}
// ORIENTACIÓN DEL APOYO: inversa del antebrazo/tibia × orientación de pared.
private static void climbOrientContact(Map<String, Pose> p, String limb, float pitch, float roll) {
String parent = limb + "_joint";
float[] origin = transform(p, parent, 0, 0, 0);
float[][] rotation = new float[3][3];
for (int axis = 0; axis < 3; axis++) {
float[] basis = transform(p, parent, axis == 0 ? 1 : 0, axis == 1 ? 1 : 0, axis == 2 ? 1 : 0);
float divisor = (float)Math.sqrt((basis[0] - origin[0]) * (basis[0] - origin[0])
+ (basis[1] - origin[1]) * (basis[1] - origin[1]) + (basis[2] - origin[2]) * (basis[2] - origin[2]));
for (int row = 0; row < 3; row++) rotation[row][axis] = (basis[row] - origin[row]) / Math.max(.001F, divisor);
}
float c = cos(roll), r = sin(roll), a = cos(pitch), b = sin(pitch);
float[][] desired = new float[][]{{c, -r * a, r * b}, {r, c * a, -c * b}, {0, b, a}};
float[][] local = new float[3][3];
for (int row = 0; row < 3; row++) for (int col = 0; col < 3; col++)
for (int axis = 0; axis < 3; axis++) local[row][col] += rotation[axis][row] * desired[axis][col];
Pose contact = p.get(limb + "_hand");
contact.yRot = (float)Math.asin(clamp(-local[2][0], -1, 1));
contact.xRot = (float)Math.atan2(local[2][1], local[2][2]);
contact.zRot = (float)Math.atan2(local[1][0], local[0][0]);
}
// ESPESOR DE CONTACTO: sólo consulta las cajas existentes y sus articulaciones.
private static float climbContactInset(Map<String, Pose> p, String arm) {
float wristZ = transform(p, arm + "_hand", 0, 0, 0)[2], nearest = Float.MAX_VALUE;
for (Bone bone : HORROR_BONES) {
String name = bone.name();
if (!(name.equals(arm + "_hand") || name.startsWith(arm + "_finger") || name.startsWith(arm + "_thumb")) || !visible(p, name)) continue;
for (Box box : bone.boxes()) for (int corner = 0; corner < 8; corner++) {
float z = transform(p, name, box.x() + ((corner & 1) == 0 ? 0 : box.w()),
box.y() + ((corner & 2) == 0 ? 0 : box.h()), box.z() + ((corner & 4) == 0 ? 0 : box.d()))[2];
nearest = Math.min(nearest, z);
}
}
return wristZ - nearest;
}
// RITMOS LOCALES: son posturas físicas, compartidas por el servidor y la vista previa.
// Las decisiones de acechar/atacar siguen perteneciendo al controlador y a Luna.
public static float encounterCompact(float tick, int stage) {
if (stage < 2 || tick < 0) return 0;
return .63636F * smooth(22, 54, tick) * (1 - smooth(104, 138, tick));
}
public static float stalkCompact(float age, int stage) {
if (stage < 2) return 0;
float tick = ((age % 360) + 360) % 360;
return .63636F * smooth(20, 56, tick) * (1 - smooth(90, 132, tick));
}
public static float chaseCompact(float age, int stage) {
if (stage < 2) return 0;
float tick = ((age % 360) + 360) % 360;
return tick < 120 ? 0 : tick < 260 ? .63636F : 1;
}
// ACECHO: una pausa incómoda se convierte en una inspección baja y una vuelta de la espalda.
private static void stalkPose(Map<String, Pose> p, State s, float mutation, float weight) {
float tick = ((s.age() % 360) + 360) % 360;
float tension = smooth(8, 34, tick) * (1 - smooth(102, 144, tick)) * mutation * weight;
float recoil = key(tick, new float[]{0, 35, 48, 63, 79, 98, 145}, new float[]{0, -.28F, -.28F, .45F, .45F, -.08F, 0}) * weight;
p.get("spine_lower").yRot += tension * .42F;
p.get("spine_mid").yRot += tension * .55F;
p.get("spine_upper").yRot -= tension * .24F;
p.get("spine_mid").zRot -= tension * .16F;
p.get("neck0").yRot -= tension * .38F;
p.get("neck1").yRot -= tension * .35F;
p.get("head").zRot += recoil * mutation;
p.get("neck2").xRot += abs(recoil) * .3F;
// Los brazos conservan el apoyo al suelo de crawlPose. Una torsión de
// acecho no inventa una pared ni levanta una mano para agarrar el aire.
}
// CONTACTO VISUAL: pelvis apoyada, columna en torsión y cabeza buscando aún al testigo.
// Hay pausas y cambios de dirección deliberados, en vez de una vibración sinusoidal constante.
private static void encounterPose(Map<String, Pose> p, State s, float mutation) {
float tick = s.encounterTicks();
float turn = key(tick, new float[]{0, 18, 43, 63, 79, 94, 114, 148, 176},
new float[]{0, -.12F, .25F, 1.05F, 1.05F, -.6F, -.6F, .18F, 0}) * mutation;
float roll = key(tick, new float[]{0, 24, 48, 61, 77, 84, 109, 148, 176},
new float[]{0, -.35F, -.35F, -.95F, -.95F, .85F, .85F, -.1F, 0}) * mutation;
p.get("spine_lower").yRot = turn * .55F;
p.get("spine_mid").yRot = turn * .95F;
p.get("spine_upper").yRot = turn * .65F;
p.get("spine_lower").zRot = -roll * .08F;
p.get("spine_mid").zRot = roll * .19F;
p.get("spine_upper").zRot = -roll * .32F;
p.get("neck0").yRot = -turn * .75F;
p.get("neck1").yRot = -turn * .75F;
p.get("neck2").yRot = -turn * .6F;
p.get("neck0").zRot = roll * .28F;
p.get("neck1").zRot = roll * .3F;
// La inversión gira alrededor de la mirada, nunca del Z local de una espalda ya torcida.
float righting = PI * smooth(55, 75, tick) * (1 - smooth(126, 154, tick)) * mutation;
p.get("neck2").xRot += abs(turn) * .32F;
float braceWeight = smooth(.08F, .32F, s.compactProgress());
braceEncounterArms(p, s, mutation, braceWeight);
p.get("arm_l_joint").xRot -= abs(turn) * .55F * (1 - braceWeight);
p.get("arm_r_joint").xRot -= abs(turn) * .26F * (1 - braceWeight);
float syllable = abs(sin(tick * .34F) * .65F + sin(tick * .51F) * .35F);
float phrase = smooth(.12F, .4F, abs(sin(tick * .018F)));
float strain = key(tick, new float[]{0, 32, 59, 77, 97, 132, 160}, new float[]{0, .05F, .65F, .65F, .22F, .1F, 0});
float opening = mutation * (.015F + syllable * phrase * .4F + strain * .65F);
p.get("jaw").xRot = opening; p.get("jaw").y += opening * 1.65F;
revealMouth(p, opening);
orientHeadToWitness(p, s, righting);
}
// MIRADA 3D: inversa real del cuello × orientación hacia el testigo × inversión sobre esa mirada.
// Mantiene el mismo pivote cervical; sumar ángulos Euler dejaba el rostro mirando noventa grados al costado.
private static void orientHeadToWitness(Map<String, Pose> p, State s, float roll) {
float yaw = clamp(s.yaw() * DEG, -1.12F, 1.12F), pitch = clamp(s.pitch() * DEG, -.8F, .8F);
float cy = cos(yaw), sy = sin(yaw), cx = cos(pitch), sx = sin(pitch), cr = cos(roll), sr = sin(roll);
float[][] desired = new float[][] {
{cy * cr + sy * sx * sr, -cy * sr + sy * sx * cr, sy * cx},
{cx * sr, cx * cr, -sx},
{-sy * cr + cy * sx * sr, sy * sr + cy * sx * cr, cy * cx}
};
float[] origin = transform(p, "neck2", 0, 0, 0);
float[][] parent = new float[3][3];
for (int axis = 0; axis < 3; axis++) {
float[] basis = transform(p, "neck2", axis == 0 ? 1 : 0, axis == 1 ? 1 : 0, axis == 2 ? 1 : 0);
float[] direction = normalized(new float[]{basis[0] - origin[0], basis[1] - origin[1], basis[2] - origin[2]});
for (int row = 0; row < 3; row++) parent[row][axis] = direction[row];
}
float[][] local = new float[3][3];
for (int row = 0; row < 3; row++) for (int col = 0; col < 3; col++)
for (int axis = 0; axis < 3; axis++) local[row][col] += parent[axis][row] * desired[axis][col];
Pose head = p.get("head");
head.yRot = (float)Math.asin(clamp(-local[2][0], -1, 1));
if (abs(cos(head.yRot)) > .00001F) {
head.xRot = (float)Math.atan2(local[2][1], local[2][2]);
head.zRot = (float)Math.atan2(local[1][0], local[0][0]);
} else {
head.xRot = 0;
head.zRot = (float)Math.atan2(-local[0][1], local[1][1]);
}
}
// APOYO TRIDIMENSIONAL: una espalda girada no puede apoyar manos usando sólo el plano Y/Z.
private static void braceEncounterArms(Map<String, Pose> p, State s, float mutation, float weight) {
float upperLength = mix(5.15F, 15, mutation), lowerLength = mix(5.15F, 16, mutation);
for (int side : new int[]{1, -1}) {
String arm = side > 0 ? "arm_l" : "arm_r";
supportArm(p, arm, new float[]{side * 5.7F, 22.35F, -19F}, upperLength, lowerLength, weight);
blendHandToFloor(p, arm, weight);
}
}
// PAREDES: puntos medidos por raycast nativo, nunca una animación que inventa un bloque.
public static void braceCorridor(Map<String, Pose> p, State s, float leftDistance, float rightDistance) {
braceCorridor(p, s, leftDistance, rightDistance, 1, 1);
}
public static void braceCorridor(Map<String, Pose> p, State s, float leftDistance, float rightDistance,
float leftWeight, float rightWeight) {
if (s.compactProgress() < .18F || s.compactProgress() > .42F || s.encounterTicks() >= 0 || s.attackTicks() > 0) return;
float clearanceWeight = smooth(.18F, .24F, s.compactProgress()) * (1 - smooth(.36F, .42F, s.compactProgress()));
float mutation = clamp(s.stage(), 0, 4) / 4F;
float upperLength = mix(5.15F, 15, mutation), lowerLength = mix(5.15F, 16, mutation);
for (int side : new int[]{1, -1}) {
float distance = side > 0 ? leftDistance : rightDistance;
if (distance < .34F || distance > .75F) continue;
float weight = clamp(side > 0 ? leftWeight : rightWeight, 0, 1) * clearanceWeight;
if (weight <= 0) continue;
String arm = side > 0 ? "arm_l" : "arm_r";
float[] shoulder = transform(p, "spine_upper", p.get(arm).x, p.get(arm).y, p.get(arm).z);
float slide = sin(s.limbSwing() * .32F + (side < 0 ? PI : 0)) * clamp(s.limbAmount(), 0, 1);
supportArm(p, arm, new float[]{side * (distance * 16 - 1.95F), shoulder[1] + 7.5F + slide, shoulder[2] - 5},
upperLength, lowerLength, weight);
Pose hand = p.get(arm + "_hand");
float beforeX = hand.xRot, beforeY = hand.yRot, beforeZ = hand.zRot;
alignHand(p, arm, -side * PI * .5F);
float targetX = hand.xRot, targetY = hand.yRot, targetZ = hand.zRot;
hand.xRot = beforeX; hand.yRot = beforeY; hand.zRot = beforeZ;
blendRotation(hand, targetX, targetY, targetZ, weight);
for (int digit = 0; digit < 4; digit++) {
Pose finger = p.get(arm + "_finger" + digit), tip = p.get(arm + "_finger" + digit + "_tip");
finger.xRot = mixAngle(finger.xRot, 1.48F, weight);
tip.xRot = mixAngle(tip.xRot, .17F, weight);
}
}
bridgeSkinJoints(p);
constrainBody(p, s, s.compactProgress());
}
private static void supportArm(Map<String, Pose> p, String arm, float[] target, float upperLength, float lowerLength, float weight) {
supportArm(p, arm, target, upperLength, lowerLength, weight, arm.endsWith("_l") ? -.15F : .15F);
}
private static void supportArm(Map<String, Pose> p, String arm, float[] target, float upperLength, float lowerLength, float weight, float medial) {
supportArm(p, arm, target, upperLength, lowerLength, weight, new float[]{medial, 0, 1});
}
private static void supportArm(Map<String, Pose> p, String arm, float[] target, float upperLength, float lowerLength, float weight, float[] worldPole) {
Pose upper = p.get(arm), joint = p.get(arm + "_joint");
float hinge = joint.z;
float upperOffset = (float)Math.atan2(hinge, upperLength), lowerOffset = (float)Math.atan2(hinge, lowerLength);
upperLength = (float)Math.hypot(upperLength, hinge);
lowerLength = (float)Math.hypot(lowerLength, hinge);
float[] origin = transform(p, "spine_upper", upper.x, upper.y, upper.z);
float[] direction = new float[3];
for (int axis = 0; axis < 3; axis++) {
float[] basis = transform(p, "spine_upper", upper.x + (axis == 0 ? 1 : 0),
upper.y + (axis == 1 ? 1 : 0), upper.z + (axis == 2 ? 1 : 0));
direction[axis] = (target[0] - origin[0]) * (basis[0] - origin[0])
+ (target[1] - origin[1]) * (basis[1] - origin[1])
+ (target[2] - origin[2]) * (basis[2] - origin[2]);
}
float distance = clamp(length(direction),
Math.abs(upperLength - lowerLength) + .05F, upperLength + lowerLength - .025F);
float[] axis = normalized(direction);
// El codo se pliega hacia atrás, dentro del corredor; no se abre atravesando la pared.
float[] pole = new float[3];
for (int i = 0; i < 3; i++) {
float[] basis = transform(p, "spine_upper", upper.x + (i == 0 ? 1 : 0),
upper.y + (i == 1 ? 1 : 0), upper.z + (i == 2 ? 1 : 0));
pole[i] = (basis[0] - origin[0]) * worldPole[0] + (basis[1] - origin[1]) * worldPole[1] + (basis[2] - origin[2]) * worldPole[2];
}
float poleDot = dot(pole, axis);
for (int i = 0; i < 3; i++) pole[i] -= axis[i] * poleDot;
if (length(pole) < .001F) pole = new float[]{1, 0, 0};
pole = normalized(pole);
float along = (upperLength * upperLength + distance * distance - lowerLength * lowerLength) / (2 * distance);
float reach = (float)Math.sqrt(Math.max(0, upperLength * upperLength - along * along));
float[] firstDirection = new float[3], secondDirection = new float[3];
for (int i = 0; i < 3; i++) {
firstDirection[i] = (axis[i] * along + pole[i] * reach) / upperLength;
secondDirection[i] = (axis[i] * distance - firstDirection[i] * upperLength) / lowerLength;
}
float[] xAxis = normalized(cross(firstDirection, secondDirection));
float[] zAxis = cross(xAxis, firstDirection);
float[] boneDirection = new float[3], boneZ = new float[3];
for (int i = 0; i < 3; i++) {
boneDirection[i] = firstDirection[i] * cos(-upperOffset) + zAxis[i] * sin(-upperOffset);
boneZ[i] = -firstDirection[i] * sin(-upperOffset) + zAxis[i] * cos(-upperOffset);
}
blendRotation(upper, (float)Math.atan2(boneDirection[2], boneZ[2]),
(float)Math.asin(clamp(-xAxis[2], -1, 1)),
(float)Math.atan2(xAxis[1], xAxis[0]), weight);
joint.xRot = mixAngle(joint.xRot,
(float)Math.atan2(dot(secondDirection, zAxis), dot(secondDirection, firstDirection)) + upperOffset + lowerOffset, weight);
}
private static float dot(float[] a, float[] b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
private static float length(float[] a) { return (float)Math.sqrt(dot(a, a)); }
private static float[] normalized(float[] a) { float divisor = Math.max(.00001F, length(a)); return new float[]{a[0]/divisor, a[1]/divisor, a[2]/divisor}; }
private static float[] cross(float[] a, float[] b) { return new float[]{a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]}; }
private static void alignHandToFloor(Map<String, Pose> p, String arm) {
alignHand(p, arm, 0);
}
// La orientación de la muñeca también entra gradualmente; el IK ya mezcla hombro y codo.
private static void blendHandToFloor(Map<String, Pose> p, String arm, float weight) {
Pose wrist = p.get(arm + "_hand");
float oldX = wrist.xRot, oldY = wrist.yRot, oldZ = wrist.zRot;
alignHandToFloor(p, arm);
float targetX = wrist.xRot, targetY = wrist.yRot, targetZ = wrist.zRot;
wrist.xRot = oldX; wrist.yRot = oldY; wrist.zRot = oldZ;
blendRotation(wrist, targetX, targetY, targetZ, weight);
}
// GIRO CONTINUO: el mismo apoyo puede representarse con ángulos Euler
// opuestos. Mezclar esos números hacía que el brazo diera media vuelta.
// Los cuaterniones interpolan la orientación física por el arco más corto.
private static void blendRotation(Pose pose, float targetX, float targetY, float targetZ, float weight) {
float amount = clamp(weight, 0, 1);
if (amount <= 0) return;
if (amount >= 1) {
pose.xRot = targetX; pose.yRot = targetY; pose.zRot = targetZ;
return;
}
float[] from = rotationQuaternion(pose.xRot, pose.yRot, pose.zRot);
float[] to = rotationQuaternion(targetX, targetY, targetZ);
float cosine = 0;
for (int i = 0; i < 4; i++) cosine += from[i] * to[i];
if (cosine < 0) {
for (int i = 0; i < 4; i++) to[i] = -to[i];
cosine = -cosine;
}
float fromWeight = 1 - amount, toWeight = amount;
if (cosine < .9995F) {
float angle = acos(cosine), divisor = sin(angle);
fromWeight = sin((1 - amount) * angle) / divisor;
toWeight = sin(amount * angle) / divisor;
}
float[] q = new float[4];
float norm = 0;
for (int i = 0; i < 4; i++) { q[i] = from[i] * fromWeight + to[i] * toWeight; norm += q[i] * q[i]; }
norm = (float)Math.sqrt(norm);
for (int i = 0; i < 4; i++) q[i] /= norm;
float x = q[0], y = q[1], z = q[2], w = q[3];
pose.yRot = (float)Math.asin(clamp(-2 * (x * z - w * y), -1, 1));
if (abs(cos(pose.yRot)) > .00001F) {
pose.xRot = (float)Math.atan2(2 * (y * z + w * x), 1 - 2 * (x * x + y * y));
pose.zRot = (float)Math.atan2(2 * (x * y + w * z), 1 - 2 * (y * y + z * z));
} else {
pose.xRot = 0;
pose.zRot = (float)Math.atan2(-2 * (x * y - w * z), 1 - 2 * (x * x + z * z));
}
}
private static float[] rotationQuaternion(float x, float y, float z) {
float sx = sin(x * .5F), cx = cos(x * .5F), sy = sin(y * .5F), cy = cos(y * .5F);
float sz = sin(z * .5F), cz = cos(z * .5F);
return new float[]{cz * cy * sx - sz * sy * cx, cz * sy * cx + sz * cy * sx,
sz * cy * cx - cz * sy * sx, cz * cy * cx + sz * sy * sx};
}
private static void alignHand(Map<String, Pose> p, String arm, float worldRoll) {
String parent = arm + "_joint";
float[] origin = transform(p, parent, 0, 0, 0);
float[][] rotation = new float[3][3];
for (int axis = 0; axis < 3; axis++) {
float[] basis = transform(p, parent, axis == 0 ? 1 : 0, axis == 1 ? 1 : 0, axis == 2 ? 1 : 0);
float length = 0;
for (int row = 0; row < 3; row++) length += (basis[row] - origin[row]) * (basis[row] - origin[row]);
length = (float)Math.sqrt(length);
for (int row = 0; row < 3; row++) rotation[row][axis] = (basis[row] - origin[row]) / Math.max(.001F, length);
}
// Rotación local = inversa del antebrazo × orientación deseada de la palma.
float[][] local = new float[3][3];
float[][] desired = new float[][]{{cos(worldRoll), -sin(worldRoll), 0}, {sin(worldRoll), cos(worldRoll), 0}, {0, 0, 1}};
for (int row = 0; row < 3; row++) for (int col = 0; col < 3; col++)
for (int axis = 0; axis < 3; axis++) local[row][col] += rotation[axis][row] * desired[axis][col];
Pose wrist = p.get(arm + "_hand");
wrist.yRot = (float)Math.asin(clamp(-local[2][0], -1, 1));
wrist.xRot = (float)Math.atan2(local[2][1], local[2][2]);
wrist.zRot = (float)Math.atan2(local[1][0], local[0][0]);
}
// ATAQUE: alcance de los brazos y mordisco ligados al golpe, con pies en su ciclo de apoyo.
private static void attackPose(Map<String, Pose> p, State s, float mutation) {
float t = 10 - clamp(s.attackTicks(), 0, 10);
float reach = key(t, new float[]{0, 2, 4, 5.5F, 10}, new float[]{0, -.15F, 1, .78F, 0});
p.get("spine_upper").yRot -= reach * .38F;
p.get("spine_mid").xRot += Math.max(0, reach) * .18F;
p.get("arm_r").xRot -= reach * 1.65F;
p.get("arm_r_joint").xRot *= 1 - Math.max(0, reach) * .75F;
p.get("arm_l").xRot -= Math.max(0, reach) * .18F;
p.get("arm_l_joint").xRot -= Math.max(0, reach) * .4F;
p.get("head").xRot -= reach * .1F;
p.get("jaw").xRot = Math.max(0, reach) * .68F * mutation; p.get("jaw").y += Math.max(0, reach) * 1.2F;
revealMouth(p, p.get("jaw").xRot);
}
private static void revealMouth(Map<String, Pose> p, float opening) {
p.get("mouth").visible = p.get("jaw_cavity").visible = opening > .035F;
p.get("mouth").yScale = 1 + opening * 6;
p.get("mouth").xScale = 1 + opening * .45F;
p.get("jaw_cavity").yScale = 1 + opening * 1.6F;
for (String cheek : new String[]{"cheek_l", "cheek_r"}) {
p.get(cheek).visible = opening > .13F;
p.get(cheek).yScale = 1 + opening * 3.2F;
p.get(cheek).xRot = opening * .38F;
}
p.get("upper_teeth").visible = p.get("lower_teeth").visible = opening > .13F;
}
// CONTORNO: la forma plegada cabe dentro del collider, también en esquinas y escotillas abiertas.
private static void constrainBody(Map<String, Pose> p, State s, float compact) {
Pose world = p.get("world"); Bounds initial = bounds(HORROR_BONES, p);
// Apoyar desplaza el cuerpo. Escalarlo a cada giro provocaba una criatura de goma.
if (!s.climbing()) world.y += 24 - initial.maxY();
if (compact > 0) {
// Descentrar una pose de pie de golpe al empezar a agacharse movía
// cuello y hombros aunque las articulaciones todavía no flexionaran.
float centered = smooth(0, .12F, compact);
world.x -= (initial.minX() + initial.maxX()) * .5F * centered;
world.z -= (initial.minZ() + initial.maxZ()) * .5F * centered;
}
}
// MEDICIONES: límites reales de las cajas visibles, incluyendo rotaciones y escalas de sus padres.
public static Bounds bounds(State state, boolean friendly) { return bounds(bones(friendly), poses(state, friendly)); }
public static Bounds bounds(List<Bone> bones, Map<String, Pose> p) {
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, minZ = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE, maxZ = -Float.MAX_VALUE;
for (Bone bone : bones) {
if (!visible(p, bone.name())) continue;
for (Box box : bone.boxes()) for (int corner = 0; corner < 8; corner++) {
float[] point = transform(p, bone.name(), box.x() + ((corner & 1) == 0 ? 0 : box.w()),
box.y() + ((corner & 2) == 0 ? 0 : box.h()), box.z() + ((corner & 4) == 0 ? 0 : box.d()));
minX = Math.min(minX, point[0]); minY = Math.min(minY, point[1]); minZ = Math.min(minZ, point[2]);
maxX = Math.max(maxX, point[0]); maxY = Math.max(maxY, point[1]); maxZ = Math.max(maxZ, point[2]);
}
}
return new Bounds(minX, minY, minZ, maxX, maxY, maxZ);
}
private static String parent(Map<String, Pose> p, String name) {
String parent = PARENTS.getOrDefault(name, ""); return p.containsKey(parent) ? parent : "";
}
private static boolean visible(Map<String, Pose> p, String name) {
if (!p.get(name).visible) return false;
String parent = parent(p, name); return parent.isEmpty() || visible(p, parent);
}
public static float[] transform(Map<String, Pose> p, String name, float x, float y, float z) {
Pose pose = p.get(name); x *= pose.xScale; y *= pose.yScale; z *= pose.zScale;
float nextY = y * cos(pose.xRot) - z * sin(pose.xRot); z = y * sin(pose.xRot) + z * cos(pose.xRot); y = nextY;
float nextX = x * cos(pose.yRot) + z * sin(pose.yRot); z = -x * sin(pose.yRot) + z * cos(pose.yRot); x = nextX;
nextX = x * cos(pose.zRot) - y * sin(pose.zRot); y = x * sin(pose.zRot) + y * cos(pose.zRot); x = nextX;
x += pose.x; y += pose.y; z += pose.z;
String parent = parent(p, name); return parent.isEmpty() ? new float[]{x, y, z} : transform(p, parent, x, y, z);
}
private static Map<String, String> parents() {
Map<String, String> result = new LinkedHashMap<>();
for (Bone bone : HORROR_BONES) result.put(bone.name(), bone.parent());
for (Bone bone : FRIENDLY_BONES) result.putIfAbsent(bone.name(), bone.parent());
return Map.copyOf(result);
}
private static void friendlyPose(Map<String, Pose> p, State s) {
float walk = cos(s.limbSwing() * .6662F) * 1.2F * clamp(s.limbAmount(), 0, 1);
p.get("leg_l").xRot = walk; p.get("leg_r").xRot = -walk;
p.get("arm_l").xRot = -walk * .75F; p.get("arm_r").xRot = walk * .75F;
p.get("arm_l").zRot = .1F; p.get("arm_r").zRot = -.1F;
p.get("arm_l_joint").yScale = p.get("arm_r_joint").yScale = .45F;
p.get("head").yRot = s.yaw() * DEG; p.get("head").xRot = s.pitch() * DEG;
// La variante amistosa conserva el apoyo y cabe en los mismos pasos que la criatura.
Bounds shape = bounds(FRIENDLY_BONES, p);
float available = mix(1.8F + clamp(s.stage(), 0, 4) * .45F, .84F, clamp(s.compactProgress(), 0, 1)) * 16;
float heightScale = Math.min(1, available / Math.max(.01F, shape.height()));
for (Bone bone : FRIENDLY_BONES) if (bone.parent().isEmpty()) {
Pose root = p.get(bone.name());
root.y = 24 + (root.y - shape.maxY()) * heightScale;
root.yScale *= heightScale;
}
}
// EXPORTADOR: el visor recibe las mismas cajas y poses, además de altura y apoyos comprobables.
public static void main(String[] args) {
String mode = args.length > 0 ? args[0] : "idle";
int stage = args.length > 1 ? Integer.parseInt(args[1]) : 4;
float seconds = args.length > 2 ? Float.parseFloat(args[2]) : 0;
int count = args.length > 3 ? Math.max(1, Math.min(2000, Integer.parseInt(args[3]))) : 1;
float fps = args.length > 4 ? Math.max(1, Float.parseFloat(args[4])) : 20;
if (count > 1) System.out.print('[');
int appearance = args.length > 5 ? Integer.parseInt(args[5]) : 0;
for (int frame = 0; frame < count; frame++) { if (frame > 0) System.out.print(','); printFrame(mode, stage, seconds + frame / fps, appearance); }
if (count > 1) System.out.print(']'); System.out.println();
}
private static void printFrame(String mode, int stage, float seconds, int appearance) {
boolean friendly = mode.equals("friendly");
boolean pursuing = mode.startsWith("chase") || mode.equals("drag");
boolean moving = friendly || pursuing || mode.equals("walk") || mode.equals("climb") || mode.startsWith("crawl") || mode.equals("compact");
float age = seconds * 20 + (mode.equals("stalk") ? 20 : 0);
float compact = mode.equals("compact") || mode.equals("crawl") || mode.equals("drag") ? 1
: mode.equals("crawl2") || mode.equals("chase4") ? .63636F : mode.equals("crawl3") ? .27272F : 0;
if (mode.equals("lore")) compact = encounterCompact(seconds * 20, stage);
if (mode.equals("stalk")) compact = stalkCompact(age, stage);
// El servidor avanza compactProgress linealmente: el easing ya vive en las articulaciones.
// Añadir otro smooth aquí concentraba todos los cambios de postura en los mismos fotogramas.
if (mode.equals("fold")) compact = seconds < 1.7F ? clamp((seconds - .2F) / 1.5F, 0, 1)
: seconds < 2.3F ? 1 : 1 - clamp((seconds - 2.3F) / 1.5F, 0, 1);
State state = new State(stage, seconds * 10, moving ? (friendly || mode.equals("walk") ? .4F : .72F) : 0,
age, mode.equals("turn") ? sin(seconds * 1.5F) * 65 : 0, 0, pursuing, mode.equals("climb"), mode.equals("ambush"),
mode.equals("crawl") || mode.equals("compact") || mode.equals("fold"), mode.equals("lore") ? (int)(seconds * 20) : -1,
mode.equals("attack") || mode.equals("reach") ? Math.max(0, 10 - (int)((seconds % .5F) * 20)) : 0, compact);
Map<String, Pose> poses = poses(state, friendly, appearance);
if (mode.equals("crawl3")) braceCorridor(poses, state, .5F, .5F);
System.out.print(export(bones(friendly), poses));
}
private static String export(List<Bone> bones, Map<String, Pose> p) {
Bounds b = bounds(bones, p);
StringBuilder out = new StringBuilder("{\"atlas\":[128,128],\"bounds\":").append(vector(b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ()));
out.append(",\"dimensions_blocks\":").append(vector(b.width() / 16, b.height() / 16, b.depth() / 16)).append(",\"feet\":{");
boolean footFirst = true;
for (String foot : new String[]{"leg_l_hand", "leg_r_hand"}) if (p.containsKey(foot)) {
if (!footFirst) out.append(','); footFirst = false;
out.append('"').append(foot).append("\":").append(vector(transform(p, foot, 0, 2, 0)));
}
out.append("},\"bones\":["); boolean first = true;
for (Bone bone : bones) {
if (!first) out.append(','); first = false;
out.append("{\"name\":\"").append(bone.name()).append("\",\"parent\":\"").append(bone.parent()).append("\",\"pivot\":")
.append(vector(bone.x(), bone.y(), bone.z())).append(",\"boxes\":[");
boolean firstBox = true;
for (Box box : bone.boxes()) {
if (!firstBox) out.append(','); firstBox = false;
out.append("{\"bounds\":").append(vector(box.x(), box.y(), box.z(), box.w(), box.h(), box.d())).append(",\"uv\":[")
.append(box.u()).append(',').append(box.v()).append("],\"mirror\":").append(box.mirror()).append('}');
}
out.append("]}");
}
out.append("],\"pose\":{"); first = true;
for (Map.Entry<String, Pose> entry : p.entrySet()) {
if (!first) out.append(','); first = false; Pose pose = entry.getValue();
out.append('"').append(entry.getKey()).append("\":{\"position\":").append(vector(pose.x, pose.y, pose.z))
.append(",\"rotation\":").append(vector(pose.xRot, pose.yRot, pose.zRot)).append(",\"scale\":")
.append(vector(pose.xScale, pose.yScale, pose.zScale)).append(",\"visible\":").append(pose.visible).append('}');
}
return out.append("}}").toString();
}
private static String vector(float... values) {
StringBuilder out = new StringBuilder("[");
for (int i = 0; i < values.length; i++) { if (i > 0) out.append(','); out.append(values[i]); }
return out.append(']').toString();
}
private static void add(List<Bone> bones, String name, String parent, float x, float y, float z, Box... boxes) {
bones.add(new Bone(name, parent, x, y, z, List.of(boxes)));
}
private static Box box(int u, int v, float x, float y, float z, float w, float h, float d) { return new Box(u, v, x, y, z, w, h, d, false); }
private static float key(float tick, float[] ticks, float[] values) {
for (int i = 1; i < ticks.length; i++) if (tick <= ticks[i]) return mix(values[i - 1], values[i], smooth(ticks[i - 1], ticks[i], tick));
return values[values.length - 1];
}
private static float sin(float value) { return (float)Math.sin(value); }
private static float cos(float value) { return (float)Math.cos(value); }
private static float acos(float value) { return (float)Math.acos(clamp(value, -1, 1)); }
private static float abs(float value) { return Math.abs(value); }
private static float clamp(float value, float min, float max) { return Math.max(min, Math.min(max, value)); }
private static float mix(float from, float to, float amount) { return from + (to - from) * amount; }
// +179° y −179° están a dos grados: interpolar números sin envolver abría las rodillas una vuelta entera.
private static float mixAngle(float from, float to, float amount) {
return from + (float)Math.atan2(sin(to - from), cos(to - from)) * amount;
}
private static float smooth(float from, float to, float value) {
float amount = clamp((value - from) / (to - from), 0, 1); return amount * amount * (3 - 2 * amount);
}
}