Glue booth keeps its model: create re-dresses, a hidden scene pauses (#1097)
The glue booth threw its model away where 1.12.1 keeps it. So the create preview restarted its animation on every customization or class change, and the login scene restarted after Credits. In 1.12.1, the create setters (0x478790 and its siblings) and the re-dress 0x470800 update the same CharacterModel. Only a race or sex change builds a new one (0x470b70). A hidden ModelFFX is unlinked from its strata bucket, so its OnUpdate (0x76d7f0) and its clock stop until it shows again, and the clock steps in whole milliseconds (0x76d846). Now the booth re-dresses its body in place and keeps a hidden or left scene parked, resuming it on return. Each scene has its own clock (one SceneClock on its root), its own materials, so two scenes on one file stay independent. Tests cover the keep, the park, the clocks and the cleanup. Fixes #1083 Fixes #1068 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -252,6 +252,31 @@ impl Creatures {
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache of built, batch-less displays, each with a rig: `(display id, skeleton,
|
||||
/// animations)`, for a booth test that bakes off [`Self::display_rig`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn rigged_for_test(
|
||||
rigs: Vec<(
|
||||
u32,
|
||||
benilla_assets::ModelSkeleton,
|
||||
Option<benilla_assets::ModelAnimations>,
|
||||
)>,
|
||||
) -> Self {
|
||||
let mut c = Creatures {
|
||||
catalog: Default::default(),
|
||||
models: HashMap::new(),
|
||||
};
|
||||
for (display_id, skeleton, animations) in rigs {
|
||||
let mut dm = display::empty_shell();
|
||||
dm.parts = Some(Vec::new());
|
||||
dm.skeleton = skeleton;
|
||||
dm.inverse_bindposes = Some(Handle::default());
|
||||
dm.animations = animations;
|
||||
c.models.insert(display_id, dm);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A display's foley material (`Material.dbc` id), the creature half of the footfall rustle.
|
||||
pub(crate) fn foley_material(&self, display_id: u32) -> Option<u32> {
|
||||
self.catalog.foley_material(display_id)
|
||||
|
||||
@@ -821,7 +821,7 @@ pub(crate) mod schedule_tests {
|
||||
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
|
||||
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
|
||||
/// that commutes by construction belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_692;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_642;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -24,15 +24,16 @@ pub(super) struct BoothPart {
|
||||
pub(super) twins: BoothTwins,
|
||||
/// Whether the builder registered this batch's material on the UV and tint lane. It records the
|
||||
/// registration, never re-tests the asset: the marker and the registration must be one fact.
|
||||
/// True only for the glue scenes, which animate texture transforms per frame (`0x715f25` to
|
||||
/// `0x7163bc` over `md+0x74`); portraits and panes are stills.
|
||||
/// True only on the glue stage (its scene, character and pet), which animates texture
|
||||
/// transforms per frame (`0x715f25` to `0x7163bc` over `md+0x74`) on the stage's own clock;
|
||||
/// portraits and panes are stills.
|
||||
pub(super) mat_anim: bool,
|
||||
}
|
||||
|
||||
/// Marks a booth child for the draw gate when its material is on the UV and tint lane; an
|
||||
/// unmarked registered batch stays frozen at its seed.
|
||||
fn mark_mat_anim(child: &mut bevy::ecs::system::EntityCommands<'_>, p: &BoothPart) {
|
||||
if p.mat_anim {
|
||||
fn mark_mat_anim(child: &mut bevy::ecs::system::EntityCommands<'_>, mat_anim: bool) {
|
||||
if mat_anim {
|
||||
child.insert(benilla_world::doodad_anim::AnimMatPart);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +116,8 @@ pub(super) struct BoothRider {
|
||||
/// An attached model composes onto its parent's alpha (`0x714000`), so a ghost's helm
|
||||
/// feathers with the head under it.
|
||||
pub(super) twins: BoothTwins,
|
||||
/// As [`BoothPart::mat_anim`]: its material has UV or tint rows of its own.
|
||||
pub(super) mat_anim: bool,
|
||||
}
|
||||
|
||||
/// One camera-facing batch headed into a booth bake: the undead or night-elf eye glow, or an
|
||||
@@ -130,6 +133,8 @@ pub(super) struct BoothBillboardSpec {
|
||||
pub(super) kind: BillboardKind,
|
||||
/// A card takes the instance alpha like every other batch of the model.
|
||||
pub(super) twins: BoothTwins,
|
||||
/// As [`BoothPart::mat_anim`].
|
||||
pub(super) mat_anim: bool,
|
||||
}
|
||||
|
||||
/// A spawned booth billboard card, faced to its own booth's camera every frame by
|
||||
@@ -386,6 +391,11 @@ pub(super) enum BoothMotion {
|
||||
Loop,
|
||||
}
|
||||
|
||||
/// Marks one of a bake's own batches, a direct child of the booth root, so a re-dress
|
||||
/// ([`redress_booth_batches`]) tells them from the rig's anchors.
|
||||
#[derive(Component)]
|
||||
pub(super) struct BoothBatch;
|
||||
|
||||
/// Spawns a booth bake under `root`: a fresh throwaway instance posed at Stand (the reference's
|
||||
/// instance `0x707400`, pose `0x7121a0`), never the unit's live pose.
|
||||
///
|
||||
@@ -419,37 +429,9 @@ pub(super) fn spawn_booth_model(
|
||||
// A re-bake must not inherit the previous model's animation state, pose buffer or palette slot.
|
||||
clear_booth_rig(commands, root);
|
||||
let Some((skeleton, ibp, anims)) = rig.filter(|(s, _, _)| !s.joints.is_empty()) else {
|
||||
// No palette slot: every part draws its static twin at bind pose.
|
||||
for p in parts {
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(p.static_mesh.clone()),
|
||||
MeshMaterial3d(instance.material(&p.material, &p.twins)),
|
||||
Transform::IDENTITY,
|
||||
layer.clone(),
|
||||
ChildOf(root),
|
||||
));
|
||||
mark_mat_anim(&mut child, p);
|
||||
// The batch's material alpha times the instance alpha, the reference's
|
||||
// `model+0x180 × the batch factor` (`0x707680`).
|
||||
if let Some(anim) = &p.alpha_anim {
|
||||
let mat_anim =
|
||||
benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None);
|
||||
child.insert((
|
||||
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
0,
|
||||
mat_anim.current * instance.alpha,
|
||||
)),
|
||||
mat_anim,
|
||||
BoothMatAlpha,
|
||||
));
|
||||
} else if instance.feathering() {
|
||||
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
0,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
if let Some(fm) = instance.fade_materials(&p.material, &p.twins) {
|
||||
child.insert(fm);
|
||||
}
|
||||
spawn_batch(commands, root, &layer, p, 0, instance, None);
|
||||
}
|
||||
return BoothRig {
|
||||
root,
|
||||
@@ -508,107 +490,25 @@ pub(super) fn spawn_booth_model(
|
||||
}
|
||||
}
|
||||
}
|
||||
// A live booth's batch alphas follow the sequence its root plays, on its clock; a still's
|
||||
// stay pinned.
|
||||
let host = (motion == BoothMotion::Loop && anims.is_some()).then_some(root);
|
||||
for p in parts {
|
||||
let use_rig = p.skinned.is_some() && rig_slot != 0;
|
||||
let mesh = if use_rig {
|
||||
p.skinned.clone().expect("use_rig ⇒ skinned twin present")
|
||||
} else {
|
||||
p.static_mesh.clone()
|
||||
};
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(instance.material(&p.material, &p.twins)),
|
||||
Transform::IDENTITY,
|
||||
layer.clone(),
|
||||
ChildOf(root),
|
||||
));
|
||||
mark_mat_anim(&mut child, p);
|
||||
// The material alpha drives the tag's alpha field itself (nothing else writes a booth
|
||||
// part's alpha) beside the rig slot. `alpha_bits` floors a true zero just above 0, so the
|
||||
// shader's all-zero "untagged, opaque" sentinel cannot fire on an invisible batch.
|
||||
let tag_slot = if use_rig { rig_slot } else { 0 };
|
||||
if let Some(anim) = &p.alpha_anim {
|
||||
let mat_anim =
|
||||
benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None);
|
||||
child.insert((
|
||||
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
tag_slot,
|
||||
mat_anim.current * instance.alpha,
|
||||
)),
|
||||
mat_anim,
|
||||
BoothMatAlpha,
|
||||
));
|
||||
} else if use_rig || instance.feathering() {
|
||||
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
tag_slot,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
// While translucent, `benilla_world::zfill` keeps a colour-masked z-writing child on it, so
|
||||
// overlapping limbs blend as one layer (the reference's `M2UseZFill`).
|
||||
if let Some(fm) = instance.fade_materials(&p.material, &p.twins) {
|
||||
child.insert(fm);
|
||||
}
|
||||
if use_rig {
|
||||
child.insert(benilla_world::rig_palette::RigPart(root));
|
||||
// A skinned booth part is never frustum-culled: Bevy's bound is the bind-pose box, the
|
||||
// camera frames the Stand pose, and on a model whose rest pose is far from Stand
|
||||
// (`Creature\CarrionBird`, bind z to 1.19, Stand z 0.54 to 6.23) every batch culls.
|
||||
child.insert(bevy::camera::visibility::NoFrustumCulling);
|
||||
}
|
||||
spawn_batch(commands, root, &layer, p, rig_slot, instance, host);
|
||||
}
|
||||
let mut booth_rig = BoothRig {
|
||||
root,
|
||||
rig: Some(pose),
|
||||
slot: rig_slot,
|
||||
};
|
||||
for r in riders {
|
||||
let Some(anchor) = booth_rig.anchor(commands, r.bone) else {
|
||||
continue; // bad bone index: bake the body without this rider
|
||||
};
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(r.mesh.clone()),
|
||||
MeshMaterial3d(instance.material(&r.material, &r.twins)),
|
||||
Transform::from_translation(r.offset),
|
||||
layer.clone(),
|
||||
ChildOf(anchor),
|
||||
));
|
||||
// An attached model composes onto its parent's colour and alpha (`0x714000`). Untagged
|
||||
// means opaque and slot 0, so the tag goes up only for a translucent instance or a slot.
|
||||
if instance.feathering() || rig_slot != 0 {
|
||||
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
rig_slot,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
if let Some(fm) = instance.fade_materials(&r.material, &r.twins) {
|
||||
child.insert(fm);
|
||||
}
|
||||
}
|
||||
// Camera-facing batches seat under their bone's anchor for `face_booth_billboards` to face;
|
||||
// a bone the rig lacks drops the card, like a rider.
|
||||
for bb in billboards {
|
||||
let Some(anchor) = booth_rig.anchor(commands, bb.bone) else {
|
||||
continue;
|
||||
};
|
||||
let mut card = commands.spawn((
|
||||
Mesh3d(bb.mesh.clone()),
|
||||
MeshMaterial3d(instance.material(&bb.material, &bb.twins)),
|
||||
Transform::from_translation(bb.offset),
|
||||
layer.clone(),
|
||||
ChildOf(anchor),
|
||||
BoothBillboard { kind: bb.kind },
|
||||
));
|
||||
if instance.feathering() || rig_slot != 0 {
|
||||
card.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
rig_slot,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
if let Some(fm) = instance.fade_materials(&bb.material, &bb.twins) {
|
||||
card.insert(fm);
|
||||
}
|
||||
}
|
||||
hang_attachments(
|
||||
commands,
|
||||
&mut booth_rig,
|
||||
&layer,
|
||||
riders,
|
||||
billboards,
|
||||
instance,
|
||||
);
|
||||
// The player is configured before insertion, so the pose lands with the first animation pass;
|
||||
// `ModelAnimations` goes beside it because the rig evaluator reads the player through it.
|
||||
if let Some(anims) = anims {
|
||||
@@ -623,15 +523,7 @@ pub(super) fn spawn_booth_model(
|
||||
player.play(clip.node).repeat();
|
||||
}
|
||||
}
|
||||
// A weapon hand plays its `HandsClosed` finger overlay over Stand, repeated because it
|
||||
// is a one-key clamp pose; armed once, since a bake's grip never changes.
|
||||
for (hand, want) in grip.into_iter().enumerate() {
|
||||
if let (true, Some(node)) = (want, anims.hand_close[hand]) {
|
||||
let active = player.play(node);
|
||||
active.repeat();
|
||||
active.set_weight(crate::creature_anim::HAND_GRIP_WEIGHT);
|
||||
}
|
||||
}
|
||||
set_grip(&mut player, anims.hand_close, grip);
|
||||
commands.entity(root).insert((
|
||||
player,
|
||||
AnimationGraphHandle(anims.graph.clone()),
|
||||
@@ -642,6 +534,255 @@ pub(super) fn spawn_booth_model(
|
||||
booth_rig
|
||||
}
|
||||
|
||||
/// The mesh a batch draws against palette slot `rig_slot`: its skinned twin when it skins there,
|
||||
/// else its static one (slot 0 is no rig).
|
||||
fn batch_mesh(p: &BoothPart, rig_slot: u16) -> &Handle<Mesh> {
|
||||
match &p.skinned {
|
||||
Some(skinned) if rig_slot != 0 => skinned,
|
||||
_ => &p.static_mesh,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns one batch under `root`, skinned against `rig_slot` when it has a skinned twin.
|
||||
fn spawn_batch(
|
||||
commands: &mut Commands,
|
||||
root: Entity,
|
||||
layer: &RenderLayers,
|
||||
p: &BoothPart,
|
||||
rig_slot: u16,
|
||||
instance: BoothInstance,
|
||||
host: Option<Entity>,
|
||||
) -> Entity {
|
||||
let use_rig = p.skinned.is_some() && rig_slot != 0;
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(batch_mesh(p, rig_slot).clone()),
|
||||
Transform::IDENTITY,
|
||||
layer.clone(),
|
||||
ChildOf(root),
|
||||
BoothBatch,
|
||||
));
|
||||
dress_batch(&mut child, p, rig_slot, instance, host);
|
||||
if use_rig {
|
||||
child.insert(benilla_world::rig_palette::RigPart(root));
|
||||
// A skinned booth part is never frustum-culled: Bevy's bound is the bind-pose box, the
|
||||
// camera frames the Stand pose, and on a model whose rest pose is far from Stand
|
||||
// (`Creature\CarrionBird`, bind z to 1.19, Stand z 0.54 to 6.23) every batch culls.
|
||||
child.insert(bevy::camera::visibility::NoFrustumCulling);
|
||||
}
|
||||
child.id()
|
||||
}
|
||||
|
||||
/// A batch's dressing: its material, its tag (the palette slot beside the instance alpha, or the
|
||||
/// material alpha that drives it) and its depth-prime record. `host` is the root whose player the
|
||||
/// material alpha follows, its sequence and its scene's clock; `None` pins it.
|
||||
fn dress_batch(
|
||||
child: &mut bevy::ecs::system::EntityCommands<'_>,
|
||||
p: &BoothPart,
|
||||
rig_slot: u16,
|
||||
instance: BoothInstance,
|
||||
host: Option<Entity>,
|
||||
) {
|
||||
child.insert(MeshMaterial3d(instance.material(&p.material, &p.twins)));
|
||||
mark_mat_anim(child, p.mat_anim);
|
||||
// The material alpha drives the tag's alpha field itself (nothing else writes a booth part's
|
||||
// alpha) beside the rig slot. `alpha_bits` floors a true zero just above 0, so the shader's
|
||||
// all-zero "untagged, opaque" sentinel cannot fire on an invisible batch.
|
||||
let use_rig = p.skinned.is_some() && rig_slot != 0;
|
||||
let tag_slot = if use_rig { rig_slot } else { 0 };
|
||||
if let Some(anim) = &p.alpha_anim {
|
||||
// The batch's material alpha times the instance alpha, the reference's
|
||||
// `model+0x180 × the batch factor` (`0x707680`).
|
||||
let mat_anim = benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None)
|
||||
.following_host(host);
|
||||
child.insert((
|
||||
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
tag_slot,
|
||||
mat_anim.current * instance.alpha,
|
||||
)),
|
||||
mat_anim,
|
||||
BoothMatAlpha,
|
||||
));
|
||||
} else if use_rig || instance.feathering() {
|
||||
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
tag_slot,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
// While translucent, `benilla_world::zfill` keeps a colour-masked z-writing child on it, so
|
||||
// overlapping limbs blend as one layer (the reference's `M2UseZFill`).
|
||||
if let Some(fm) = instance.fade_materials(&p.material, &p.twins) {
|
||||
child.insert(fm);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seats the riders on their bones' anchors and the camera-facing cards under theirs; a bone the
|
||||
/// rig lacks drops its rider or card.
|
||||
fn hang_attachments(
|
||||
commands: &mut Commands,
|
||||
booth_rig: &mut BoothRig,
|
||||
layer: &RenderLayers,
|
||||
riders: &[BoothRider],
|
||||
billboards: &[BoothBillboardSpec],
|
||||
instance: BoothInstance,
|
||||
) {
|
||||
let rig_slot = booth_rig.slot;
|
||||
for r in riders {
|
||||
let Some(anchor) = booth_rig.anchor(commands, r.bone) else {
|
||||
continue; // bad bone index: bake the body without this rider
|
||||
};
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(r.mesh.clone()),
|
||||
MeshMaterial3d(instance.material(&r.material, &r.twins)),
|
||||
Transform::from_translation(r.offset),
|
||||
layer.clone(),
|
||||
ChildOf(anchor),
|
||||
));
|
||||
mark_mat_anim(&mut child, r.mat_anim);
|
||||
// An attached model composes onto its parent's colour and alpha (`0x714000`). Untagged
|
||||
// means opaque and slot 0, so the tag goes up only for a translucent instance or a slot.
|
||||
if instance.feathering() || rig_slot != 0 {
|
||||
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
rig_slot,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
if let Some(fm) = instance.fade_materials(&r.material, &r.twins) {
|
||||
child.insert(fm);
|
||||
}
|
||||
}
|
||||
// Camera-facing batches seat under their bone's anchor for `face_booth_billboards` to face.
|
||||
for bb in billboards {
|
||||
let Some(anchor) = booth_rig.anchor(commands, bb.bone) else {
|
||||
continue;
|
||||
};
|
||||
let mut card = commands.spawn((
|
||||
Mesh3d(bb.mesh.clone()),
|
||||
MeshMaterial3d(instance.material(&bb.material, &bb.twins)),
|
||||
Transform::from_translation(bb.offset),
|
||||
layer.clone(),
|
||||
ChildOf(anchor),
|
||||
BoothBillboard { kind: bb.kind },
|
||||
));
|
||||
mark_mat_anim(&mut card, bb.mat_anim);
|
||||
if instance.feathering() || rig_slot != 0 {
|
||||
card.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
|
||||
rig_slot,
|
||||
instance.alpha,
|
||||
)));
|
||||
}
|
||||
if let Some(fm) = instance.fade_materials(&bb.material, &bb.twins) {
|
||||
card.insert(fm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-dresses the instance standing on `root` without replacing it, as the reference's setters
|
||||
/// act on one `CharacterModel`: the skin setter `0x478790` rebinds its textures and raises the
|
||||
/// dirty byte `[cm+8]`, and the class re-dress `0x470800` re-equips the same model. The root keeps
|
||||
/// its player, global sequences and palette slot `rig_slot`, so its clock runs on. A batch whose
|
||||
/// mesh is still worn keeps its entity and takes its new dressing; the others spawn or go, as a
|
||||
/// geoset toggles. `standing` is the root's [`BoothBatch`] children by the mesh each draws.
|
||||
///
|
||||
/// Opaque instances only: a feathering batch's depth-prime twin belongs to the zfill lane, which
|
||||
/// does not reap it when the record goes.
|
||||
pub(super) fn redress_booth_batches(
|
||||
commands: &mut Commands,
|
||||
root: Entity,
|
||||
layer: &RenderLayers,
|
||||
parts: &[BoothPart],
|
||||
standing: &[(Entity, AssetId<Mesh>)],
|
||||
rig_slot: u16,
|
||||
instance: BoothInstance,
|
||||
) {
|
||||
debug_assert!(
|
||||
!instance.feathering(),
|
||||
"a re-dress keeps opaque batches only"
|
||||
);
|
||||
let mut free: Vec<Option<(Entity, AssetId<Mesh>)>> =
|
||||
standing.iter().copied().map(Some).collect();
|
||||
for p in parts {
|
||||
let want = batch_mesh(p, rig_slot).id();
|
||||
let kept = free
|
||||
.iter_mut()
|
||||
.find(|s| s.is_some_and(|(_, mesh)| mesh == want))
|
||||
.and_then(Option::take);
|
||||
match kept {
|
||||
Some((batch, _)) => {
|
||||
let mut child = commands.entity(batch);
|
||||
// Strip the old dressing; `Visibility` too, which the material-alpha writer owns.
|
||||
child
|
||||
.remove::<(
|
||||
bevy::mesh::MeshTag,
|
||||
benilla_world::doodad_anim::MatAnim,
|
||||
BoothMatAlpha,
|
||||
benilla_world::doodad_anim::AnimMatPart,
|
||||
)>()
|
||||
.insert(Visibility::Inherited);
|
||||
dress_batch(&mut child, p, rig_slot, instance, Some(root));
|
||||
}
|
||||
None => {
|
||||
spawn_batch(commands, root, layer, p, rig_slot, instance, Some(root));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (batch, _) in free.into_iter().flatten() {
|
||||
commands.entity(batch).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
impl BoothRig {
|
||||
/// A fresh bind-pose buffer for the instance standing on `root`, on its palette slot `slot`:
|
||||
/// a re-dress re-hangs its attachments on new anchors ([`Self::hang`]), and the pose
|
||||
/// evaluator re-poses it from the root's player before it draws.
|
||||
pub(super) fn rehang(
|
||||
root: Entity,
|
||||
skeleton: &benilla_assets::ModelSkeleton,
|
||||
slot: u16,
|
||||
) -> Self {
|
||||
Self {
|
||||
root,
|
||||
rig: Some(
|
||||
benilla_world::rig_anim::RigPose::new(root, skeleton).without_camera_billboards(),
|
||||
),
|
||||
slot,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seats `riders` and `billboards` on this rig ([`hang_attachments`]).
|
||||
pub(super) fn hang(
|
||||
&mut self,
|
||||
commands: &mut Commands,
|
||||
layer: &RenderLayers,
|
||||
riders: &[BoothRider],
|
||||
billboards: &[BoothBillboardSpec],
|
||||
instance: BoothInstance,
|
||||
) {
|
||||
hang_attachments(commands, self, layer, riders, billboards, instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes each hand whose attach point holds a weapon (`CloseHand` `0x479660`) and opens the
|
||||
/// other, as an unequip's detach does (`0x47a380`): the `HandsClosed` finger overlay over Stand,
|
||||
/// repeated because it is a one-key clamp pose.
|
||||
pub(super) fn set_grip(
|
||||
player: &mut AnimationPlayer,
|
||||
hand_close: [Option<bevy::animation::graph::AnimationNodeIndex>; 2],
|
||||
grip: [bool; 2],
|
||||
) {
|
||||
for (hand, want) in grip.into_iter().enumerate() {
|
||||
let Some(node) = hand_close[hand] else {
|
||||
continue;
|
||||
};
|
||||
if !want {
|
||||
player.stop(node);
|
||||
} else if player.animation(node).is_none() {
|
||||
let active = player.play(node);
|
||||
active.repeat();
|
||||
active.set_weight(crate::creature_anim::HAND_GRIP_WEIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids `PlayerModel:SetRotation` chooses between, from the reference's name table
|
||||
/// (`0x6143b6`, `[ecx*4+0x8686a8]`): 0 `Stand`, 11 `ShuffleLeft`, 12 `ShuffleRight`.
|
||||
const STAND: u16 = 0;
|
||||
@@ -1218,6 +1359,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A live booth's batch alpha follows its root's player, its sequence and its scene's clock,
|
||||
/// so a glue scene's dimming loops stop with the scene; a still's stays pinned.
|
||||
#[test]
|
||||
fn a_live_booths_batch_alpha_follows_its_root() {
|
||||
let skeleton = benilla_assets::ModelSkeleton {
|
||||
joints: vec![benilla_assets::ModelJoint {
|
||||
parent: -1,
|
||||
local_translation: Vec3::ZERO,
|
||||
billboard: None,
|
||||
parent_arm: None,
|
||||
}],
|
||||
spine_bone: None,
|
||||
head_bone: None,
|
||||
};
|
||||
let anims = turning_model();
|
||||
for (motion, follows) in [(BoothMotion::Loop, true), (BoothMotion::Frozen, false)] {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<benilla_world::rig_palette::RigPalettes>();
|
||||
let parts = vec![BoothPart {
|
||||
skinned: None,
|
||||
static_mesh: Handle::default(),
|
||||
material: Handle::default(),
|
||||
alpha_anim: Some(constant_alpha(0.55)),
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
}];
|
||||
let root = app.world_mut().spawn(Transform::IDENTITY).id();
|
||||
let mut palettes = app
|
||||
.world_mut()
|
||||
.remove_resource::<benilla_world::rig_palette::RigPalettes>()
|
||||
.expect("just inserted");
|
||||
let mut queue = bevy::ecs::world::CommandQueue::default();
|
||||
{
|
||||
let mut commands = Commands::new(&mut queue, app.world());
|
||||
spawn_booth_model(
|
||||
&mut commands,
|
||||
&mut palettes,
|
||||
root,
|
||||
RenderLayers::layer(9),
|
||||
&parts,
|
||||
&[],
|
||||
Some((&skeleton, &Handle::default(), Some(&anims))),
|
||||
None,
|
||||
motion,
|
||||
[false, false],
|
||||
&[],
|
||||
BoothInstance::default(),
|
||||
)
|
||||
.finish(&mut commands);
|
||||
}
|
||||
queue.apply(app.world_mut());
|
||||
let mut q = app.world_mut().query::<&MatAnim>();
|
||||
let hosts: Vec<_> = q.iter(app.world()).map(MatAnim::host).collect();
|
||||
assert_eq!(
|
||||
hosts,
|
||||
vec![follows.then_some(root)],
|
||||
"{} booth",
|
||||
if follows { "a live" } else { "a still" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// World parts carry `MatAnim` too; their alpha belongs to the world's visibility pass.
|
||||
#[test]
|
||||
fn the_booth_alpha_writer_ignores_unmarked_parts() {
|
||||
|
||||
@@ -229,6 +229,7 @@ pub(super) fn sync_dressup_booth(
|
||||
bone: r.bone,
|
||||
offset: r.offset,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
})
|
||||
.collect();
|
||||
let booth_billboards: Vec<BoothBillboardSpec> = bake
|
||||
@@ -241,6 +242,7 @@ pub(super) fn sync_dressup_booth(
|
||||
offset: b.offset,
|
||||
kind: b.kind,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
})
|
||||
.collect();
|
||||
// Never latch a world-lane material into a pane ([`super::light`]): retry instead.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -804,6 +804,12 @@ impl Plugin for PortraitPlugin {
|
||||
glue_booth::pillarbox_glue_scene,
|
||||
// After the scene: the pet's seat and its light are the scene's to publish.
|
||||
glue_booth::sync_glue_pet,
|
||||
// After every glue spawn: the stage's clock reaches the players it made.
|
||||
glue_booth::pace_glue_clock
|
||||
.after(glue_booth::drive_create_test)
|
||||
.after(test_bake::dump_booth_target)
|
||||
.before(benilla_world::doodad_anim::MatAnimTick)
|
||||
.before(benilla_world::doodad_anim::sample_mat_anim),
|
||||
dressup::sync_dressup_booth,
|
||||
// After the syncs, the only writers of the yaw it spends.
|
||||
booth::drive_booth_turn,
|
||||
@@ -1492,6 +1498,7 @@ fn sync_portraits(
|
||||
bone: r.bone,
|
||||
offset: r.offset,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
})
|
||||
.collect();
|
||||
let booth_billboards: Vec<BoothBillboardSpec> = billboards
|
||||
@@ -1503,6 +1510,7 @@ fn sync_portraits(
|
||||
offset: b.seat.offset(),
|
||||
kind: b.kind,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
})
|
||||
.collect();
|
||||
// A source material was not resident, so a twin is the world one; retry next frame.
|
||||
@@ -1906,6 +1914,7 @@ fn sync_body_booth(
|
||||
bone: r.bone,
|
||||
offset: r.offset,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
})
|
||||
.collect();
|
||||
let booth_billboards: Vec<BoothBillboardSpec> = billboards
|
||||
@@ -1917,6 +1926,7 @@ fn sync_body_booth(
|
||||
offset: b.seat.offset(),
|
||||
kind: b.kind,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
})
|
||||
.collect();
|
||||
// The worn items' effects, spawned after the model hands over the joints.
|
||||
@@ -2111,7 +2121,8 @@ fn gate_booth_cameras(
|
||||
booth.pipes_since = None;
|
||||
false
|
||||
};
|
||||
let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some();
|
||||
// A hidden pane's scene stays up but undrawn: its camera sleeps, so its emitters freeze.
|
||||
let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some() && !preview.hidden;
|
||||
// The glue screens publish no pane and stay on `live_scene`.
|
||||
let live_pane = booth.live && panes.0.contains_key(token.as_str());
|
||||
let active = test
|
||||
|
||||
@@ -33,7 +33,7 @@ use benilla_world::particles::buffer::EffectLightOverride;
|
||||
use benilla_world::particles::{
|
||||
spawn_emitter, EmitClock, EmitterFrames, OwnerLoss, ParticleEmitter,
|
||||
};
|
||||
use benilla_world::rig_anim::{AnimParked, GlobalSeqDrive, RigPose};
|
||||
use benilla_world::rig_anim::{AnimParked, GlobalSeqDrive, RigPose, SceneClock};
|
||||
use benilla_world::rig_palette::{RigPaletteMirrors, RigPalettes, RigPart, RigSkin};
|
||||
|
||||
use crate::portrait::{
|
||||
@@ -685,7 +685,7 @@ fn sync_tiles(
|
||||
&mut Transform,
|
||||
&mut Visibility,
|
||||
Option<&mut AnimationPlayer>,
|
||||
Option<&mut GlobalSeqDrive>,
|
||||
Option<&mut SceneClock>,
|
||||
),
|
||||
(
|
||||
With<TileRoot>,
|
||||
@@ -984,7 +984,7 @@ fn sync_tiles(
|
||||
continue;
|
||||
};
|
||||
bridge.cells.insert(*handle, cell);
|
||||
let Ok((mut tf, mut vis, player, drive)) = roots.get_mut(tile.root) else {
|
||||
let Ok((mut tf, mut vis, player, scene_clock)) = roots.get_mut(tile.root) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -1110,8 +1110,8 @@ fn sync_tiles(
|
||||
// attach snapshot, and a `<Model>` owns a private `CM2Scene` (`CSimpleModel+0x314`) that
|
||||
// only its `OnUpdate` advances (`0x76d7f0`). So the ping's 4833 ms spinner resumes where
|
||||
// the last ping stopped; the drive anchors on its first tick, the attach.
|
||||
if let Some(mut d) = drive {
|
||||
d.set_clock(gseq_s);
|
||||
if let Some(mut clock) = scene_clock.filter(|c| c.0 != gseq_s) {
|
||||
clock.0 = gseq_s;
|
||||
}
|
||||
let light_trace = if trace_on() {
|
||||
let sc = &render.rig.lights[tile.light_slot].scene;
|
||||
@@ -1634,7 +1634,8 @@ fn build_tile(
|
||||
anims.clone(),
|
||||
));
|
||||
if let Some(drive) = GlobalSeqDrive::new_rig(&anims.global_bones, p.locals.len()) {
|
||||
commands.entity(root).insert(drive);
|
||||
// The drive samples the pane's clock, which `sync_tiles` writes here.
|
||||
commands.entity(root).insert((drive, SceneClock::default()));
|
||||
}
|
||||
}
|
||||
pose = Some(p);
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
//! facing (`[0xb420f4]`, `SetCharacterCreateFacing`).
|
||||
//!
|
||||
//! The scene fills the pane at the pane's own aspect, as the reference frames it, and starts over
|
||||
//! with each `SetModel` instance; the pane's showing resets the glue glow (`0x46fa60`).
|
||||
//! with each `SetModel` instance; the pane's showing resets the glue glow (`0x46fa60`). A hidden
|
||||
//! pane keeps its scene with its clock stopped (`0x76d7f0`), so a screen with none (the credits)
|
||||
//! leaves the last one standing hidden, and its return resumes it. A pane hidden behind another's
|
||||
//! keeps its scene too (the login's `UI_MainMenu` has no `SetModel` but its `file=`): each (pane,
|
||||
//! instance) keeps one stage number while its pane holds it, and the booth parks it meanwhile.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -84,12 +88,14 @@ fn stock_preview(
|
||||
(scene, look, yaw, fog)
|
||||
}
|
||||
|
||||
/// What the feeder remembers about the glue VM between frames: the pane shown last and the
|
||||
/// instance it held, numbered into [`GluePreview::stage`].
|
||||
/// What the feeder remembers about the glue VM between frames: the pane shown last, the pane and
|
||||
/// instance the scene stands, and the number each (pane, instance) took in [`GluePreview::stage`].
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StockFeed {
|
||||
shown: Option<benilla_ui::widget::FrameHandle>,
|
||||
instance: Option<(benilla_ui::widget::FrameHandle, u64)>,
|
||||
/// Every scene a pane still holds, by its (pane, instance), with its stage number.
|
||||
stages: Vec<((benilla_ui::widget::FrameHandle, u64), u64)>,
|
||||
}
|
||||
|
||||
/// Feed the glue booth from the glue VM while the stock glue screens are up, and stand it down
|
||||
@@ -100,10 +106,24 @@ pub(crate) fn feed_stock_glue_preview(
|
||||
state: Option<Res<State<crate::char_select::ClientState>>>,
|
||||
mut preview: ResMut<GluePreview>,
|
||||
mut feed: Local<super::VmMemo<StockFeed>>,
|
||||
// The highest stage number given out, kept across VMs: a new VM's first scene must not take a
|
||||
// number a scene parked under the old one still holds.
|
||||
mut top: Local<u64>,
|
||||
) {
|
||||
let in_world = state.is_some_and(|s| *s.get() == crate::char_select::ClientState::InWorld);
|
||||
let script = script.filter(|s| s.vm_kind() == VmKind::Glue && !in_world);
|
||||
let pane = script.as_deref().and_then(|s| s.glue_scene());
|
||||
// A new VM's panes are new frames: the memo starts over with it.
|
||||
let feed = feed.get_for(script.as_deref());
|
||||
let visible = script.as_deref().and_then(|s| s.glue_scene());
|
||||
// With none shown, the pane last stood keeps its scene, hidden: a `ModelFFX` holds its model
|
||||
// and scene while hidden, and only its clock stops (`0x76d7f0`, called by `0x765650`).
|
||||
let hidden = match (&visible, script.as_deref(), feed.instance) {
|
||||
(None, Some(s), Some((h, _))) => s.glue_pane(h),
|
||||
_ => None,
|
||||
};
|
||||
let is_hidden = hidden.is_some();
|
||||
let shown = visible.as_ref().map(|p| p.handle);
|
||||
let pane = visible.or(hidden);
|
||||
let mgr = mgr.get();
|
||||
let (scene, look, yaw, fog) = match script.as_deref() {
|
||||
Some(s) => {
|
||||
@@ -121,19 +141,40 @@ pub(crate) fn feed_stock_glue_preview(
|
||||
}
|
||||
None => (None, None, 0.0, None),
|
||||
};
|
||||
// A new VM's panes are new frames: the memo starts over with it.
|
||||
let feed = feed.get_for(script.as_deref());
|
||||
// A pane that was not the one shown last frame has just shown: its OnShow (`0x46fa60`).
|
||||
let shown = pane.as_ref().map(|p| p.handle);
|
||||
if shown.is_some() && shown != feed.shown {
|
||||
preview.shown = preview.shown.wrapping_add(1);
|
||||
}
|
||||
feed.shown = shown;
|
||||
// A new instance, or another pane's, is another scene.
|
||||
// A new instance, or another pane's, is another scene; a hidden pane's own is the same one.
|
||||
// Each (pane, instance) keeps one number, so a pane shown again on the instance it held finds
|
||||
// its scene as it left it: a hidden `ModelFFX` keeps model, scene and stopped clock.
|
||||
let instance = pane.as_ref().map(|p| (p.handle, p.instance));
|
||||
if instance.is_some() && instance != feed.instance {
|
||||
if let Some(key) = instance.filter(|_| instance != feed.instance) {
|
||||
feed.instance = instance;
|
||||
preview.stage = preview.stage.wrapping_add(1);
|
||||
let id = match feed.stages.iter().find(|(k, _)| *k == key) {
|
||||
Some(&(_, id)) => id,
|
||||
None => {
|
||||
*top = top.max(preview.stage).wrapping_add(1);
|
||||
feed.stages.push((key, *top));
|
||||
*top
|
||||
}
|
||||
};
|
||||
preview.stage = id;
|
||||
}
|
||||
// A pane that took a new model let its old instance go (`0x76cce0`), and its scene with it.
|
||||
match script.as_deref() {
|
||||
Some(s) => feed
|
||||
.stages
|
||||
.retain(|&((h, inst), _)| s.glue_pane(h).is_some_and(|p| p.instance == inst)),
|
||||
None => feed.stages.clear(),
|
||||
}
|
||||
let live: Vec<u64> = feed.stages.iter().map(|&(_, id)| id).collect();
|
||||
if preview.live != live {
|
||||
preview.live = live;
|
||||
}
|
||||
if preview.hidden != is_hidden {
|
||||
preview.hidden = is_hidden;
|
||||
}
|
||||
let rect = pane.as_ref().and_then(|p| p.rect);
|
||||
if preview.pane != rect {
|
||||
@@ -282,4 +323,141 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetGlueScreen("credits")` hides `AccountLogin` and shows no model (`GlueParent.lua:34-45`):
|
||||
/// the login pane keeps its model and scene while hidden, so the booth keeps standing it,
|
||||
/// hidden; the return shows the same instance, which fires its OnShow (`0x46fa60`) again.
|
||||
#[test]
|
||||
fn a_hidden_pane_keeps_its_scene_and_its_return_is_the_same_scene() {
|
||||
let mut app = App::new();
|
||||
let s = UiScript::new_glue().unwrap();
|
||||
s.run(
|
||||
"CreateFrame('ModelFFX', 'Login')\
|
||||
:SetModel('Interface/Glues/Models/UI_MainMenu/UI_MainMenu.mdx')",
|
||||
)
|
||||
.unwrap();
|
||||
app.insert_non_send_resource(s)
|
||||
.insert_non_send_resource(SharedGlueMgr::default())
|
||||
.init_resource::<GluePreview>()
|
||||
.add_systems(Update, feed_stock_glue_preview);
|
||||
let run = |app: &mut App, chunk: &str| {
|
||||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
s.run(chunk).unwrap();
|
||||
s.resolve();
|
||||
app.update();
|
||||
let p = app.world().resource::<GluePreview>();
|
||||
(p.scene, p.hidden, p.stage, p.shown)
|
||||
};
|
||||
let (scene, hidden, stage, shown) = run(&mut app, "");
|
||||
assert_eq!((scene, hidden), (Some(GlueScene::MainMenu), false));
|
||||
|
||||
assert_eq!(
|
||||
run(&mut app, "Login:Hide()"),
|
||||
(Some(GlueScene::MainMenu), true, stage, shown),
|
||||
"hidden, the scene stays up: no teardown, no new instance"
|
||||
);
|
||||
assert_eq!(
|
||||
run(&mut app, "Login:Show()"),
|
||||
(Some(GlueScene::MainMenu), false, stage, shown + 1),
|
||||
"shown again, the same instance resumes, and its OnShow pins the glow"
|
||||
);
|
||||
|
||||
// A new `SetModel` instance while hidden is a new scene when it shows.
|
||||
run(
|
||||
&mut app,
|
||||
"Login:Hide() Login:SetModel('Interface/Glues/Models/UI_Orc/UI_Orc.mdx')",
|
||||
);
|
||||
let (scene, hidden, again, _) = run(&mut app, "Login:Show()");
|
||||
assert_eq!((scene, hidden), (Some(GlueScene::Orc), false));
|
||||
assert_ne!(again, stage);
|
||||
}
|
||||
|
||||
/// Each (pane, instance) keeps its stage number while its pane holds it, so a pane shown again
|
||||
/// on the instance it held brings that number back; a new `SetModel` there lets it go.
|
||||
#[test]
|
||||
fn a_pane_shown_again_on_its_instance_brings_its_stage_back() {
|
||||
let mut app = App::new();
|
||||
let s = UiScript::new_glue().unwrap();
|
||||
s.run(
|
||||
"CreateFrame('ModelFFX', 'Login')\
|
||||
:SetModel('Interface/Glues/Models/UI_MainMenu/UI_MainMenu.mdx') \
|
||||
CreateFrame('ModelFFX', 'Select')\
|
||||
:SetModel('Interface/Glues/Models/UI_Orc/UI_Orc.mdx') Select:Hide()",
|
||||
)
|
||||
.unwrap();
|
||||
app.insert_non_send_resource(s)
|
||||
.insert_non_send_resource(SharedGlueMgr::default())
|
||||
.init_resource::<GluePreview>()
|
||||
.add_systems(Update, feed_stock_glue_preview);
|
||||
let run = |app: &mut App, chunk: &str| {
|
||||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
s.run(chunk).unwrap();
|
||||
s.resolve();
|
||||
app.update();
|
||||
let p = app.world().resource::<GluePreview>();
|
||||
(p.scene, p.stage, p.live.clone())
|
||||
};
|
||||
let (scene, login, live) = run(&mut app, "");
|
||||
assert_eq!((scene, live), (Some(GlueScene::MainMenu), vec![login]));
|
||||
|
||||
let (scene, select, live) = run(&mut app, "Login:Hide() Select:Show()");
|
||||
assert_eq!(scene, Some(GlueScene::Orc));
|
||||
assert_ne!(select, login);
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![login, select],
|
||||
"the hidden login pane still holds its scene"
|
||||
);
|
||||
|
||||
let (scene, back, live) = run(&mut app, "Select:Hide() Login:Show()");
|
||||
assert_eq!(
|
||||
(scene, back),
|
||||
(Some(GlueScene::MainMenu), login),
|
||||
"the same instance, the same stage"
|
||||
);
|
||||
assert_eq!(live, vec![login, select]);
|
||||
|
||||
let (_, _, live) = run(
|
||||
&mut app,
|
||||
"Select:SetModel('Interface/Glues/Models/UI_Human/UI_Human.mdx')",
|
||||
);
|
||||
assert_eq!(live, vec![login], "the select pane let its old scene go");
|
||||
}
|
||||
|
||||
/// A new VM's panes are new frames, but their scenes' numbers must not repeat one the old VM
|
||||
/// gave out: a scene parked under it would stand in for the new VM's first.
|
||||
#[test]
|
||||
fn a_new_vm_never_reuses_a_stage_number() {
|
||||
let mut app = App::new();
|
||||
let vm = || {
|
||||
let s = UiScript::new_glue().unwrap();
|
||||
s.run(
|
||||
"CreateFrame('ModelFFX', 'Login')\
|
||||
:SetModel('Interface/Glues/Models/UI_MainMenu/UI_MainMenu.mdx') \
|
||||
CreateFrame('ModelFFX', 'Select')\
|
||||
:SetModel('Interface/Glues/Models/UI_Orc/UI_Orc.mdx') Select:Hide()",
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
};
|
||||
app.insert_non_send_resource(vm())
|
||||
.insert_non_send_resource(SharedGlueMgr::default())
|
||||
.init_resource::<GluePreview>()
|
||||
.add_systems(Update, feed_stock_glue_preview);
|
||||
let run = |app: &mut App, chunk: &str| {
|
||||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
s.run(chunk).unwrap();
|
||||
s.resolve();
|
||||
app.update();
|
||||
app.world().resource::<GluePreview>().stage
|
||||
};
|
||||
let login = run(&mut app, "");
|
||||
let select = run(&mut app, "Login:Hide() Select:Show()");
|
||||
assert_eq!(run(&mut app, "Select:Hide() Login:Show()"), login);
|
||||
|
||||
// A new VM: its login scene takes a number past every one given out.
|
||||
app.insert_non_send_resource(vm());
|
||||
let fresh = run(&mut app, "");
|
||||
assert!(fresh > select.max(login), "{fresh} after {login}, {select}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,9 @@ mod tests {
|
||||
("ui_script/input.rs", "pressed"),
|
||||
// The catalog's bit-15 spells, a fact about the spell catalog, rebuilt when it changes.
|
||||
("ui_unit/control_gate.rs", "candidates"),
|
||||
// The glue booth's stage numbers, which must outlive the VM: a scene parked under the old
|
||||
// VM keeps its number until the booth drops it, so a new VM's scenes take fresh ones.
|
||||
("ui_script/glue_scene.rs", "top"),
|
||||
];
|
||||
|
||||
use crate::test_support::{fn_parameter_lists, rust_files};
|
||||
|
||||
@@ -96,11 +96,14 @@ const PUBLISHED: &[(&str, &str)] = &[
|
||||
("doodad_anim::AnimMatPart", "record"),
|
||||
("doodad_anim::DoodadAnimHost", "record"),
|
||||
("doodad_anim::MatAnim", "record"),
|
||||
("doodad_anim::MatAnimTick", "record"),
|
||||
("doodad_anim::TintLoop", "record"),
|
||||
("doodad_anim::UvLoops", "record"),
|
||||
("doodad_anim::clocked_twin", "record"),
|
||||
("doodad_anim::register_entity_uv", "record"),
|
||||
("doodad_anim::register_fx_uv", "record"),
|
||||
("doodad_anim::register_tint", "record"),
|
||||
("doodad_anim::scene_clocked", "record"),
|
||||
("doodad_anim::spawn_anim_host", "record"),
|
||||
("ffx_glow::FfxBackdrop", "record"),
|
||||
("ffx_glow::FfxGlow", "record"),
|
||||
@@ -145,6 +148,7 @@ const PUBLISHED: &[(&str, &str)] = &[
|
||||
("rig_anim::RigAnchor", "wall"),
|
||||
("rig_anim::RigFrame", "wall"),
|
||||
("rig_anim::RigPose", "wall"),
|
||||
("rig_anim::SceneClock", "record"),
|
||||
("rig_palette::RigPalettes", "record"),
|
||||
("rig_palette::RigSkin", "record"),
|
||||
("rig_rider::RigRider", "record"),
|
||||
|
||||
@@ -77,6 +77,11 @@ impl<K: Eq + Hash, V> SpatialCache<K, V> {
|
||||
&mut slot.0
|
||||
}
|
||||
|
||||
/// Keep only the entries whose key passes `keep`: an owner dropping what it alone keyed.
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&K) -> bool) {
|
||||
self.map.retain(|k, _| keep(k));
|
||||
}
|
||||
|
||||
/// Stamp everything used since the last sweep at `focus` and `now`, then drop every entry both
|
||||
/// more than `radius` yd away and idle for [`MIN_DWELL_SECS`]; returns how many were dropped.
|
||||
pub fn scope(&mut self, focus: [f32; 3], radius: f32, now: f32) -> usize {
|
||||
@@ -238,4 +243,18 @@ mod tests {
|
||||
assert_eq!(c.fetch(&1), Some(10));
|
||||
assert_eq!(c.len(), 1);
|
||||
}
|
||||
|
||||
/// An owner drops exactly what it keyed: a glue scene going takes its own materials, and no
|
||||
/// other scene's.
|
||||
#[test]
|
||||
fn retain_drops_only_the_keys_it_is_told_to() {
|
||||
let mut c: SpatialCache<(u32, Option<u32>), u32> = SpatialCache::default();
|
||||
c.insert((1, Some(7)), 10);
|
||||
c.insert((2, Some(8)), 20);
|
||||
c.insert((3, None), 30);
|
||||
c.retain(|k| k.1 != Some(7));
|
||||
assert!(c.fetch(&(1, Some(7))).is_none());
|
||||
assert_eq!(c.fetch(&(2, Some(8))), Some(20));
|
||||
assert_eq!(c.fetch(&(3, None)), Some(30));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,49 +209,26 @@ impl super::UiScript {
|
||||
/// shown; `None` with none shown, or one holding no file.
|
||||
pub fn glue_scene(&self) -> Option<GlueScenePane> {
|
||||
let model = self.model_ref();
|
||||
let select = model_frame_handle(&model);
|
||||
let create = super::glue_create::create_frame_handle(&model);
|
||||
let (sw, sh) = (model.screen.width(), model.screen.height());
|
||||
model
|
||||
.arena
|
||||
.model_kinds()
|
||||
.iter()
|
||||
.filter(|&&h| model.arena.frame(h).is_some_and(|f| f.effective_visible))
|
||||
.filter_map(|&h| {
|
||||
let f = model.arena.frame(h)?;
|
||||
let crate::widget::KindState::Model(m) = &f.kind_state else {
|
||||
return None;
|
||||
};
|
||||
if !m.ffx || !f.effective_visible {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
(f.strata, f.level),
|
||||
GlueScenePane {
|
||||
handle: h,
|
||||
rect: model
|
||||
.resolved
|
||||
.get(&h)
|
||||
.filter(|_| sw > 0.0 && sh > 0.0)
|
||||
.map(|r| {
|
||||
[
|
||||
r.left / sw,
|
||||
r.right / sw,
|
||||
1.0 - r.top / sh,
|
||||
1.0 - r.bottom / sh,
|
||||
]
|
||||
}),
|
||||
instance: m.instance,
|
||||
path: m.path.clone()?,
|
||||
fog: m.scene_fog(),
|
||||
select: select == Some(h),
|
||||
create: create == Some(h),
|
||||
},
|
||||
))
|
||||
Some(((f.strata, f.level), scene_pane(&model, h)?))
|
||||
})
|
||||
.max_by_key(|(order, _)| *order)
|
||||
.map(|(_, pane)| pane)
|
||||
}
|
||||
|
||||
/// The scene of the `ModelFFX` pane `h`, shown or hidden: a hidden pane keeps its model and
|
||||
/// scene, and only its clock stops (its `OnUpdate`, `0x76d7f0`, runs from the visible-frame
|
||||
/// pump `0x765650`). `None` for a frame that is no `ModelFFX` or holds no file.
|
||||
pub fn glue_pane(&self, h: crate::widget::FrameHandle) -> Option<GlueScenePane> {
|
||||
scene_pane(&self.model_ref(), h)
|
||||
}
|
||||
|
||||
/// The selected character's guid, none off the roster.
|
||||
pub fn glue_selected_guid(&self) -> Option<u64> {
|
||||
let model = self.model_ref();
|
||||
@@ -387,6 +364,38 @@ pub(super) fn teardown_roster(lua: &Lua) {
|
||||
super::tick::fire_event_into(lua, "CHARACTER_LIST_UPDATE", Vec::new());
|
||||
}
|
||||
|
||||
/// The scene a `ModelFFX` pane `h` holds, visible or not ([`UiScript::glue_scene`]).
|
||||
fn scene_pane(model: &Model, h: crate::widget::FrameHandle) -> Option<GlueScenePane> {
|
||||
let f = model.arena.frame(h)?;
|
||||
let crate::widget::KindState::Model(m) = &f.kind_state else {
|
||||
return None;
|
||||
};
|
||||
if !m.ffx {
|
||||
return None;
|
||||
}
|
||||
let (sw, sh) = (model.screen.width(), model.screen.height());
|
||||
Some(GlueScenePane {
|
||||
handle: h,
|
||||
rect: model
|
||||
.resolved
|
||||
.get(&h)
|
||||
.filter(|_| sw > 0.0 && sh > 0.0)
|
||||
.map(|r| {
|
||||
[
|
||||
r.left / sw,
|
||||
r.right / sw,
|
||||
1.0 - r.top / sh,
|
||||
1.0 - r.bottom / sh,
|
||||
]
|
||||
}),
|
||||
instance: m.instance,
|
||||
path: m.path.clone()?,
|
||||
fog: m.scene_fog(),
|
||||
select: model_frame_handle(model) == Some(h),
|
||||
create: super::glue_create::create_frame_handle(model) == Some(h),
|
||||
})
|
||||
}
|
||||
|
||||
/// `[0xb42174]`'s frame, while the name `SetCharSelectModelFrame` took still names a model pane.
|
||||
fn model_frame_handle(model: &Model) -> Option<crate::widget::FrameHandle> {
|
||||
let h = model
|
||||
|
||||
@@ -237,7 +237,7 @@ impl UiScript {
|
||||
/// call (`0x7075af`), so `OnAnimFinished` (`0x76cdc0`) fires here, once per arm, against the
|
||||
/// arm the last paint left. A zero advance tests nothing (`0x7074b7`).
|
||||
pub(super) fn advance_pane(&mut self, h: FrameHandle, elapsed: f32) {
|
||||
let dt_ms = (elapsed * 1000.0).trunc().max(0.0) as u64;
|
||||
let dt_ms = crate::widget::pane_clock_step_ms(elapsed);
|
||||
let finished = {
|
||||
let mut model = self.model_mut();
|
||||
let model = &mut *model;
|
||||
|
||||
@@ -298,6 +298,14 @@ pub const TOOLTIP_FADE_SECS: f32 = 2.0;
|
||||
/// A wrap-flagged line's width, pinned when it is appended; the reference's column is untraced.
|
||||
pub const TOOLTIP_WRAP_WIDTH: f32 = 260.0;
|
||||
|
||||
/// One frame's advance of a model pane's scene clock in whole milliseconds: `elapsed · 1000`
|
||||
/// truncated toward zero with no `+0.5` (`0x76d846`, then `__ftol` `0x40a2b0`), where the world's
|
||||
/// clock rounds (`0x483659`), so each frame's sub-millisecond remainder is dropped with no carry.
|
||||
/// A negative or NaN `elapsed` advances nothing.
|
||||
pub fn pane_clock_step_ms(elapsed: f32) -> u64 {
|
||||
(elapsed * 1000.0).trunc().max(0.0) as u64
|
||||
}
|
||||
|
||||
/// A model pane's scene: the `CSimpleModel` (`0x76c8e0`) members every model kind carries, read
|
||||
/// and written by `Model`'s 23 methods (table `0x878948`); the render is the app's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -126,11 +126,11 @@ impl<T> Arena<T> {
|
||||
|
||||
mod kinds;
|
||||
pub use kinds::{
|
||||
model_key, slider_fraction, slider_grab, slider_set_value, ArmedSequence, ButtonFont,
|
||||
ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome,
|
||||
EditUnit, FrameKind, InsertMode, KindState, KindUpdate, MessageFrameState, MessageLine,
|
||||
MinimapState, ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind,
|
||||
ScrollFrameState, ScrollingMessageState, SequenceFacts, ShownWrites, SliderState,
|
||||
model_key, pane_clock_step_ms, slider_fraction, slider_grab, slider_set_value, ArmedSequence,
|
||||
ButtonFont, ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState,
|
||||
EditOutcome, EditUnit, FrameKind, InsertMode, KindState, KindUpdate, MessageFrameState,
|
||||
MessageLine, MinimapState, ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState,
|
||||
RegionKind, ScrollFrameState, ScrollingMessageState, SequenceFacts, ShownWrites, SliderState,
|
||||
StatusBarState, TooltipAnchor, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK,
|
||||
MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN,
|
||||
MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
|
||||
@@ -100,6 +100,11 @@ impl MatAnim {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity whose player this instance follows, `None` when pinned.
|
||||
pub fn host(&self) -> Option<Entity> {
|
||||
self.host
|
||||
}
|
||||
|
||||
/// Whether the unit lane composes this instance's tag: a hosted or attach-model batch, never an
|
||||
/// effect part that drives its own.
|
||||
pub fn composes_unit_tag(&self) -> bool {
|
||||
@@ -124,27 +129,32 @@ pub fn playing_seq(player: &AnimationPlayer, anims: &ModelAnimations) -> Option<
|
||||
.or_else(|| anims.idle_seq().map(|seq| (seq, 0.0)))
|
||||
}
|
||||
|
||||
/// Sample every instance: a hosted one on its host's sequence and clip clock, a pinned one on its
|
||||
/// spawn clock; frozen ones keep their t = 0 sample. Hidden instances sample too, as the reference
|
||||
/// What a hosted alpha reads off its host: the rig that names its sequence and clip time, and the
|
||||
/// scene clock its global sequences run on, either absent.
|
||||
type AlphaHost = (
|
||||
Option<(&'static AnimationPlayer, &'static ModelAnimations)>,
|
||||
Option<&'static crate::rig_anim::SceneClock>,
|
||||
);
|
||||
|
||||
/// Sample every instance: a hosted one on its host's sequence and clip clock, and its global
|
||||
/// sequences on the host's scene clock when it stands in one ([`crate::rig_anim::SceneClock`]);
|
||||
/// a pinned one on its spawn clock; frozen ones keep their t = 0 sample. Hidden instances sample too, as the reference
|
||||
/// evaluates the tracks every frame regardless of the cull (`0x707b3a`–`0x707b5c`), or a batch
|
||||
/// born at weight 0 would stay hidden. Runs before the visibility authority.
|
||||
pub fn sample_mat_anim(
|
||||
time: Res<Time>,
|
||||
hosts: Query<(&AnimationPlayer, &ModelAnimations)>,
|
||||
mut q: Query<&mut MatAnim>,
|
||||
) {
|
||||
pub fn sample_mat_anim(time: Res<Time>, hosts: Query<AlphaHost>, mut q: Query<&mut MatAnim>) {
|
||||
let now = time.elapsed_secs();
|
||||
// The scene clock in f64: a long-uptime f32 drifts whole milliseconds.
|
||||
let shared = time.elapsed_secs_f64();
|
||||
// The world clock in f64: a long-uptime f32 drifts whole milliseconds.
|
||||
let world = time.elapsed_secs_f64();
|
||||
for mut m in &mut q {
|
||||
if m.frozen {
|
||||
continue;
|
||||
}
|
||||
// A host with no player yet keeps its last slot at t = 0, a bind-posed model's pose.
|
||||
let played = m
|
||||
let (rig, clock) = m
|
||||
.host
|
||||
.and_then(|h| hosts.get(h).ok())
|
||||
.and_then(|(p, a)| playing_seq(p, a));
|
||||
.unwrap_or((None, None));
|
||||
let played = rig.and_then(|(p, a)| playing_seq(p, a));
|
||||
let (seq, elapsed) = match played {
|
||||
Some((seq, t)) => {
|
||||
m.seq = Some(seq);
|
||||
@@ -153,7 +163,9 @@ pub fn sample_mat_anim(
|
||||
None if m.host.is_some() => (m.seq, 0.0),
|
||||
None => (m.seq, now - m.spawned_at),
|
||||
};
|
||||
// The gseq cursor is sceneNow − attach, the anchor stamped on the first pass.
|
||||
// The gseq cursor is sceneNow − attach, the anchor stamped on the first pass, on the
|
||||
// host's own scene clock when it has one.
|
||||
let shared = clock.map_or(world, |c| c.0);
|
||||
let attach = *m.gseq_attach.get_or_insert(shared);
|
||||
m.current = m.anim.sample(seq, elapsed, shared - attach);
|
||||
}
|
||||
@@ -202,6 +214,9 @@ pub struct UvAnimEntry {
|
||||
pub seed: [f32; 2],
|
||||
/// The rotation and scaling half ([`UvAffine`]), `None` when the transform only translates.
|
||||
affine: Option<UvAffine>,
|
||||
/// The entity whose [`crate::rig_anim::SceneClock`] this entry runs on, `None` for the
|
||||
/// world's clock.
|
||||
clock: Option<Entity>,
|
||||
}
|
||||
|
||||
/// A texture transform's rotation and scaling, which the translation row cannot carry: a second
|
||||
@@ -282,7 +297,75 @@ impl UvAnimEntry {
|
||||
}
|
||||
}
|
||||
|
||||
impl UvLoop {
|
||||
/// Move a per-sequence or per-instance lane to `host`'s player; a shared one has none.
|
||||
fn set_host(&mut self, to: Entity) {
|
||||
if let UvLoop::PerSeq { host, .. } | UvLoop::Instance { host, .. } = self {
|
||||
*host = to;
|
||||
}
|
||||
}
|
||||
|
||||
/// This lane for a twin of the material on another instance: the same loops, a per-sequence
|
||||
/// or per-instance one following `host`'s player.
|
||||
fn for_twin(&self, host: Entity) -> Self {
|
||||
match self {
|
||||
UvLoop::Shared(a) => UvLoop::Shared(a.clone()),
|
||||
UvLoop::PerSeq { seqs, .. } => UvLoop::PerSeq {
|
||||
seqs: seqs.clone(),
|
||||
host,
|
||||
},
|
||||
UvLoop::Instance {
|
||||
seqs,
|
||||
single,
|
||||
attached_at,
|
||||
..
|
||||
} => UvLoop::Instance {
|
||||
seqs: seqs.clone(),
|
||||
single: single.clone(),
|
||||
host,
|
||||
attached_at: *attached_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TintLoop {
|
||||
/// As [`UvLoop::set_host`].
|
||||
fn set_host(&mut self, to: Entity) {
|
||||
if let TintLoop::PerSeq { host, .. } = self {
|
||||
*host = to;
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`UvLoop`]'s: the same loop, a per-sequence one following `host`.
|
||||
fn for_twin(&self, host: Entity) -> Self {
|
||||
match self {
|
||||
TintLoop::Shared(a) => TintLoop::Shared(a.clone()),
|
||||
TintLoop::PerSeq { seqs, .. } => TintLoop::PerSeq {
|
||||
seqs: seqs.clone(),
|
||||
host,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UvAnimEntry {
|
||||
/// This frame's translation row and affine row, on `scene`, the time of the scene clock the
|
||||
/// entry runs on, else on the world's `now`: what the tick writes.
|
||||
fn rows(
|
||||
&self,
|
||||
scene: Option<f64>,
|
||||
now: f32,
|
||||
gseq_now: f64,
|
||||
playing: Option<(usize, f32)>,
|
||||
) -> ([f32; 4], Option<(u16, [f32; 4])>) {
|
||||
let (now, gseq_now) = scene.map_or((now, gseq_now), |t| (t as f32, t));
|
||||
(
|
||||
self.delta(now, gseq_now, playing),
|
||||
self.affine(now, gseq_now, playing),
|
||||
)
|
||||
}
|
||||
|
||||
fn host(&self) -> Option<Entity> {
|
||||
match &self.anim {
|
||||
UvLoop::Shared(_) => None,
|
||||
@@ -317,6 +400,18 @@ impl UvAnimEntry {
|
||||
}
|
||||
|
||||
impl TintAnimEntry {
|
||||
/// This frame's row on `scene`'s time, else the world's, as [`UvAnimEntry`]'s rows.
|
||||
fn row(
|
||||
&self,
|
||||
scene: Option<f64>,
|
||||
now: f32,
|
||||
gseq_now: f64,
|
||||
playing: Option<(usize, f32)>,
|
||||
) -> [f32; 4] {
|
||||
let (now, gseq_now) = scene.map_or((now, gseq_now), |t| (t as f32, t));
|
||||
self.delta(now, gseq_now, playing)
|
||||
}
|
||||
|
||||
fn host(&self) -> Option<Entity> {
|
||||
match &self.anim {
|
||||
TintLoop::Shared(_) => None,
|
||||
@@ -341,6 +436,69 @@ pub struct UvAnimMaterials(
|
||||
>,
|
||||
);
|
||||
|
||||
/// Give `twin`, a booth's relit clone of the registered material `source`, rows of its own for the
|
||||
/// model `root`: a copy of each of the source's lanes on `root`'s scene clock, a per-sequence one
|
||||
/// following `root`'s player. The source's rows run on the world's clock and are written only
|
||||
/// while the world draws it; a scene that owns its clock animates its models on that clock alone.
|
||||
/// An already registered twin moves to `root`, clock and player both. Returns whether the twin has
|
||||
/// a row, which its part's [`AnimMatPart`] marker must agree with.
|
||||
pub fn clocked_twin(
|
||||
uv: &mut UvAnimMaterials,
|
||||
tint: &mut TintAnimMaterials,
|
||||
table: &mut crate::mat_anim_table::MatAnimTable,
|
||||
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
|
||||
source: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
|
||||
twin: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
|
||||
root: Entity,
|
||||
) -> bool {
|
||||
if source == twin {
|
||||
return false; // no twin was made: the source's rows are the world's
|
||||
}
|
||||
if let Some(src) = uv.0.get(&source) {
|
||||
let lane = src.anim.for_twin(root);
|
||||
let affine = src
|
||||
.affine
|
||||
.as_ref()
|
||||
.map(|a| (a.rot.clone(), a.scale.clone()));
|
||||
if !uv.0.contains_key(&twin) {
|
||||
register_uv(uv, table, materials, twin, lane);
|
||||
if let Some((rot, scale)) = affine {
|
||||
attach_affine(uv, table, materials, twin, rot, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(src) = tint.0.get(&source) {
|
||||
let lane = src.anim.for_twin(root);
|
||||
register_tint(tint, table, materials, twin, lane);
|
||||
}
|
||||
// A twin registered for an earlier model follows this one.
|
||||
if let Some(e) = uv.0.get_mut(&twin) {
|
||||
e.anim.set_host(root);
|
||||
}
|
||||
if let Some(e) = tint.0.get_mut(&twin) {
|
||||
e.anim.set_host(root);
|
||||
}
|
||||
scene_clocked(uv, tint, twin, root);
|
||||
uv.0.contains_key(&twin) || tint.0.contains_key(&twin)
|
||||
}
|
||||
|
||||
/// Put material `id`'s registered UV and tint entries on the clock of the scene `clock` stands in
|
||||
/// ([`crate::rig_anim::SceneClock`] on that entity): a scene with its own clock calls it after
|
||||
/// registering each of its batches.
|
||||
pub fn scene_clocked(
|
||||
uv: &mut UvAnimMaterials,
|
||||
tint: &mut TintAnimMaterials,
|
||||
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
|
||||
clock: Entity,
|
||||
) {
|
||||
if let Some(e) = uv.0.get_mut(&id) {
|
||||
e.clock = Some(clock);
|
||||
}
|
||||
if let Some(e) = tint.0.get_mut(&id) {
|
||||
e.clock = Some(clock);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register material `id` for the UV scroll: allocate its table slot, record the built seed and
|
||||
/// write the slot into `anim_slots.x`, the one material write this lane makes. A full table skips
|
||||
/// registration, leaving the batch at its seed.
|
||||
@@ -390,6 +548,7 @@ pub(crate) fn register_uv(
|
||||
slot,
|
||||
seed,
|
||||
affine: None,
|
||||
clock: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -552,6 +711,7 @@ impl UvLoops {
|
||||
/// spell-effect lane ([`UvAnimEntry::instance_lane`]).
|
||||
pub(super) fn tick_anim_materials(
|
||||
time: Res<Time>,
|
||||
clocks: Query<&crate::rig_anim::SceneClock>,
|
||||
real: Res<Time<bevy::time::Real>>,
|
||||
mut uv_reg: ResMut<UvAnimMaterials>,
|
||||
mut tint_reg: ResMut<TintAnimMaterials>,
|
||||
@@ -615,9 +775,10 @@ pub(super) fn tick_anim_materials(
|
||||
// lives about a second, so an undrawn one costs little.
|
||||
let fx = entry.instance_lane();
|
||||
if (fx || drawn.contains(id)) && (!frozen || fx) {
|
||||
let playing = host_seq(&hosts, entry.host());
|
||||
table.set(entry.slot, entry.delta(now, gseq_now, playing));
|
||||
if let Some((slot, row)) = entry.affine(now, gseq_now, playing) {
|
||||
let scene = entry.clock.and_then(|e| clocks.get(e).ok()).map(|c| c.0);
|
||||
let (row, affine) = entry.rows(scene, now, gseq_now, host_seq(&hosts, entry.host()));
|
||||
table.set(entry.slot, row);
|
||||
if let Some((slot, row)) = affine {
|
||||
table.set(slot, row);
|
||||
}
|
||||
}
|
||||
@@ -629,9 +790,10 @@ pub(super) fn tick_anim_materials(
|
||||
return false;
|
||||
}
|
||||
if drawn.contains(id) && !frozen {
|
||||
let scene = entry.clock.and_then(|e| clocks.get(e).ok()).map(|c| c.0);
|
||||
table.set(
|
||||
entry.slot,
|
||||
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
|
||||
entry.row(scene, now, gseq_now, host_seq(&hosts, entry.host())),
|
||||
);
|
||||
}
|
||||
true
|
||||
@@ -677,6 +839,8 @@ pub struct TintAnimEntry {
|
||||
pub anim: TintLoop,
|
||||
pub slot: u16,
|
||||
pub seed: [f32; 3],
|
||||
/// As [`UvAnimEntry`]'s: the entity whose scene clock it runs on, `None` for the world's.
|
||||
clock: Option<Entity>,
|
||||
}
|
||||
|
||||
impl TintAnimEntry {
|
||||
@@ -739,7 +903,15 @@ pub fn register_tint(
|
||||
table.free(slot);
|
||||
return;
|
||||
};
|
||||
reg.0.insert(id, TintAnimEntry { anim, slot, seed });
|
||||
reg.0.insert(
|
||||
id,
|
||||
TintAnimEntry {
|
||||
anim,
|
||||
slot,
|
||||
seed,
|
||||
clock: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `WOW_MATANIM_PROBE=<secs>`: once, `secs` after boot, log one line per [`AnimMatPart`] with what
|
||||
@@ -909,6 +1081,7 @@ mod delta_tests {
|
||||
slot: 9,
|
||||
seed: [0.0, 0.0],
|
||||
affine: None,
|
||||
clock: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,6 +1141,7 @@ mod delta_tests {
|
||||
scale: None,
|
||||
slot: 5,
|
||||
}),
|
||||
clock: None,
|
||||
};
|
||||
// No translation channel: its row stays at zero.
|
||||
assert_eq!(e.delta(1.0, 1.0, None), [0.0, 0.0, 0.0, 0.0]);
|
||||
@@ -1072,6 +1246,7 @@ mod delta_tests {
|
||||
slot: 3,
|
||||
seed,
|
||||
affine: None,
|
||||
clock: None,
|
||||
};
|
||||
for t in [0.0_f32, 0.35, 1.0, 1.7] {
|
||||
let d = entry.delta(t, f64::from(t), None);
|
||||
@@ -1119,6 +1294,7 @@ mod delta_tests {
|
||||
slot: 5,
|
||||
seed: [0.0, 0.0],
|
||||
affine: None,
|
||||
clock: None,
|
||||
};
|
||||
// On slot 1, past its step key: the whole V flip shows.
|
||||
let d = entry.delta(0.0, 0.0, Some((1, 3.0)));
|
||||
@@ -1149,6 +1325,7 @@ mod delta_tests {
|
||||
anim: TintLoop::Shared(anim.clone()),
|
||||
slot: 4,
|
||||
seed,
|
||||
clock: None,
|
||||
};
|
||||
let d = entry.delta(0.4, 0.4, None);
|
||||
let old = benilla_assets::quant255(anim.sample(0.4));
|
||||
@@ -1156,4 +1333,173 @@ mod delta_tests {
|
||||
assert_eq!(seed[i] + d[i], old[i], "channel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A scene that owns its clock animates its texture transforms and colour tracks on it: a
|
||||
/// hidden glue pane's clock stops (`0x76d7f0`), and its cloud scroll with it.
|
||||
#[test]
|
||||
fn a_scene_clocked_material_samples_its_scenes_time_not_the_worlds() {
|
||||
let id = bevy::asset::AssetId::<benilla_assets::materials::WowModelMaterial>::Uuid {
|
||||
uuid: bevy::asset::uuid::Uuid::from_u128(7),
|
||||
};
|
||||
let scene = Entity::from_raw_u32(9).unwrap();
|
||||
let mut uv = UvAnimMaterials::default();
|
||||
uv.0.insert(
|
||||
id,
|
||||
UvAnimEntry {
|
||||
anim: UvLoop::Shared(Some(uv_loop())),
|
||||
slot: 3,
|
||||
seed: [0.0, 0.0],
|
||||
affine: None,
|
||||
clock: None,
|
||||
},
|
||||
);
|
||||
let pulse = std::sync::Arc::new(benilla_formats::RgbAnim {
|
||||
period: 2.0,
|
||||
step: false,
|
||||
wrap: true,
|
||||
gseq: false,
|
||||
keys: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3]), (2.0, [0.0; 3])],
|
||||
});
|
||||
let mut tint = TintAnimMaterials::default();
|
||||
tint.0.insert(
|
||||
id,
|
||||
TintAnimEntry {
|
||||
anim: TintLoop::Shared(pulse),
|
||||
slot: 4,
|
||||
seed: [0.0; 3],
|
||||
clock: None,
|
||||
},
|
||||
);
|
||||
scene_clocked(&mut uv, &mut tint, id, scene);
|
||||
assert_eq!(uv.0[&id].clock, Some(scene));
|
||||
assert_eq!(tint.0[&id].clock, Some(scene));
|
||||
|
||||
// The world is at 10 s, the scene's clock at 0.5 s: the rows are the scene's.
|
||||
let (row, _) = uv.0[&id].rows(Some(0.5), 10.0, 10.0, None);
|
||||
assert_eq!(row, uv.0[&id].delta(0.5, 0.5, None));
|
||||
assert_ne!(row, uv.0[&id].delta(10.0, 10.0, None));
|
||||
let row = tint.0[&id].row(Some(0.5), 10.0, 10.0, None);
|
||||
assert_eq!(row, tint.0[&id].delta(0.5, 0.5, None));
|
||||
assert_ne!(row, tint.0[&id].delta(10.0, 10.0, None));
|
||||
|
||||
// An entry on no scene clock runs on the world's.
|
||||
let other = UvAnimEntry {
|
||||
anim: UvLoop::Shared(Some(uv_loop())),
|
||||
slot: 5,
|
||||
seed: [0.0, 0.0],
|
||||
affine: None,
|
||||
clock: None,
|
||||
};
|
||||
assert_eq!(
|
||||
other.rows(None, 10.0, 10.0, None).0,
|
||||
other.delta(10.0, 10.0, None)
|
||||
);
|
||||
}
|
||||
|
||||
/// A batch alpha following a host that stands in a scene with its own clock samples its
|
||||
/// global sequences on that clock: hidden, the scene's vignette or flicker stands still.
|
||||
#[test]
|
||||
fn a_hosted_alpha_samples_its_hosts_scene_clock() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
|
||||
std::time::Duration::from_millis(100),
|
||||
));
|
||||
app.add_systems(Update, sample_mat_anim);
|
||||
// A 2 s gseq weight ramp, 0 → 1 → 0.
|
||||
let anim = std::sync::Arc::new(
|
||||
benilla_formats::AlphaAnim::new(vec![benilla_formats::AlphaSeq {
|
||||
color: None,
|
||||
weight: Some(benilla_formats::ScalarAnim {
|
||||
period: 2.0,
|
||||
step: false,
|
||||
wrap: true,
|
||||
gseq: true,
|
||||
keys: vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)],
|
||||
}),
|
||||
}])
|
||||
.expect("an animated weight is carried"),
|
||||
);
|
||||
let host = app.world_mut().spawn(crate::rig_anim::SceneClock(3.0)).id();
|
||||
let part = app
|
||||
.world_mut()
|
||||
.spawn(MatAnim::driving_tag(anim, 0.0, None).following_host(Some(host)))
|
||||
.id();
|
||||
app.update(); // attach at the scene's 3.0
|
||||
let current = |app: &App| app.world().get::<MatAnim>(part).unwrap().current;
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
assert_eq!(current(&app), 0.0, "the world ran on; the scene did not");
|
||||
app.world_mut()
|
||||
.get_mut::<crate::rig_anim::SceneClock>(host)
|
||||
.unwrap()
|
||||
.0 = 3.5;
|
||||
app.update();
|
||||
assert!(
|
||||
(current(&app) - 0.5).abs() < 1e-4,
|
||||
"half a second of the scene's clock past attach: {}",
|
||||
current(&app)
|
||||
);
|
||||
}
|
||||
|
||||
/// A booth's twin of a registered material takes the same loops on its own row, a
|
||||
/// per-sequence lane following the booth's model instead of the world's.
|
||||
#[test]
|
||||
fn a_twin_lane_keeps_its_loops_and_follows_its_own_host() {
|
||||
let world_unit = Entity::from_raw_u32(1).unwrap();
|
||||
let booth = Entity::from_raw_u32(2).unwrap();
|
||||
let seqs = std::sync::Arc::new(
|
||||
benilla_formats::SeqLoops::new(vec![Some(benilla_formats::KeyAnim {
|
||||
period: 1.0,
|
||||
step: false,
|
||||
wrap: true,
|
||||
gseq: false,
|
||||
keys: vec![(0.0, [0.0, 0.0]), (1.0, [1.0, 0.0])],
|
||||
})])
|
||||
.expect("one animating slot"),
|
||||
);
|
||||
let per_seq = UvLoop::PerSeq {
|
||||
seqs: seqs.clone(),
|
||||
host: world_unit,
|
||||
};
|
||||
match per_seq.for_twin(booth) {
|
||||
UvLoop::PerSeq { seqs: s, host } => {
|
||||
assert!(std::sync::Arc::ptr_eq(&s, &seqs));
|
||||
assert_eq!(host, booth);
|
||||
}
|
||||
_ => panic!("a per-sequence lane stays one"),
|
||||
}
|
||||
// A twin already registered for another model moves to this one's player.
|
||||
let mut moved = UvLoop::PerSeq {
|
||||
seqs: seqs.clone(),
|
||||
host: world_unit,
|
||||
};
|
||||
moved.set_host(booth);
|
||||
assert!(matches!(moved, UvLoop::PerSeq { host, .. } if host == booth));
|
||||
let shared = uv_loop();
|
||||
match UvLoop::Shared(Some(shared.clone())).for_twin(booth) {
|
||||
UvLoop::Shared(Some(a)) => assert!(std::sync::Arc::ptr_eq(&a, &shared)),
|
||||
_ => panic!("a shared lane stays shared"),
|
||||
}
|
||||
let tint_seqs = std::sync::Arc::new(
|
||||
benilla_formats::SeqLoops::new(vec![Some(benilla_formats::KeyAnim {
|
||||
period: 1.0,
|
||||
step: false,
|
||||
wrap: true,
|
||||
gseq: false,
|
||||
keys: vec![(0.0, [1.0; 3]), (1.0, [0.0; 3])],
|
||||
})])
|
||||
.expect("one animating slot"),
|
||||
);
|
||||
match (TintLoop::PerSeq {
|
||||
seqs: tint_seqs,
|
||||
host: world_unit,
|
||||
})
|
||||
.for_twin(booth)
|
||||
{
|
||||
TintLoop::PerSeq { host, .. } => assert_eq!(host, booth),
|
||||
TintLoop::Shared(_) => panic!("a per-sequence tint stays one"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,14 @@ mod mat_anim;
|
||||
pub(crate) use lazy::{LazyRig, SkinnedTwin};
|
||||
use mat_anim::tick_anim_materials;
|
||||
pub use mat_anim::{
|
||||
playing_seq, register_entity_uv, register_fx_uv, register_tint, sample_mat_anim, AnimMatPart,
|
||||
MatAnim, TintAnimMaterials, TintLoop, UvAnimMaterials, UvLoops,
|
||||
clocked_twin, playing_seq, register_entity_uv, register_fx_uv, register_tint, sample_mat_anim,
|
||||
scene_clocked, AnimMatPart, MatAnim, TintAnimMaterials, TintLoop, UvAnimMaterials, UvLoops,
|
||||
};
|
||||
|
||||
/// The material tick's set: a scene that owns its clock ([`crate::rig_anim::SceneClock`]) writes
|
||||
/// it before this.
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MatAnimTick;
|
||||
pub(crate) use mat_anim::{register_uv, UvLoop};
|
||||
|
||||
/// What a placed doodad model animates, decided per model at spawn.
|
||||
@@ -462,7 +467,9 @@ impl Plugin for DoodadAnimPlugin {
|
||||
Update,
|
||||
(
|
||||
sample_mat_anim.before(crate::model_render::ModelVisSet),
|
||||
tick_anim_materials.after(crate::model_render::ModelVisSet),
|
||||
tick_anim_materials
|
||||
.in_set(MatAnimTick)
|
||||
.after(crate::model_render::ModelVisSet),
|
||||
// After the tick, so the probe's `row` column is this frame's write.
|
||||
mat_anim::matanim_probe.after(tick_anim_materials),
|
||||
),
|
||||
|
||||
@@ -118,6 +118,11 @@ impl ShadeSel {
|
||||
/// A material-dedup cache, one per model-spawning subsystem; entries expire by distance if swept.
|
||||
pub type MaterialCache = benilla_assets::SpatialCache<MatKey, Handle<WowModelMaterial>>;
|
||||
|
||||
/// Drop every material `cache` keyed to `instance`, which is gone.
|
||||
pub(crate) fn forget_instance(cache: &mut MaterialCache, instance: Entity) {
|
||||
cache.retain(|key| key.instance != Some(instance));
|
||||
}
|
||||
|
||||
/// Build or fetch the deduped [`WowModelMaterial`] for one model batch. `fade_variant` marks the
|
||||
/// blend twin every fade rides, built with the source blend, never `Blend`: the twin always blends
|
||||
/// (`0x70c1fd`) but its alpha test keys on the stored mode (`0x70c237`), so AlphaKey keeps its
|
||||
|
||||
@@ -168,6 +168,36 @@ impl M2BatchMaterials<'_> {
|
||||
self.build(sub, texture, order, shade, false, false, rig, light)
|
||||
}
|
||||
|
||||
/// [`Self::off_world`] for one scene's own batches: keyed to `scene`, so two scenes of one
|
||||
/// file never share a material, and with it the UV and tint rows the scene's own clock drives
|
||||
/// (`doodad_anim::scene_clocked`). [`Self::forget_scene`] lets them go with the scene.
|
||||
pub fn off_world_for(
|
||||
&mut self,
|
||||
sub: &ModelSubmesh,
|
||||
texture: Option<Handle<Image>>,
|
||||
order: u16,
|
||||
light: &Buffer,
|
||||
scene: Entity,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
self.build_for(
|
||||
sub,
|
||||
texture,
|
||||
order,
|
||||
ShadeSel::Rig,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
light,
|
||||
Some(scene),
|
||||
)
|
||||
}
|
||||
|
||||
/// Drop the cached materials [`Self::off_world_for`] built for `scene`, which is gone: once its
|
||||
/// batches despawn, nothing holds them, and their animated rows are freed by the tick.
|
||||
pub fn forget_scene(&mut self, scene: Entity) {
|
||||
super::forget_instance(&mut self.cache.0, scene);
|
||||
}
|
||||
|
||||
/// The variant set of any entity part, all lit with one indoor pair: the reference gives every
|
||||
/// entity M2 the same entity-node fill (`0x672a20`). Registers each variant's UV loop on `uv`
|
||||
/// in the same step; the depth-prime twin, drawn only mid-feather, is not registered.
|
||||
@@ -375,6 +405,26 @@ impl M2BatchMaterials<'_> {
|
||||
fade: bool,
|
||||
play_uv: bool,
|
||||
light: &Buffer,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
// Entity batches share one material per batch: an entity resolves its sequence through
|
||||
// `MatAnim::host`, so only the world streamer keys one per placement.
|
||||
self.build_for(
|
||||
sub, texture, order, shade, probe, fade, play_uv, light, None,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Self::build`], keyed to `instance` when the material belongs to one.
|
||||
fn build_for(
|
||||
&mut self,
|
||||
sub: &ModelSubmesh,
|
||||
texture: Option<Handle<Image>>,
|
||||
order: u16,
|
||||
shade: ShadeSel,
|
||||
probe: bool,
|
||||
fade: bool,
|
||||
play_uv: bool,
|
||||
light: &Buffer,
|
||||
instance: Option<Entity>,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
model_material(
|
||||
&mut self.cache.0,
|
||||
@@ -403,9 +453,7 @@ impl M2BatchMaterials<'_> {
|
||||
// The sky lane overrides the batch's depth state, so only `Self::skybox` builds it.
|
||||
false,
|
||||
light,
|
||||
// Entity batches share one material per batch: an entity resolves its sequence
|
||||
// through `MatAnim::host`, so only the world streamer keys one per placement.
|
||||
None,
|
||||
instance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@ pub use compose::{finalize_rig_worlds, PosePost};
|
||||
pub use global_seq::GlobalSeqDrive;
|
||||
pub use pose::{RigAnchor, RigFrame, RigPose};
|
||||
|
||||
/// A scene's own clock in seconds, on each model root that stands in a scene that keeps one: a
|
||||
/// `<Model>` widget's private `CM2Scene` (`CSimpleModel+0x314`, `0x76cfc0`), zeroed by its
|
||||
/// constructor (`0x706ff0`) and advanced only by the widget's `OnUpdate` (`0x76d7f0`), which the
|
||||
/// visible-frame pump calls, so a hidden pane's scene stands still. A model with none runs on the
|
||||
/// world's clock. Read by the global-sequence channels ([`GlobalSeqDrive`]) and the material lanes
|
||||
/// (`doodad_anim`), each against its own anchor: `clock − attach` for a global sequence
|
||||
/// (`CM2Model+0x68`), `clock − lo` for a sequence band (`0x714530`).
|
||||
#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct SceneClock(pub f64);
|
||||
|
||||
/// Parks this rig's per-bone pose evaluation: the evaluator and the pose post-passes skip it,
|
||||
/// while the sequence clocks, the driver state machine and the event scanner keep running.
|
||||
#[derive(Component)]
|
||||
|
||||
@@ -18,12 +18,10 @@ enum SeqTarget {
|
||||
#[derive(Component)]
|
||||
pub struct GlobalSeqDrive {
|
||||
bones: Vec<(SeqTarget, GlobalBone)>,
|
||||
/// The scene clock at attach (secs), stamped once, like the reference's `+0x68`.
|
||||
/// The scene clock at attach (secs), stamped once, like the reference's `+0x68`. The clock is
|
||||
/// the host's [`super::SceneClock`] when it carries one (the reference reads
|
||||
/// `[[model+0x2c]+0xc]`, its own scene's), else the world's.
|
||||
anchor: Option<f64>,
|
||||
/// The owning scene's clock (secs) when it is not the world's: the reference reads
|
||||
/// `[[model+0x2c]+0xc]`, and a `<Model>` widget owns a private scene (`CSimpleModel+0x314`,
|
||||
/// `0x76cfc0`) advanced only by its `OnUpdate` (`0x76d7f0`), so a hidden pane's clock stops.
|
||||
clock: Option<f64>,
|
||||
/// Skip the writes: the doodad lane pauses culled instances, as the reference evaluates a
|
||||
/// model only when drawn (`0x707680`). The cursor is absolute, so a resume needs no seek.
|
||||
paused: bool,
|
||||
@@ -43,7 +41,6 @@ impl GlobalSeqDrive {
|
||||
(!bones.is_empty()).then_some(Self {
|
||||
bones,
|
||||
anchor: None,
|
||||
clock: None,
|
||||
paused: false,
|
||||
})
|
||||
}
|
||||
@@ -58,7 +55,6 @@ impl GlobalSeqDrive {
|
||||
(!bones.is_empty()).then_some(Self {
|
||||
bones,
|
||||
anchor: None,
|
||||
clock: None,
|
||||
paused: false,
|
||||
})
|
||||
}
|
||||
@@ -67,24 +63,24 @@ impl GlobalSeqDrive {
|
||||
pub fn set_paused(&mut self, paused: bool) {
|
||||
self.paused = paused;
|
||||
}
|
||||
|
||||
/// Set this instance's scene clock (secs), each frame, for a scene that is not the world's.
|
||||
pub fn set_clock(&mut self, secs: f64) {
|
||||
self.clock = Some(secs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample each drive at `sceneNow − anchor`, wrapped in f64 for a long uptime, and write only the
|
||||
/// driven components, so the eyelid blinks over any gait.
|
||||
fn apply_global_sequences(
|
||||
time: Res<Time>,
|
||||
mut drives: Query<(Entity, &mut GlobalSeqDrive, Has<super::AnimParked>)>,
|
||||
mut drives: Query<(
|
||||
Entity,
|
||||
&mut GlobalSeqDrive,
|
||||
Has<super::AnimParked>,
|
||||
Option<&super::SceneClock>,
|
||||
)>,
|
||||
mut joints: Query<&mut Transform>,
|
||||
mut rigs: Query<&mut super::RigPose>,
|
||||
) {
|
||||
let now = time.elapsed_secs_f64();
|
||||
for (host, mut drive, parked) in &mut drives {
|
||||
let scene_now = drive.clock.unwrap_or(now);
|
||||
for (host, mut drive, parked, clock) in &mut drives {
|
||||
let scene_now = clock.map_or(now, |c| c.0);
|
||||
// Stamped even while parked: the reference stamps `+0x68` at attach, not at first draw.
|
||||
let t = scene_now - *drive.anchor.get_or_insert(scene_now);
|
||||
if drive.paused || parked {
|
||||
@@ -164,6 +160,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A drive whose host stands in a scene with its own clock samples that clock, not the
|
||||
/// world's: a hidden pane's scene stops (`0x76d7f0`), and its eyelids with it.
|
||||
#[test]
|
||||
fn a_drive_samples_its_scenes_clock_when_it_has_one() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
|
||||
std::time::Duration::from_millis(60),
|
||||
));
|
||||
app.add_systems(Update, apply_global_sequences);
|
||||
let joint = app.world_mut().spawn(Transform::default()).id();
|
||||
let host = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
GlobalSeqDrive::new(&[ramp()], &[joint]).expect("a keyed channel maps"),
|
||||
crate::rig_anim::SceneClock(2.0),
|
||||
))
|
||||
.id();
|
||||
app.update(); // attach: the anchor is the scene's 2.0
|
||||
let scale = |app: &App| app.world().get::<Transform>(joint).unwrap().scale.x;
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
assert_eq!(
|
||||
scale(&app),
|
||||
0.0,
|
||||
"the world ran on, the scene's clock did not"
|
||||
);
|
||||
app.world_mut()
|
||||
.get_mut::<crate::rig_anim::SceneClock>(host)
|
||||
.unwrap()
|
||||
.0 = 2.5;
|
||||
app.update();
|
||||
assert!(
|
||||
(scale(&app) - 0.5).abs() < 1e-4,
|
||||
"half a second of the scene's clock past attach: {}",
|
||||
scale(&app)
|
||||
);
|
||||
}
|
||||
|
||||
/// A drive spawned later reads a smaller cursor: the phase is per instance.
|
||||
#[test]
|
||||
fn fresh_drives_anchor_at_attach() {
|
||||
|
||||
Reference in New Issue
Block a user