basic advanced example
This commit is contained in:
1 parent
6fe57915e8
commit
bd8edf5afb
29 files changed
+1501
-38
No files matched your search
@@ -0,0 +1,89 @@
|
||||
"""Maintain static editor enums as models and animations are imported."""
|
||||
import ast
|
||||
import importlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from .paths import slug
|
||||
|
||||
CATALOG = Path(__file__).with_name("animations.py")
|
||||
HEADER = '''"""Automatically generated clip names and durations for VS Code.
|
||||
|
||||
Updated by scene.skeletal_model() using imported UE animation assets.
|
||||
This module works without Unreal. Do not edit durations manually.
|
||||
"""
|
||||
from .animation_clip import AnimationClip
|
||||
'''
|
||||
|
||||
|
||||
def _read_classes(text):
|
||||
classes = {}
|
||||
for node in ast.parse(text).body:
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
data = {}
|
||||
for statement in node.body:
|
||||
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
|
||||
key, seconds = ast.literal_eval(statement.value)
|
||||
data[key] = float(seconds)
|
||||
classes[node.name] = data
|
||||
return classes
|
||||
|
||||
|
||||
def _class_source(name, clips):
|
||||
lines = [f'class {name}(AnimationClip):',
|
||||
f' """Clip names and durations: type {name}. for editor completion."""']
|
||||
members = set()
|
||||
for key, seconds in sorted(clips.items()):
|
||||
member = slug(key).upper()
|
||||
if member in members:
|
||||
raise ValueError(f"Powtorzona nazwa enuma: {name}.{member}")
|
||||
members.add(member)
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
raise ValueError(f"Nieprawidlowa dlugosc animacji {key}: {seconds}")
|
||||
lines.extend([f' {member} = ({key!r}, {seconds!r})',
|
||||
f' """{seconds:.3f} seconds at speed=1; clip: {key}."""'])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _publish_class(source, filename):
|
||||
# Refresh only this class, preserving other enum classes and existing imports.
|
||||
# Names are validated and data are emitted with repr; source is generated here.
|
||||
module = sys.modules.get(__package__ + ".animations")
|
||||
if module is not None:
|
||||
exec(compile(source, str(filename), "exec"), vars(module))
|
||||
importlib.invalidate_caches()
|
||||
|
||||
|
||||
def update_catalog(name, clips, *, replace=True, output=None):
|
||||
"""Write NameAnimations. Partial selections merge; full discovery replaces it.
|
||||
|
||||
Other models are preserved. Identical metadata does not rewrite the file.
|
||||
clips maps actual playback keys to durations in seconds, without Unreal objects.
|
||||
"""
|
||||
name = slug(name)
|
||||
class_name = name[0].upper() + name[1:] + "Animations"
|
||||
path = CATALOG if output is None else Path(output)
|
||||
old_text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
classes = _read_classes(old_text)
|
||||
data = {} if replace else dict(classes.get(class_name, {}))
|
||||
data.update({key: float(seconds) for key, seconds in clips.items()})
|
||||
classes[class_name] = data
|
||||
sources = {key: _class_source(key, value) for key, value in sorted(classes.items())}
|
||||
text = HEADER + '\n\n' + '\n\n\n'.join(sources.values()) + '\n'
|
||||
if text != old_text:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n",
|
||||
dir=path.parent, prefix=path.name + ".", suffix=".tmp",
|
||||
delete=False) as temporary:
|
||||
temporary.write(text)
|
||||
temporary_path = Path(temporary.name)
|
||||
try:
|
||||
temporary_path.replace(path)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
if output is None:
|
||||
_publish_class(sources[class_name], path)
|
||||
return class_name
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Animation metadata usable in normal Python and VS Code without Unreal."""
|
||||
from enum import Enum
|
||||
import math
|
||||
|
||||
|
||||
class AnimationClip(Enum):
|
||||
"""A named clip with its exported duration. Subclasses list concrete clips."""
|
||||
|
||||
def __init__(self, key: str, seconds: float):
|
||||
self._key = key
|
||||
self._seconds = seconds
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""Clip name used by shot.animation(), derived from the FBX filename."""
|
||||
return self._key
|
||||
|
||||
@property
|
||||
def seconds(self) -> float:
|
||||
"""Duration of one playback at speed=1, exported from Unreal."""
|
||||
return self._seconds
|
||||
|
||||
def frames(self, fps: int = 24, speed: float = 1.0) -> int:
|
||||
"""Frames needed for one playback at this sequence FPS and speed."""
|
||||
if type(fps) is not int or fps < 1:
|
||||
raise ValueError("fps musi byc dodatnia liczba calkowita.")
|
||||
if isinstance(speed, bool) or not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
|
||||
raise ValueError("speed musi byc dodatnia liczba.")
|
||||
value = self.seconds * fps / speed
|
||||
# Unreal stores durations as float32; avoid adding a whole extra frame
|
||||
# for values such as 3.200000047683716 seconds at 30 FPS.
|
||||
nearest = round(value)
|
||||
return max(1, nearest if math.isclose(value, nearest, rel_tol=0, abs_tol=0.0001) else math.ceil(value))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Automatically generated clip names and durations for VS Code.
|
||||
|
||||
Updated by scene.skeletal_model() using imported UE animation assets.
|
||||
This module works without Unreal. Do not edit durations manually.
|
||||
"""
|
||||
from .animation_clip import AnimationClip
|
||||
|
||||
|
||||
class CreatureAnimations(AnimationClip):
|
||||
"""Clip names and durations: type CreatureAnimations. for editor completion."""
|
||||
LEFT_TURN_45 = ('left_turn_45', 1.3333333730697632)
|
||||
"""1.333 seconds at speed=1; clip: left_turn_45."""
|
||||
MUTANT_BREATHING_IDLE = ('mutant_breathing_idle', 4.0333333015441895)
|
||||
"""4.033 seconds at speed=1; clip: mutant_breathing_idle."""
|
||||
MUTANT_DYING = ('mutant_dying', 4.599999904632568)
|
||||
"""4.600 seconds at speed=1; clip: mutant_dying."""
|
||||
MUTANT_FLEXING_MUSCLES = ('mutant_flexing_muscles', 4.666666507720947)
|
||||
"""4.667 seconds at speed=1; clip: mutant_flexing_muscles."""
|
||||
MUTANT_IDLE = ('mutant_idle', 5.300000190734863)
|
||||
"""5.300 seconds at speed=1; clip: mutant_idle."""
|
||||
MUTANT_JUMPING = ('mutant_jumping', 3.1666667461395264)
|
||||
"""3.167 seconds at speed=1; clip: mutant_jumping."""
|
||||
MUTANT_LEFT_TURN_45 = ('mutant_left_turn_45', 1.3333333730697632)
|
||||
"""1.333 seconds at speed=1; clip: mutant_left_turn_45."""
|
||||
MUTANT_RIGHT_TURN_45 = ('mutant_right_turn_45', 1.9666666984558105)
|
||||
"""1.967 seconds at speed=1; clip: mutant_right_turn_45."""
|
||||
MUTANT_RIGHT_TURN_45_2 = ('mutant_right_turn_45_2', 1.3333333730697632)
|
||||
"""1.333 seconds at speed=1; clip: mutant_right_turn_45_2."""
|
||||
MUTANT_RUN = ('mutant_run', 0.8666666746139526)
|
||||
"""0.867 seconds at speed=1; clip: mutant_run."""
|
||||
MUTANT_SWIPING = ('mutant_swiping', 2.6666667461395264)
|
||||
"""2.667 seconds at speed=1; clip: mutant_swiping."""
|
||||
MUTANT_WALKING = ('mutant_walking', 1.4333332777023315)
|
||||
"""1.433 seconds at speed=1; clip: mutant_walking."""
|
||||
|
||||
|
||||
class FighterAnimations(AnimationClip):
|
||||
"""Clip names and durations: type FighterAnimations. for editor completion."""
|
||||
DRAW_A_GREAT_SWORD_1 = ('draw_a_great_sword_1', 0.5)
|
||||
"""0.500 seconds at speed=1; clip: draw_a_great_sword_1."""
|
||||
DRAW_A_GREAT_SWORD_2 = ('draw_a_great_sword_2', 0.800000011920929)
|
||||
"""0.800 seconds at speed=1; clip: draw_a_great_sword_2."""
|
||||
GREAT_SWORD_180_TURN = ('great_sword_180_turn', 0.800000011920929)
|
||||
"""0.800 seconds at speed=1; clip: great_sword_180_turn."""
|
||||
GREAT_SWORD_180_TURN_2 = ('great_sword_180_turn_2', 0.5666666626930237)
|
||||
"""0.567 seconds at speed=1; clip: great_sword_180_turn_2."""
|
||||
GREAT_SWORD_ATTACK = ('great_sword_attack', 1.2000000476837158)
|
||||
"""1.200 seconds at speed=1; clip: great_sword_attack."""
|
||||
GREAT_SWORD_BLOCKING = ('great_sword_blocking', 0.5)
|
||||
"""0.500 seconds at speed=1; clip: great_sword_blocking."""
|
||||
GREAT_SWORD_BLOCKING_2 = ('great_sword_blocking_2', 0.9666666388511658)
|
||||
"""0.967 seconds at speed=1; clip: great_sword_blocking_2."""
|
||||
GREAT_SWORD_BLOCKING_3 = ('great_sword_blocking_3', 0.5)
|
||||
"""0.500 seconds at speed=1; clip: great_sword_blocking_3."""
|
||||
GREAT_SWORD_CASTING = ('great_sword_casting', 4.800000190734863)
|
||||
"""4.800 seconds at speed=1; clip: great_sword_casting."""
|
||||
GREAT_SWORD_CROUCHING = ('great_sword_crouching', 0.6666666865348816)
|
||||
"""0.667 seconds at speed=1; clip: great_sword_crouching."""
|
||||
GREAT_SWORD_CROUCHING_2 = ('great_sword_crouching_2', 0.699999988079071)
|
||||
"""0.700 seconds at speed=1; clip: great_sword_crouching_2."""
|
||||
GREAT_SWORD_CROUCHING_3 = ('great_sword_crouching_3', 1.8333333730697632)
|
||||
"""1.833 seconds at speed=1; clip: great_sword_crouching_3."""
|
||||
GREAT_SWORD_CROUCHING_4 = ('great_sword_crouching_4', 0.36666667461395264)
|
||||
"""0.367 seconds at speed=1; clip: great_sword_crouching_4."""
|
||||
GREAT_SWORD_CROUCHING_5 = ('great_sword_crouching_5', 1.399999976158142)
|
||||
"""1.400 seconds at speed=1; clip: great_sword_crouching_5."""
|
||||
GREAT_SWORD_CROUCHING_6 = ('great_sword_crouching_6', 0.46666666865348816)
|
||||
"""0.467 seconds at speed=1; clip: great_sword_crouching_6."""
|
||||
GREAT_SWORD_HIGH_SPIN_ATTACK = ('great_sword_high_spin_attack', 1.8666666746139526)
|
||||
"""1.867 seconds at speed=1; clip: great_sword_high_spin_attack."""
|
||||
GREAT_SWORD_IDLE = ('great_sword_idle', 2.0)
|
||||
"""2.000 seconds at speed=1; clip: great_sword_idle."""
|
||||
GREAT_SWORD_IDLE_2 = ('great_sword_idle_2', 3.700000047683716)
|
||||
"""3.700 seconds at speed=1; clip: great_sword_idle_2."""
|
||||
GREAT_SWORD_IDLE_3 = ('great_sword_idle_3', 3.6666667461395264)
|
||||
"""3.667 seconds at speed=1; clip: great_sword_idle_3."""
|
||||
GREAT_SWORD_IDLE_4 = ('great_sword_idle_4', 3.7666666507720947)
|
||||
"""3.767 seconds at speed=1; clip: great_sword_idle_4."""
|
||||
GREAT_SWORD_IDLE_5 = ('great_sword_idle_5', 7.566666603088379)
|
||||
"""7.567 seconds at speed=1; clip: great_sword_idle_5."""
|
||||
GREAT_SWORD_IMPACT = ('great_sword_impact', 0.8666666746139526)
|
||||
"""0.867 seconds at speed=1; clip: great_sword_impact."""
|
||||
GREAT_SWORD_IMPACT_2 = ('great_sword_impact_2', 1.2000000476837158)
|
||||
"""1.200 seconds at speed=1; clip: great_sword_impact_2."""
|
||||
GREAT_SWORD_IMPACT_3 = ('great_sword_impact_3', 1.2666666507720947)
|
||||
"""1.267 seconds at speed=1; clip: great_sword_impact_3."""
|
||||
GREAT_SWORD_IMPACT_4 = ('great_sword_impact_4', 0.6666666865348816)
|
||||
"""0.667 seconds at speed=1; clip: great_sword_impact_4."""
|
||||
GREAT_SWORD_IMPACT_5 = ('great_sword_impact_5', 0.9666666388511658)
|
||||
"""0.967 seconds at speed=1; clip: great_sword_impact_5."""
|
||||
GREAT_SWORD_JUMP = ('great_sword_jump', 0.6333333253860474)
|
||||
"""0.633 seconds at speed=1; clip: great_sword_jump."""
|
||||
GREAT_SWORD_JUMP_2 = ('great_sword_jump_2', 0.8999999761581421)
|
||||
"""0.900 seconds at speed=1; clip: great_sword_jump_2."""
|
||||
GREAT_SWORD_JUMP_ATTACK = ('great_sword_jump_attack', 2.1666667461395264)
|
||||
"""2.167 seconds at speed=1; clip: great_sword_jump_attack."""
|
||||
GREAT_SWORD_KICK = ('great_sword_kick', 1.5)
|
||||
"""1.500 seconds at speed=1; clip: great_sword_kick."""
|
||||
GREAT_SWORD_KICK_2 = ('great_sword_kick_2', 1.7333333492279053)
|
||||
"""1.733 seconds at speed=1; clip: great_sword_kick_2."""
|
||||
GREAT_SWORD_POWER_UP = ('great_sword_power_up', 3.5)
|
||||
"""3.500 seconds at speed=1; clip: great_sword_power_up."""
|
||||
GREAT_SWORD_RUN = ('great_sword_run', 0.7333333492279053)
|
||||
"""0.733 seconds at speed=1; clip: great_sword_run."""
|
||||
GREAT_SWORD_RUN_2 = ('great_sword_run_2', 0.6000000238418579)
|
||||
"""0.600 seconds at speed=1; clip: great_sword_run_2."""
|
||||
GREAT_SWORD_SLASH = ('great_sword_slash', 1.2666666507720947)
|
||||
"""1.267 seconds at speed=1; clip: great_sword_slash."""
|
||||
GREAT_SWORD_SLASH_2 = ('great_sword_slash_2', 3.5333333015441895)
|
||||
"""3.533 seconds at speed=1; clip: great_sword_slash_2."""
|
||||
GREAT_SWORD_SLASH_3 = ('great_sword_slash_3', 1.8333333730697632)
|
||||
"""1.833 seconds at speed=1; clip: great_sword_slash_3."""
|
||||
GREAT_SWORD_SLASH_4 = ('great_sword_slash_4', 1.7999999523162842)
|
||||
"""1.800 seconds at speed=1; clip: great_sword_slash_4."""
|
||||
GREAT_SWORD_SLASH_5 = ('great_sword_slash_5', 1.4333332777023315)
|
||||
"""1.433 seconds at speed=1; clip: great_sword_slash_5."""
|
||||
GREAT_SWORD_SLIDE_ATTACK = ('great_sword_slide_attack', 2.133333444595337)
|
||||
"""2.133 seconds at speed=1; clip: great_sword_slide_attack."""
|
||||
GREAT_SWORD_STRAFE = ('great_sword_strafe', 1.100000023841858)
|
||||
"""1.100 seconds at speed=1; clip: great_sword_strafe."""
|
||||
GREAT_SWORD_STRAFE_2 = ('great_sword_strafe_2', 1.1666666269302368)
|
||||
"""1.167 seconds at speed=1; clip: great_sword_strafe_2."""
|
||||
GREAT_SWORD_STRAFE_3 = ('great_sword_strafe_3', 0.5666666626930237)
|
||||
"""0.567 seconds at speed=1; clip: great_sword_strafe_3."""
|
||||
GREAT_SWORD_STRAFE_4 = ('great_sword_strafe_4', 0.6333333253860474)
|
||||
"""0.633 seconds at speed=1; clip: great_sword_strafe_4."""
|
||||
GREAT_SWORD_TURN = ('great_sword_turn', 0.7333333492279053)
|
||||
"""0.733 seconds at speed=1; clip: great_sword_turn."""
|
||||
GREAT_SWORD_TURN_2 = ('great_sword_turn_2', 0.8333333134651184)
|
||||
"""0.833 seconds at speed=1; clip: great_sword_turn_2."""
|
||||
GREAT_SWORD_WALK = ('great_sword_walk', 1.3666666746139526)
|
||||
"""1.367 seconds at speed=1; clip: great_sword_walk."""
|
||||
GREAT_SWORD_WALK_2 = ('great_sword_walk_2', 1.2999999523162842)
|
||||
"""1.300 seconds at speed=1; clip: great_sword_walk_2."""
|
||||
SPELL_CAST = ('spell_cast', 1.1333333253860474)
|
||||
"""1.133 seconds at speed=1; clip: spell_cast."""
|
||||
TWO_HANDED_SWORD_DEATH = ('two_handed_sword_death', 2.4000000953674316)
|
||||
"""2.400 seconds at speed=1; clip: two_handed_sword_death."""
|
||||
TWO_HANDED_SWORD_DEATH_2 = ('two_handed_sword_death_2', 2.5999999046325684)
|
||||
"""2.600 seconds at speed=1; clip: two_handed_sword_death_2."""
|
||||
@@ -3,21 +3,55 @@ import unreal
|
||||
from .camera import look_at
|
||||
|
||||
|
||||
def _setup(actor, component_class, intensity, color):
|
||||
light = actor.get_component_by_class(component_class)
|
||||
light.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
light.set_editor_property("intensity", float(intensity))
|
||||
light.set_light_color(unreal.LinearColor(*color, 1.0))
|
||||
return light
|
||||
|
||||
|
||||
def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100),
|
||||
intensity=12000, color=(1, 1, 1), size=180):
|
||||
actor = scene.spawn(unreal.RectLight, name, location)
|
||||
light = actor.get_component_by_class(unreal.RectLightComponent)
|
||||
light.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
light.set_editor_property("intensity", float(intensity))
|
||||
light = _setup(actor, unreal.RectLightComponent, intensity, color)
|
||||
light.set_editor_property("source_width", float(size))
|
||||
light.set_editor_property("source_height", float(size))
|
||||
light.set_editor_property("attenuation_radius", 2000.0)
|
||||
light.set_light_color(unreal.LinearColor(*color, 1.0))
|
||||
look_at(actor, target)
|
||||
return actor
|
||||
|
||||
|
||||
def point(scene, name="Point", location=(0, 0, 250), intensity=10000, color=(1, 1, 1)):
|
||||
"""Bulb shining in all directions."""
|
||||
actor = scene.spawn(unreal.PointLight, name, location)
|
||||
light = _setup(actor, unreal.PointLightComponent, intensity, color)
|
||||
light.set_editor_property("attenuation_radius", 2000.0)
|
||||
return actor
|
||||
|
||||
|
||||
def spot(scene, name="Spot", location=(200, -200, 400), target=(0, 0, 100),
|
||||
intensity=20000, color=(1, 1, 1), angle=30):
|
||||
"""Cone of light; angle is measured in degrees from the cone axis."""
|
||||
actor = scene.spawn(unreal.SpotLight, name, location)
|
||||
light = _setup(actor, unreal.SpotLightComponent, intensity, color)
|
||||
light.set_editor_property("attenuation_radius", 2000.0)
|
||||
light.set_editor_property("outer_cone_angle", float(angle))
|
||||
light.set_editor_property("inner_cone_angle", float(angle) * 0.7)
|
||||
look_at(actor, target)
|
||||
return actor
|
||||
|
||||
|
||||
def sun(scene, name="Sun", location=(300, -300, 500), target=(0, 0, 0),
|
||||
intensity=500, color=(1, 1, 1)):
|
||||
"""Parallel light for the whole scene; only the location-target direction matters."""
|
||||
actor = scene.spawn(unreal.DirectionalLight, name, location)
|
||||
_setup(actor, unreal.DirectionalLightComponent, intensity, color)
|
||||
look_at(actor, target)
|
||||
return actor
|
||||
|
||||
|
||||
def studio(scene, key_color=(1.0, 0.82, 0.65), fill_color=(0.5, 0.7, 1.0)):
|
||||
return [area(scene, "Key", (200, -250, 350), color=key_color),
|
||||
return [area(scene, "Key", (200, -500, 350), color=key_color),
|
||||
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
|
||||
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Transfer horizontal root motion to Sequencer actor keys without changing source clips."""
|
||||
import math
|
||||
import hashlib
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED
|
||||
|
||||
|
||||
def prepare(clip, child, scene_name):
|
||||
component = child.skeletal_mesh_component
|
||||
bone = component.get_bone_name(0)
|
||||
count = int(clip.get_editor_property("number_of_sampled_keys"))
|
||||
duration = clip.get_play_length()
|
||||
if count < 2 or duration <= 0:
|
||||
raise ValueError("keep_end_position wymaga animacji z co najmniej dwiema probkami.")
|
||||
poses = [unreal.AnimationLibrary.get_bone_pose_for_time(
|
||||
clip, bone, duration * index / (count - 1), False) for index in range(count)]
|
||||
first = poses[0].translation
|
||||
# Extract only horizontal travel. Keep vertical motion (jumps/bobbing),
|
||||
# every root rotation and scale, and all other bone tracks unchanged.
|
||||
positions = [unreal.Vector(first.x, first.y, pose.translation.z) for pose in poses]
|
||||
folder = f"{GENERATED}/{scene_name}/RootMotion"
|
||||
identity = hashlib.sha1(clip.get_path_name().encode("utf-8")).hexdigest()[:8]
|
||||
name = clip.get_name() + "_" + identity + "_InPlace"
|
||||
path = folder + "/" + name
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(path):
|
||||
# This path contains only our generated derivative for this scene.
|
||||
if not unreal.EditorAssetLibrary.delete_asset(path):
|
||||
raise RuntimeError("Nie mozna odswiezyc wygenerowanego klipu " + path)
|
||||
copy = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(name, folder, clip)
|
||||
if copy is None:
|
||||
raise RuntimeError("Nie mozna utworzyc kopii animacji dla keep_end_position.")
|
||||
controller = copy.get_editor_property("controller")
|
||||
if not controller.set_bone_track_keys(bone, positions, [pose.rotation for pose in poses],
|
||||
[pose.scale3d for pose in poses], should_transact=False):
|
||||
raise RuntimeError("Nie mozna przeniesc ruchu kosci glownej do Transform.")
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(copy)
|
||||
return copy, poses, duration
|
||||
|
||||
|
||||
def displacement(poses, duration, elapsed, reverse=False):
|
||||
"""Interpolate horizontal root travel, accumulating complete playback loops."""
|
||||
elapsed = max(0.0, elapsed)
|
||||
loops = math.floor(elapsed / duration)
|
||||
time = elapsed - loops * duration
|
||||
if reverse:
|
||||
time = duration - time
|
||||
index = min(time / duration * (len(poses) - 1), len(poses) - 1)
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(poses) - 1)
|
||||
alpha = index - lower
|
||||
a, b = poses[lower].translation, poses[upper].translation
|
||||
start, end = poses[0].translation, poses[-1].translation
|
||||
if reverse:
|
||||
start, end = end, start
|
||||
return unreal.Vector(a.x + (b.x - a.x) * alpha - start.x + loops * (end.x - start.x),
|
||||
a.y + (b.y - a.y) * alpha - start.y + loops * (end.y - start.y), 0)
|
||||
@@ -111,6 +111,8 @@ class Scene:
|
||||
By default all animation FBXs beside the model are available by filename:
|
||||
shot.animation(actor, "great_sword_walk"). Optional animations={...}
|
||||
selects specific files; animations={} keeps only embedded animations.
|
||||
Also writes NameAnimations with durations to ue_workshop.animations for
|
||||
editor completion, including custom models (name="Ninja" -> NinjaAnimations).
|
||||
"""
|
||||
from .skeletal import import_model
|
||||
if height is not None and (not isinstance(height, (int, float)) or
|
||||
|
||||
@@ -4,6 +4,16 @@ import math
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, slug
|
||||
from .animation_clip import AnimationClip
|
||||
|
||||
|
||||
def _key_mode(interpolation):
|
||||
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
|
||||
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
|
||||
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
|
||||
if interpolation not in modes:
|
||||
raise ValueError("Interpolacja: linear, auto lub constant.")
|
||||
return modes[interpolation]
|
||||
|
||||
|
||||
class Sequence:
|
||||
@@ -28,7 +38,11 @@ class Sequence:
|
||||
self.asset.set_playback_end(frames)
|
||||
self._bindings = {}
|
||||
self._transforms = {}
|
||||
self._light_tracks = {}
|
||||
self._animations = {}
|
||||
self._root_motion = {}
|
||||
self._root_motion_copies = {}
|
||||
self._pose_holds = {}
|
||||
|
||||
def bind(self, actor):
|
||||
key = actor.get_path_name()
|
||||
@@ -66,19 +80,41 @@ class Sequence:
|
||||
weight.add_key(unreal.FrameNumber(frame), value,
|
||||
interpolation=unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
|
||||
def animation(self, actor, clip=None, start=0, end=None, speed=1.0):
|
||||
def animation(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None,
|
||||
start=0, end=None, speed=1.0, keep_end_position=False, reverse=False,
|
||||
keep_end_pose=False):
|
||||
"""Play/loop an imported clip over [start, end) in sequence frames.
|
||||
|
||||
clip is an alias from scene.skeletal_model(animations=...), an AnimSequence,
|
||||
or None for the only embedded animation. Transform keys still move the parent.
|
||||
Pass a FighterAnimations/CreatureAnimations member, a filename key, an
|
||||
AnimSequence, or None for the only available animation. With an enum and
|
||||
no end, play once using its stored duration, sequence FPS and speed.
|
||||
Strings/AnimSequence/None keep looping to the sequence end if end is omitted.
|
||||
An explicit end sets the section range and can repeat the clip.
|
||||
Overlapping clips automatically crossfade over their entire shared range.
|
||||
Add clips in timeline order; at most two clips can overlap at a time.
|
||||
keep_end_position transfers horizontal root travel into actor Transform
|
||||
keys, keeping the final position for subsequent clips. Defaults to False.
|
||||
reverse=True plays the clip backwards, including transferred root travel.
|
||||
keep_end_pose=True retains the final pose with a frozen section after
|
||||
playback and Sequencer Keep State. A later clip ends this pose hold.
|
||||
"""
|
||||
if type(start) is not int:
|
||||
raise ValueError("start musi byc numerem klatki (liczba calkowita).")
|
||||
if isinstance(clip, AnimationClip):
|
||||
if end is None:
|
||||
end = start + clip.frames(self.fps, speed)
|
||||
clip = clip.key
|
||||
end = self.frames if end is None else end
|
||||
if type(start) is not int or type(end) is not int or not 0 <= start < end <= self.frames:
|
||||
raise ValueError(f"Zakres animacji: 0 <= start < end <= {self.frames}.")
|
||||
if not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
|
||||
raise ValueError("Predkosc animacji musi byc dodatnia.")
|
||||
if type(keep_end_position) is not bool:
|
||||
raise ValueError("keep_end_position musi byc True lub False.")
|
||||
if type(reverse) is not bool:
|
||||
raise ValueError("reverse musi byc True lub False.")
|
||||
if type(keep_end_pose) is not bool:
|
||||
raise ValueError("keep_end_pose musi byc True lub False.")
|
||||
entry = self.scene._skeletal_models.get(actor.get_path_name())
|
||||
if entry is None:
|
||||
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
|
||||
@@ -109,29 +145,97 @@ class Sequence:
|
||||
previous = overlaps[0]
|
||||
blend_frames = previous[1] - start
|
||||
track = sections[0][2] if sections else self.bind(child).add_track(unreal.MovieSceneSkeletalAnimationTrack)
|
||||
hold = self._pose_holds.pop(key, None)
|
||||
if hold is not None:
|
||||
hold_start, hold_section = hold
|
||||
if start <= hold_start:
|
||||
track.remove_section(hold_section)
|
||||
else:
|
||||
hold_section.set_range(hold_start, start)
|
||||
if keep_end_position:
|
||||
from .root_motion import prepare
|
||||
source_key = clip.get_path_name()
|
||||
if source_key not in self._root_motion_copies:
|
||||
self._root_motion_copies[source_key] = prepare(
|
||||
clip, child, self.scene.name + "/" + self.asset.get_name())
|
||||
clip, poses, duration = self._root_motion_copies[source_key]
|
||||
section = track.add_section()
|
||||
section.set_range(start, end)
|
||||
params = section.get_editor_property("params")
|
||||
params.set_editor_property("animation", clip)
|
||||
params.set_editor_property("reverse", reverse)
|
||||
rate = unreal.MovieSceneTimeWarpVariant()
|
||||
rate.set_fixed_play_rate(float(speed))
|
||||
params.set_editor_property("play_rate", rate)
|
||||
params.set_editor_property("force_custom_mode", True)
|
||||
section.set_editor_property("params", params)
|
||||
# UE may rescale the range when play_rate changes. Restore the requested
|
||||
# display-frame bounds after applying parameters (including speed).
|
||||
section.set_range(start, end)
|
||||
if keep_end_pose:
|
||||
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
|
||||
self._animation_blend(section, start, end, blend_frames, 0)
|
||||
if previous:
|
||||
self._animation_blend(previous[3], previous[0], previous[1], previous[4], blend_frames)
|
||||
sections.append((start, end, track, section, blend_frames))
|
||||
if keep_end_position:
|
||||
self._keep_end_position(actor, child, start, end, speed, poses, duration, reverse)
|
||||
if keep_end_pose and end < self.frames:
|
||||
length = clip.get_play_length()
|
||||
final_time = min((end - start) / self.fps * speed, length)
|
||||
if reverse:
|
||||
final_time = length - final_time
|
||||
self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time))
|
||||
return section
|
||||
|
||||
def _hold_pose(self, track, clip, start, time):
|
||||
section = track.add_section()
|
||||
params = section.get_editor_property("params")
|
||||
params.set_editor_property("animation", clip)
|
||||
rate = unreal.MovieSceneTimeWarpVariant()
|
||||
rate.set_fixed_play_rate(0.0)
|
||||
params.set_editor_property("play_rate", rate)
|
||||
ticks = self.asset.get_tick_resolution()
|
||||
tick_rate = ticks.numerator / ticks.denominator
|
||||
# Stay just inside the asset end to avoid modulo wrapping to frame zero.
|
||||
time = max(0.0, min(time, clip.get_play_length() - 1 / tick_rate))
|
||||
params.set_editor_property("first_loop_start_frame_offset", unreal.FrameNumber(round(time * tick_rate)))
|
||||
params.set_editor_property("force_custom_mode", True)
|
||||
section.set_editor_property("params", params)
|
||||
section.set_range(start, self.frames)
|
||||
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
|
||||
self._animation_blend(section, start, self.frames, 0, 0)
|
||||
return section
|
||||
|
||||
def _keep_end_position(self, actor, child, start, end, speed, poses, duration, reverse=False):
|
||||
from .root_motion import displacement
|
||||
key = actor.get_path_name()
|
||||
if key not in self._root_motion:
|
||||
self._root_motion[key] = {
|
||||
"origin": actor.get_actor_location(),
|
||||
"mesh_transform": child.get_actor_transform(),
|
||||
"clips": [],
|
||||
}
|
||||
motion = self._root_motion[key]
|
||||
motion["clips"].append((start, end, speed, poses, duration, reverse))
|
||||
mesh = motion["mesh_transform"]
|
||||
zero = mesh.transform_location(unreal.Vector(0, 0, 0))
|
||||
# These are ordinary Transform keys. The final key holds the position
|
||||
# after playback; subsequent animation calls do not reset it.
|
||||
first = min(item[0] for item in motion["clips"])
|
||||
last = min(self.frames - 1, max(item[1] for item in motion["clips"]))
|
||||
for frame in range(first, last + 1):
|
||||
total = unreal.Vector(0, 0, 0)
|
||||
for clip_start, clip_end, clip_speed, clip_poses, clip_duration, clip_reverse in motion["clips"]:
|
||||
elapsed = min(max(frame - clip_start, 0), clip_end - clip_start) / self.fps * clip_speed
|
||||
total += displacement(clip_poses, clip_duration, elapsed, reverse=clip_reverse)
|
||||
world_delta = mesh.transform_location(total) - zero
|
||||
self.transform_key(actor, frame, location=(motion["origin"] + world_delta).to_tuple())
|
||||
|
||||
def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"):
|
||||
if not isinstance(frame, int) or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
|
||||
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
|
||||
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
|
||||
if interpolation not in modes:
|
||||
raise ValueError("Interpolacja: linear, auto lub constant.")
|
||||
mode = _key_mode(interpolation)
|
||||
key = actor.get_path_name()
|
||||
if key not in self._transforms:
|
||||
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
|
||||
@@ -151,7 +255,88 @@ class Sequence:
|
||||
if len(values) != 3:
|
||||
raise ValueError("Transformacja wymaga trzech wartosci.")
|
||||
for channel, value in zip(channels[offset:offset + 3], values):
|
||||
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation])
|
||||
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
|
||||
|
||||
def light_key(self, actor, frame, color=None, intensity=None, interpolation="linear"):
|
||||
"""Key a light's color (linear RGB 0-1) and/or intensity at a sequence frame."""
|
||||
if not isinstance(frame, int) or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
mode = _key_mode(interpolation)
|
||||
if color is not None and len(color) != 3:
|
||||
raise ValueError("Kolor wymaga trzech wartosci.")
|
||||
light = actor.get_component_by_class(unreal.LightComponent)
|
||||
if light is None:
|
||||
raise ValueError("light_key wymaga aktora swiatla, np. z lighting.spot().")
|
||||
if color is not None:
|
||||
channels = self._light_channels(light, unreal.MovieSceneColorTrack, "LightColor")
|
||||
for channel, value in zip(channels, color):
|
||||
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
|
||||
if intensity is not None:
|
||||
channels = self._light_channels(light, unreal.MovieSceneFloatTrack, "Intensity")
|
||||
channels[0].add_key(unreal.FrameNumber(frame), float(intensity), interpolation=mode)
|
||||
|
||||
def _light_channels(self, light, track_class, name):
|
||||
key = (light.get_path_name(), name)
|
||||
if key not in self._light_tracks:
|
||||
track = self.bind(light).add_track(track_class)
|
||||
track.set_property_name_and_path(name, name)
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
channels = section.get_all_channels()
|
||||
if name == "LightColor":
|
||||
channels[3].set_default(1.0) # Alpha is never keyed.
|
||||
self._light_tracks[key] = channels
|
||||
return self._light_tracks[key]
|
||||
|
||||
def position(self, actor, frame: int) -> tuple[float, float, float]:
|
||||
"""Actor Transform position at a sequence frame, including keep_end_position.
|
||||
|
||||
Evaluates Sequencer curves without moving the playhead. Does not include
|
||||
motion of bones inside an animation that was not transferred to Transform.
|
||||
"""
|
||||
if type(frame) is not int or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
channels = self._transforms.get(actor.get_path_name())
|
||||
if channels is None:
|
||||
return actor.get_actor_location().to_tuple()
|
||||
interval = self.asset.make_range(frame, 1)
|
||||
rate = unreal.FrameRate(self.fps, 1)
|
||||
values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]]
|
||||
if any(not axis for axis in values):
|
||||
raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.")
|
||||
return tuple(float(axis[0]) for axis in values)
|
||||
|
||||
def move(self, actor, start: int, end: int, offset: tuple[float, float, float],
|
||||
interpolation="auto") -> tuple[float, float, float]:
|
||||
"""Move by offset (world cm) from the actor's position at start.
|
||||
|
||||
Replaces position keys in the range and shifts later position keys by
|
||||
the resulting change, preserving later motion. Call after animation().
|
||||
Returns the target position. Rotation/scale keys are kept.
|
||||
"""
|
||||
if (type(start) is not int or type(end) is not int or
|
||||
not 0 <= start < end < self.frames):
|
||||
raise ValueError(f"Ruch wymaga 0 <= start < end < {self.frames}.")
|
||||
if interpolation not in {"linear", "auto", "constant"}:
|
||||
raise ValueError("Interpolacja: linear, auto lub constant.")
|
||||
if len(offset) != 3 or any(not math.isfinite(value) for value in offset):
|
||||
raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.")
|
||||
origin = self.position(actor, start)
|
||||
target = tuple(value + delta for value, delta in zip(origin, offset))
|
||||
channels = self._transforms.get(actor.get_path_name())
|
||||
if channels is not None:
|
||||
previous_end = self.position(actor, end)
|
||||
for axis, channel in enumerate(channels[:3]):
|
||||
correction = target[axis] - previous_end[axis]
|
||||
for key in list(channel.get_keys()):
|
||||
time = key.get_time().frame_number.value
|
||||
if start <= time <= end:
|
||||
channel.remove_key(key)
|
||||
elif time > end:
|
||||
key.set_value(key.get_value() + correction)
|
||||
self.transform_key(actor, start, location=origin, interpolation=interpolation)
|
||||
self.transform_key(actor, end, location=target, interpolation=interpolation)
|
||||
return target
|
||||
|
||||
def save(self, open_editor=True):
|
||||
self.scene.save()
|
||||
|
||||
@@ -6,6 +6,7 @@ import unreal
|
||||
|
||||
from .paths import GENERATED, PROJECT, slug
|
||||
from .characters import _clips
|
||||
from .animation_catalog import update_catalog
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -111,7 +112,8 @@ def import_model(filename, name="Character", animations=None, collection="Charac
|
||||
"""
|
||||
source = Path(filename).expanduser()
|
||||
source = (source if source.is_absolute() else PROJECT / source).resolve()
|
||||
if animations is None:
|
||||
discover_all = animations is None
|
||||
if discover_all:
|
||||
animations = _clips(source)
|
||||
name = slug(name)
|
||||
destination = f"{GENERATED}/Skeletal/{name}"
|
||||
@@ -131,4 +133,6 @@ def import_model(filename, name="Character", animations=None, collection="Charac
|
||||
for alias, filename in (animations or {}).items():
|
||||
clips[alias] = import_animation(filename, skeleton, name=slug(alias),
|
||||
destination=destination + "/Animations", reimport=reimport)
|
||||
update_catalog(name, {key: clip.get_play_length() for key, clip in clips.items()},
|
||||
replace=discover_all)
|
||||
return SkeletalImport(meshes[0], clips)
|
||||
@@ -199,6 +199,33 @@ animację kości przez 120 klatek (5 s); krótszy klip zapętla się w swojej se
|
||||
Klucze `location`, `rotation` i `scale` mogą dodatkowo poruszać całym modelem.
|
||||
Przykład dwóch postaci z różnymi klipami: `examples/mixamo_characters.py`.
|
||||
|
||||
Do pisania scen w VS Code możesz używać enumów z gotowymi długościami — bez
|
||||
szukania assetów ani uruchamiania komend w UE:
|
||||
|
||||
```python
|
||||
from ue_workshop.animations import FighterAnimations
|
||||
|
||||
clip = FighterAnimations.GREAT_SWORD_ATTACK
|
||||
clip.seconds # czas klipu w sekundach
|
||||
clip.frames(shot.fps) # liczba klatek przy FPS sekwencji
|
||||
shot.animation(character, clip, start=48) # bez end: jeden przebieg
|
||||
```
|
||||
|
||||
Po wpisaniu `FighterAnimations.` edytor podpowiada nazwy. `CreatureAnimations`
|
||||
udostępnia klipy stwora. [Dane offline, łączenie klipów i aktualizacja katalogu](docs/animation_catalog.md).
|
||||
|
||||
Własne modele również dostają katalog automatycznie: pierwszy import
|
||||
`scene.skeletal_model(path, name="Ninja")` zapisze `NinjaAnimations` w
|
||||
`ue_workshop.animations`. Kolejne klipy i długości są aktualizowane przy imporcie,
|
||||
bez osobnego polecenia generowania. `REIMPORT=False` nadal wykorzystuje gotowe assety.
|
||||
|
||||
Gdy atak przesuwa postać, `shot.animation(character, clip, start=57,
|
||||
keep_end_position=True)` automatycznie przenosi poziomy ruch do kluczy Transform
|
||||
i zachowuje końcową pozycję na kolejne klipy. [Przykład slash_2](docs/animation_catalog.md).
|
||||
|
||||
Po śmierci `shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150,
|
||||
keep_end_pose=True)` utrzymuje leżącą pozę do końca sekwencji lub następnej animacji.
|
||||
|
||||
```python
|
||||
character = scene.skeletal_model(
|
||||
PROJECT / "source_assets/character/creature/Maw J Laygo.fbx",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Nazwy i długości animacji w VS Code
|
||||
|
||||
W `ue_workshop.animations` są statycznie zapisane enumy `FighterAnimations` i
|
||||
`CreatureAnimations`. Działają w zwykłym Pythonie, bez `unreal`. Po wpisaniu kropki
|
||||
VS Code może podpowiedzieć wszystkie klipy. Opisy członków podają długość w sekundach.
|
||||
Nazwy odpowiadają nazwom plików; nie ma skrótów typu `attack`.
|
||||
|
||||
```python
|
||||
from ue_workshop.animations import FighterAnimations, CreatureAnimations
|
||||
|
||||
draw = FighterAnimations.DRAW_A_GREAT_SWORD_1
|
||||
attack = FighterAnimations.GREAT_SWORD_ATTACK
|
||||
|
||||
draw.key # "draw_a_great_sword_1"
|
||||
draw.seconds # 0.5 sekundy
|
||||
draw.frames(24) # 12 klatek przy 24 FPS
|
||||
attack.seconds # około 1.2 sekundy
|
||||
attack.frames(24) # 29 klatek przy 24 FPS, zaokrąglone w górę
|
||||
attack.frames(24, speed=2)
|
||||
|
||||
# Bez end klip wybrany enumem gra raz, z automatycznie policzonym końcem.
|
||||
shot.animation(character_fighter, draw, start=0)
|
||||
next_start = draw.frames(shot.fps) - 6
|
||||
shot.animation(character_fighter, attack, start=next_start)
|
||||
```
|
||||
|
||||
W tym przykładzie przejście trwa 6 wspólnych klatek. Przy zmianie klipu albo FPS
|
||||
nie trzeba pamiętać jego długości. `speed` jest uwzględniane zarówno przez `frames`,
|
||||
jak i przez automatyczny koniec w `shot.animation`.
|
||||
|
||||
Podanie `end` nadal ustawia własny zakres i pozwala zapętlić klip:
|
||||
|
||||
```python
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=0, end=120)
|
||||
```
|
||||
|
||||
Dotychczasowe nazwy tekstowe nadal działają. Dla tekstu lub bez wskazania klipu
|
||||
pominięte `end` zachowuje dotychczasowe znaczenie: gra do końca sekwencji.
|
||||
Koniec sekcji musi mieścić się w `Sequence(..., frames=...)`.
|
||||
|
||||
## Zachowanie pozycji po ataku
|
||||
|
||||
Jeśli klip przesuwa postać do przodu, włącz opcję na tym klipie:
|
||||
|
||||
```python
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_2,
|
||||
start=57, keep_end_position=True)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_3, start=161)
|
||||
```
|
||||
|
||||
`animation()` przenosi poziomy ruch kości głównej do zwykłych kluczy pozycji
|
||||
aktora na ścieżce Transform. Ostatni klucz zachowuje osiągnięte przesunięcie,
|
||||
więc następny atak zaczyna się w nowym miejscu. Klucze możesz edytować w Sequencerze.
|
||||
Ruch pionowy i obroty kości pozostają w animacji.
|
||||
|
||||
### Pozostawienie postaci na ziemi po śmierci
|
||||
|
||||
```python
|
||||
shot.animation(character_creature, CreatureAnimations.MUTANT_DYING,
|
||||
start=150, keep_end_pose=True)
|
||||
```
|
||||
|
||||
Enum bez `end` odtwarza klip raz. `keep_end_pose=True` dodaje po nim odcinek
|
||||
z zamrożoną końcową pozą aż do końca sekwencji. Postać pozostaje na ziemi także
|
||||
przy renderowaniu pojedynczej późniejszej klatki. Kolejna animacja tej samej
|
||||
postaci kończy utrzymywanie pozy. Oryginalny klip nie jest zmieniany.
|
||||
|
||||
`keep_end_pose` dotyczy ułożenia kości; `keep_end_position` dotyczy przesunięcia
|
||||
aktora. Można włączyć obie opcje. Jeśli jawnie wydłużysz zakres przez `end`,
|
||||
klip nadal może się zapętlać w tym zakresie — dla śmierci pozostaw pojedynczy przebieg.
|
||||
|
||||
Aby uniknąć podwójnego przesuwania, sekcja korzysta z wygenerowanej kopii klipu
|
||||
bez jego poziomego przesunięcia. Oryginalny asset animacji pozostaje bez zmian.
|
||||
Opcja domyślnie jest wyłączona. Jeśli chcesz własne klucze pozycji, dodaj je po
|
||||
wywołaniach `animation()` — zastąpią wygenerowane wartości na tych samych klatkach.
|
||||
|
||||
### Ruch względem aktualnej pozycji
|
||||
|
||||
Po `keep_end_position` nie musisz obliczać nowych współrzędnych ręcznie:
|
||||
|
||||
```python
|
||||
# Po wywołaniach shot.animation(...): przesunięcie o 50 cm w osi Y.
|
||||
shot.move(character_creature, start=80, end=120, offset=(0, 50, 0))
|
||||
```
|
||||
|
||||
Helper odczyta pozycję aktora w klatce 80 z krzywych Sequencera, doda klucz
|
||||
początkowy, a w klatce 120 klucz przesunięty o `offset`. To współrzędne świata
|
||||
w centymetrach. Ostatnia klatka ruchu jest kluczem, więc musi być mniejsza od
|
||||
`shot.frames`. Dodawaj ruch po animacjach. `move()` zastępuje klucze pozycji wewnątrz
|
||||
odcinka, także wygenerowane przez `keep_end_position`. Późniejsze klucze pozycji są
|
||||
przesuwane o wynikową różnicę, żeby zachować dalszy ruch. Klucze rotacji i skali zostają.
|
||||
|
||||
### Cofanie podczas chodzenia
|
||||
|
||||
`reverse=True` odtwarza klip od końca. W połączeniu z `keep_end_position=True`
|
||||
odwraca także przesunięcie zapisane w klipie i zachowuje nową pozycję:
|
||||
|
||||
```python
|
||||
shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING,
|
||||
start=80, end=120, reverse=True, keep_end_position=True)
|
||||
```
|
||||
|
||||
Postać odtwarza kroki do tyłu bez zmiany kierunku, w którym patrzy. Jeśli chcesz
|
||||
ustalić odległość cofnięcia samodzielnie, dopisz po animacjach:
|
||||
|
||||
```python
|
||||
shot.move(character_creature, start=80, end=120, offset=(0, -200, 0))
|
||||
```
|
||||
|
||||
To ustawia cofnięcie o 200 cm w osi Y; `reverse` odpowiada za kierunek odtwarzania
|
||||
ruchu nóg, a `move` zastępuje trasę pozycjami wybranymi przez autora sceny.
|
||||
|
||||
Do własnych obliczeń służy:
|
||||
|
||||
```python
|
||||
x, y, z = shot.position(character_creature, frame=80)
|
||||
```
|
||||
|
||||
Odczyt uwzględnia klucze Transform, `keep_end_position` i interpolację. Nie przestawia
|
||||
kursora w Sequencerze. Dotyczy pozycji aktora; ruchy samych kości wewnątrz klipu
|
||||
bez `keep_end_position` nie zmieniają pozycji jego aktora. Odczyt wykonuje się w UE
|
||||
podczas uruchamiania skryptu, więc VS Code nie musi znać liczbowych współrzędnych.
|
||||
|
||||
Listę z danymi też można odczytać bez UE:
|
||||
|
||||
```python
|
||||
for clip in FighterAnimations:
|
||||
print(clip.key, clip.seconds, clip.frames(24))
|
||||
```
|
||||
|
||||
## Własny model studenta — generowanie automatyczne
|
||||
|
||||
Każde `scene.skeletal_model(...)` automatycznie zapisuje nazwy i długości jego
|
||||
zaimportowanych klipów do `Content/Python/ue_workshop/animations.py`. Dotyczy to
|
||||
także modeli pobranych przez studentów, FBX z animacją w środku i `.blend`.
|
||||
|
||||
Umieść model i jego animacje FBX w osobnym folderze, np. `source_assets/ninja`.
|
||||
Przy pierwszym uruchomieniu w UE:
|
||||
|
||||
```python
|
||||
character = scene.skeletal_model(
|
||||
PROJECT / "source_assets/ninja/model.fbx",
|
||||
name="Ninja",
|
||||
reimport=False,
|
||||
)
|
||||
```
|
||||
|
||||
Powstanie `NinjaAnimations` — nazwa enuma wynika z `name`. VS Code zobaczy nowy
|
||||
statyczny kod i będzie mógł go podpowiadać. W kolejnej edycji skryptu możesz użyć:
|
||||
|
||||
```python
|
||||
from ue_workshop.animations import NinjaAnimations
|
||||
|
||||
# Jeśli w folderze był walking.fbx:
|
||||
clip = NinjaAnimations.WALKING
|
||||
clip.seconds
|
||||
clip.frames(shot.fps)
|
||||
shot.animation(character, clip, start=0)
|
||||
```
|
||||
|
||||
Pierwszy import w UE jest potrzebny do odczytania rzeczywistych długości.
|
||||
Późniejsze pisanie sceny korzysta z zapisanego katalogu i nie wymaga komend w UE.
|
||||
Różnym modelom nadaj różne `name` — to również nazwa ich assetów w projekcie.
|
||||
|
||||
Po dodaniu FBX do folderu uruchom swój skrypt ponownie: brakujący klip zostanie
|
||||
zaimportowany i dopisany do enuma. Po zmianie istniejącego klipu użyj `REIMPORT=True`;
|
||||
nowa długość zapisze się automatycznie. Nie trzeba uruchamiać osobnego generatora.
|
||||
`REIMPORT=False` nadal używa istniejących assetów. Nie ma sprawdzania dat źródeł.
|
||||
|
||||
Katalogi innych modeli pozostają w pliku. Jawny wybór `animations={...}` aktualizuje
|
||||
tylko wybrane wpisy, zachowując inne znane klipy tej postaci. Automatyczny odczyt
|
||||
całego folderu odświeża pełną listę. Jeśli dane się nie zmieniły, plik nie jest
|
||||
zapisywany ponownie.
|
||||
|
||||
Opcjonalna ręczna aktualizacja dołączonego fightera i creature:
|
||||
|
||||
```text
|
||||
python tools/run_workshop.py tools/update_animation_catalog.py
|
||||
```
|
||||
|
||||
To narzędzie również zachowuje katalogi własnych modeli studentów.
|
||||
@@ -7,6 +7,33 @@ Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, ma
|
||||
Własny skrypt: `scene.skeletal_model(..., reimport=True)` lub `scene.model(..., reimport=True)`.
|
||||
Nie ma automatycznego wykrywania zmian źródeł.
|
||||
|
||||
Enumy i długości bez uruchamiania UE:
|
||||
|
||||
```python
|
||||
from ue_workshop.animations import FighterAnimations
|
||||
clip = FighterAnimations.GREAT_SWORD_ATTACK
|
||||
clip.seconds
|
||||
clip.frames(shot.fps, speed=1.0)
|
||||
shot.animation(character, clip, start=48) # jeden przebieg; end obliczony z długości
|
||||
```
|
||||
|
||||
Jawne `end` nadal pozwala zapętlić klip. `scene.skeletal_model(path, name="Ninja")`
|
||||
automatycznie wygeneruje `NinjaAnimations` po pierwszym imporcie w UE, także dla
|
||||
własnych plików studentów. [Katalog offline i jego odświeżanie](animation_catalog.md).
|
||||
|
||||
`shot.animation(character, clip, start=57, keep_end_position=True)` zapisuje poziomy
|
||||
ruch z klipu jako klucze Transform; końcowy klucz utrzymuje pozycję po ataku.
|
||||
Źródłowy klip pozostaje bez zmian. Opcja dotyczy ruchu kości głównej.
|
||||
|
||||
`shot.position(character, frame=80)` odczytuje pozycję aktora z krzywych sekwencji.
|
||||
`shot.move(character, start=80, end=120, offset=(0, 50, 0))` dodaje klucze ruchu
|
||||
o 50 cm względem pozycji w klatce 80, także po `keep_end_position`.
|
||||
`move` zastępuje klucze pozycji w tym odcinku i odpowiednio przesuwa późniejsze.
|
||||
`shot.animation(character, clip, reverse=True, keep_end_position=True, start=80, end=120)`
|
||||
odtwarza kroki i przeniesiony ruch do tyłu. Odległość można nadpisać przez `shot.move`.
|
||||
`shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150, keep_end_pose=True)`
|
||||
odtwarza śmierć raz i zachowuje końcową pozę na dalszą część sekwencji.
|
||||
|
||||
`camera.create(..., fov=60)` ustawia poziomy kąt widzenia w stopniach: większy FOV
|
||||
daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywowi
|
||||
45 mm na sensorze o szerokości 36 mm.
|
||||
@@ -24,6 +51,9 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo
|
||||
| Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` |
|
||||
| Światła studyjne | `lights = lighting.studio(scene)` |
|
||||
| Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` |
|
||||
| Żarówka | `lamp = lighting.point(scene, location=(0, 0, 250), intensity=10000, color=(1, 0.5, 0.2))` |
|
||||
| Reflektor | `spot = lighting.spot(scene, location=(200, -200, 400), target=(0, 0, 100), angle=30)` |
|
||||
| Słońce (liczy się tylko kierunek) | `sun = lighting.sun(scene, location=(300, -300, 500), target=(0, 0, 0), intensity=500)` |
|
||||
| Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6)` |
|
||||
| Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` |
|
||||
| Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` |
|
||||
@@ -31,6 +61,8 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo
|
||||
| Klucz pozycji | `shot.transform_key(actor, 0, location=(0, 0, 0))` |
|
||||
| Klucz rotacji | `shot.transform_key(actor, 47, rotation=(0, 0, 360))` |
|
||||
| Klucz skali | `shot.transform_key(actor, 24, scale=(1.2, 1.2, 1.2), interpolation="auto")` |
|
||||
| Klucz koloru światła | `shot.light_key(spot, 0, color=(1, 0, 0))` |
|
||||
| Klucz jasności światła | `shot.light_key(spot, 47, intensity=40000, interpolation="auto")` |
|
||||
| Zapis + otwarcie Sequencera | `shot.save()` |
|
||||
| Własny renderer | `renderer = Renderer(width=1920, height=1080, samples=8, warmup=32, mp4=True)` |
|
||||
| Preset z własnymi wartościami | `renderer = Renderer.from_preset("final", samples=8)` |
|
||||
|
||||
@@ -100,3 +100,76 @@ Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA
|
||||
nakładania; stykające się klipy nie dostają fade. Sprawdzone łańcuchy trzech
|
||||
klipów oraz odrzucanie niejednoznacznych zakresów i dodawania w odwrotnej kolejności.
|
||||
- Parametr `blend_in` usunięty z API i przykładów. Zakresy scen pozostają takie same.
|
||||
|
||||
## Katalog enumów i długości dostępnych offline
|
||||
|
||||
- 34 testy poza UE: statyczne enumy obejmują wszystkie pliki źródłowe, importują
|
||||
się bez `unreal`, udostępniają sekundy i przeliczenie klatek z FPS i prędkością.
|
||||
- Sprawdzone zaokrąglanie czasu float32 bez dodawania zbędnej klatki, automatyczny
|
||||
koniec dla enuma, jawny koniec oraz odrzucenie sekcji dłuższej niż sekwencja.
|
||||
- `tools/smoke_animation_catalog.py` w UE: porównane długości wszystkich 63 klipów
|
||||
z rzeczywistym `get_play_length()`, odtworzone enumy, w tym `speed=2`, oraz render
|
||||
klatki ze środka automatycznego przejścia. Katalog pochodzi z eksportu assetów UE.
|
||||
- Po przypisaniu parametrów sekcji przywracany jest zakres podany w Pythonie,
|
||||
ponieważ edytor może przeskalować go przy zmianie prędkości.
|
||||
|
||||
## Automatyczne katalogi własnych modeli
|
||||
|
||||
- 41 testów poza UE: generowanie nowej klasy, metadane bez `unreal`, zachowanie
|
||||
innych modeli, aktualizacja podzbioru, usuniętych klipów i niezmienionych danych.
|
||||
- Sprawdzone odrzucanie kolizji nazw i nieprawidłowych czasów bez naruszenia pliku
|
||||
oraz dodanie nowej klasy do już załadowanego modułu.
|
||||
- `tools/smoke_automatic_catalog.py` zaliczony w UE: pierwszy import pod nazwą
|
||||
StudentCatalogSmoke, automatyczny zapis dwóch klipów, odczyt wygenerowanego enuma
|
||||
i użycie go do animacji z automatycznym końcem. Katalog testowy zapisano w build.
|
||||
- Zwykły hook odświeżył także pełne katalogi Fighter i Creature z istniejących assetów.
|
||||
|
||||
## keep_end_position
|
||||
|
||||
- 45 testów poza UE: dodatkowo pozioma interpolacja ruchu, sumowanie pętli,
|
||||
włączenie przeniesienia do Transform i ponowne użycie jednej kopii klipu.
|
||||
- `tools/smoke_keep_end_position.py` zaliczony w UE: slash_2 od klatki 57 przy
|
||||
30 FPS, wejście slash_3 w klatce 161. Zapisane rzeczywiste klucze rodzica;
|
||||
po klatce 163 aktor zachowuje około 354 cm przesunięcia dla modelu wysokości 200 cm.
|
||||
- Sprawdzone ciągłe położenie bioder przy przejściu, brak podwojenia przesunięcia,
|
||||
niezmieniona źródłowa animacja, ponowne wykonanie i render klatki po ataku.
|
||||
- Przenoszony jest poziomy ruch pierwszej kości szkieletu (Hips w tym modelu).
|
||||
Pionowe ruchy i obroty kości zostają w klipie; kopie są w wygenerowanym folderze RootMotion.
|
||||
|
||||
## Odczyt pozycji i ruch względny
|
||||
|
||||
- 49 testów poza UE: odczyt istniejących kanałów pozycji, pozycja startowa bez
|
||||
ścieżki, dodanie względnego offsetu oraz walidacja bez tworzenia częściowych kluczy.
|
||||
- `tools/smoke_relative_move.py` zaliczony w UE: creature wysokości 300 cm,
|
||||
start `(0, -850, 0)`, MUTANT_RUN z keep_end_position, swiping i walking.
|
||||
- Pozycja w klatce 80 odczytana z natywnych krzywych Sequencera. Ruch 80–120
|
||||
zwiększył Y dokładnie o 50 cm, zachował punkt początkowy i pozycję po klatce 120.
|
||||
Sprawdzono też interpolowaną pozycję w środku ruchu.
|
||||
|
||||
## Cofanie i zastępowanie trasy
|
||||
|
||||
- 53 testy poza UE: odwrócone odtwarzanie, cofanie ruchu po nierównomiernej krzywej,
|
||||
sumowanie pętli do tyłu, usuwanie kluczy wewnątrz move i przesuwanie późniejszych.
|
||||
- `tools/smoke_reverse_walk.py` zaliczony w UE: run → swiping → walking z reverse,
|
||||
naturalny ruch wstecz oraz zastąpienie trasy cofnięciem o 200 cm.
|
||||
- Sprawdzone pozycje początkowa, środkowa i końcowa, brak wewnętrznych kluczy
|
||||
z poprzedniej trasy i zachowanie względnego ruchu późniejszego klucza.
|
||||
|
||||
## Zachowanie końcowej pozy
|
||||
|
||||
- 55 testów poza UE: natywne Keep State, zamrożony odcinek końcowej pozy oraz
|
||||
skracanie tego odcinka, gdy pojawi się następny klip tej samej postaci.
|
||||
- `tools/smoke_keep_end_pose.py`: śmierć mutanta od klatki 0 przy 30 FPS, render
|
||||
klatki 299 po zakończeniu klipu. Wizualnie sprawdzono, że mutant leży na ziemi.
|
||||
- Samo Keep State dawało w takim renderze pozę referencyjną; dodatkowy odcinek
|
||||
z prędkością 0 i przesunięciem czasu do końca klipu utrzymuje właściwą pozę.
|
||||
|
||||
## Rodzaje świateł i klucze światła
|
||||
|
||||
- 60 testów poza UE: klucze koloru i jasności, ponowne użycie tracków oraz
|
||||
odrzucanie błędnych argumentów bez tworzenia tracka.
|
||||
- `tools/smoke_lights.py` w UE: point, spot i sun w jednej scenie, render klatek
|
||||
0, 24 i 47. Wizualnie sprawdzono zmianę koloru reflektora z czerwonego na niebieski
|
||||
oraz wzrost jasności reflektora i żarówki.
|
||||
- Domyślne `intensity=500` słońca dobrano do stałej ekspozycji `camera.create`;
|
||||
przy `intensity=1` słońce nie było widoczne na renderze.
|
||||
@@ -9,8 +9,8 @@ if "ue_workshop" in sys.modules:
|
||||
if _render_module is not None and _render_module.is_rendering():
|
||||
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
|
||||
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
|
||||
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
|
||||
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
|
||||
for _name in ("paths", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets",
|
||||
"_gltf", "blender", "characters", "skeletal", "root_motion", "scene", "sequence", "render"):
|
||||
_module = sys.modules.get("ue_workshop." + _name)
|
||||
if _module is not None:
|
||||
importlib.reload(_module)
|
||||
@@ -19,32 +19,53 @@ if "ue_workshop" in sys.modules:
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.paths import PROJECT
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
|
||||
|
||||
# POSTACIE
|
||||
from ue_workshop.animations import FighterAnimations, CreatureAnimations
|
||||
|
||||
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
|
||||
scene = Scene("MyShot")
|
||||
floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11))
|
||||
character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200)
|
||||
#character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300)
|
||||
character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200, location=(30, -40, 0.16), rotation=(0,0,180))
|
||||
character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0,-850,0))
|
||||
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0))
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=40)
|
||||
|
||||
lights = [
|
||||
lighting.area(scene, "Key", (200, -500, 350), color=(1.0, 0.75, 0.5)),
|
||||
lighting.area(scene, "Fill", (100, 300, 250), intensity=7000, color=(0.3, 0.6, 1.0)),
|
||||
lighting.area(scene, "Rim", (-200, 0, 300), intensity=15000)
|
||||
]
|
||||
|
||||
cam = camera.create(scene, location=(200, -300, 200), target=(0, 0, 100), fov=90)
|
||||
|
||||
# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
|
||||
shot = Sequence(scene, fps=24, frames=120)
|
||||
shot = Sequence(scene, fps=30, frames=260)
|
||||
|
||||
# Fighter
|
||||
shot.animation(character_fighter, FighterAnimations.DRAW_A_GREAT_SWORD_2, start=0)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=22, end=55)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING, start=48)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING_2, start=61)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH, start=80)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_JUMP_ATTACK, start=117, keep_end_position=True)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_STRAFE, start=180, keep_end_position=True)
|
||||
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_RUN_2, start=207, end=259, keep_end_position=True)
|
||||
shot.transform_key(character_fighter,58, location=(30, -40, 0.16), interpolation="auto")
|
||||
shot.transform_key(character_fighter,64, location=(30, 20, 0.16), interpolation="auto")
|
||||
shot.move(character_fighter, start=134, end=146, offset=(0, -170, 0), interpolation="auto")
|
||||
|
||||
# Creature
|
||||
shot.animation(character_creature, CreatureAnimations.MUTANT_RUN, start=0, end=60, keep_end_position=True)
|
||||
shot.animation(character_creature, CreatureAnimations.MUTANT_SWIPING, start=20)
|
||||
shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING, start=95, end=160, keep_end_position=True, reverse=True)
|
||||
shot.animation(character_creature, CreatureAnimations.MUTANT_DYING, start=130, end=259, keep_end_pose=True)
|
||||
|
||||
# Camera
|
||||
shot.camera(cam)
|
||||
r = cam.get_actor_rotation()
|
||||
shot.transform_key(cam, 0, rotation=(r.roll, r.pitch, r.yaw))
|
||||
shot.transform_key(cam, 10, location=(200, -300, 200), interpolation="auto")
|
||||
shot.transform_key(cam, 75, location=(450, -550, 300), rotation=(0, -15, 150), interpolation="auto")
|
||||
|
||||
shot.animation(character_fighter, "draw_a_great_sword_1", start=0, end=60)
|
||||
|
||||
|
||||
|
||||
# shot.animation(character_fighter, "great_sword_idle", start=0, end=60)
|
||||
# shot.animation(character_fighter, "great_sword_attack", start=48, end=120)
|
||||
# shot.transform_key(character_fighter, 70, location=(0, 0, 0), interpolation="auto")
|
||||
# shot.transform_key(character_fighter, 100, location=(0, 200, 0), interpolation="auto")
|
||||
shot.save()
|
||||
|
||||
# 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True.
|
||||
|
||||
@@ -9,8 +9,8 @@ if "ue_workshop" in sys.modules:
|
||||
if _render_module is not None and _render_module.is_rendering():
|
||||
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
|
||||
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
|
||||
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
|
||||
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
|
||||
for _name in ("paths", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets",
|
||||
"_gltf", "blender", "characters", "skeletal", "root_motion", "scene", "sequence", "render"):
|
||||
_module = sys.modules.get("ue_workshop." + _name)
|
||||
if _module is not None:
|
||||
importlib.reload(_module)
|
||||
|
||||
@@ -13,7 +13,7 @@ def main():
|
||||
raise RuntimeError("Ten plik uruchom z terminala zwyklym Pythonem, poza edytorem UE.")
|
||||
# --render zapewnia film takze wtedy, gdy w my_shot.py jest RENDER=False.
|
||||
# Argumenty launchera, np. --editor i --timeout, mozna dopisac w terminalu.
|
||||
script = Path(__file__).with_name("my_shot.py")
|
||||
script = Path(__file__).with_name("advanced_example.py")
|
||||
return run_workshop([str(script), "--render", *sys.argv[1:]])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Offline, statically declared enums and timing metadata for editor use."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
|
||||
from ue_workshop.animation_clip import AnimationClip
|
||||
from ue_workshop.animations import FighterAnimations, CreatureAnimations
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL, _clips
|
||||
|
||||
|
||||
class TimingClip(AnimationClip):
|
||||
WALK = ("walk", 2.0)
|
||||
FLOAT_DURATION = ("float_duration", 3.200000047683716)
|
||||
FRACTIONAL = ("fractional", 2.1)
|
||||
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
def test_catalog_covers_all_source_files_without_duplicate_keys(self):
|
||||
for catalog, model in ((FighterAnimations, FIGHTER_MODEL), (CreatureAnimations, CREATURE_MODEL)):
|
||||
with self.subTest(model=model):
|
||||
keys = [clip.key for clip in catalog]
|
||||
self.assertEqual(set(keys), set(_clips(model)))
|
||||
self.assertEqual(len(keys), len(set(keys)))
|
||||
self.assertTrue(all(clip.seconds > 0 for clip in catalog))
|
||||
|
||||
def test_metadata_is_available_without_loading_unreal(self):
|
||||
self.assertNotIn("unreal", sys.modules)
|
||||
self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.key, "draw_a_great_sword_1")
|
||||
self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.seconds, 0.5)
|
||||
self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.frames(24), 12)
|
||||
|
||||
def test_frames_respect_fps_speed_and_fractional_end(self):
|
||||
self.assertEqual(TimingClip.WALK.frames(24), 48)
|
||||
self.assertEqual(TimingClip.WALK.frames(30, speed=2), 30)
|
||||
self.assertEqual(TimingClip.FRACTIONAL.frames(24), 51)
|
||||
self.assertEqual(TimingClip.FLOAT_DURATION.frames(30), 96)
|
||||
|
||||
def test_invalid_fps_and_speed_are_rejected(self):
|
||||
for fps in (0, -1, True, 24.5):
|
||||
with self.subTest(fps=fps), self.assertRaises(ValueError):
|
||||
TimingClip.WALK.frames(fps)
|
||||
for speed in (0, -1, True, float("nan"), float("inf")):
|
||||
with self.subTest(speed=speed), self.assertRaises(ValueError):
|
||||
TimingClip.WALK.frames(24, speed)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Automatic catalogs preserve other models, partial selections and offline imports."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
|
||||
from ue_workshop import animation_catalog
|
||||
from ue_workshop.animation_clip import AnimationClip
|
||||
|
||||
|
||||
class AutomaticCatalogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.output = Path(self.folder.name) / "animations.py"
|
||||
|
||||
def load(self):
|
||||
namespace = {"__name__": "ue_workshop._catalog_test", "__package__": "ue_workshop"}
|
||||
exec(compile(self.output.read_text(encoding="utf-8"), str(self.output), "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
def update(self, name, clips, **options):
|
||||
return animation_catalog.update_catalog(name, clips, output=self.output, **options)
|
||||
|
||||
def test_new_model_produces_enum_with_durations_without_unreal(self):
|
||||
self.assertEqual(self.update("StudentHero", {"walking": 2.0}), "StudentHeroAnimations")
|
||||
clip = self.load()["StudentHeroAnimations"].WALKING
|
||||
self.assertEqual(clip.key, "walking")
|
||||
self.assertEqual(clip.seconds, 2)
|
||||
self.assertEqual(clip.frames(24), 48)
|
||||
self.assertNotIn("unreal", sys.modules)
|
||||
|
||||
def test_full_update_preserves_other_models_and_removes_deleted_clips(self):
|
||||
self.update("Fighter", {"walk": 1.0, "attack": 2.0})
|
||||
self.update("StudentHero", {"jump": 3.0})
|
||||
self.update("Fighter", {"walk": 1.5})
|
||||
namespace = self.load()
|
||||
self.assertEqual([clip.key for clip in namespace["FighterAnimations"]], ["walk"])
|
||||
self.assertEqual(namespace["StudentHeroAnimations"].JUMP.seconds, 3.0)
|
||||
|
||||
def test_partial_selection_updates_duration_without_erasing_other_clips(self):
|
||||
self.update("Hero", {"walk": 1.0, "attack": 2.0})
|
||||
self.update("Hero", {"walk": 1.5}, replace=False)
|
||||
catalog = self.load()["HeroAnimations"]
|
||||
self.assertEqual(catalog.WALK.seconds, 1.5)
|
||||
self.assertEqual(catalog.ATTACK.seconds, 2.0)
|
||||
|
||||
def test_unchanged_metadata_does_not_rewrite_file(self):
|
||||
self.update("Hero", {"walk": 1.0})
|
||||
with patch.object(animation_catalog.tempfile, "NamedTemporaryFile", side_effect=AssertionError("rewritten")):
|
||||
self.update("Hero", {"walk": 1.0})
|
||||
|
||||
def test_empty_model_is_valid(self):
|
||||
self.update("Hero", {})
|
||||
self.assertEqual(list(self.load()["HeroAnimations"]), [])
|
||||
|
||||
def test_invalid_metadata_does_not_damage_existing_catalog(self):
|
||||
self.update("Hero", {"walk": 1.0})
|
||||
before = self.output.read_bytes()
|
||||
for data in ({"walk": 0}, {"walk": float("nan")}, {"walk": 1.0, "WALK": 2.0}):
|
||||
with self.subTest(data=data), self.assertRaises(ValueError):
|
||||
self.update("Hero", data)
|
||||
self.assertEqual(self.output.read_bytes(), before)
|
||||
|
||||
def test_publish_adds_class_to_loaded_module_without_replacing_other_enums(self):
|
||||
module = ModuleType("ue_workshop.animations")
|
||||
module.AnimationClip = AnimationClip
|
||||
module.ExistingAnimations = object()
|
||||
original = module.ExistingAnimations
|
||||
source = animation_catalog._class_source("StudentHeroAnimations", {"walk": 2.0})
|
||||
with patch.dict(sys.modules, {"ue_workshop.animations": module}):
|
||||
animation_catalog._publish_class(source, self.output)
|
||||
self.assertEqual(module.StudentHeroAnimations.WALK.frames(24), 48)
|
||||
self.assertIs(module.ExistingAnimations, original)
|
||||
@@ -19,6 +19,9 @@ class Clip:
|
||||
def get_name(self):
|
||||
return "EmbeddedTake"
|
||||
|
||||
def get_play_length(self):
|
||||
return 1.5
|
||||
|
||||
|
||||
class DiscoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -40,6 +43,7 @@ class DiscoveryTests(unittest.TestCase):
|
||||
self.cache = patch.object(self.module, "_cached", return_value=[Mesh()]).start()
|
||||
self.importer = patch.object(self.module, "_import", return_value=[Mesh()]).start()
|
||||
self.animation = patch.object(self.module, "import_animation", side_effect=lambda *a, **kw: Clip()).start()
|
||||
self.catalog = patch.object(self.module, "update_catalog").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_discovery_excludes_model_and_derives_names_without_aliases(self):
|
||||
@@ -55,6 +59,8 @@ class DiscoveryTests(unittest.TestCase):
|
||||
self.assertEqual(self.animation.call_count, 2)
|
||||
for call in self.animation.call_args_list:
|
||||
self.assertFalse(call.kwargs["reimport"])
|
||||
self.catalog.assert_called_once_with("Hero", {
|
||||
"great_sword_attack_2": 1.5, "great_sword_walk": 1.5}, replace=True)
|
||||
|
||||
def test_empty_dictionary_disables_discovery_and_keeps_embedded_name(self):
|
||||
self.cache.return_value = [Mesh(), Clip()]
|
||||
@@ -67,6 +73,7 @@ class DiscoveryTests(unittest.TestCase):
|
||||
self.assertEqual(set(result.animations), {"selected"})
|
||||
self.animation.assert_called_once()
|
||||
self.assertEqual(self.animation.call_args.args[0], self.walk)
|
||||
self.catalog.assert_called_once_with("Hero", {"selected": 1.5}, replace=False)
|
||||
|
||||
def test_reimport_true_reimports_model_and_discovered_clips(self):
|
||||
self.module.import_model(self.model, name="Hero", reimport=True)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Validate light keys without an Unreal installation."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class LightKeyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.unreal = MagicMock()
|
||||
self.unreal.FrameNumber.side_effect = lambda frame: frame
|
||||
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/sequence.py"
|
||||
spec = importlib.util.spec_from_file_location("ue_workshop._light_key_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"unreal": self.unreal}):
|
||||
spec.loader.exec_module(module)
|
||||
self.shot = module.Sequence.__new__(module.Sequence)
|
||||
self.shot.frames = 48
|
||||
self.shot._light_tracks = {}
|
||||
self.shot.bind = MagicMock()
|
||||
self.track = self.shot.bind.return_value.add_track.return_value
|
||||
self.channels = [MagicMock() for _ in range(4)]
|
||||
self.track.add_section.return_value.get_all_channels.return_value = self.channels
|
||||
self.actor = MagicMock()
|
||||
self.light = self.actor.get_component_by_class.return_value
|
||||
self.light.get_path_name.return_value = "Light"
|
||||
|
||||
def test_color_keys_rgb_and_leaves_alpha_at_default(self):
|
||||
self.shot.light_key(self.actor, 10, color=(1, 0.5, 0))
|
||||
self.shot.bind.assert_called_once_with(self.light)
|
||||
self.track.set_property_name_and_path.assert_called_once_with("LightColor", "LightColor")
|
||||
self.assertEqual(self.channels[1].add_key.call_args.args, (10, 0.5))
|
||||
self.channels[3].set_default.assert_called_once_with(1.0)
|
||||
self.channels[3].add_key.assert_not_called()
|
||||
|
||||
def test_later_keys_reuse_the_track(self):
|
||||
self.shot.light_key(self.actor, 0, intensity=0)
|
||||
self.shot.light_key(self.actor, 47, intensity=5000)
|
||||
self.track.add_section.assert_called_once()
|
||||
self.track.set_property_name_and_path.assert_called_once_with("Intensity", "Intensity")
|
||||
self.assertEqual(self.channels[0].add_key.call_count, 2)
|
||||
|
||||
def test_color_and_intensity_use_separate_tracks(self):
|
||||
self.shot.light_key(self.actor, 0, color=(1, 1, 1), intensity=100)
|
||||
self.assertEqual(self.track.add_section.call_count, 2)
|
||||
|
||||
def test_invalid_arguments_create_no_track(self):
|
||||
for args in [dict(frame=48, color=(1, 1, 1)), dict(frame=0, color=(1, 1)),
|
||||
dict(frame=0, intensity=1, interpolation="smooth")]:
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.light_key(self.actor, **args)
|
||||
self.shot.bind.assert_not_called()
|
||||
|
||||
def test_actor_without_light_is_rejected(self):
|
||||
self.actor.get_component_by_class.return_value = None
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.light_key(self.actor, 0, intensity=1)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Horizontal root travel, interpolation and accumulated playback loops."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class Vector:
|
||||
def __init__(self, x, y, z):
|
||||
self.x, self.y, self.z = x, y, z
|
||||
|
||||
|
||||
class RootMotionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
unreal = MagicMock()
|
||||
unreal.Vector = Vector
|
||||
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/root_motion.py"
|
||||
spec = importlib.util.spec_from_file_location("ue_workshop._root_motion_test", path)
|
||||
self.module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"unreal": unreal}):
|
||||
spec.loader.exec_module(self.module)
|
||||
self.poses = [SimpleNamespace(translation=Vector(0, 0, 100)),
|
||||
SimpleNamespace(translation=Vector(0, 50, 110)),
|
||||
SimpleNamespace(translation=Vector(0, 100, 100))]
|
||||
|
||||
def test_horizontal_interpolation_keeps_vertical_motion_in_clip(self):
|
||||
value = self.module.displacement(self.poses, 2.0, 0.5)
|
||||
self.assertEqual((value.x, value.y, value.z), (0, 25, 0))
|
||||
|
||||
def test_end_and_repeated_playback_accumulate_instead_of_reset(self):
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, 2).y, 100)
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, 5).y, 250)
|
||||
|
||||
def test_before_start_has_no_displacement(self):
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, -1).y, 0)
|
||||
|
||||
def test_reverse_accumulates_backwards_without_initial_jump(self):
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, 0, reverse=True).y, 0)
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, 1, reverse=True).y, -50)
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, 5, reverse=True).y, -250)
|
||||
|
||||
def test_reverse_samples_the_end_of_a_nonuniform_trajectory(self):
|
||||
self.poses[1].translation.y = 20
|
||||
self.assertEqual(self.module.displacement(self.poses, 2.0, 0.5, reverse=True).y, -40)
|
||||
@@ -7,6 +7,11 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
|
||||
from ue_workshop.animation_clip import AnimationClip
|
||||
|
||||
|
||||
class KnownClip(AnimationClip):
|
||||
WALK = ("walk", 2.0)
|
||||
|
||||
|
||||
class Clip:
|
||||
@@ -17,6 +22,12 @@ class Clip:
|
||||
assert name == "skeleton"
|
||||
return self.skeleton
|
||||
|
||||
def get_path_name(self):
|
||||
return "OriginalClip"
|
||||
|
||||
def get_play_length(self):
|
||||
return 2.0
|
||||
|
||||
|
||||
class AnimationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -35,6 +46,10 @@ class AnimationTests(unittest.TestCase):
|
||||
self.shot.asset = MagicMock()
|
||||
self.shot.asset.get_tick_resolution.return_value = SimpleNamespace(numerator=24000, denominator=1)
|
||||
self.shot._animations = {}
|
||||
self.shot._transforms = {}
|
||||
self.shot._root_motion = {}
|
||||
self.shot._root_motion_copies = {}
|
||||
self.shot._pose_holds = {}
|
||||
self.shot.bind = MagicMock()
|
||||
self.actor = MagicMock()
|
||||
self.actor.get_path_name.return_value = "Parent"
|
||||
@@ -44,7 +59,7 @@ class AnimationTests(unittest.TestCase):
|
||||
self.clip = Clip(self.skeleton)
|
||||
self.imported = SimpleNamespace(mesh=MagicMock(), animations={"walk": self.clip})
|
||||
self.imported.mesh.get_editor_property.return_value = self.skeleton
|
||||
self.shot.scene = SimpleNamespace(_skeletal_models={"Parent": (self.child, self.imported)})
|
||||
self.shot.scene = SimpleNamespace(name="TestScene", _skeletal_models={"Parent": (self.child, self.imported)})
|
||||
|
||||
def test_adjacent_clips_share_child_track_and_set_speed(self):
|
||||
self.shot.animation(self.actor, "walk", start=0, end=48)
|
||||
@@ -55,6 +70,24 @@ class AnimationTests(unittest.TestCase):
|
||||
track.add_section.return_value.set_range.assert_called_with(48, 120)
|
||||
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.8)
|
||||
|
||||
def test_enum_plays_once_when_end_is_omitted(self):
|
||||
section = self.shot.animation(self.actor, KnownClip.WALK, start=10)
|
||||
section.set_range.assert_called_with(10, 58)
|
||||
section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", self.clip)
|
||||
|
||||
def test_enum_automatic_end_respects_speed(self):
|
||||
section = self.shot.animation(self.actor, KnownClip.WALK, start=10, speed=2)
|
||||
section.set_range.assert_called_with(10, 34)
|
||||
|
||||
def test_explicit_end_overrides_enum_duration(self):
|
||||
section = self.shot.animation(self.actor, KnownClip.WALK, start=0, end=120)
|
||||
section.set_range.assert_called_with(0, 120)
|
||||
|
||||
def test_enum_cannot_exceed_sequence_length(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, KnownClip.WALK, start=100)
|
||||
self.shot.bind.assert_not_called()
|
||||
|
||||
def test_nested_clip_is_rejected_without_creating_section(self):
|
||||
self.shot.animation(self.actor, "walk", start=0, end=48)
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -64,11 +97,62 @@ class AnimationTests(unittest.TestCase):
|
||||
def test_invalid_ranges_and_speeds_do_not_create_track(self):
|
||||
for options in ({"start": -1}, {"end": 121}, {"start": 48, "end": 48},
|
||||
{"start": True}, {"speed": 0}, {"speed": float("nan")},
|
||||
{"speed": float("inf")}):
|
||||
{"speed": float("inf")}, {"keep_end_position": 1}, {"reverse": 1},
|
||||
{"keep_end_pose": 1}):
|
||||
with self.subTest(options=options), self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, "walk", **options)
|
||||
self.shot.bind.assert_not_called()
|
||||
|
||||
def test_reverse_is_set_on_native_animation_parameters(self):
|
||||
section = self.shot.animation(self.actor, "walk", start=0, end=60, reverse=True)
|
||||
section.get_editor_property.return_value.set_editor_property.assert_any_call("reverse", True)
|
||||
|
||||
def test_keep_end_pose_uses_native_keep_state(self):
|
||||
main, hold = MagicMock(), MagicMock()
|
||||
self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold]
|
||||
section = self.shot.animation(self.actor, KnownClip.WALK, start=10, keep_end_pose=True)
|
||||
section.set_completion_mode.assert_called_once_with(self.unreal.MovieSceneCompletionMode.KEEP_STATE)
|
||||
section.set_range.assert_called_with(10, 58)
|
||||
hold.set_range.assert_called_with(58, 120)
|
||||
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.0)
|
||||
|
||||
def test_later_clip_truncates_frozen_pose(self):
|
||||
main, hold, next_section = MagicMock(), MagicMock(), MagicMock()
|
||||
self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold, next_section]
|
||||
self.shot.animation(self.actor, KnownClip.WALK, start=0, keep_end_pose=True)
|
||||
self.shot.animation(self.actor, KnownClip.WALK, start=60)
|
||||
hold.set_range.assert_called_with(48, 60)
|
||||
|
||||
def test_relative_move_replaces_baked_keys_and_offsets_future_motion(self):
|
||||
axes = [MagicMock() for _ in range(3)]
|
||||
future_keys = []
|
||||
for axis in axes:
|
||||
interior, future = MagicMock(), MagicMock()
|
||||
interior.get_time.return_value.frame_number.value = 90
|
||||
future.get_time.return_value.frame_number.value = 150
|
||||
future.get_value.return_value = 100
|
||||
axis.get_keys.return_value = [interior, future]
|
||||
future_keys.append(future)
|
||||
self.shot._transforms["Parent"] = axes
|
||||
self.shot.position = MagicMock(side_effect=[(0, 300, 0), (0, 350, 0)])
|
||||
self.shot.transform_key = MagicMock()
|
||||
self.assertEqual(self.shot.move(self.actor, 80, 110, offset=(0, -200, 0)), (0, 100, 0))
|
||||
for axis in axes:
|
||||
axis.remove_key.assert_called_once_with(axis.get_keys.return_value[0])
|
||||
future_keys[1].set_value.assert_called_once_with(-150)
|
||||
|
||||
def test_keep_end_position_uses_copy_and_adds_transform_motion(self):
|
||||
copy = Clip(self.skeleton)
|
||||
prepare = MagicMock(return_value=(copy, ["poses"], 2.0))
|
||||
self.shot._keep_end_position = MagicMock()
|
||||
with patch.dict(sys.modules, {"ue_workshop.root_motion": SimpleNamespace(prepare=prepare)}):
|
||||
section = self.shot.animation(self.actor, "walk", start=0, end=48, keep_end_position=True)
|
||||
self.shot.animation(self.actor, "walk", start=60, end=108, keep_end_position=True)
|
||||
prepare.assert_called_once()
|
||||
self.assertIs(self.imported.animations["walk"], self.clip)
|
||||
section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", copy)
|
||||
self.assertEqual(self.shot._keep_end_position.call_count, 2)
|
||||
|
||||
def test_crossfade_sets_complementary_weight_curves(self):
|
||||
track = self.shot.bind.return_value.add_track.return_value
|
||||
first, second = MagicMock(), MagicMock()
|
||||
@@ -143,3 +227,33 @@ class AnimationTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, "walk")
|
||||
self.shot.bind.assert_not_called()
|
||||
|
||||
def test_position_uses_existing_sequencer_curves(self):
|
||||
axes = [MagicMock(), MagicMock(), MagicMock()]
|
||||
for channel, value in zip(axes, (10, 250, 3)):
|
||||
channel.evaluate_keys.return_value = [value]
|
||||
self.shot._transforms["Parent"] = axes
|
||||
self.assertEqual(self.shot.position(self.actor, 80), (10, 250, 3))
|
||||
self.shot.asset.make_range.assert_called_once_with(80, 1)
|
||||
|
||||
def test_position_without_track_uses_spawn_position(self):
|
||||
self.actor.get_actor_location.return_value.to_tuple.return_value = (30, -850, 0)
|
||||
self.assertEqual(self.shot.position(self.actor, 80), (30, -850, 0))
|
||||
|
||||
def test_relative_move_anchors_existing_position_and_adds_offset(self):
|
||||
self.shot.position = MagicMock(return_value=(10, 250, 3))
|
||||
self.shot.transform_key = MagicMock()
|
||||
target = self.shot.move(self.actor, start=80, end=110, offset=(0, 50, 0))
|
||||
self.assertEqual(target, (10, 300, 3))
|
||||
self.shot.position.assert_called_once_with(self.actor, 80)
|
||||
self.shot.transform_key.assert_any_call(self.actor, 80, location=(10, 250, 3), interpolation="auto")
|
||||
self.shot.transform_key.assert_any_call(self.actor, 110, location=(10, 300, 3), interpolation="auto")
|
||||
|
||||
def test_invalid_relative_move_does_not_write_keys(self):
|
||||
self.shot.transform_key = MagicMock()
|
||||
for options in ({"start": 80, "end": 80}, {"start": 80, "end": 120},
|
||||
{"start": 80, "end": 110, "offset": (0, float("nan"), 0)}):
|
||||
options.setdefault("offset", (0, 50, 0))
|
||||
with self.subTest(options=options), self.assertRaises(ValueError):
|
||||
self.shot.move(self.actor, **options)
|
||||
self.shot.transform_key.assert_not_called()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Validate offline catalog timings against UE and play enum clips with automatic ends."""
|
||||
import math
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
|
||||
from ue_workshop.animations import FighterAnimations, CreatureAnimations
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
from ue_workshop.skeletal import import_model
|
||||
|
||||
for model, name, catalog in ((FIGHTER_MODEL, "Fighter", FighterAnimations),
|
||||
(CREATURE_MODEL, "Creature", CreatureAnimations)):
|
||||
imported = import_model(model, name=name, reimport=False)
|
||||
for clip in catalog:
|
||||
assert math.isclose(clip.seconds, imported.animations[clip.key].get_play_length(),
|
||||
rel_tol=0, abs_tol=0.000001), clip.key
|
||||
|
||||
scene = Scene("CatalogSmoke")
|
||||
fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", rotation=(0, 0, 180))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=45)
|
||||
shot = Sequence(scene, fps=24, frames=120)
|
||||
shot.camera(cam)
|
||||
draw = FighterAnimations.DRAW_A_GREAT_SWORD_1
|
||||
attack = FighterAnimations.GREAT_SWORD_ATTACK
|
||||
first = shot.animation(fighter, draw, start=0)
|
||||
second_start = draw.frames(shot.fps) - 6
|
||||
second = shot.animation(fighter, attack, start=second_start)
|
||||
fast = shot.animation(fighter, attack, start=60, speed=2)
|
||||
assert first.get_end_frame() == draw.frames(shot.fps)
|
||||
assert second.get_end_frame() == second_start + attack.frames(shot.fps)
|
||||
assert fast.get_end_frame() == 60 + attack.frames(shot.fps, speed=2)
|
||||
assert second.get_editor_property("params").animation.get_name() == attack.key
|
||||
shot.save(open_editor=False)
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Import a model under a student name and verify its enum is generated automatically."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from ue_workshop import Scene, Sequence
|
||||
from ue_workshop import animation_catalog, skeletal
|
||||
from ue_workshop.characters import FIGHTER_MODEL, _clips
|
||||
from ue_workshop.paths import PROJECT
|
||||
|
||||
scene = Scene("AutomaticCatalogSmoke")
|
||||
clips = _clips(FIGHTER_MODEL)
|
||||
output = PROJECT / "build/AutomaticCatalogSmoke/animations.py"
|
||||
update = animation_catalog.update_catalog
|
||||
with patch.object(skeletal, "update_catalog", side_effect=lambda name, data, **options:
|
||||
update(name, data, output=output, **options)):
|
||||
actor = scene.skeletal_model(
|
||||
FIGHTER_MODEL, name="StudentCatalogSmoke", reimport=False,
|
||||
animations={key: clips[key] for key in ("great_sword_idle", "great_sword_attack")})
|
||||
|
||||
namespace = {"__name__": "ue_workshop._generated_smoke", "__package__": "ue_workshop"}
|
||||
exec(compile(output.read_text(encoding="utf-8"), str(output), "exec"), namespace)
|
||||
catalog = namespace["StudentCatalogSmokeAnimations"]
|
||||
assert len(catalog) == 2
|
||||
attack = catalog.GREAT_SWORD_ATTACK
|
||||
assert attack.seconds > 0
|
||||
shot = Sequence(scene, frames=120)
|
||||
section = shot.animation(actor, attack, start=0)
|
||||
assert section.get_end_frame() == attack.frames(shot.fps)
|
||||
shot.save(open_editor=False)
|
||||
|
||||
# Check the normal hook updates the real existing catalog and preserves models.
|
||||
skeletal.import_model(FIGHTER_MODEL, name="Fighter", reimport=False)
|
||||
skeletal.import_model(PROJECT / "source_assets/character/creature/Maw J Laygo.fbx", name="Creature", reimport=False)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Check native pose retention and render a frame after the dying clip."""
|
||||
|
||||
import unreal
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
|
||||
from ue_workshop.animations import CreatureAnimations as C
|
||||
from ue_workshop.characters import CREATURE_MODEL
|
||||
|
||||
scene = Scene("KeepPoseSmoke")
|
||||
actor = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300)
|
||||
child, imported = scene._skeletal_models[actor.get_path_name()]
|
||||
component = child.skeletal_mesh_component
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(500, -650, 400), target=(0, 0, 80), fov=60)
|
||||
shot = Sequence(scene, fps=30, frames=300)
|
||||
shot.camera(cam)
|
||||
section = shot.animation(actor, C.MUTANT_DYING, start=0, keep_end_pose=True)
|
||||
assert section.get_completion_mode() == unreal.MovieSceneCompletionMode.KEEP_STATE
|
||||
shot.save(open_editor=False)
|
||||
clip = imported.animations[C.MUTANT_DYING.key]
|
||||
changed = False
|
||||
for bone in unreal.AnimationLibrary.get_animation_track_names(clip):
|
||||
first = unreal.AnimationLibrary.get_bone_pose_for_time(clip, bone, 0, False)
|
||||
last = unreal.AnimationLibrary.get_bone_pose_for_time(clip, bone, clip.get_play_length(), False)
|
||||
if not first.rotation.equals(last.rotation, 0.001):
|
||||
changed = True
|
||||
break
|
||||
assert changed, "Dying source has no pose change"
|
||||
assert section.get_end_frame() < 299
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Verify moving slash root travel becomes persistent parent Transform keys."""
|
||||
import json
|
||||
import math
|
||||
|
||||
import unreal
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
|
||||
from ue_workshop.animations import FighterAnimations as F
|
||||
from ue_workshop.characters import FIGHTER_MODEL
|
||||
from ue_workshop.paths import PROJECT
|
||||
|
||||
scene = Scene("KeepEndSmoke")
|
||||
actor = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200)
|
||||
child, imported = scene._skeletal_models[actor.get_path_name()]
|
||||
original = imported.animations[F.GREAT_SWORD_SLASH_2.key]
|
||||
bone = child.skeletal_mesh_component.get_bone_name(0)
|
||||
before = unreal.AnimationLibrary.get_bone_pose_for_time(original, bone, original.get_play_length(), False)
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(650, -650, 450), target=(0, 150, 100), fov=55)
|
||||
shot = Sequence(scene, fps=30, frames=300)
|
||||
shot.camera(cam)
|
||||
section = shot.animation(actor, F.GREAT_SWORD_SLASH_2, start=57, keep_end_position=True)
|
||||
shot.animation(actor, F.GREAT_SWORD_SLASH_3, start=161)
|
||||
copy = section.get_editor_property("params").animation
|
||||
assert copy != original
|
||||
end = unreal.AnimationLibrary.get_bone_pose_for_time(copy, bone, copy.get_play_length(), False)
|
||||
start = unreal.AnimationLibrary.get_bone_pose_for_time(copy, bone, 0, False)
|
||||
assert abs(end.translation.y - start.translation.y) < 0.001
|
||||
after = unreal.AnimationLibrary.get_bone_pose_for_time(original, bone, original.get_play_length(), False)
|
||||
assert abs(after.translation.y - before.translation.y) < 0.001
|
||||
channels = shot._transforms[actor.get_path_name()]
|
||||
last = [channel.get_keys()[-1].get_value() for channel in channels[:3]]
|
||||
assert math.hypot(last[0], last[1]) > 100, last
|
||||
assert channels[0].get_keys()[-1].get_time().frame_number.value == section.get_end_frame()
|
||||
shot.save(open_editor=False)
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(shot.asset)
|
||||
positions = {}
|
||||
hips_positions = {}
|
||||
for frame in (57, 160, 161, 162, 163, 164, 190):
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.set_current_time(frame)
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.refresh_current_level_sequence()
|
||||
positions[frame] = actor.get_actor_location().to_tuple()
|
||||
hips_positions[frame] = child.skeletal_mesh_component.get_socket_location(bone).to_tuple()
|
||||
assert math.dist(positions[163], positions[190]) < 0.01, positions
|
||||
assert math.dist(positions[162], positions[163]) < 20, positions
|
||||
assert math.dist(hips_positions[162], hips_positions[163]) < 30, hips_positions
|
||||
assert abs((hips_positions[160][1] - hips_positions[57][1]) -
|
||||
(positions[160][1] - positions[57][1])) < 1, hips_positions
|
||||
(PROJECT / "Saved/Workshop/keep_end_position.json").write_text(json.dumps(positions, indent=2), encoding="utf-8")
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Check every light type and render keyed color and intensity (frames 0 and 47)."""
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
|
||||
scene = Scene("LightsSmoke")
|
||||
floor = materials.color("LightsFloor", rgb=(0.5, 0.5, 0.5))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
scene.primitive("Sphere", name="Ball", location=(0, 0, 50), material=floor)
|
||||
sun = lighting.sun(scene, color=(1, 0.9, 0.8))
|
||||
point = lighting.point(scene, location=(-300, 200, 200), color=(0, 1, 0))
|
||||
spot = lighting.spot(scene, location=(0, -300, 400), target=(0, 0, 0))
|
||||
cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 50))
|
||||
shot = Sequence(scene, fps=24, frames=48)
|
||||
shot.camera(cam)
|
||||
shot.light_key(spot, 0, color=(1, 0, 0), intensity=5000)
|
||||
shot.light_key(spot, 47, color=(0, 0, 1), intensity=40000, interpolation="auto")
|
||||
shot.light_key(point, 0, intensity=0)
|
||||
shot.light_key(point, 47, intensity=20000)
|
||||
shot.light_key(sun, 0, intensity=0)
|
||||
shot.light_key(sun, 24, intensity=500)
|
||||
shot.light_key(sun, 25, intensity=0, interpolation="constant")
|
||||
shot.save(open_editor=False)
|
||||
assert len(shot.asset.get_bindings()) >= 2
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=8, mp4=False)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Verify position queries and relative movement after kept Mixamo root travel."""
|
||||
import math
|
||||
|
||||
from ue_workshop import Scene, Sequence
|
||||
from ue_workshop.animations import CreatureAnimations as C
|
||||
from ue_workshop.characters import CREATURE_MODEL
|
||||
|
||||
scene = Scene("RelativeMoveSmoke")
|
||||
creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0, -850, 0))
|
||||
shot = Sequence(scene, fps=30, frames=180)
|
||||
shot.animation(creature, C.MUTANT_RUN, start=0, end=60, keep_end_position=True)
|
||||
shot.animation(creature, C.MUTANT_SWIPING, start=20)
|
||||
shot.animation(creature, C.MUTANT_WALKING, start=80, end=120)
|
||||
before = shot.position(creature, 80)
|
||||
assert math.dist(before, (0, -850, 0)) > 100, before
|
||||
target = shot.move(creature, start=80, end=120, offset=(0, 50, 0), interpolation="auto")
|
||||
assert math.dist(shot.position(creature, 80), before) < 0.001
|
||||
assert math.dist(shot.position(creature, 120), target) < 0.001
|
||||
assert abs(target[1] - before[1] - 50) < 0.001
|
||||
assert math.dist(shot.position(creature, 179), target) < 0.001
|
||||
middle = shot.position(creature, 100)
|
||||
assert before[1] < middle[1] < target[1]
|
||||
shot.save(open_editor=False)
|
||||
print("Relative movement from", before, "to", target)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Reverse a walking clip and replace baked travel with an explicit retreat."""
|
||||
import math
|
||||
|
||||
from ue_workshop import Scene, Sequence
|
||||
from ue_workshop.animations import CreatureAnimations as C
|
||||
from ue_workshop.characters import CREATURE_MODEL
|
||||
|
||||
scene = Scene("ReverseWalkingSmoke")
|
||||
creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0, -850, 0))
|
||||
shot = Sequence(scene, fps=30, frames=180)
|
||||
shot.animation(creature, C.MUTANT_RUN, start=0, end=60, keep_end_position=True)
|
||||
shot.animation(creature, C.MUTANT_SWIPING, start=20)
|
||||
walk = shot.animation(creature, C.MUTANT_WALKING, start=80, end=120,
|
||||
keep_end_position=True, reverse=True)
|
||||
assert walk.get_editor_property("params").get_editor_property("reverse")
|
||||
before = shot.position(creature, 80)
|
||||
natural_end = shot.position(creature, 120)
|
||||
assert natural_end[1] < before[1] - 10, (before, natural_end)
|
||||
# A later authored key should retain its relative movement after replacement.
|
||||
shot.transform_key(creature, 150, location=(natural_end[0], natural_end[1] + 30, natural_end[2]))
|
||||
target = shot.move(creature, start=80, end=120, offset=(0, -200, 0))
|
||||
assert math.dist(shot.position(creature, 80), before) < 0.001
|
||||
assert math.dist(shot.position(creature, 120), target) < 0.001
|
||||
assert abs(target[1] - before[1] + 200) < 0.001
|
||||
middle = shot.position(creature, 100)
|
||||
assert target[1] < middle[1] < before[1], middle
|
||||
assert abs(shot.position(creature, 150)[1] - target[1] - 30) < 0.001
|
||||
channels = shot._transforms[creature.get_path_name()]
|
||||
assert all(not 80 < key.get_time().frame_number.value < 120 for key in channels[1].get_keys())
|
||||
shot.save(open_editor=False)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Optional refresh of bundled enums; other models are preserved. Imports update them automatically."""
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
from ue_workshop.skeletal import import_model
|
||||
|
||||
counts = {}
|
||||
for model, name in (
|
||||
(FIGHTER_MODEL, "Fighter"),
|
||||
(CREATURE_MODEL, "Creature"),
|
||||
):
|
||||
imported = import_model(model, name=name, reimport=False)
|
||||
counts[name + "Animations"] = len(imported.animations)
|
||||
print("Exported animation catalog:", counts)
|
||||
Reference in new issue
Block a user