diff --git a/assets/agents/agent_rig.blend b/assets/agents/agent_rig.blend new file mode 100644 index 0000000..75ec184 Binary files /dev/null and b/assets/agents/agent_rig.blend differ diff --git a/assets/agents/catalog.yaml b/assets/agents/catalog.yaml new file mode 100644 index 0000000..ef45415 --- /dev/null +++ b/assets/agents/catalog.yaml @@ -0,0 +1,43 @@ +version: 1 +frame: {width: 70, height: 90, pivot_x: 35, pivot_y: 87} +directions: [south, southwest, west, northwest, north, northeast, east, southeast] +animations: + idle: {frames: 4, fps: 8} + walk: {frames: 8, fps: 12} + attack: {frames: 6, fps: 12} + +# Одна камера на все компоненты и оба конвейера (Blender и растровый). +# elevation_deg = atan(TILE_H / TILE_W) = atan(0.5): ровно тот же 2:1, по +# которому в игре лежит пол (src/tuning.h). Разъедься эти два числа — фигура +# встала бы к полу под другим углом, чем ромб под ней. +camera: + elevation_deg: 26.565 + # Alien-Shooter-like gameplay scale: an upright 1.8 m operator occupies + # roughly 28-30 internal pixels, not half the screen. + pixels_per_unit: 28.0 + distance: 10.0 # только для клиппинга: проекция ортографическая + body_height: 1.80 # метры, худой взрослый; не бронированный громила + source_model: "assets/vendor/quaternius-universal-animation-library/files/Animation Library[Standard]/Godot/AnimationLibrary_Godot_Standard.glb" + source_rotation_deg: 0.0 + source_scale: 0.96 + source_ground_z: 0.0 + # Low-resolution silhouette compensation applied after skin deformation. + # It preserves the authored skeleton/actions but prevents arms and legs from + # collapsing to one-pixel wires in a 70x90 sprite cell. + source_silhouette_thickness: 0.022 + +# Every component is rendered against the same skeleton/camera. Missing source +# files are allowed while authoring; `agent_assets.py validate --complete` +# turns them into errors for a release build. +components: + body_sleek: {slot: body, style: sleek infiltration body} + armor_jacket: {slot: armor, style: fitted field jacket and thin tactical harness} + armor_vest: {slot: armor, style: light low-profile ballistic vest} + armor_plate: {slot: armor, style: segmented medium ceramic chest plates} + armor_shroud: {slot: armor, style: close-fitting stealth shroud, no cape} + weapon_sword: {slot: weapon, style: narrow tactical longsword} + weapon_dagger: {slot: weapon, style: compact combat dagger} + weapon_rifle: {slot: weapon, style: compact suppressed assault rifle} + weapon_shotgun: {slot: weapon, style: short industrial combat shotgun} + weapon_pistol: {slot: weapon, style: suppressed service pistol} + weapon_bow: {slot: weapon, style: compact near-future compound bow} diff --git a/assets/art_masters/operative_turnaround_chroma_v1.png b/assets/art_masters/operative_turnaround_chroma_v1.png new file mode 100644 index 0000000..621bc66 Binary files /dev/null and b/assets/art_masters/operative_turnaround_chroma_v1.png differ diff --git a/assets/art_masters/operative_turnaround_v1.png b/assets/art_masters/operative_turnaround_v1.png new file mode 100644 index 0000000..a3f357f Binary files /dev/null and b/assets/art_masters/operative_turnaround_v1.png differ diff --git a/assets/art_masters/stalker_turnaround_chroma_v1.png b/assets/art_masters/stalker_turnaround_chroma_v1.png new file mode 100644 index 0000000..f7af9c0 Binary files /dev/null and b/assets/art_masters/stalker_turnaround_chroma_v1.png differ diff --git a/assets/art_masters/stalker_turnaround_v1.png b/assets/art_masters/stalker_turnaround_v1.png new file mode 100644 index 0000000..2d6f9c4 Binary files /dev/null and b/assets/art_masters/stalker_turnaround_v1.png differ diff --git a/assets/content_coverage.yaml b/assets/content_coverage.yaml new file mode 100644 index 0000000..a0c8988 --- /dev/null +++ b/assets/content_coverage.yaml @@ -0,0 +1,60 @@ +version: 1 +art_direction: + world: "derelict near-future facility, industrial sci-fi horror, invasive wet biomass" + agents: "lean covert operators, grounded tactical sci-fi; human, not bulky power armor" + enemies: "readable body-horror anatomy, surgically mutated flesh and industrial implants" + +agents: + components: + body: [body_sleek] + armor: [armor_jacket, armor_vest, armor_plate, armor_shroud] + weapon: [weapon_sword, weapon_dagger, weapon_rifle, weapon_shotgun, weapon_pistol, weapon_bow] + animations: [idle, walk, attack] + release_requires_all_combinations: true + +enemies: + kinds: [shambler, rusher, spitter, brute, lurker] + animations: [walk, attack, hit, death] + +environment: + required: + - floor_clean + - floor_cracked + - floor_grate + - floor_biomass + - wall_industrial + - wall_infested + - prop_crate + - prop_barrel + - prop_locker + +item_icons: + required: + - item_icon_rifle + - item_icon_pistol + - item_icon_shotgun + - item_icon_smg + - item_icon_magnum + - item_icon_bow + - item_icon_saber + - item_icon_knife + - item_icon_cleaver + - item_icon_jacket + - item_icon_vest + - item_icon_plate + - item_icon_shroud + - item_icon_lantern + - item_icon_charm + - item_icon_spurs + - item_icon_bandage + - item_icon_stimpack + +procedural_runtime_exemptions: + # These are temporal primitives, not reusable bitmap assets. + - bullet_tracer + - muzzle_flash + - impact_spark + - melee_arc + - fog_of_war + - blood_field + - ui_chrome diff --git a/assets/manifests/agents.blender.yaml b/assets/manifests/agents.blender.yaml new file mode 100644 index 0000000..a0ff2c3 --- /dev/null +++ b/assets/manifests/agents.blender.yaml @@ -0,0 +1,56 @@ +# Production agent sources rendered directly by SpriteForge. Project-specific +# object names live here; the standalone tool only understands logical layers. +version: 1 +defaults: + backend: blender + dirs: 8 + fps: 12 + size: 70x90 + postprocess_scale: 0.25 + pivot: "140,348" + tags: [agent, modular, blender] + +agent_body_sleek: + rig: ../agents/agent_rig.blend + parts: {body: body_sleek_mesh} + anims: [idle, walk, attack] + params: &agent_render + executable: blender + render_size: 280x360 + ortho_scale: 3.2142857 + elevation: 26.565 + camera_distance: 10 + # With the 26.565 degree camera this projects world origin (the point + # between the feet) to the declared supersampled pivot at y=348. + camera_target: [0, 0, 1.70] + direction_offset_deg: -90 + engine: BLENDER_EEVEE + samples: 16 + actions: {idle: idle, walk: walk, attack: attack} + frames: {idle: 4, walk: 8, attack: 6} + loop_sampling: true + part_members: + body: [body_sleek_mesh, body_sleek_helmet, body_sleek_visor, + body_sleek_respirator, body_sleek_collar] + +agent_armor_vest: + tags: [agent, modular, blender, armor, paperdoll_layer] + rig: ../agents/agent_rig.blend + parts: {armor: armor_vest_shell} + anims: [idle, walk, attack] + params: + <<: *agent_render + part_members: + armor: [armor_vest_shell, armor_vest_chest_plate, + armor_vest_mag_l, armor_vest_mag_r] + +agent_weapon_rifle: + tags: [agent, modular, blender, weapon, paperdoll_layer] + rig: ../agents/agent_rig.blend + parts: {weapon: weapon_rifle_receiver} + anims: [idle, walk, attack] + params: + <<: *agent_render + part_members: + weapon: [weapon_rifle_receiver, weapon_rifle_barrel, + weapon_rifle_stock, weapon_rifle_optic] diff --git a/assets/manifests/agents.generated.yaml b/assets/manifests/agents.generated.yaml new file mode 100644 index 0000000..e37adda --- /dev/null +++ b/assets/manifests/agents.generated.yaml @@ -0,0 +1,239 @@ +# Собрано `python tools/agent_assets.py sync`. +# Записи с backend: import перегенерируются по PNG в assets/sources/agents/sleek/. +# Записи с любым другим backend (например blender) sync СОХРАНЯЕТ как есть: +# правь их руками здесь, это единственное место, где такая правка переживает sync. +version: 1 +defaults: + dirs: 8 + pivot: "35,87" + +agent_body_sleek_idle: + backend: import + source: ../sources/agents/sleek/body_sleek_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, body, idle] + +agent_body_sleek_walk: + backend: import + source: ../sources/agents/sleek/body_sleek_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, body, walk] + +agent_body_sleek_attack: + backend: import + source: ../sources/agents/sleek/body_sleek_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, body, attack] + +agent_armor_jacket_idle: + backend: import + source: ../sources/agents/sleek/armor_jacket_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, armor, idle, paperdoll_layer] + +agent_armor_jacket_walk: + backend: import + source: ../sources/agents/sleek/armor_jacket_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, armor, walk, paperdoll_layer] + +agent_armor_jacket_attack: + backend: import + source: ../sources/agents/sleek/armor_jacket_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, armor, attack, paperdoll_layer] + +agent_armor_vest_idle: + backend: import + source: ../sources/agents/sleek/armor_vest_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, armor, idle, paperdoll_layer] + +agent_armor_vest_walk: + backend: import + source: ../sources/agents/sleek/armor_vest_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, armor, walk, paperdoll_layer] + +agent_armor_vest_attack: + backend: import + source: ../sources/agents/sleek/armor_vest_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, armor, attack, paperdoll_layer] + +agent_armor_plate_idle: + backend: import + source: ../sources/agents/sleek/armor_plate_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, armor, idle, paperdoll_layer] + +agent_armor_plate_walk: + backend: import + source: ../sources/agents/sleek/armor_plate_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, armor, walk, paperdoll_layer] + +agent_armor_plate_attack: + backend: import + source: ../sources/agents/sleek/armor_plate_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, armor, attack, paperdoll_layer] + +agent_armor_shroud_idle: + backend: import + source: ../sources/agents/sleek/armor_shroud_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, armor, idle, paperdoll_layer] + +agent_armor_shroud_walk: + backend: import + source: ../sources/agents/sleek/armor_shroud_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, armor, walk, paperdoll_layer] + +agent_armor_shroud_attack: + backend: import + source: ../sources/agents/sleek/armor_shroud_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, armor, attack, paperdoll_layer] + +agent_weapon_sword_idle: + backend: import + source: ../sources/agents/sleek/weapon_sword_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, weapon, idle, paperdoll_layer] + +agent_weapon_sword_walk: + backend: import + source: ../sources/agents/sleek/weapon_sword_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, weapon, walk, paperdoll_layer] + +agent_weapon_sword_attack: + backend: import + source: ../sources/agents/sleek/weapon_sword_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, weapon, attack, paperdoll_layer] + +agent_weapon_dagger_idle: + backend: import + source: ../sources/agents/sleek/weapon_dagger_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, weapon, idle, paperdoll_layer] + +agent_weapon_dagger_walk: + backend: import + source: ../sources/agents/sleek/weapon_dagger_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, weapon, walk, paperdoll_layer] + +agent_weapon_dagger_attack: + backend: import + source: ../sources/agents/sleek/weapon_dagger_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, weapon, attack, paperdoll_layer] + +agent_weapon_rifle_idle: + backend: import + source: ../sources/agents/sleek/weapon_rifle_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, weapon, idle, paperdoll_layer] + +agent_weapon_rifle_walk: + backend: import + source: ../sources/agents/sleek/weapon_rifle_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, weapon, walk, paperdoll_layer] + +agent_weapon_rifle_attack: + backend: import + source: ../sources/agents/sleek/weapon_rifle_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, weapon, attack, paperdoll_layer] + +agent_weapon_shotgun_idle: + backend: import + source: ../sources/agents/sleek/weapon_shotgun_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, weapon, idle, paperdoll_layer] + +agent_weapon_shotgun_walk: + backend: import + source: ../sources/agents/sleek/weapon_shotgun_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, weapon, walk, paperdoll_layer] + +agent_weapon_shotgun_attack: + backend: import + source: ../sources/agents/sleek/weapon_shotgun_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, weapon, attack, paperdoll_layer] + +agent_weapon_pistol_idle: + backend: import + source: ../sources/agents/sleek/weapon_pistol_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, weapon, idle, paperdoll_layer] + +agent_weapon_pistol_walk: + backend: import + source: ../sources/agents/sleek/weapon_pistol_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, weapon, walk, paperdoll_layer] + +agent_weapon_pistol_attack: + backend: import + source: ../sources/agents/sleek/weapon_pistol_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, weapon, attack, paperdoll_layer] + +agent_weapon_bow_idle: + backend: import + source: ../sources/agents/sleek/weapon_bow_idle.png + fps: 8 + params: {frame_width: 70, frame_height: 90, frames: 32} + tags: [agent, weapon, idle, paperdoll_layer] + +agent_weapon_bow_walk: + backend: import + source: ../sources/agents/sleek/weapon_bow_walk.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 64} + tags: [agent, weapon, walk, paperdoll_layer] + +agent_weapon_bow_attack: + backend: import + source: ../sources/agents/sleek/weapon_bow_attack.png + fps: 12 + params: {frame_width: 70, frame_height: 90, frames: 48} + tags: [agent, weapon, attack, paperdoll_layer] diff --git a/assets/manifests/diagnostics/agent_blender_gate.yaml b/assets/manifests/diagnostics/agent_blender_gate.yaml new file mode 100644 index 0000000..3302e03 --- /dev/null +++ b/assets/manifests/diagnostics/agent_blender_gate.yaml @@ -0,0 +1,29 @@ +# One-frame visual/geometry gate for iterating on the production agent rig. +# It is intentionally outside the normal manifest set and builds in seconds. +version: 1 +agent_body_gate: + backend: blender + rig: ../../agents/agent_rig.blend + parts: {body: body_sleek_mesh} + anims: [idle] + dirs: 1 + fps: 8 + size: 70x90 + postprocess_scale: 0.25 + pivot: "140,348" + tags: [diagnostic, agent, blender] + params: + executable: blender + render_size: 280x360 + ortho_scale: 3.2142857 + elevation: 26.565 + camera_distance: 10 + camera_target: [0, 0, 1.70] + direction_offset_deg: -90 + engine: BLENDER_EEVEE + samples: 16 + actions: {idle: idle} + frames: {idle: 1} + part_members: + body: [body_sleek_mesh, body_sleek_helmet, body_sleek_visor, + body_sleek_respirator, body_sleek_collar] diff --git a/assets/manifests/diagnostics/monster_blender_gate.yaml b/assets/manifests/diagnostics/monster_blender_gate.yaml new file mode 100644 index 0000000..a1a5463 --- /dev/null +++ b/assets/manifests/diagnostics/monster_blender_gate.yaml @@ -0,0 +1,26 @@ +version: 1 +rusher_gate: + backend: blender + rig: ../../monsters/rusher.blend + parts: {body: unused_when_prefix_is_set} + anims: [walk] + dirs: 1 + fps: 14 + size: 96x112 + postprocess_scale: 0.5 + pivot: "96,176" + tags: [diagnostic, enemy, blender] + params: + executable: blender + render_size: 192x224 + ortho_scale: 3.5 + elevation: 26.565 + camera_distance: 12 + camera_target: [0, 0, 1.118] + direction_offset_deg: -90 + engine: BLENDER_EEVEE + samples: 12 + actions: {walk: rusher_walk} + frames: {walk: 1} + loop_sampling: {walk: true} + part_prefixes: {body: rusher_} diff --git a/assets/manifests/enemies.yaml b/assets/manifests/enemies.yaml new file mode 100644 index 0000000..060f288 --- /dev/null +++ b/assets/manifests/enemies.yaml @@ -0,0 +1,8 @@ +# Legacy placeholder manifest retained so old build commands keep working. +# Real eight-direction body-horror enemies are generated into +# monsters.generated.yaml by tools/monster_assets.py. +version: 1 +defaults: + dirs: 1 + fps: 10 + pivot: bottom_center diff --git a/assets/manifests/environment.yaml b/assets/manifests/environment.yaml new file mode 100644 index 0000000..19745a5 --- /dev/null +++ b/assets/manifests/environment.yaml @@ -0,0 +1,31 @@ +# Тайлы уровня. Геометрия обязана совпадать с src/tuning.h: +# ромб пола TILE_W x TILE_H = 32 x 16 +# блок стены TILE_W x WALL_H = 32 x 32 (крыша 16 + вертикальные грани 16) +version: 1 + +defaults: + dirs: 1 + fps: 12 + pivot: bottom_center + +# Шахматка пола — два варианта. Ромб 32x16, pivot по центру ромба. +floor_slab: + backend: import + source: ../sources/imports/floor_slab.png + pivot: "15,8" + tags: [environment, floor] + +floor_slab_dim: + backend: import + source: ../sources/imports/floor_slab_dim.png + pivot: "15,8" + tags: [environment, floor] + +# Блок стены: верхняя грань 32x16 + боковые грани 16px вниз = 32x32. +# pivot — нижний край блока (низ боковых граней), anchor в C++ перекладывает +# его на центр крыши (TILE_HALF_W, TILE_HALF_H + TILE_H). +wall_block: + backend: import + source: ../sources/imports/wall_block.png + pivot: "15,31" + tags: [environment, wall] diff --git a/assets/manifests/monsters.blender.yaml b/assets/manifests/monsters.blender.yaml new file mode 100644 index 0000000..94eb3e7 --- /dev/null +++ b/assets/manifests/monsters.blender.yaml @@ -0,0 +1,71 @@ +# Five production monster rigs rendered directly by SpriteForge. Each asset +# contains every gameplay clip; no intermediate sprite sheets are required. +version: 1 +defaults: + backend: blender + dirs: 8 + fps: 12 + size: 96x112 + postprocess_scale: 0.5 + pivot: "96,176" + tags: [enemy, body_horror, blender, consolidated] + +shambler: + rig: ../monsters/shambler.blend + parts: {body: unused_when_prefix_is_set} + anims: [walk, attack, hit, death] + params: &monster_render + executable: blender + render_size: 192x224 + ortho_scale: 3.5 + elevation: 26.565 + camera_distance: 12 + camera_target: [0, 0, 1.118] + direction_offset_deg: -90 + engine: BLENDER_EEVEE + samples: 12 + frames: {walk: 8, attack: 6, hit: 4, death: 8} + animation_fps: {walk: 8, attack: 10, hit: 11, death: 8} + loop_sampling: {walk: true, attack: false, hit: false, death: false} + actions: {walk: shambler_walk, attack: shambler_attack, hit: shambler_hit, death: shambler_death} + part_prefixes: {body: shambler_} + +rusher: + rig: ../monsters/rusher.blend + parts: {body: unused_when_prefix_is_set} + anims: [walk, attack, hit, death] + params: + <<: *monster_render + animation_fps: {walk: 14, attack: 17, hit: 20, death: 14} + actions: {walk: rusher_walk, attack: rusher_attack, hit: rusher_hit, death: rusher_death} + part_prefixes: {body: rusher_} + +spitter: + rig: ../monsters/spitter.blend + parts: {body: unused_when_prefix_is_set} + anims: [walk, attack, hit, death] + params: + <<: *monster_render + animation_fps: {walk: 9, attack: 11, hit: 13, death: 9} + actions: {walk: spitter_walk, attack: spitter_attack, hit: spitter_hit, death: spitter_death} + part_prefixes: {body: spitter_} + +brute: + rig: ../monsters/brute.blend + parts: {body: unused_when_prefix_is_set} + anims: [walk, attack, hit, death] + params: + <<: *monster_render + animation_fps: {walk: 7, attack: 8, hit: 10, death: 7} + actions: {walk: brute_walk, attack: brute_attack, hit: brute_hit, death: brute_death} + part_prefixes: {body: brute_} + +lurker: + rig: ../monsters/lurker.blend + parts: {body: unused_when_prefix_is_set} + anims: [walk, attack, hit, death] + params: + <<: *monster_render + animation_fps: {walk: 12, attack: 14, hit: 17, death: 12} + actions: {walk: lurker_walk, attack: lurker_attack, hit: lurker_hit, death: lurker_death} + part_prefixes: {body: lurker_} diff --git a/assets/manifests/monsters.generated.yaml b/assets/manifests/monsters.generated.yaml new file mode 100644 index 0000000..e7b964a --- /dev/null +++ b/assets/manifests/monsters.generated.yaml @@ -0,0 +1,151 @@ +# Собрано `python tools/monster_assets.py sync`. +# Записи с backend: import перегенерируются по PNG в assets/sources/monsters/. +# Записи с любым другим backend sync СОХРАНЯЕТ как есть: правь их руками +# здесь, это единственное место, где такая правка переживает sync. +# +# ID ассета — _: ровно то, что требует +# tools/asset_coverage.py от группы enemy. Переименуешь — упадёт покрытие. +version: 1 +defaults: + dirs: 8 + pivot: "48,88" + +shambler_walk: + backend: import + source: ../sources/monsters/shambler_walk.png + fps: 8 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, shambler, walk] + +shambler_attack: + backend: import + source: ../sources/monsters/shambler_attack.png + fps: 10 + params: {frame_width: 96, frame_height: 112, frames: 48} + tags: [enemy, shambler, attack] + +shambler_hit: + backend: import + source: ../sources/monsters/shambler_hit.png + fps: 11 + params: {frame_width: 96, frame_height: 112, frames: 32} + tags: [enemy, shambler, hit] + +shambler_death: + backend: import + source: ../sources/monsters/shambler_death.png + fps: 8 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, shambler, death] + +rusher_walk: + backend: import + source: ../sources/monsters/rusher_walk.png + fps: 14 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, rusher, walk] + +rusher_attack: + backend: import + source: ../sources/monsters/rusher_attack.png + fps: 17 + params: {frame_width: 96, frame_height: 112, frames: 48} + tags: [enemy, rusher, attack] + +rusher_hit: + backend: import + source: ../sources/monsters/rusher_hit.png + fps: 20 + params: {frame_width: 96, frame_height: 112, frames: 32} + tags: [enemy, rusher, hit] + +rusher_death: + backend: import + source: ../sources/monsters/rusher_death.png + fps: 14 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, rusher, death] + +spitter_walk: + backend: import + source: ../sources/monsters/spitter_walk.png + fps: 9 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, spitter, walk] + +spitter_attack: + backend: import + source: ../sources/monsters/spitter_attack.png + fps: 11 + params: {frame_width: 96, frame_height: 112, frames: 48} + tags: [enemy, spitter, attack] + +spitter_hit: + backend: import + source: ../sources/monsters/spitter_hit.png + fps: 13 + params: {frame_width: 96, frame_height: 112, frames: 32} + tags: [enemy, spitter, hit] + +spitter_death: + backend: import + source: ../sources/monsters/spitter_death.png + fps: 9 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, spitter, death] + +brute_walk: + backend: import + source: ../sources/monsters/brute_walk.png + fps: 7 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, brute, walk] + +brute_attack: + backend: import + source: ../sources/monsters/brute_attack.png + fps: 8 + params: {frame_width: 96, frame_height: 112, frames: 48} + tags: [enemy, brute, attack] + +brute_hit: + backend: import + source: ../sources/monsters/brute_hit.png + fps: 10 + params: {frame_width: 96, frame_height: 112, frames: 32} + tags: [enemy, brute, hit] + +brute_death: + backend: import + source: ../sources/monsters/brute_death.png + fps: 7 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, brute, death] + +lurker_walk: + backend: import + source: ../sources/monsters/lurker_walk.png + fps: 12 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, lurker, walk] + +lurker_attack: + backend: import + source: ../sources/monsters/lurker_attack.png + fps: 14 + params: {frame_width: 96, frame_height: 112, frames: 48} + tags: [enemy, lurker, attack] + +lurker_hit: + backend: import + source: ../sources/monsters/lurker_hit.png + fps: 17 + params: {frame_width: 96, frame_height: 112, frames: 32} + tags: [enemy, lurker, hit] + +lurker_death: + backend: import + source: ../sources/monsters/lurker_death.png + fps: 12 + params: {frame_width: 96, frame_height: 112, frames: 64} + tags: [enemy, lurker, death] diff --git a/assets/manifests/static.generated.yaml b/assets/manifests/static.generated.yaml new file mode 100644 index 0000000..c04daf0 --- /dev/null +++ b/assets/manifests/static.generated.yaml @@ -0,0 +1,166 @@ +version: 1 +defaults: + dirs: 1 + fps: 1 + +floor_clean: + backend: import + source: ../sources/world/floor_clean.png + pivot: "15,8" + tags: [environment, floor] + +floor_cracked: + backend: import + source: ../sources/world/floor_cracked.png + pivot: "15,8" + tags: [environment, floor] + +floor_grate: + backend: import + source: ../sources/world/floor_grate.png + pivot: "15,8" + tags: [environment, floor] + +floor_biomass: + backend: import + source: ../sources/world/floor_biomass.png + pivot: "15,8" + tags: [environment, floor] + +wall_industrial: + backend: import + source: ../sources/world/wall_industrial.png + pivot: "15,31" + tags: [environment, wall] + +wall_infested: + backend: import + source: ../sources/world/wall_infested.png + pivot: "15,31" + tags: [environment, wall] + +prop_crate: + backend: import + source: ../sources/world/prop_crate.png + pivot: bottom_center + tags: [environment, prop] + +prop_barrel: + backend: import + source: ../sources/world/prop_barrel.png + pivot: bottom_center + tags: [environment, prop] + +prop_locker: + backend: import + source: ../sources/world/prop_locker.png + pivot: bottom_center + tags: [environment, prop] + +item_icon_rifle: + backend: import + source: ../sources/items/item_icon_rifle.png + pivot: center + tags: [ui, item] + +item_icon_pistol: + backend: import + source: ../sources/items/item_icon_pistol.png + pivot: center + tags: [ui, item] + +item_icon_shotgun: + backend: import + source: ../sources/items/item_icon_shotgun.png + pivot: center + tags: [ui, item] + +item_icon_smg: + backend: import + source: ../sources/items/item_icon_smg.png + pivot: center + tags: [ui, item] + +item_icon_magnum: + backend: import + source: ../sources/items/item_icon_magnum.png + pivot: center + tags: [ui, item] + +item_icon_bow: + backend: import + source: ../sources/items/item_icon_bow.png + pivot: center + tags: [ui, item] + +item_icon_saber: + backend: import + source: ../sources/items/item_icon_saber.png + pivot: center + tags: [ui, item] + +item_icon_knife: + backend: import + source: ../sources/items/item_icon_knife.png + pivot: center + tags: [ui, item] + +item_icon_cleaver: + backend: import + source: ../sources/items/item_icon_cleaver.png + pivot: center + tags: [ui, item] + +item_icon_jacket: + backend: import + source: ../sources/items/item_icon_jacket.png + pivot: center + tags: [ui, item] + +item_icon_vest: + backend: import + source: ../sources/items/item_icon_vest.png + pivot: center + tags: [ui, item] + +item_icon_plate: + backend: import + source: ../sources/items/item_icon_plate.png + pivot: center + tags: [ui, item] + +item_icon_shroud: + backend: import + source: ../sources/items/item_icon_shroud.png + pivot: center + tags: [ui, item] + +item_icon_lantern: + backend: import + source: ../sources/items/item_icon_lantern.png + pivot: center + tags: [ui, item] + +item_icon_charm: + backend: import + source: ../sources/items/item_icon_charm.png + pivot: center + tags: [ui, item] + +item_icon_spurs: + backend: import + source: ../sources/items/item_icon_spurs.png + pivot: center + tags: [ui, item] + +item_icon_bandage: + backend: import + source: ../sources/items/item_icon_bandage.png + pivot: center + tags: [ui, item] + +item_icon_stimpack: + backend: import + source: ../sources/items/item_icon_stimpack.png + pivot: center + tags: [ui, item] diff --git a/assets/monsters/brute.blend b/assets/monsters/brute.blend new file mode 100644 index 0000000..bb115c3 Binary files /dev/null and b/assets/monsters/brute.blend differ diff --git a/assets/monsters/catalog.yaml b/assets/monsters/catalog.yaml new file mode 100644 index 0000000..01e3289 --- /dev/null +++ b/assets/monsters/catalog.yaml @@ -0,0 +1,62 @@ +# Каталог тварей: единственный источник геометрии monster-конвейера. +# +# Твари — НЕ бумажная кукла. У агента слои обязаны совпадать пиксель в пиксель, +# поэтому там один силуэт на всех. Здесь наоборот: пять видов обязаны читаться +# по пятну, не глядя на детали, — значит у каждого своя морфология, свой рост и +# своя походка. Общее у них ровно две вещи: камера и точка привязки. +version: 1 + +# Ячейка листа и общий pivot. pivot_y НЕ на нижней кромке: изометрический пол +# уходит от точки опоры ВНИЗ по экрану, и всё, что у твари позади (хвост +# бегуна, задние лапы затаившегося), проецируется ниже точки опоры. Девять +# строк под pivot — это запас на глубину подставки, а не «воздух». +# Monsters may sprawl wider than the 70x90 human paper-doll when dying. The +# camera scale stays unchanged: this is transparent safety padding, not a +# larger creature. Human agent layers remain exactly 70x90. +frame: {width: 96, height: 112, pivot_x: 48, pivot_y: 88} + +# Порядок направлений тот же, что у агентов: по -45° от юга. +directions: [south, southwest, west, northwest, north, northeast, east, southeast] + +# Кадры на ОДНО направление. Лист direction-major: сначала все кадры юга, +# затем юго-запада и так далее. +animations: + walk: {frames: 8, fps: 10} + attack: {frames: 6, fps: 12} + hit: {frames: 4, fps: 14} + death: {frames: 8, fps: 10} + +# Камера ровно та же, что у агентов: elevation_deg = atan(TILE_H / TILE_W) = +# atan(0.5). Разъедься эти числа — тварь встала бы к полу под другим углом, +# чем боец рядом с ней. +camera: + elevation_deg: 26.565 + # Common mutants read slightly larger than an operator; brute dominates. + pixels_per_unit: 32.0 + distance: 12.0 # только для клиппинга: проекция ортографическая + +# height_m — справочная высота силуэта в метрах, ею меряется читаемость +# размеров: громила обязан быть вдвое выше затаившегося. +# fps_scale — темп клипа относительно animations.fps: бредущий волочится, +# бегун частит. Ассету это даёт разный fps при одинаковом числе кадров. +kinds: + shambler: + style: "flayed penitent; thorax cranked open, ribs splayed outward, one arm grown past the knee" + height_m: 1.58 + fps_scale: 0.8 + rusher: + style: "quadruped sprinter; horizontal spine, digitigrade hind legs, scythe forelimbs, headless split-jaw bloom" + height_m: 0.78 + fps_scale: 1.4 + spitter: + style: "pressure vessel; dragging gland sac, implanted throat valve and nozzle, vestigial arms" + height_m: 1.22 + fps_scale: 0.9 + brute: + style: "the fused; a second half-absorbed body grafted to the shoulder, bolted plating, hammer of fused bone" + height_m: 1.96 + fps_scale: 0.7 + lurker: + style: "arachnid harvester; flat sutured carapace slung under four long arching limbs, eyeless hook wedge" + height_m: 0.74 + fps_scale: 1.2 diff --git a/assets/monsters/lurker.blend b/assets/monsters/lurker.blend new file mode 100644 index 0000000..5ad0494 Binary files /dev/null and b/assets/monsters/lurker.blend differ diff --git a/assets/monsters/monster_rig.blend b/assets/monsters/monster_rig.blend new file mode 100644 index 0000000..408efe9 Binary files /dev/null and b/assets/monsters/monster_rig.blend differ diff --git a/assets/monsters/rusher.blend b/assets/monsters/rusher.blend new file mode 100644 index 0000000..b2437ee Binary files /dev/null and b/assets/monsters/rusher.blend differ diff --git a/assets/monsters/shambler.blend b/assets/monsters/shambler.blend new file mode 100644 index 0000000..c1a3133 Binary files /dev/null and b/assets/monsters/shambler.blend differ diff --git a/assets/monsters/spitter.blend b/assets/monsters/spitter.blend new file mode 100644 index 0000000..ae91dec Binary files /dev/null and b/assets/monsters/spitter.blend differ diff --git a/assets/references/alien-shooter-gameplay.jpg b/assets/references/alien-shooter-gameplay.jpg new file mode 100644 index 0000000..2487a99 Binary files /dev/null and b/assets/references/alien-shooter-gameplay.jpg differ diff --git a/assets/sources/README.md b/assets/sources/README.md new file mode 100644 index 0000000..d8546d4 --- /dev/null +++ b/assets/sources/README.md @@ -0,0 +1,21 @@ +# assets/sources + +Исходники ассетов, на которые ссылаются манифесты из `assets/manifests/`. + +- `imports/` — готовые PNG для бэкенда `import` (поле `source`); +- `rigs/` — `.blend`-риги для бэкенда `blender` (поле `rig`). + +Процедурные ассеты исходников не имеют: генератор и параметры целиком описаны +в манифесте. Пути в манифесте разрешаются относительно самого манифеста, +поэтому импорт выглядит так: + +```yaml +health_potion: + backend: import + source: ../sources/imports/health_potion.png + size: 32x32 + tags: [item, consumable] +``` + +Собранные `.sfa` и `palettes.sfp` сюда не кладутся — они живут в `build/sprites/` +и не версионируются. Подробности: [docs/12-assets.md](../../docs/12-assets.md). diff --git a/assets/sources/agents/sleek/armor_jacket_attack.png b/assets/sources/agents/sleek/armor_jacket_attack.png new file mode 100644 index 0000000..6f6fbd0 Binary files /dev/null and b/assets/sources/agents/sleek/armor_jacket_attack.png differ diff --git a/assets/sources/agents/sleek/armor_jacket_idle.png b/assets/sources/agents/sleek/armor_jacket_idle.png new file mode 100644 index 0000000..c173b57 Binary files /dev/null and b/assets/sources/agents/sleek/armor_jacket_idle.png differ diff --git a/assets/sources/agents/sleek/armor_jacket_walk.png b/assets/sources/agents/sleek/armor_jacket_walk.png new file mode 100644 index 0000000..a763698 Binary files /dev/null and b/assets/sources/agents/sleek/armor_jacket_walk.png differ diff --git a/assets/sources/agents/sleek/armor_plate_attack.png b/assets/sources/agents/sleek/armor_plate_attack.png new file mode 100644 index 0000000..99d134d Binary files /dev/null and b/assets/sources/agents/sleek/armor_plate_attack.png differ diff --git a/assets/sources/agents/sleek/armor_plate_idle.png b/assets/sources/agents/sleek/armor_plate_idle.png new file mode 100644 index 0000000..cbff3c0 Binary files /dev/null and b/assets/sources/agents/sleek/armor_plate_idle.png differ diff --git a/assets/sources/agents/sleek/armor_plate_walk.png b/assets/sources/agents/sleek/armor_plate_walk.png new file mode 100644 index 0000000..1682812 Binary files /dev/null and b/assets/sources/agents/sleek/armor_plate_walk.png differ diff --git a/assets/sources/agents/sleek/armor_shroud_attack.png b/assets/sources/agents/sleek/armor_shroud_attack.png new file mode 100644 index 0000000..1288f4c Binary files /dev/null and b/assets/sources/agents/sleek/armor_shroud_attack.png differ diff --git a/assets/sources/agents/sleek/armor_shroud_idle.png b/assets/sources/agents/sleek/armor_shroud_idle.png new file mode 100644 index 0000000..055bbc0 Binary files /dev/null and b/assets/sources/agents/sleek/armor_shroud_idle.png differ diff --git a/assets/sources/agents/sleek/armor_shroud_walk.png b/assets/sources/agents/sleek/armor_shroud_walk.png new file mode 100644 index 0000000..63295d9 Binary files /dev/null and b/assets/sources/agents/sleek/armor_shroud_walk.png differ diff --git a/assets/sources/agents/sleek/armor_vest_attack.png b/assets/sources/agents/sleek/armor_vest_attack.png new file mode 100644 index 0000000..3eb2260 Binary files /dev/null and b/assets/sources/agents/sleek/armor_vest_attack.png differ diff --git a/assets/sources/agents/sleek/armor_vest_idle.png b/assets/sources/agents/sleek/armor_vest_idle.png new file mode 100644 index 0000000..9469372 Binary files /dev/null and b/assets/sources/agents/sleek/armor_vest_idle.png differ diff --git a/assets/sources/agents/sleek/armor_vest_walk.png b/assets/sources/agents/sleek/armor_vest_walk.png new file mode 100644 index 0000000..1b34a92 Binary files /dev/null and b/assets/sources/agents/sleek/armor_vest_walk.png differ diff --git a/assets/sources/agents/sleek/body_sleek_attack.png b/assets/sources/agents/sleek/body_sleek_attack.png new file mode 100644 index 0000000..7c3cde5 Binary files /dev/null and b/assets/sources/agents/sleek/body_sleek_attack.png differ diff --git a/assets/sources/agents/sleek/body_sleek_idle.png b/assets/sources/agents/sleek/body_sleek_idle.png new file mode 100644 index 0000000..c07cf9b Binary files /dev/null and b/assets/sources/agents/sleek/body_sleek_idle.png differ diff --git a/assets/sources/agents/sleek/body_sleek_walk.png b/assets/sources/agents/sleek/body_sleek_walk.png new file mode 100644 index 0000000..3c4598e Binary files /dev/null and b/assets/sources/agents/sleek/body_sleek_walk.png differ diff --git a/assets/sources/agents/sleek/weapon_bow_attack.png b/assets/sources/agents/sleek/weapon_bow_attack.png new file mode 100644 index 0000000..f9d6ae4 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_bow_attack.png differ diff --git a/assets/sources/agents/sleek/weapon_bow_idle.png b/assets/sources/agents/sleek/weapon_bow_idle.png new file mode 100644 index 0000000..c5922f7 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_bow_idle.png differ diff --git a/assets/sources/agents/sleek/weapon_bow_walk.png b/assets/sources/agents/sleek/weapon_bow_walk.png new file mode 100644 index 0000000..667c6ed Binary files /dev/null and b/assets/sources/agents/sleek/weapon_bow_walk.png differ diff --git a/assets/sources/agents/sleek/weapon_dagger_attack.png b/assets/sources/agents/sleek/weapon_dagger_attack.png new file mode 100644 index 0000000..3db5c52 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_dagger_attack.png differ diff --git a/assets/sources/agents/sleek/weapon_dagger_idle.png b/assets/sources/agents/sleek/weapon_dagger_idle.png new file mode 100644 index 0000000..7823f92 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_dagger_idle.png differ diff --git a/assets/sources/agents/sleek/weapon_dagger_walk.png b/assets/sources/agents/sleek/weapon_dagger_walk.png new file mode 100644 index 0000000..46c618c Binary files /dev/null and b/assets/sources/agents/sleek/weapon_dagger_walk.png differ diff --git a/assets/sources/agents/sleek/weapon_pistol_attack.png b/assets/sources/agents/sleek/weapon_pistol_attack.png new file mode 100644 index 0000000..1262e96 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_pistol_attack.png differ diff --git a/assets/sources/agents/sleek/weapon_pistol_idle.png b/assets/sources/agents/sleek/weapon_pistol_idle.png new file mode 100644 index 0000000..f2c4ef6 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_pistol_idle.png differ diff --git a/assets/sources/agents/sleek/weapon_pistol_walk.png b/assets/sources/agents/sleek/weapon_pistol_walk.png new file mode 100644 index 0000000..7bc8c47 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_pistol_walk.png differ diff --git a/assets/sources/agents/sleek/weapon_rifle_attack.png b/assets/sources/agents/sleek/weapon_rifle_attack.png new file mode 100644 index 0000000..86e7be4 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_rifle_attack.png differ diff --git a/assets/sources/agents/sleek/weapon_rifle_idle.png b/assets/sources/agents/sleek/weapon_rifle_idle.png new file mode 100644 index 0000000..bdefe11 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_rifle_idle.png differ diff --git a/assets/sources/agents/sleek/weapon_rifle_walk.png b/assets/sources/agents/sleek/weapon_rifle_walk.png new file mode 100644 index 0000000..454f679 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_rifle_walk.png differ diff --git a/assets/sources/agents/sleek/weapon_shotgun_attack.png b/assets/sources/agents/sleek/weapon_shotgun_attack.png new file mode 100644 index 0000000..412fc52 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_shotgun_attack.png differ diff --git a/assets/sources/agents/sleek/weapon_shotgun_idle.png b/assets/sources/agents/sleek/weapon_shotgun_idle.png new file mode 100644 index 0000000..aaf3735 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_shotgun_idle.png differ diff --git a/assets/sources/agents/sleek/weapon_shotgun_walk.png b/assets/sources/agents/sleek/weapon_shotgun_walk.png new file mode 100644 index 0000000..817fb3c Binary files /dev/null and b/assets/sources/agents/sleek/weapon_shotgun_walk.png differ diff --git a/assets/sources/agents/sleek/weapon_sword_attack.png b/assets/sources/agents/sleek/weapon_sword_attack.png new file mode 100644 index 0000000..df703ef Binary files /dev/null and b/assets/sources/agents/sleek/weapon_sword_attack.png differ diff --git a/assets/sources/agents/sleek/weapon_sword_idle.png b/assets/sources/agents/sleek/weapon_sword_idle.png new file mode 100644 index 0000000..82ac42c Binary files /dev/null and b/assets/sources/agents/sleek/weapon_sword_idle.png differ diff --git a/assets/sources/agents/sleek/weapon_sword_walk.png b/assets/sources/agents/sleek/weapon_sword_walk.png new file mode 100644 index 0000000..c1c4511 Binary files /dev/null and b/assets/sources/agents/sleek/weapon_sword_walk.png differ diff --git a/assets/sources/imports/brute_walk.png b/assets/sources/imports/brute_walk.png new file mode 100644 index 0000000..d79c0a8 Binary files /dev/null and b/assets/sources/imports/brute_walk.png differ diff --git a/assets/sources/imports/floor_slab.png b/assets/sources/imports/floor_slab.png new file mode 100644 index 0000000..c2764b0 Binary files /dev/null and b/assets/sources/imports/floor_slab.png differ diff --git a/assets/sources/imports/floor_slab_dim.png b/assets/sources/imports/floor_slab_dim.png new file mode 100644 index 0000000..d4360d9 Binary files /dev/null and b/assets/sources/imports/floor_slab_dim.png differ diff --git a/assets/sources/imports/lurker_walk.png b/assets/sources/imports/lurker_walk.png new file mode 100644 index 0000000..f2cca9c Binary files /dev/null and b/assets/sources/imports/lurker_walk.png differ diff --git a/assets/sources/imports/rusher_walk.png b/assets/sources/imports/rusher_walk.png new file mode 100644 index 0000000..5f7068e Binary files /dev/null and b/assets/sources/imports/rusher_walk.png differ diff --git a/assets/sources/imports/shambler_walk.png b/assets/sources/imports/shambler_walk.png new file mode 100644 index 0000000..df13fdb Binary files /dev/null and b/assets/sources/imports/shambler_walk.png differ diff --git a/assets/sources/imports/spitter_walk.png b/assets/sources/imports/spitter_walk.png new file mode 100644 index 0000000..aca0031 Binary files /dev/null and b/assets/sources/imports/spitter_walk.png differ diff --git a/assets/sources/imports/wall_block.png b/assets/sources/imports/wall_block.png new file mode 100644 index 0000000..e3c4511 Binary files /dev/null and b/assets/sources/imports/wall_block.png differ diff --git a/assets/sources/items/item_icon_bandage.png b/assets/sources/items/item_icon_bandage.png new file mode 100644 index 0000000..8f09f1e Binary files /dev/null and b/assets/sources/items/item_icon_bandage.png differ diff --git a/assets/sources/items/item_icon_bow.png b/assets/sources/items/item_icon_bow.png new file mode 100644 index 0000000..584f1b8 Binary files /dev/null and b/assets/sources/items/item_icon_bow.png differ diff --git a/assets/sources/items/item_icon_charm.png b/assets/sources/items/item_icon_charm.png new file mode 100644 index 0000000..5491a14 Binary files /dev/null and b/assets/sources/items/item_icon_charm.png differ diff --git a/assets/sources/items/item_icon_cleaver.png b/assets/sources/items/item_icon_cleaver.png new file mode 100644 index 0000000..c3f70bb Binary files /dev/null and b/assets/sources/items/item_icon_cleaver.png differ diff --git a/assets/sources/items/item_icon_jacket.png b/assets/sources/items/item_icon_jacket.png new file mode 100644 index 0000000..40b2ca4 Binary files /dev/null and b/assets/sources/items/item_icon_jacket.png differ diff --git a/assets/sources/items/item_icon_knife.png b/assets/sources/items/item_icon_knife.png new file mode 100644 index 0000000..c1afa03 Binary files /dev/null and b/assets/sources/items/item_icon_knife.png differ diff --git a/assets/sources/items/item_icon_lantern.png b/assets/sources/items/item_icon_lantern.png new file mode 100644 index 0000000..4db0558 Binary files /dev/null and b/assets/sources/items/item_icon_lantern.png differ diff --git a/assets/sources/items/item_icon_magnum.png b/assets/sources/items/item_icon_magnum.png new file mode 100644 index 0000000..43be04f Binary files /dev/null and b/assets/sources/items/item_icon_magnum.png differ diff --git a/assets/sources/items/item_icon_pistol.png b/assets/sources/items/item_icon_pistol.png new file mode 100644 index 0000000..349c7e3 Binary files /dev/null and b/assets/sources/items/item_icon_pistol.png differ diff --git a/assets/sources/items/item_icon_plate.png b/assets/sources/items/item_icon_plate.png new file mode 100644 index 0000000..6db2013 Binary files /dev/null and b/assets/sources/items/item_icon_plate.png differ diff --git a/assets/sources/items/item_icon_rifle.png b/assets/sources/items/item_icon_rifle.png new file mode 100644 index 0000000..6d0add7 Binary files /dev/null and b/assets/sources/items/item_icon_rifle.png differ diff --git a/assets/sources/items/item_icon_saber.png b/assets/sources/items/item_icon_saber.png new file mode 100644 index 0000000..c3f70bb Binary files /dev/null and b/assets/sources/items/item_icon_saber.png differ diff --git a/assets/sources/items/item_icon_shotgun.png b/assets/sources/items/item_icon_shotgun.png new file mode 100644 index 0000000..b30c158 Binary files /dev/null and b/assets/sources/items/item_icon_shotgun.png differ diff --git a/assets/sources/items/item_icon_shroud.png b/assets/sources/items/item_icon_shroud.png new file mode 100644 index 0000000..c3030fb Binary files /dev/null and b/assets/sources/items/item_icon_shroud.png differ diff --git a/assets/sources/items/item_icon_smg.png b/assets/sources/items/item_icon_smg.png new file mode 100644 index 0000000..a4a3a09 Binary files /dev/null and b/assets/sources/items/item_icon_smg.png differ diff --git a/assets/sources/items/item_icon_spurs.png b/assets/sources/items/item_icon_spurs.png new file mode 100644 index 0000000..4d3ca15 Binary files /dev/null and b/assets/sources/items/item_icon_spurs.png differ diff --git a/assets/sources/items/item_icon_stimpack.png b/assets/sources/items/item_icon_stimpack.png new file mode 100644 index 0000000..8f09f1e Binary files /dev/null and b/assets/sources/items/item_icon_stimpack.png differ diff --git a/assets/sources/items/item_icon_vest.png b/assets/sources/items/item_icon_vest.png new file mode 100644 index 0000000..49344c8 Binary files /dev/null and b/assets/sources/items/item_icon_vest.png differ diff --git a/assets/sources/make_enemy_sheets.py b/assets/sources/make_enemy_sheets.py new file mode 100644 index 0000000..412d7c4 --- /dev/null +++ b/assets/sources/make_enemy_sheets.py @@ -0,0 +1,292 @@ +"""Генератор листов анимации тварей для SpriteForge (бэкенд import). + +Зачем скрипт, а не нарисованные руками PNG: силуэт твари обязан совпадать с +процедурным запасным вариантом из src/render/sprites.cpp — иначе переключение +--sf-enemies меняло бы не «плавность», а саму тварь, и игрок переставал бы её +узнавать. Поэтому здесь ровно та же геометрия (объединение эллипсов, свет по +верхне-левой кромке, обводка по силуэту), только конечности сдвигаются по фазе +шага. + +Скрипт одноразовый и детерминированный: ни одного случайного числа, тот же +хеш-шум, что в C++. Перегенерация даёт побитово тот же PNG. + + python assets/sources/make_enemy_sheets.py + +Пишет листы в assets/sources/imports/_walk.png — по кадрам в ряд. +""" + +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image + +OUT_DIR = Path(__file__).resolve().parent / "imports" + +FRAMES = 6 # кадров в цикле шага + + +# --- цвет -------------------------------------------------------------------- +# Тот же ShadeColor, что в src/render/sprites.cpp: множитель по RGB с обрезкой. +def shade(rgb: tuple[int, int, int], k: float) -> tuple[int, int, int]: + return tuple(max(0, min(255, int(c * k))) for c in rgb) + + +# Хеш из src/core/hash.h. Нужен, чтобы рваный низ у твари совпадал с +# процедурным вариантом кадр в кадр. +def hash2(a: int, b: int) -> int: + h = (a * 0x9E3779B1) & 0xFFFFFFFF + h ^= (b + 0x85EBCA6B + ((h << 6) & 0xFFFFFFFF) + (h >> 2)) & 0xFFFFFFFF + h &= 0xFFFFFFFF + h ^= h >> 15 + h = (h * 0x2C1B3C6D) & 0xFFFFFFFF + h ^= h >> 12 + return h + + +def hash01(h: int) -> float: + return (h & 0xFFFFFF) / float(0x1000000) + + +# --- силуэт ------------------------------------------------------------------ +class Canvas: + def __init__(self, w: int, h: int) -> None: + self.w, self.h = w, h + self.px: list[list[tuple[int, int, int] | None]] = [[None] * w for _ in range(h)] + + def get(self, x: int, y: int): + if x < 0 or y < 0 or x >= self.w or y >= self.h: + return None + return self.px[y][x] + + def set(self, x: int, y: int, c) -> None: + if 0 <= x < self.w and 0 <= y < self.h: + self.px[y][x] = c + + def paint(self, x: int, y: int, c) -> None: + """Записать только по уже занятому пикселю: силуэт не расширяется.""" + if self.get(x, y) is not None: + self.set(x, y, c) + + def image(self) -> Image.Image: + img = Image.new("RGBA", (self.w, self.h), (0, 0, 0, 0)) + out = img.load() + for y in range(self.h): + for x in range(self.w): + c = self.px[y][x] + if c is not None: + out[x, y] = (c[0], c[1], c[2], 255) + return img + + +def fill_blobs(cv: Canvas, blobs, body) -> None: + for y in range(cv.h): + for x in range(cv.w): + for cx, cy, rx, ry in blobs: + dx = (x - cx) / max(0.5, rx) + dy = (y - cy) / max(0.5, ry) + if dx * dx + dy * dy <= 1.0: + cv.set(x, y, body) + break + + +def shade_silhouette(cv: Canvas, body) -> None: + """Объём по краю: свет сверху-слева. Копия ShadeSilhouette из sprites.cpp.""" + hilite, shadow, deep = shade(body, 1.32), shade(body, 0.66), shade(body, 0.48) + snapshot = [row[:] for row in cv.px] + + def at(x: int, y: int): + if x < 0 or y < 0 or x >= cv.w or y >= cv.h: + return None + return snapshot[y][x] + + for y in range(cv.h): + for x in range(cv.w): + if at(x, y) is None: + continue + lit = at(x - 2, y - 1) is None + dark = at(x + 2, y + 1) is None + if lit and not dark: + cv.set(x, y, hilite) + elif dark and not lit: + cv.set(x, y, shadow) + elif dark: + cv.set(x, y, deep) + + +def outline(cv: Canvas, col) -> None: + snapshot = [row[:] for row in cv.px] + + def at(x: int, y: int): + if x < 0 or y < 0 or x >= cv.w or y >= cv.h: + return None + return snapshot[y][x] + + for y in range(cv.h): + for x in range(cv.w): + if at(x, y) is None: + continue + if (x == 0 or y == 0 or x == cv.w - 1 or y == cv.h - 1 + or at(x - 1, y) is None or at(x + 1, y) is None + or at(x, y - 1) is None or at(x, y + 1) is None): + cv.set(x, y, col) + + +def tatter_bottom(cv: Canvas, seed: int, max_cut: int) -> None: + for x in range(cv.w): + cut = int(hash01(hash2(seed, x)) * (max_cut + 1)) + for i in range(cut): + cv.set(x, cv.h - 1 - i, None) + + +def eyes(cv: Canvas, x0: int, x1: int, y: int, col) -> None: + cv.set(x0, y, col) + cv.set(x1, y, col) + + +# --- твари ------------------------------------------------------------------- +# Фаза шага: swing — размах конечностей, bob — подскок корпуса. Обе выведены из +# одного угла, поэтому цикл замкнут и на стыке кадров не дёргается. +def phase(frame: int) -> tuple[float, float]: + a = 2.0 * math.pi * frame / FRAMES + return math.sin(a), math.cos(2.0 * a) + + +def shambler(frame: int, body): + swing, bob = phase(frame) + cv = Canvas(13, 17) + dy = 0.6 * bob # тяжёлый переваливающийся шаг + fill_blobs(cv, [ + (6.5, 10.0 + dy, 4.2, 6.0), + (8.0 + 0.5 * swing, 4.5 + dy, 3.0, 2.8), + (2.5 - 0.9 * swing, 10.5 + dy, 1.8, 4.5), + (10.5 + 0.9 * swing, 11.5 + dy, 1.6, 4.0), + (5.0 + 0.8 * swing, 15.0, 2.4, 2.4), + ], body) + shade_silhouette(cv, body) + maw = shade(body, 0.20) + for x in range(7, 10): + cv.paint(int(x + 0.5 * swing), int(5 + dy), maw) + tatter_bottom(cv, 0x5A17, 2) + outline(cv, shade(body, 0.28)) + return cv + + +def rusher(frame: int, body): + swing, bob = phase(frame) + cv = Canvas(11, 18) + dy = 0.9 * bob # быстрый частый шаг: подскок заметнее + fill_blobs(cv, [ + (5.0, 10.5 + dy, 2.6, 6.0), + (7.0 + 0.4 * swing, 3.8 + dy, 2.4, 3.2), + (8.6 + 1.1 * swing, 8.0 + dy, 1.4, 3.6), + (2.0 - 1.1 * swing, 11.0 + dy, 1.3, 3.4), + (4.5 + 1.0 * swing, 15.5, 2.2, 2.2), + ], body) + shade_silhouette(cv, body) + eyes(cv, int(6 + 0.4 * swing), int(8 + 0.4 * swing), int(3 + dy), shade(body, 1.85)) + tatter_bottom(cv, 0x9E37, 3) + outline(cv, shade(body, 0.30)) + return cv + + +def spitter(frame: int, body): + swing, bob = phase(frame) + cv = Canvas(16, 14) + dy = 0.5 * bob + fill_blobs(cv, [ + (8.0, 8.0 + dy, 6.6, 4.8), + (8.0, 3.6 + dy, 3.4, 2.6), + (3.0 - 0.7 * swing, 12.0, 1.8, 1.8), + (13.0 + 0.7 * swing, 12.0, 1.8, 1.8), + ], body) + shade_silhouette(cv, body) + maw, bile = shade(body, 0.18), shade(body, 1.70) + for x in range(5, 12): + cv.paint(x, int(4 + dy), maw) + for x in range(6, 11): + cv.paint(x, int(5 + dy), maw) + for y in range(7, 11): + for x in range(2, cv.w - 2): + if (x * 3 + y * 7) % 13 == 0: + cv.paint(x, y, bile) + outline(cv, shade(body, 0.30)) + return cv + + +def brute(frame: int, body): + swing, bob = phase(frame) + cv = Canvas(22, 25) + dy = 0.7 * bob # туша качается медленно и широко + fill_blobs(cv, [ + (11.0, 9.0 + dy, 9.0, 6.0), + (11.0, 16.0 + dy, 5.5, 6.0), + (11.0, 4.5 + dy, 3.0, 2.8), + (2.5 - 0.8 * swing, 17.0 + dy, 2.6, 5.5), + (19.5 + 0.8 * swing, 17.0 + dy, 2.6, 5.5), + (7.0 + 1.2 * swing, 23.0, 3.0, 2.2), + (15.0 - 1.2 * swing, 23.0, 3.0, 2.2), + ], body) + shade_silhouette(cv, body) + bone, dark = shade(body, 1.55), shade(body, 0.34) + for i in range(4): + cv.paint(3 + i, int(5 + i + dy), bone) + cv.paint(cv.w - 4 - i, int(5 + i + dy), bone) + for y in range(cv.h - 6, cv.h - 2): + for x in range(1, 4): + cv.paint(x, y, bone) + cv.paint(cv.w - 1 - x, y, bone) + eyes(cv, 10, 12, int(4 + dy), dark) + outline(cv, shade(body, 0.25)) + return cv + + +def lurker(frame: int, body): + swing, bob = phase(frame) + cv = Canvas(17, 15) + dy = 0.5 * bob + # Паучий ход: лапы работают в противофазе парами, тело почти не качается. + fill_blobs(cv, [ + (8.5, 9.0 + dy, 4.0, 3.6), + (8.5, 5.5 + dy, 2.4, 2.2), + (2.0 - 0.8 * swing, 7.0 - 0.9 * swing, 1.3, 4.6), + (15.0 + 0.8 * swing, 7.0 + 0.9 * swing, 1.3, 4.6), + (4.0 + 0.9 * swing, 12.5, 1.4, 2.2), + (13.0 - 0.9 * swing, 12.5, 1.4, 2.2), + ], body) + shade_silhouette(cv, body) + claw = shade(body, 1.75) + cv.paint(1, 2, claw) + cv.paint(2, 3, claw) + cv.paint(cv.w - 2, 2, claw) + cv.paint(cv.w - 3, 3, claw) + eyes(cv, 7, 10, int(5 + dy), shade(body, 1.9)) + outline(cv, shade(body, 0.26)) + return cv + + +# Цвета — копия EnemyDef из src/sim/target.cpp. Разъедутся — тварь сменит цвет +# при переключении на собранные спрайты, и это будет видно сразу. +KINDS = [ + ("shambler", shambler, (116, 52, 52), 13, 17), + ("rusher", rusher, (158, 74, 44), 11, 18), + ("spitter", spitter, (96, 132, 72), 16, 14), + ("brute", brute, (142, 60, 78), 22, 25), + ("lurker", lurker, (88, 62, 118), 17, 15), +] + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + for name, maker, colour, w, h in KINDS: + sheet = Image.new("RGBA", (w * FRAMES, h), (0, 0, 0, 0)) + for f in range(FRAMES): + sheet.paste(maker(f, colour).image(), (f * w, 0)) + path = OUT_DIR / f"{name}_walk.png" + sheet.save(path) + print(f"{path.name}: {w * FRAMES}x{h} ({FRAMES} кадров по {w}x{h})") + + +if __name__ == "__main__": + main() diff --git a/assets/sources/monsters/brute_attack.png b/assets/sources/monsters/brute_attack.png new file mode 100644 index 0000000..731705d Binary files /dev/null and b/assets/sources/monsters/brute_attack.png differ diff --git a/assets/sources/monsters/brute_death.png b/assets/sources/monsters/brute_death.png new file mode 100644 index 0000000..53baee6 Binary files /dev/null and b/assets/sources/monsters/brute_death.png differ diff --git a/assets/sources/monsters/brute_hit.png b/assets/sources/monsters/brute_hit.png new file mode 100644 index 0000000..b757a3e Binary files /dev/null and b/assets/sources/monsters/brute_hit.png differ diff --git a/assets/sources/monsters/brute_walk.png b/assets/sources/monsters/brute_walk.png new file mode 100644 index 0000000..d510ef1 Binary files /dev/null and b/assets/sources/monsters/brute_walk.png differ diff --git a/assets/sources/monsters/lurker_attack.png b/assets/sources/monsters/lurker_attack.png new file mode 100644 index 0000000..100ae75 Binary files /dev/null and b/assets/sources/monsters/lurker_attack.png differ diff --git a/assets/sources/monsters/lurker_death.png b/assets/sources/monsters/lurker_death.png new file mode 100644 index 0000000..dff6800 Binary files /dev/null and b/assets/sources/monsters/lurker_death.png differ diff --git a/assets/sources/monsters/lurker_hit.png b/assets/sources/monsters/lurker_hit.png new file mode 100644 index 0000000..d6b06c2 Binary files /dev/null and b/assets/sources/monsters/lurker_hit.png differ diff --git a/assets/sources/monsters/lurker_walk.png b/assets/sources/monsters/lurker_walk.png new file mode 100644 index 0000000..5ea7836 Binary files /dev/null and b/assets/sources/monsters/lurker_walk.png differ diff --git a/assets/sources/monsters/rusher_attack.png b/assets/sources/monsters/rusher_attack.png new file mode 100644 index 0000000..51bcae0 Binary files /dev/null and b/assets/sources/monsters/rusher_attack.png differ diff --git a/assets/sources/monsters/rusher_death.png b/assets/sources/monsters/rusher_death.png new file mode 100644 index 0000000..9ab1820 Binary files /dev/null and b/assets/sources/monsters/rusher_death.png differ diff --git a/assets/sources/monsters/rusher_hit.png b/assets/sources/monsters/rusher_hit.png new file mode 100644 index 0000000..fec994b Binary files /dev/null and b/assets/sources/monsters/rusher_hit.png differ diff --git a/assets/sources/monsters/rusher_walk.png b/assets/sources/monsters/rusher_walk.png new file mode 100644 index 0000000..c903da7 Binary files /dev/null and b/assets/sources/monsters/rusher_walk.png differ diff --git a/assets/sources/monsters/shambler_attack.png b/assets/sources/monsters/shambler_attack.png new file mode 100644 index 0000000..9329d43 Binary files /dev/null and b/assets/sources/monsters/shambler_attack.png differ diff --git a/assets/sources/monsters/shambler_death.png b/assets/sources/monsters/shambler_death.png new file mode 100644 index 0000000..a344683 Binary files /dev/null and b/assets/sources/monsters/shambler_death.png differ diff --git a/assets/sources/monsters/shambler_hit.png b/assets/sources/monsters/shambler_hit.png new file mode 100644 index 0000000..e974fcb Binary files /dev/null and b/assets/sources/monsters/shambler_hit.png differ diff --git a/assets/sources/monsters/shambler_walk.png b/assets/sources/monsters/shambler_walk.png new file mode 100644 index 0000000..2ce3831 Binary files /dev/null and b/assets/sources/monsters/shambler_walk.png differ diff --git a/assets/sources/monsters/spitter_attack.png b/assets/sources/monsters/spitter_attack.png new file mode 100644 index 0000000..abcd08b Binary files /dev/null and b/assets/sources/monsters/spitter_attack.png differ diff --git a/assets/sources/monsters/spitter_death.png b/assets/sources/monsters/spitter_death.png new file mode 100644 index 0000000..55a8594 Binary files /dev/null and b/assets/sources/monsters/spitter_death.png differ diff --git a/assets/sources/monsters/spitter_hit.png b/assets/sources/monsters/spitter_hit.png new file mode 100644 index 0000000..402d461 Binary files /dev/null and b/assets/sources/monsters/spitter_hit.png differ diff --git a/assets/sources/monsters/spitter_walk.png b/assets/sources/monsters/spitter_walk.png new file mode 100644 index 0000000..f37c3ce Binary files /dev/null and b/assets/sources/monsters/spitter_walk.png differ diff --git a/assets/sources/world/floor_biomass.png b/assets/sources/world/floor_biomass.png new file mode 100644 index 0000000..a505463 Binary files /dev/null and b/assets/sources/world/floor_biomass.png differ diff --git a/assets/sources/world/floor_clean.png b/assets/sources/world/floor_clean.png new file mode 100644 index 0000000..87f15bd Binary files /dev/null and b/assets/sources/world/floor_clean.png differ diff --git a/assets/sources/world/floor_cracked.png b/assets/sources/world/floor_cracked.png new file mode 100644 index 0000000..b74486b Binary files /dev/null and b/assets/sources/world/floor_cracked.png differ diff --git a/assets/sources/world/floor_grate.png b/assets/sources/world/floor_grate.png new file mode 100644 index 0000000..257f615 Binary files /dev/null and b/assets/sources/world/floor_grate.png differ diff --git a/assets/sources/world/prop_barrel.png b/assets/sources/world/prop_barrel.png new file mode 100644 index 0000000..f12f358 Binary files /dev/null and b/assets/sources/world/prop_barrel.png differ diff --git a/assets/sources/world/prop_crate.png b/assets/sources/world/prop_crate.png new file mode 100644 index 0000000..74ae08c Binary files /dev/null and b/assets/sources/world/prop_crate.png differ diff --git a/assets/sources/world/prop_locker.png b/assets/sources/world/prop_locker.png new file mode 100644 index 0000000..305ae04 Binary files /dev/null and b/assets/sources/world/prop_locker.png differ diff --git a/assets/sources/world/wall_industrial.png b/assets/sources/world/wall_industrial.png new file mode 100644 index 0000000..959084d Binary files /dev/null and b/assets/sources/world/wall_industrial.png differ diff --git a/assets/sources/world/wall_infested.png b/assets/sources/world/wall_infested.png new file mode 100644 index 0000000..7fa9add Binary files /dev/null and b/assets/sources/world/wall_infested.png differ diff --git a/generated/agent_paperdoll.h b/generated/agent_paperdoll.h new file mode 100644 index 0000000..ef133ce --- /dev/null +++ b/generated/agent_paperdoll.h @@ -0,0 +1,27 @@ +/* Generated by tools/agent_assets.py sync (consolidated Blender assets). */ +#pragma once +#include +#include "render/anim.h" +#include "sim/items.h" + +constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0xC4B41EA2); + +constexpr uint32_t AgentBodyAsset(AnimKind) { return AGENT_BODY_ASSET; } + +constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind = AnimKind::IDLE) { + switch (id) { + case ItemId::JACKET: case ItemId::VEST: case ItemId::PLATE: case ItemId::SHROUD: + return UINT32_C(0xC8F6040D); + default: return 0; + } +} + +constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind = AnimKind::IDLE) { + switch (id) { + case ItemId::RIFLE: case ItemId::PISTOL: case ItemId::SHOTGUN: + case ItemId::SMG: case ItemId::MAGNUM: case ItemId::BOW: + case ItemId::SABER: case ItemId::KNIFE: case ItemId::CLEAVER: + return UINT32_C(0xA68530E2); + default: return 0; + } +} diff --git a/generated/monster_sprites.h b/generated/monster_sprites.h new file mode 100644 index 0000000..fe1377c --- /dev/null +++ b/generated/monster_sprites.h @@ -0,0 +1,76 @@ +/* Generated by tools/monster_assets.py sync. Do not edit. */ +#pragma once +#include + +#include "render/anim.h" +#include "sim/target.h" + +constexpr uint32_t MONSTER_ASSET_SHAMBLER_WALK = UINT32_C(0xDA564B3B); +constexpr uint32_t MONSTER_ASSET_SHAMBLER_ATTACK = UINT32_C(0x2821014E); +constexpr uint32_t MONSTER_ASSET_SHAMBLER_HIT = UINT32_C(0x8498BC33); +constexpr uint32_t MONSTER_ASSET_SHAMBLER_DEATH = UINT32_C(0x0B950530); +constexpr uint32_t MONSTER_ASSET_RUSHER_WALK = UINT32_C(0x442741A8); +constexpr uint32_t MONSTER_ASSET_RUSHER_ATTACK = UINT32_C(0x219BA505); +constexpr uint32_t MONSTER_ASSET_RUSHER_HIT = UINT32_C(0x3404E732); +constexpr uint32_t MONSTER_ASSET_RUSHER_DEATH = UINT32_C(0xAEBDED05); +constexpr uint32_t MONSTER_ASSET_SPITTER_WALK = UINT32_C(0xE887C90E); +constexpr uint32_t MONSTER_ASSET_SPITTER_ATTACK = UINT32_C(0x02D54BDB); +constexpr uint32_t MONSTER_ASSET_SPITTER_HIT = UINT32_C(0x84B7B754); +constexpr uint32_t MONSTER_ASSET_SPITTER_DEATH = UINT32_C(0x9142CAF7); +constexpr uint32_t MONSTER_ASSET_BRUTE_WALK = UINT32_C(0xC97ABA5D); +constexpr uint32_t MONSTER_ASSET_BRUTE_ATTACK = UINT32_C(0xE166F8E8); +constexpr uint32_t MONSTER_ASSET_BRUTE_HIT = UINT32_C(0xBC752911); +constexpr uint32_t MONSTER_ASSET_BRUTE_DEATH = UINT32_C(0x3D96DA4E); +constexpr uint32_t MONSTER_ASSET_LURKER_WALK = UINT32_C(0x8FF69CC2); +constexpr uint32_t MONSTER_ASSET_LURKER_ATTACK = UINT32_C(0x9A5A0F2F); +constexpr uint32_t MONSTER_ASSET_LURKER_HIT = UINT32_C(0xFF50F498); +constexpr uint32_t MONSTER_ASSET_LURKER_DEATH = UINT32_C(0xA573E6AB); + +// AnimKind::IDLE у тварей нет: покоя у них не бывает, стоящая тварь +// показывает первый кадр шага. +constexpr uint32_t MonsterAsset(EnemyKind kind, AnimKind anim) +{ + switch (kind) { + case EnemyKind::SHAMBLER: + switch (anim) { + case AnimKind::WALK: return MONSTER_ASSET_SHAMBLER_WALK; + case AnimKind::ATTACK: return MONSTER_ASSET_SHAMBLER_ATTACK; + case AnimKind::HURT: return MONSTER_ASSET_SHAMBLER_HIT; + case AnimKind::DIE: return MONSTER_ASSET_SHAMBLER_DEATH; + default: return MONSTER_ASSET_SHAMBLER_WALK; + } + case EnemyKind::RUSHER: + switch (anim) { + case AnimKind::WALK: return MONSTER_ASSET_RUSHER_WALK; + case AnimKind::ATTACK: return MONSTER_ASSET_RUSHER_ATTACK; + case AnimKind::HURT: return MONSTER_ASSET_RUSHER_HIT; + case AnimKind::DIE: return MONSTER_ASSET_RUSHER_DEATH; + default: return MONSTER_ASSET_RUSHER_WALK; + } + case EnemyKind::SPITTER: + switch (anim) { + case AnimKind::WALK: return MONSTER_ASSET_SPITTER_WALK; + case AnimKind::ATTACK: return MONSTER_ASSET_SPITTER_ATTACK; + case AnimKind::HURT: return MONSTER_ASSET_SPITTER_HIT; + case AnimKind::DIE: return MONSTER_ASSET_SPITTER_DEATH; + default: return MONSTER_ASSET_SPITTER_WALK; + } + case EnemyKind::BRUTE: + switch (anim) { + case AnimKind::WALK: return MONSTER_ASSET_BRUTE_WALK; + case AnimKind::ATTACK: return MONSTER_ASSET_BRUTE_ATTACK; + case AnimKind::HURT: return MONSTER_ASSET_BRUTE_HIT; + case AnimKind::DIE: return MONSTER_ASSET_BRUTE_DEATH; + default: return MONSTER_ASSET_BRUTE_WALK; + } + case EnemyKind::LURKER: + switch (anim) { + case AnimKind::WALK: return MONSTER_ASSET_LURKER_WALK; + case AnimKind::ATTACK: return MONSTER_ASSET_LURKER_ATTACK; + case AnimKind::HURT: return MONSTER_ASSET_LURKER_HIT; + case AnimKind::DIE: return MONSTER_ASSET_LURKER_DEATH; + default: return MONSTER_ASSET_LURKER_WALK; + } + default: return 0; + } +} diff --git a/generated/spriteforge_assets.h b/generated/spriteforge_assets.h new file mode 100644 index 0000000..8b234ff --- /dev/null +++ b/generated/spriteforge_assets.h @@ -0,0 +1,200 @@ +/* Generated by SpriteForge. Do not edit. */ +#ifndef SPRITEFORGE_ASSETS_H +#define SPRITEFORGE_ASSETS_H +#include + +typedef enum sf_asset_id { + SF_ASSET_AGENT_ARMOR_JACKET_ATTACK = UINT32_C(0x1C894556), + SF_ASSET_AGENT_ARMOR_JACKET_IDLE = UINT32_C(0xB82571AC), + SF_ASSET_AGENT_ARMOR_JACKET_WALK = UINT32_C(0x3F71CC33), + SF_ASSET_AGENT_ARMOR_PLATE_ATTACK = UINT32_C(0x4EBF60DE), + SF_ASSET_AGENT_ARMOR_PLATE_IDLE = UINT32_C(0x87166A84), + SF_ASSET_AGENT_ARMOR_PLATE_WALK = UINT32_C(0xE8E1EA2B), + SF_ASSET_AGENT_ARMOR_SHROUD_ATTACK = UINT32_C(0xB5831D53), + SF_ASSET_AGENT_ARMOR_SHROUD_IDLE = UINT32_C(0xD9180FC1), + SF_ASSET_AGENT_ARMOR_SHROUD_WALK = UINT32_C(0x0ED988D6), + SF_ASSET_AGENT_ARMOR_VEST = UINT32_C(0xC8F6040D), + SF_ASSET_AGENT_ARMOR_VEST_ATTACK = UINT32_C(0xDBA217C2), + SF_ASSET_AGENT_ARMOR_VEST_IDLE = UINT32_C(0xDF630660), + SF_ASSET_AGENT_ARMOR_VEST_WALK = UINT32_C(0xD6F4927F), + SF_ASSET_AGENT_BODY_SLEEK = UINT32_C(0xC4B41EA2), + SF_ASSET_AGENT_BODY_SLEEK_ATTACK = UINT32_C(0xF1122AAF), + SF_ASSET_AGENT_BODY_SLEEK_IDLE = UINT32_C(0xCD80CAD5), + SF_ASSET_AGENT_BODY_SLEEK_WALK = UINT32_C(0xB0776C42), + SF_ASSET_AGENT_WEAPON_BOW_ATTACK = UINT32_C(0x0761E96D), + SF_ASSET_AGENT_WEAPON_BOW_IDLE = UINT32_C(0x0E0AF843), + SF_ASSET_AGENT_WEAPON_BOW_WALK = UINT32_C(0xF24E8CA0), + SF_ASSET_AGENT_WEAPON_DAGGER_ATTACK = UINT32_C(0x41086BDF), + SF_ASSET_AGENT_WEAPON_DAGGER_IDLE = UINT32_C(0xC9B18F45), + SF_ASSET_AGENT_WEAPON_DAGGER_WALK = UINT32_C(0xF6E01112), + SF_ASSET_AGENT_WEAPON_PISTOL_ATTACK = UINT32_C(0x907FFED8), + SF_ASSET_AGENT_WEAPON_PISTOL_IDLE = UINT32_C(0x0C63E8C6), + SF_ASSET_AGENT_WEAPON_PISTOL_WALK = UINT32_C(0xAA18B06D), + SF_ASSET_AGENT_WEAPON_RIFLE = UINT32_C(0xA68530E2), + SF_ASSET_AGENT_WEAPON_RIFLE_ATTACK = UINT32_C(0xE98508EF), + SF_ASSET_AGENT_WEAPON_RIFLE_IDLE = UINT32_C(0xE828C015), + SF_ASSET_AGENT_WEAPON_RIFLE_WALK = UINT32_C(0xE6E67F82), + SF_ASSET_AGENT_WEAPON_SHOTGUN_ATTACK = UINT32_C(0x6DCC0133), + SF_ASSET_AGENT_WEAPON_SHOTGUN_IDLE = UINT32_C(0x6FAD72A1), + SF_ASSET_AGENT_WEAPON_SHOTGUN_WALK = UINT32_C(0xBEBBA0B6), + SF_ASSET_AGENT_WEAPON_SWORD_ATTACK = UINT32_C(0x6AA4992A), + SF_ASSET_AGENT_WEAPON_SWORD_IDLE = UINT32_C(0x47F65BF8), + SF_ASSET_AGENT_WEAPON_SWORD_WALK = UINT32_C(0x18C0F257), + SF_ASSET_BRUTE_ATTACK = UINT32_C(0xE166F8E8), + SF_ASSET_BRUTE_DEATH = UINT32_C(0x3D96DA4E), + SF_ASSET_BRUTE_HIT = UINT32_C(0xBC752911), + SF_ASSET_BRUTE_WALK = UINT32_C(0xC97ABA5D), + SF_ASSET_FLOOR_BIOMASS = UINT32_C(0x106896E0), + SF_ASSET_FLOOR_CLEAN = UINT32_C(0x9A0C8C83), + SF_ASSET_FLOOR_CRACKED = UINT32_C(0x2C625609), + SF_ASSET_FLOOR_GRATE = UINT32_C(0x20A05799), + SF_ASSET_FLOOR_SLAB = UINT32_C(0x78D3F27E), + SF_ASSET_FLOOR_SLAB_DIM = UINT32_C(0x30CD3FBB), + SF_ASSET_ITEM_ICON_BANDAGE = UINT32_C(0x730D0DDB), + SF_ASSET_ITEM_ICON_BOW = UINT32_C(0x7626252D), + SF_ASSET_ITEM_ICON_CHARM = UINT32_C(0xDB48D1CE), + SF_ASSET_ITEM_ICON_CLEAVER = UINT32_C(0x2516EFDB), + SF_ASSET_ITEM_ICON_JACKET = UINT32_C(0x81553CE1), + SF_ASSET_ITEM_ICON_KNIFE = UINT32_C(0x13043C2C), + SF_ASSET_ITEM_ICON_LANTERN = UINT32_C(0x34C9B6D1), + SF_ASSET_ITEM_ICON_MAGNUM = UINT32_C(0x429211AE), + SF_ASSET_ITEM_ICON_PISTOL = UINT32_C(0xD7A06F30), + SF_ASSET_ITEM_ICON_PLATE = UINT32_C(0x917C60D9), + SF_ASSET_ITEM_ICON_RIFLE = UINT32_C(0x512188A7), + SF_ASSET_ITEM_ICON_SABER = UINT32_C(0xC61BE6EC), + SF_ASSET_ITEM_ICON_SHOTGUN = UINT32_C(0x168A5BFB), + SF_ASSET_ITEM_ICON_SHROUD = UINT32_C(0x8D439236), + SF_ASSET_ITEM_ICON_SMG = UINT32_C(0xE98FD794), + SF_ASSET_ITEM_ICON_SPURS = UINT32_C(0x5F97F1E8), + SF_ASSET_ITEM_ICON_STIMPACK = UINT32_C(0x4D5224BB), + SF_ASSET_ITEM_ICON_VEST = UINT32_C(0x2973B52D), + SF_ASSET_LURKER_ATTACK = UINT32_C(0x9A5A0F2F), + SF_ASSET_LURKER_DEATH = UINT32_C(0xA573E6AB), + SF_ASSET_LURKER_HIT = UINT32_C(0xFF50F498), + SF_ASSET_LURKER_WALK = UINT32_C(0x8FF69CC2), + SF_ASSET_PROP_BARREL = UINT32_C(0x0C0931F1), + SF_ASSET_PROP_CRATE = UINT32_C(0x2DADDAE0), + SF_ASSET_PROP_LOCKER = UINT32_C(0x62DA4D55), + SF_ASSET_RUSHER_ATTACK = UINT32_C(0x219BA505), + SF_ASSET_RUSHER_DEATH = UINT32_C(0xAEBDED05), + SF_ASSET_RUSHER_HIT = UINT32_C(0x3404E732), + SF_ASSET_RUSHER_WALK = UINT32_C(0x442741A8), + SF_ASSET_SHAMBLER_ATTACK = UINT32_C(0x2821014E), + SF_ASSET_SHAMBLER_DEATH = UINT32_C(0x0B950530), + SF_ASSET_SHAMBLER_HIT = UINT32_C(0x8498BC33), + SF_ASSET_SHAMBLER_WALK = UINT32_C(0xDA564B3B), + SF_ASSET_SPITTER_ATTACK = UINT32_C(0x02D54BDB), + SF_ASSET_SPITTER_DEATH = UINT32_C(0x9142CAF7), + SF_ASSET_SPITTER_HIT = UINT32_C(0x84B7B754), + SF_ASSET_SPITTER_WALK = UINT32_C(0xE887C90E), + SF_ASSET_WALL_BLOCK = UINT32_C(0x892CB17B), + SF_ASSET_WALL_INDUSTRIAL = UINT32_C(0x1E7AD4C5), + SF_ASSET_WALL_INFESTED = UINT32_C(0xA5D6F746), +} sf_asset_id; + +typedef enum sf_animation_id { + SF_ANIM_ATTACK = UINT32_C(0x4595B8FD), + SF_ANIM_DEFAULT = UINT32_C(0x933B5BDE), + SF_ANIM_IDLE = UINT32_C(0xC301CF93), + SF_ANIM_WALK = UINT32_C(0xA0262210), +} sf_animation_id; + +typedef enum sf_palette_id { + SF_PALETTE_DEFAULT = UINT32_C(0x933B5BDE), +} sf_palette_id; + +typedef struct sf_asset_metadata { + sf_asset_id id; uint16_t frames; uint16_t directions; uint8_t has_normals; uint8_t has_depth; +} sf_asset_metadata; + +static const sf_asset_metadata SF_ASSET_METADATA[86] = { + {SF_ASSET_AGENT_ARMOR_JACKET_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_JACKET_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_JACKET_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_PLATE_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_PLATE_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_PLATE_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_SHROUD_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_SHROUD_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_SHROUD_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_VEST, 144, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_VEST_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_VEST_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_ARMOR_VEST_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_BODY_SLEEK, 144, 8, 1, 1}, + {SF_ASSET_AGENT_BODY_SLEEK_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_BODY_SLEEK_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_BODY_SLEEK_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_BOW_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_BOW_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_BOW_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_DAGGER_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_DAGGER_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_DAGGER_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_PISTOL_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_PISTOL_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_PISTOL_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_RIFLE, 144, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_RIFLE_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_RIFLE_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_RIFLE_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_SHOTGUN_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_SHOTGUN_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_SHOTGUN_WALK, 64, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_SWORD_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_SWORD_IDLE, 8, 8, 1, 1}, + {SF_ASSET_AGENT_WEAPON_SWORD_WALK, 64, 8, 1, 1}, + {SF_ASSET_BRUTE_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_BRUTE_DEATH, 64, 8, 1, 1}, + {SF_ASSET_BRUTE_HIT, 32, 8, 1, 1}, + {SF_ASSET_BRUTE_WALK, 64, 8, 1, 1}, + {SF_ASSET_FLOOR_BIOMASS, 1, 1, 1, 1}, + {SF_ASSET_FLOOR_CLEAN, 1, 1, 1, 1}, + {SF_ASSET_FLOOR_CRACKED, 1, 1, 1, 1}, + {SF_ASSET_FLOOR_GRATE, 1, 1, 1, 1}, + {SF_ASSET_FLOOR_SLAB, 1, 1, 1, 1}, + {SF_ASSET_FLOOR_SLAB_DIM, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_BANDAGE, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_BOW, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_CHARM, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_CLEAVER, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_JACKET, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_KNIFE, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_LANTERN, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_MAGNUM, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_PISTOL, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_PLATE, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_RIFLE, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_SABER, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_SHOTGUN, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_SHROUD, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_SMG, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_SPURS, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_STIMPACK, 1, 1, 1, 1}, + {SF_ASSET_ITEM_ICON_VEST, 1, 1, 1, 1}, + {SF_ASSET_LURKER_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_LURKER_DEATH, 64, 8, 1, 1}, + {SF_ASSET_LURKER_HIT, 32, 8, 1, 1}, + {SF_ASSET_LURKER_WALK, 64, 8, 1, 1}, + {SF_ASSET_PROP_BARREL, 1, 1, 1, 1}, + {SF_ASSET_PROP_CRATE, 1, 1, 1, 1}, + {SF_ASSET_PROP_LOCKER, 1, 1, 1, 1}, + {SF_ASSET_RUSHER_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_RUSHER_DEATH, 64, 8, 1, 1}, + {SF_ASSET_RUSHER_HIT, 32, 8, 1, 1}, + {SF_ASSET_RUSHER_WALK, 64, 8, 1, 1}, + {SF_ASSET_SHAMBLER_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_SHAMBLER_DEATH, 64, 8, 1, 1}, + {SF_ASSET_SHAMBLER_HIT, 32, 8, 1, 1}, + {SF_ASSET_SHAMBLER_WALK, 64, 8, 1, 1}, + {SF_ASSET_SPITTER_ATTACK, 48, 8, 1, 1}, + {SF_ASSET_SPITTER_DEATH, 64, 8, 1, 1}, + {SF_ASSET_SPITTER_HIT, 32, 8, 1, 1}, + {SF_ASSET_SPITTER_WALK, 64, 8, 1, 1}, + {SF_ASSET_WALL_BLOCK, 1, 1, 1, 1}, + {SF_ASSET_WALL_INDUSTRIAL, 1, 1, 1, 1}, + {SF_ASSET_WALL_INFESTED, 1, 1, 1, 1}, +}; +#define SF_ASSET_COUNT 86u + +#endif diff --git a/tools/__pycache__/agent_assets.cpython-313.pyc b/tools/__pycache__/agent_assets.cpython-313.pyc new file mode 100644 index 0000000..45219c2 Binary files /dev/null and b/tools/__pycache__/agent_assets.cpython-313.pyc differ diff --git a/tools/__pycache__/asset_coverage.cpython-313.pyc b/tools/__pycache__/asset_coverage.cpython-313.pyc new file mode 100644 index 0000000..a9278d4 Binary files /dev/null and b/tools/__pycache__/asset_coverage.cpython-313.pyc differ diff --git a/tools/__pycache__/monster_assets.cpython-313.pyc b/tools/__pycache__/monster_assets.cpython-313.pyc new file mode 100644 index 0000000..dd80c48 Binary files /dev/null and b/tools/__pycache__/monster_assets.cpython-313.pyc differ diff --git a/tools/agent_assets.py b/tools/agent_assets.py new file mode 100644 index 0000000..3293424 --- /dev/null +++ b/tools/agent_assets.py @@ -0,0 +1,904 @@ +"""Agent paper-doll authoring bridge: Blender / raster PNG -> SpriteForge. + +Два входа, один выход. Blender-конвейер (`blender-init` + `blender-render`) +детерминированно рендерит компонент под одной неподвижной ортографической +изометрической камерой. Растровый конвейер (`ingest`) принимает 4x2 доски с +хромакеем от генератора картинок. Оба дают один и тот же лист: направления +подряд, ячейка 70x90, pivot (35,87). + +Ключ к выравниванию бумажной куклы — общий для слоёв, а не индивидуальный +подгон: + +* в Blender начало координат рига всегда проецируется в центр кадра, поэтому + упаковщик режет все кадры ОДНИМ прямоугольником — bbox не участвует вовсе; +* в растре масштаб и смещение считаются один раз по слою тела и лежат в + `_calibration.yaml`; броня и оружие проходят через ту же самую аффинную + подстановку, поэтому сохраняют свои координаты относительно ПОЛНОЙ ячейки. + +Индивидуальный fit по bbox для экипировки запрещён: он растянул бы кинжал на +всю клетку и посадил бы его на уровень стоп. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops +import yaml + +DEFAULT_BLENDER = r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" + +# Фигура тела занимает по высоте ячейку минус этот зазор. +FIT_MARGIN_Y = 6 +# Допуски проверок (в пикселях листа). +PIVOT_BOTTOM_SLACK = 5 +PIVOT_CENTER_SLACK = 8 +# Насколько экипировке разрешено выходить за силуэт тела. +EQUIP_MARGIN = 10 +# Порог зеркального совпадения силуэтов для проверки порядка направлений. +MIRROR_IOU_MIN = 0.55 +# Выше этого юг и север считаются одной и той же картинкой. +DISTINCT_IOU_MAX = 0.98 + + +def fnv1a(text: str) -> int: + value = 0x811C9DC5 + for byte in text.encode("utf-8"): + value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF + return value + + +def symbol(text: str) -> str: + return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").upper() + + +# --------------------------------------------------------------------------- +# Пути и конфигурация +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Workspace: + """Все пути конвейера в одном месте: тесты подставляют временный корень.""" + root: Path + + @property + def catalog(self) -> Path: return self.root / "assets/agents/catalog.yaml" + + @property + def source_root(self) -> Path: return self.root / "assets/sources/agents/sleek" + + @property + def manifest(self) -> Path: return self.root / "assets/manifests/agents.generated.yaml" + + @property + def header(self) -> Path: return self.root / "generated/agent_paperdoll.h" + + @property + def blend(self) -> Path: return self.root / "assets/agents/agent_rig.blend" + + @property + def calibration(self) -> Path: return self.source_root / "_calibration.yaml" + + @property + def rig_script(self) -> Path: return self.root / "tools/blender/agent_rig.py" + + def source_path(self, component: str, animation: str) -> Path: + return self.source_root / f"{component}_{animation}.png" + + +WORKSPACE = Workspace(Path(__file__).resolve().parents[1]) + + +@dataclass(frozen=True) +class Config: + width: int + height: int + pivot_x: int + pivot_y: int + directions: tuple[str, ...] + animations: dict + components: dict + camera: dict + + # Кадр рендера симметричен относительно pivot: начало координат рига + # смотрит в его центр, значит вырезка — постоянный прямоугольник. + @property + def render_width(self) -> int: return 2 * max(self.pivot_x, self.width - self.pivot_x) + + @property + def render_height(self) -> int: return 2 * max(self.pivot_y, self.height - self.pivot_y) + + @property + def crop_x(self) -> int: return self.render_width // 2 - self.pivot_x + + @property + def crop_y(self) -> int: return self.render_height // 2 - self.pivot_y + + def frames(self, animation: str) -> int: + return int(self.animations[animation]["frames"]) + + def cells(self, animation: str) -> int: + return len(self.directions) * self.frames(animation) + + def sheet_size(self, animation: str) -> tuple[int, int]: + return (self.width * self.cells(animation), self.height) + + def slot(self, component: str) -> str: + return self.components[component]["slot"] + + +def load_config(ws: Workspace = WORKSPACE) -> Config: + raw = yaml.safe_load(ws.catalog.read_text(encoding="utf-8")) + if raw.get("version") != 1: + raise ValueError("assets/agents/catalog.yaml: only version 1 is supported") + frame = raw["frame"] + return Config(frame["width"], frame["height"], frame["pivot_x"], frame["pivot_y"], + tuple(raw["directions"]), raw["animations"], raw["components"], + raw.get("camera", {})) + + +def check_names(cfg: Config, component: str, animation: str) -> None: + if component not in cfg.components: + raise ValueError(f"unknown component: {component}") + if animation not in cfg.animations: + raise ValueError(f"unknown animation: {animation}") + + +def asset_id(component: str, animation: str) -> str: + return f"agent_{component}_{animation}" + + +# --------------------------------------------------------------------------- +# Работа с картинками +# --------------------------------------------------------------------------- + +def resize_rgba(image: Image.Image, size: tuple[int, int]) -> Image.Image: + """LANCZOS по предумноженной альфе: иначе по кромке ползёт чёрная кайма.""" + if image.size == size: + return image.copy() + r, g, b, a = image.convert("RGBA").split() + premultiplied = Image.merge("RGBA", (ImageChops.multiply(r, a), + ImageChops.multiply(g, a), + ImageChops.multiply(b, a), a)) + scaled = premultiplied.resize(size, Image.Resampling.LANCZOS) + px = scaled.load() + for y in range(scaled.height): + for x in range(scaled.width): + pr, pg, pb, pa = px[x, y] + if pa: + px[x, y] = (min(255, pr * 255 // pa), min(255, pg * 255 // pa), + min(255, pb * 255 // pa), pa) + else: + px[x, y] = (0, 0, 0, 0) + return scaled + + +def remove_key(image: Image.Image) -> Image.Image: + rgba = image.convert("RGBA") + px = rgba.load(); key = px[0, 0][:3] + for y in range(rgba.height): + for x in range(rgba.width): + r, g, b, _ = px[x, y] + distance = max(abs(r-key[0]), abs(g-key[1]), abs(b-key[2])) + alpha = 0 if distance <= 18 else (255 if distance >= 64 else (distance-18)*255//46) + px[x, y] = (r, g, b, alpha) + return rgba + + +def mask_of(image: Image.Image) -> Image.Image: + return image.convert("RGBA").getchannel("A").point(lambda v: 255 if v >= 24 else 0).convert("1") + + +def cell_at(cfg: Config, sheet: Image.Image, index: int) -> Image.Image: + x = index * cfg.width + return sheet.crop((x, 0, x + cfg.width, cfg.height)) + + +def paste_cell(cfg: Config, sheet: Image.Image, index: int, layer: Image.Image) -> None: + sheet.paste(layer, (index * cfg.width, 0)) + + +# --------------------------------------------------------------------------- +# Blender: создание .blend и рендер +# --------------------------------------------------------------------------- + +def blender_binary(explicit: str | None = None) -> str: + candidate = explicit or os.environ.get("BLENDER_BIN") or DEFAULT_BLENDER + if Path(candidate).is_file(): + return candidate + found = shutil.which(candidate) or shutil.which("blender") + if found: + return found + raise ValueError(f"blender not found: {candidate} (set BLENDER_BIN or pass --blender)") + + +def run_blender(binary: str, args: list[str]) -> None: + process = subprocess.run([binary, *args], capture_output=True, text=True, + encoding="utf-8", errors="replace") + for line in (process.stdout or "").splitlines(): + if line.startswith("[agent_rig]") or line.startswith("Error"): + print(line) + if process.returncode != 0: + sys.stderr.write(process.stdout or "") + sys.stderr.write(process.stderr or "") + raise ValueError(f"blender exited with {process.returncode}") + + +def blender_config(cfg: Config) -> dict: + return {"width": cfg.width, "height": cfg.height, + "pivot_x": cfg.pivot_x, "pivot_y": cfg.pivot_y, + "render_width": cfg.render_width, "render_height": cfg.render_height, + "directions": list(cfg.directions), "animations": cfg.animations, + "camera": cfg.camera} + + +def blender_init(ws: Workspace, cfg: Config, blender: str | None, force: bool) -> Path: + if ws.blend.is_file() and not force: + raise ValueError(f"{ws.blend.relative_to(ws.root).as_posix()} already exists; pass --force to rebuild") + binary = blender_binary(blender) + ws.blend.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as work: + config_path = Path(work) / "config.json" + config_path.write_text(json.dumps(blender_config(cfg)), encoding="utf-8") + run_blender(binary, ["--factory-startup", "-b", "--python", str(ws.rig_script), "--", + "build", "--config", str(config_path), "--out", str(ws.blend)]) + if not ws.blend.is_file(): + raise ValueError("blender finished but wrote no .blend") + print(f"wrote {ws.blend.relative_to(ws.root).as_posix()}") + return ws.blend + + +def pack_render_frames(cfg: Config, animation: str, images: list[Image.Image], + supersample: int) -> Image.Image: + """Кадры Blender -> лист. Одна вырезка на все ячейки, никакого bbox. + + Именно здесь живёт гарантия выравнивания: прямоугольник вырезки зависит + только от каталога, а не от содержимого кадра, поэтому тело, броня и + оружие не могут разъехаться, даже если их силуэты совсем разные. + """ + expected = cfg.cells(animation) + if len(images) != expected: + raise ValueError(f"{animation}: expected {expected} rendered cell(s), got {len(images)}") + if supersample < 1: + raise ValueError("supersample must be >= 1") + source = (cfg.render_width * supersample, cfg.render_height * supersample) + box = (cfg.crop_x * supersample, cfg.crop_y * supersample, + (cfg.crop_x + cfg.width) * supersample, (cfg.crop_y + cfg.height) * supersample) + sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) + for index, image in enumerate(images): + rgba = image.convert("RGBA") + if rgba.size != source: + raise ValueError(f"cell {index}: render is {rgba.size}, expected {source}") + paste_cell(cfg, sheet, index, resize_rgba(rgba.crop(box), (cfg.width, cfg.height))) + return sheet + + +def blender_render(ws: Workspace, cfg: Config, component: str, animation: str, + blender: str | None, blend: Path | None, supersample: int, + keep: Path | None = None) -> Path: + check_names(cfg, component, animation) + blend_path = blend or ws.blend + if not blend_path.is_file(): + raise ValueError(f"no rig at {blend_path}; run `agent_assets.py blender-init` first") + binary = blender_binary(blender) + with tempfile.TemporaryDirectory() as work: + outdir = Path(work) / "frames" + payload = blender_config(cfg) + payload.update({"component": component, "animation": animation, + "frames": cfg.frames(animation), "supersample": supersample, + "outdir": str(outdir)}) + config_path = Path(work) / "config.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + run_blender(binary, ["--factory-startup", "-b", str(blend_path), + "--python", str(ws.rig_script), "--", + "render", "--config", str(config_path)]) + images = [] + for index in range(cfg.cells(animation)): + frame = outdir / f"frame_{index:03d}.png" + if not frame.is_file(): + raise ValueError(f"blender did not write {frame.name}") + images.append(Image.open(frame).copy()) + if keep is not None: + keep.mkdir(parents=True, exist_ok=True) + for frame in sorted(outdir.glob("frame_*.png")): + shutil.copy2(frame, keep / frame.name) + sheet = pack_render_frames(cfg, animation, images, supersample) + target = ws.source_path(component, animation) + target.parent.mkdir(parents=True, exist_ok=True) + sheet.save(target) + return target + + +# --------------------------------------------------------------------------- +# Растровый конвейер: калибровка и ingest +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Calibration: + """Преобразование ячейки доски в кадр листа, общее для ВСЕХ слоёв. + + Числа нормированы на размер ячейки, поэтому доски разных компонентов могут + приходить в разном разрешении — подстановка от этого не зависит. + """ + height_frac: float # высота силуэта тела в долях высоты ячейки + anchor_u: float # центр силуэта по X в долях ширины ячейки + anchor_v: float # низ силуэта по Y в долях высоты ячейки + source: str = "" + + def to_yaml(self) -> str: + return ("# Записано `agent_assets.py ingest` по слою тела.\n" + "# Броня и оружие проходят через ЭТУ ЖЕ подстановку — руками не править.\n" + "version: 1\n" + f"source: {self.source}\n" + f"height_frac: {self.height_frac:.6f}\n" + f"anchor_u: {self.anchor_u:.6f}\n" + f"anchor_v: {self.anchor_v:.6f}\n") + + @staticmethod + def from_yaml(text: str) -> "Calibration": + raw = yaml.safe_load(text) or {} + return Calibration(float(raw["height_frac"]), float(raw["anchor_u"]), + float(raw["anchor_v"]), str(raw.get("source", ""))) + + +def load_calibration(ws: Workspace) -> Calibration | None: + if not ws.calibration.is_file(): + return None + return Calibration.from_yaml(ws.calibration.read_text(encoding="utf-8")) + + +def board_cells(cfg: Config, boards: list[Image.Image]) -> list[list[Image.Image]]: + """boards[frame] -> cells[direction][frame], строгая сетка 4x2.""" + for board in boards: + if board.width % 4 or board.height % 2: + raise ValueError("every direction board must divide exactly into a 4x2 grid") + grid = [] + for direction in range(len(cfg.directions)): + row = [] + for board in boards: + cw, ch = board.width // 4, board.height // 2 + row.append(board.crop(((direction % 4) * cw, (direction // 4) * ch, + (direction % 4 + 1) * cw, (direction // 4 + 1) * ch))) + grid.append(row) + return grid + + +def measure_calibration(cfg: Config, grid: list[list[Image.Image]], source: str) -> Calibration: + """Один масштаб на весь ingest: объединённый bbox по всем направлениям. + + Подгонять каждое направление отдельно нельзя — фигура бы дышала при + повороте, а слои разъехались бы уже между собой. + """ + cw, ch = grid[0][0].size + left, top, right, bottom = cw, ch, 0, 0 + for row in grid: + for cell in row: + box = cell.getchannel("A").getbbox() + if box is None: + continue + left, top = min(left, box[0]), min(top, box[1]) + right, bottom = max(right, box[2]), max(bottom, box[3]) + if right <= left or bottom <= top: + raise ValueError("every cell of the calibration board is empty") + return Calibration(height_frac=(bottom - top) / ch, + anchor_u=(left + right) / 2.0 / cw, + anchor_v=bottom / ch, source=source) + + +def cell_transform(cfg: Config, cal: Calibration, cell_size: tuple[int, int]): + """(scale, offset_x, offset_y) для ячейки: куда ложится её левый верхний угол.""" + cw, ch = cell_size + scale = (cfg.height - FIT_MARGIN_Y) / (cal.height_frac * ch) + return scale, cfg.pivot_x - cal.anchor_u * cw * scale, cfg.pivot_y - cal.anchor_v * ch * scale + + +def place_cell(cfg: Config, cal: Calibration, cell: Image.Image) -> Image.Image: + """Кладёт ЦЕЛУЮ ячейку в кадр 70x90 общей подстановкой. + + Обрезки по bbox тут нет намеренно: кинжал в руке обязан остаться в руке, + а не съехать к стопам и не растянуться на всю клетку. + """ + cw, ch = cell.size + scale, ox, oy = cell_transform(cfg, cal, cell.size) + scaled = resize_rgba(cell, (max(1, round(cw * scale)), max(1, round(ch * scale)))) + layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) + layer.paste(scaled, (round(ox), round(oy))) + return layer + + +def ingest(ws: Workspace, cfg: Config, component: str, animation: str, + inputs: list[Path], calibrate: bool = False) -> Path: + check_names(cfg, component, animation) + expected = cfg.frames(animation) + if len(inputs) != expected: + raise ValueError(f"{animation} requires {expected} direction board(s), got {len(inputs)}") + boards = [remove_key(Image.open(path)) for path in inputs] + grid = board_cells(cfg, boards) + for direction, row in enumerate(grid): + for frame, cell in enumerate(row): + if cell.getchannel("A").getbbox() is None: + raise ValueError(f"empty cell: {cfg.directions[direction]} frame {frame}") + + is_body = cfg.slot(component) == "body" + cal = load_calibration(ws) + if calibrate and not is_body: + raise ValueError("--calibrate is only allowed on a body layer: it sets the shared scale") + if cal is None or calibrate: + if not is_body: + raise ValueError("no _calibration.yaml: ingest the body layer first — equipment " + "must reuse the body's scale, not fit itself") + cal = measure_calibration(cfg, grid, f"{component}/{animation}") + ws.calibration.parent.mkdir(parents=True, exist_ok=True) + ws.calibration.write_text(cal.to_yaml(), encoding="utf-8", newline="\n") + print(f"wrote {ws.calibration.relative_to(ws.root).as_posix()}") + + sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) + for direction, row in enumerate(grid): + for frame, cell in enumerate(row): + paste_cell(cfg, sheet, direction * expected + frame, place_cell(cfg, cal, cell)) + target = ws.source_path(component, animation) + target.parent.mkdir(parents=True, exist_ok=True) + sheet.save(target) + return target + + +# --------------------------------------------------------------------------- +# prompt +# --------------------------------------------------------------------------- + +def prompt_for(cfg: Config, component: str, animation: str) -> str: + check_names(cfg, component, animation) + meta = cfg.components[component] + frames = cfg.frames(animation) + elevation = cfg.camera.get("elevation_deg", 26.565) + layer_rule = ("render only the unarmored fitted base body; hands remain visible" if meta["slot"] == "body" else + "render only this equipment layer on an otherwise invisible body; preserve every occluded gap") + return f"""Production sprite source for the existing sleek dark-sci-fi agent paper-doll. +Component: {component} ({meta['style']}). Animation: {animation}, {frames} frame(s) per direction. +{layer_rule}. Use one fixed orthographic isometric camera, {elevation:g} degrees downward, +identical in every board — the normalizer does NOT re-fit layers to each other. +Directions in exact order: {', '.join(cfg.directions)}. Exact same skeleton, pose timing, +scale, lighting and foot pivot in every component. Realistic lean adult proportions, fitted +near-future covert-operations design; no bulky power armor, space marine, astronaut, chibi, +Among Us, medieval clothing or cape. Fixed dim world-space upper-left light. +For EACH animation frame produce a separate strict 4 columns x 2 rows direction board. +One complete direction per equal cell, flat uniform #ff00ff background, no shadow, text, +grid, labels or extra objects. Keep the figure at the SAME place and size inside every cell: +equipment is mapped by the body's calibration, so a cell-relative shift moves the gear off +the body. Leave generous padding. Output is source art; the normalizer maps the whole cell +into a {cfg.width}x{cfg.height} frame with pivot ({cfg.pivot_x},{cfg.pivot_y}).""" + + +# --------------------------------------------------------------------------- +# sync +# --------------------------------------------------------------------------- + +SYNC_BANNER = [ + "# Собрано `python tools/agent_assets.py sync`.", + "# Записи с backend: import перегенерируются по PNG в assets/sources/agents/sleek/.", + "# Записи с любым другим backend (например blender) sync СОХРАНЯЕТ как есть:", + "# правь их руками здесь, это единственное место, где такая правка переживает sync.", +] + + +def existing_specs(ws: Workspace) -> dict: + if not ws.manifest.is_file(): + return {} + raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} + return {key: value for key, value in raw.items() + if isinstance(value, dict) and "backend" in value} + + +def manual_specs(ws: Workspace) -> dict: + """Ручные спеки — всё, что не `import`: их sync не трогает.""" + return {key: value for key, value in existing_specs(ws).items() + if value.get("backend") != "import"} + + +def write_consolidated_header(ws: Workspace) -> None: + """Generate runtime IDs for multi-animation SpriteForge paper-doll layers.""" + body = fnv1a("agent_body_sleek") + armor = fnv1a("agent_armor_vest") + weapon = fnv1a("agent_weapon_rifle") + lines = [ + "/* Generated by tools/agent_assets.py sync (consolidated Blender assets). */", + "#pragma once", "#include ", '#include "render/anim.h"', + '#include "sim/items.h"', "", + f"constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0x{body:08X});", "", + "constexpr uint32_t AgentBodyAsset(AnimKind) { return AGENT_BODY_ASSET; }", "", + "constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind = AnimKind::IDLE) {", + " switch (id) {", + " case ItemId::JACKET: case ItemId::VEST: case ItemId::PLATE: case ItemId::SHROUD:", + f" return UINT32_C(0x{armor:08X});", + " default: return 0;", " }", "}", "", + "constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind = AnimKind::IDLE) {", + " switch (id) {", + " case ItemId::RIFLE: case ItemId::PISTOL: case ItemId::SHOTGUN:", + " case ItemId::SMG: case ItemId::MAGNUM: case ItemId::BOW:", + " case ItemId::SABER: case ItemId::KNIFE: case ItemId::CLEAVER:", + f" return UINT32_C(0x{weapon:08X});", + " default: return 0;", " }", "}", "", + ] + ws.header.parent.mkdir(parents=True, exist_ok=True) + ws.header.write_text("\n".join(lines), encoding="utf-8", newline="\n") + + +def sync(ws: Workspace, cfg: Config) -> None: + manual = manual_specs(ws) + lines = list(SYNC_BANNER) + ["version: 1", "defaults:", f" dirs: {len(cfg.directions)}", + f" pivot: \"{cfg.pivot_x},{cfg.pivot_y}\"", ""] + for aid, spec in manual.items(): + lines.append(yaml.safe_dump({aid: spec}, sort_keys=False, allow_unicode=True, + default_flow_style=False).rstrip("\n")) + lines.append("") + + generated = 0 + for component in cfg.components: + for animation, anim in cfg.animations.items(): + aid = asset_id(component, animation) + if aid in manual: + continue + path = ws.source_path(component, animation) + if not path.is_file(): + continue + rel = Path(os.path.relpath(path, ws.manifest.parent)).as_posix() + slot = cfg.slot(component) + tags = f"agent, {slot}, {animation}" + if slot in {"armor", "weapon"}: + tags += ", paperdoll_layer" + lines += [f"{aid}:", " backend: import", f" source: {rel}", f" fps: {anim['fps']}", + " params: {frame_width: %d, frame_height: %d, frames: %d}" + % (cfg.width, cfg.height, cfg.frames(animation) * len(cfg.directions)), + f" tags: [{tags}]", ""] + generated += 1 + + ws.manifest.parent.mkdir(parents=True, exist_ok=True) + ws.manifest.write_text("\n".join(lines), encoding="utf-8", newline="\n") + + # A project may keep legacy PNG imports while migrating, but once the + # production Blender manifest exists runtime addressing must use its single + # multi-clip asset per paper-doll layer. + if (ws.root / "assets" / "manifests" / "agents.blender.yaml").is_file(): + write_consolidated_header(ws) + print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} " + f"({generated} import asset(s), {len(manual)} hand-written spec(s) preserved)") + print(f"wrote {ws.header.relative_to(ws.root).as_posix()} (consolidated Blender IDs)") + return + + body = "agent_body_sleek_idle" + armor = {"JACKET": "jacket", "VEST": "vest", "PLATE": "plate", "SHROUD": "shroud"} + weapon = {"RIFLE": "rifle", "PISTOL": "pistol", "SHOTGUN": "shotgun", "SMG": "rifle", + "MAGNUM": "pistol", "BOW": "bow", "SABER": "sword", "KNIFE": "dagger", "CLEAVER": "sword"} + def anim_switch(prefix: str, indent: str) -> list[str]: + return [f"{indent}case AnimKind::{animation.upper()}: return UINT32_C(0x{fnv1a(prefix+'_'+animation):08X});" + for animation in ("idle", "walk", "attack")] + + h = ["/* Generated by tools/agent_assets.py sync. */", "#pragma once", "#include ", + "#include \"render/anim.h\"", "#include \"sim/items.h\"", "", + f"constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0x{fnv1a(body):08X});", "", + "constexpr uint32_t AgentBodyAsset(AnimKind anim) {", " switch (anim) {"] + h += anim_switch("agent_body_sleek", " ") + h += [" default: return AGENT_BODY_ASSET;", " }", "}", "", + "constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind anim = AnimKind::IDLE) {", + " switch (id) {"] + for key, name in armor.items(): + h += [f" case ItemId::{key}:", " switch (anim) {"] + h += anim_switch(f"agent_armor_{name}", " ") + h += [" default: return 0;", " }"] + h += [" default: return 0;", " }", "}", "", + "constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind anim = AnimKind::IDLE) {", " switch (id) {"] + for key, name in weapon.items(): + h += [f" case ItemId::{key}:", " switch (anim) {"] + h += anim_switch(f"agent_weapon_{name}", " ") + h += [" default: return 0;", " }"] + h += [" default: return 0;", " }", "}", ""] + ws.header.parent.mkdir(parents=True, exist_ok=True) + ws.header.write_text("\n".join(h), encoding="utf-8", newline="\n") + print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} " + f"({generated} import asset(s), {len(manual)} hand-written spec(s) preserved)") + print(f"wrote {ws.header.relative_to(ws.root).as_posix()}") + + +# --------------------------------------------------------------------------- +# validate +# --------------------------------------------------------------------------- + +def mirror(cfg: Config, mask: Image.Image) -> Image.Image: + """Отражение вокруг колонки pivot_x, а не вокруг центра холста. + + Разница в полпикселя, но именно она и решает: холст 70 широкий, а pivot + стоит на 35 — центр холста 34.5, и зеркало вокруг него сдвигало бы силуэт. + """ + flipped = mask.convert("L").transform(mask.size, Image.AFFINE, + (-1, 0, 2 * cfg.pivot_x, 0, 1, 0), + resample=Image.Resampling.NEAREST) + return flipped.point(lambda v: 255 if v >= 128 else 0).convert("1") + + +def iou(a: Image.Image, b: Image.Image) -> float: + intersection = sum(ImageChops.logical_and(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) + union = sum(ImageChops.logical_or(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) + return intersection / union if union else 1.0 + + +def union_masks(cfg: Config, sheet: Image.Image, animation: str) -> list[Image.Image]: + """По одной маске на направление: объединение всех кадров цикла. + + Сравнивать покадрово нельзя — в середине шага у зеркальных направлений + вперёд вынесена разная нога, и честное отражение дало бы ложную тревогу. + """ + frames = cfg.frames(animation) + out = [] + for direction in range(len(cfg.directions)): + merged = Image.new("1", (cfg.width, cfg.height), 0) + for frame in range(frames): + merged = ImageChops.logical_or(merged, mask_of(cell_at(cfg, sheet, direction * frames + frame))) + out.append(merged) + return out + + +def bbox_of(cell: Image.Image): + return mask_of(cell).convert("L").getbbox() + + +@dataclass +class Report: + errors: list[str] + warnings: list[str] + + def fail(self, message: str) -> None: self.errors.append(message) + + def warn(self, message: str) -> None: self.warnings.append(message) + + +def check_sheet(cfg: Config, path: Path, animation: str, report: Report) -> Image.Image | None: + if not path.is_file(): + report.fail(f"missing: {path.name}") + return None + sheet = Image.open(path).convert("RGBA") + expected = cfg.sheet_size(animation) + if sheet.size != expected: + report.fail(f"{path.name}: canvas {sheet.size}, expected {expected} " + f"({len(cfg.directions)} directions x {cfg.frames(animation)} frame(s) of " + f"{cfg.width}x{cfg.height})") + return None + if sheet.getextrema()[3][0] != 0: + report.fail(f"{path.name}: no transparent pixels — the key was not removed") + for index in range(cfg.cells(animation)): + if bbox_of(cell_at(cfg, sheet, index)) is None: + direction = cfg.directions[index // cfg.frames(animation)] + report.fail(f"{path.name}: empty cell {index} ({direction} frame {index % cfg.frames(animation)})") + return sheet + + +def check_body_pivot(cfg: Config, name: str, sheet: Image.Image, animation: str, report: Report) -> None: + for index in range(cfg.cells(animation)): + box = bbox_of(cell_at(cfg, sheet, index)) + if box is None: + continue + if not (cfg.pivot_y - PIVOT_BOTTOM_SLACK <= box[3] <= cfg.height): + report.fail(f"{name}: cell {index} stands on y={box[3]}, pivot_y is {cfg.pivot_y}") + # A dynamic attack may extend one arm far to the side; bbox centre is + # not the character origin. The static idle pose remains a useful + # authoring check, while every animation still has to stand on pivot_y. + if animation == "idle": + center = (box[0] + box[2]) / 2.0 + if abs(center - cfg.pivot_x) > PIVOT_CENTER_SLACK: + report.fail(f"{name}: cell {index} centred at x={center:.1f}, pivot_x is {cfg.pivot_x}") + + +def check_layer_alignment(cfg: Config, name: str, sheet: Image.Image, body: Image.Image, + animation: str, slot: str, report: Report) -> None: + """Экипировка обязана лежать внутри силуэта тела плюс небольшой запас. + + Так ловится ровно та поломка, ради которой переписан ingest: слой, который + подогнали по собственному bbox, вылезает за тело сразу во всех ячейках. + """ + for index in range(cfg.cells(animation)): + gear = bbox_of(cell_at(cfg, sheet, index)) + host = bbox_of(cell_at(cfg, body, index)) + if gear is None or host is None: + continue + if slot == "weapon": + # Blades and bows legitimately extend well outside the body. What + # the broken per-bbox fitter produced was a layer inflated to + # almost the entire 70x90 cell, independent of attachment point. + if gear[2] - gear[0] >= cfg.width - 8 or gear[3] - gear[1] >= cfg.height - 10: + report.fail(f"{name}: cell {index} bbox {gear} fills almost the whole frame " + "— weapon was fitted on its own") + continue + limits = (host[0] - EQUIP_MARGIN, host[1] - EQUIP_MARGIN, + host[2] + EQUIP_MARGIN, host[3] + EQUIP_MARGIN) + if gear[0] < limits[0] or gear[1] < limits[1] or gear[2] > limits[2] or gear[3] > limits[3]: + report.fail(f"{name}: cell {index} bbox {gear} escapes the body {host} " + f"by more than {EQUIP_MARGIN}px — layer was fitted on its own") + + +def check_direction_order(cfg: Config, name: str, sheet: Image.Image, animation: str, + report: Report) -> None: + """Порядок направлений — насколько его вообще видно текстом. + + Каталог обещает юг, юго-запад, запад, ... — значит пары (ЮЗ,ЮВ), (З,В), + (СЗ,СВ) обязаны совпадать при отражении вокруг pivot_x, а юг и север — + различаться. Это не доказательство порядка, но любую перестановку соседей + оно ловит. + """ + names = cfg.directions + if len(names) != 8: + return + masks = union_masks(cfg, sheet, animation) + for left, right in ((1, 7), (2, 6), (3, 5)): + score = iou(masks[left], mirror(cfg, masks[right])) + if score < MIRROR_IOU_MIN: + report.warn(f"{name}: {names[left]} and {names[right]} are not mirror images " + f"(IoU {score:.2f} < {MIRROR_IOU_MIN}) — direction order or camera differs") + for index in (0, 4): + score = iou(masks[index], mirror(cfg, masks[index])) + if score < MIRROR_IOU_MIN: + report.warn(f"{name}: {names[index]} is not symmetric about pivot_x " + f"(IoU {score:.2f}) — that cell may not face the camera") + # Side-on cardinals should normally be no wider than their neighbouring + # diagonals. This catches the common board-order swap that mirror-pair + # checks alone cannot see for simple/symmetric silhouettes. + if animation == "idle": + widths = [] + for mask in masks: + box = mask.convert("L").getbbox() + widths.append(0 if box is None else box[2] - box[0]) + for side, before, after in ((2, 1, 3), (6, 5, 7)): + if widths[side] > max(widths[before], widths[after]): + report.warn(f"{name}: {names[side]} is wider than both adjacent diagonals " + "— direction order may be shuffled") + + similarities = [iou(masks[0], masks[index]) for index in range(1, 8)] + if all(score > DISTINCT_IOU_MAX for score in similarities): + report.warn(f"{name}: all eight directions have the same silhouette " + "— the board may hold one direction eight times") + + +def check_manifest(ws: Workspace, cfg: Config, report: Report) -> None: + if not ws.manifest.is_file(): + return + raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} + defaults = raw.get("defaults", {}) + if defaults.get("dirs") != len(cfg.directions): + report.fail(f"manifest defaults.dirs is {defaults.get('dirs')}, catalog has {len(cfg.directions)}") + if defaults.get("pivot") != f"{cfg.pivot_x},{cfg.pivot_y}": + report.fail(f"manifest defaults.pivot is {defaults.get('pivot')!r}, " + f"catalog has '{cfg.pivot_x},{cfg.pivot_y}'") + for aid, spec in raw.items(): + if not isinstance(spec, dict) or spec.get("backend") != "import": + continue + params = spec.get("params", {}) + if params.get("frame_width") != cfg.width or params.get("frame_height") != cfg.height: + report.fail(f"manifest {aid}: frame {params.get('frame_width')}x{params.get('frame_height')}, " + f"catalog has {cfg.width}x{cfg.height}") + + +def validate(ws: Workspace, cfg: Config, complete: bool = False, strict: bool = False) -> int: + report = Report([], []) + check_manifest(ws, cfg, report) + bodies = [c for c in cfg.components if cfg.slot(c) == "body"] + for animation in cfg.animations: + sheets = {} + for component in cfg.components: + path = ws.source_path(component, animation) + if not (complete or path.is_file()): + continue + sheet = check_sheet(cfg, path, animation, report) + if sheet is None: + continue + sheets[component] = sheet + name = path.name + if cfg.slot(component) == "body": + check_body_pivot(cfg, name, sheet, animation, report) + check_direction_order(cfg, name, sheet, animation, report) + body = next((sheets[c] for c in bodies if c in sheets), None) + if body is not None: + for component, sheet in sheets.items(): + if cfg.slot(component) != "body": + check_layer_alignment(cfg, ws.source_path(component, animation).name, + sheet, body, animation, cfg.slot(component), report) + elif sheets: + report.warn(f"{animation}: no body layer — cross-layer alignment unchecked") + + for warning in report.warnings: + print(f"{'ERROR' if strict else 'WARN'}: {warning}") + for error in report.errors: + print(f"ERROR: {error}") + failures = len(report.errors) + (len(report.warnings) if strict else 0) + print(f"agent assets: {len(report.errors)} error(s), {len(report.warnings)} warning(s)") + return 1 if failures else 0 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> int: + ws = WORKSPACE + cfg = load_config(ws) + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + + prompt = sub.add_parser("prompt", help="art brief for a raster generator") + prompt.add_argument("component"); prompt.add_argument("animation") + + ingest_p = sub.add_parser("ingest", help="normalize 4x2 chroma-key boards into a sheet") + ingest_p.add_argument("component"); ingest_p.add_argument("animation") + ingest_p.add_argument("inputs", nargs="+", type=Path) + ingest_p.add_argument("--calibrate", action="store_true", + help="re-measure the shared scale from this body layer") + + init = sub.add_parser("blender-init", help="create the rig .blend with Blender") + init.add_argument("--blender"); init.add_argument("--force", action="store_true") + + render = sub.add_parser("blender-render", help="render one component+animation into a sheet") + render.add_argument("component"); render.add_argument("animation") + render.add_argument("--blender"); render.add_argument("--blend", type=Path) + render.add_argument("--supersample", type=int, default=4) + render.add_argument("--keep-renders", type=Path) + + render_all = sub.add_parser("blender-render-all", help="batch-render catalog components") + render_all.add_argument("components", nargs="*", help="default: every catalog component") + render_all.add_argument("--animations", nargs="+", choices=list(cfg.animations), + default=list(cfg.animations)) + render_all.add_argument("--blender"); render_all.add_argument("--blend", type=Path) + render_all.add_argument("--supersample", type=int, default=2) + render_all.add_argument("--force", action="store_true", help="replace existing sheets") + + sub.add_parser("sync", help="regenerate the SpriteForge manifest and the C++ header") + + val = sub.add_parser("validate", help="check canvas, pivot, layer alignment, direction order") + val.add_argument("--complete", action="store_true", help="missing layers are errors") + val.add_argument("--strict", action="store_true", help="warnings are errors") + + args = parser.parse_args() + if args.command == "prompt": + print(prompt_for(cfg, args.component, args.animation)); return 0 + if args.command == "ingest": + print(ingest(ws, cfg, args.component, args.animation, args.inputs, args.calibrate)) + sync(ws, cfg); return 0 + if args.command == "blender-init": + blender_init(ws, cfg, args.blender, args.force); return 0 + if args.command == "blender-render": + print(blender_render(ws, cfg, args.component, args.animation, args.blender, + args.blend, args.supersample, args.keep_renders)) + sync(ws, cfg); return 0 + if args.command == "blender-render-all": + components = args.components or list(cfg.components) + for component in components: + if component not in cfg.components: + raise ValueError(f"unknown component: {component}") + done = skipped = 0 + for component in components: + for animation in args.animations: + target = ws.source_path(component, animation) + if target.is_file() and not args.force: + print(f"skip {target.relative_to(ws.root).as_posix()}") + skipped += 1 + continue + print(blender_render(ws, cfg, component, animation, args.blender, + args.blend, args.supersample)) + done += 1 + sync(ws, cfg) + print(f"batch render: {done} rendered, {skipped} cached") + return 0 + if args.command == "sync": + sync(ws, cfg); return 0 + return validate(ws, cfg, args.complete, args.strict) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError) as error: + print(f"ERROR: {error}", file=sys.stderr); raise SystemExit(1) diff --git a/tools/asset_coverage.py b/tools/asset_coverage.py new file mode 100644 index 0000000..0d1e822 --- /dev/null +++ b/tools/asset_coverage.py @@ -0,0 +1,72 @@ +"""Text-only coverage gate for every visual kind currently present in the game.""" +from __future__ import annotations + +import argparse +from collections import defaultdict +from pathlib import Path +import sys + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +COVERAGE = ROOT / "assets/content_coverage.yaml" +MANIFESTS = ROOT / "assets/manifests" + + +def declared_assets() -> set[str]: + result: set[str] = set() + for path in sorted(MANIFESTS.glob("*.yaml")): + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + result.update(key for key, value in raw.items() + if key not in {"version", "defaults"} and isinstance(value, dict)) + return result + + +def requirements(spec: dict) -> dict[str, list[str]]: + result: dict[str, list[str]] = defaultdict(list) + agent = spec["agents"] + for slot, components in agent["components"].items(): + for component in components: + for animation in agent["animations"]: + result[f"agent/{slot}"].append(f"agent_{component}_{animation}") + for kind in spec["enemies"]["kinds"]: + for animation in spec["enemies"]["animations"]: + result["enemy"].append(f"{kind}_{animation}") + result["environment"].extend(spec["environment"]["required"]) + result["item_icon"].extend(spec["item_icons"]["required"]) + return result + + +def report(release: bool) -> int: + spec = yaml.safe_load(COVERAGE.read_text(encoding="utf-8")) + if spec.get("version") != 1: + raise ValueError("assets/content_coverage.yaml: unsupported version") + have = declared_assets() + groups = requirements(spec) + missing_total = 0 + print(f"{'GROUP':<20} {'HAVE':>5} {'NEED':>5} {'MISSING':>7}") + print("-" * 40) + for group, wanted in groups.items(): + missing = sorted(set(wanted) - have) + missing_total += len(missing) + print(f"{group:<20} {len(wanted)-len(missing):>5} {len(wanted):>5} {len(missing):>7}") + for asset in missing: + print(f" - {asset}") + covered = sum(len(v) for v in groups.values()) - missing_total + total = sum(len(v) for v in groups.values()) + print(f"coverage: {covered}/{total}; missing={missing_total}") + return 1 if release and missing_total else 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--release", action="store_true", help="fail if any required asset is absent") + return report(parser.parse_args().release) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(2) diff --git a/tools/blender/__pycache__/monster_rig.cpython-313.pyc b/tools/blender/__pycache__/monster_rig.cpython-313.pyc new file mode 100644 index 0000000..9228c9a Binary files /dev/null and b/tools/blender/__pycache__/monster_rig.cpython-313.pyc differ diff --git a/tools/blender/agent_rig.py b/tools/blender/agent_rig.py new file mode 100644 index 0000000..ee6200a --- /dev/null +++ b/tools/blender/agent_rig.py @@ -0,0 +1,949 @@ +"""Blender-сторона конвейера модульных агентов. Запускается ВНУТРИ Blender. + + blender --factory-startup -b --python tools/blender/agent_rig.py -- build --config cfg.json + blender --factory-startup -b rig.blend --python tools/blender/agent_rig.py -- render --config cfg.json + +Скрипт ничего не знает про SpriteForge и про игру: он строит детерминированную +сцену (риг, тело, броня, оружие, точки привязки) и рендерит один компонент под +одной фиксированной ортографической изометрической камерой. + +Правило выравнивания: камера СТОИТ, поворачивается модель. Начало координат +рига (точка между стопами) всегда проецируется ровно в центр кадра, поэтому +упаковщик на стороне Python режет все кадры одним и тем же прямоугольником и +никакого подгона по bbox для брони и оружия не существует в принципе. + +PyYAML внутри Blender нет — конфиг приходит одним JSON-файлом. +""" +from __future__ import annotations + +import json +import math +import os +import sys + +import bmesh +import bpy +from mathutils import Matrix, Quaternion, Vector + +# ---------------------------------------------------------------------------- +# Пропорции. Худой взрослый оперативник, 1.80 м, размах плеч 0.40 — +# это силуэт Splinter Cell, а не силовая броня. +# ---------------------------------------------------------------------------- + +BONES = [ + # name, head, tail, parent + ("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None), + ("hips", (0.00, 0.00, 0.95), (0.00, 0.00, 1.08), "root"), + ("spine", (0.00, 0.00, 1.08), (0.00, 0.00, 1.30), "hips"), + ("chest", (0.00, 0.00, 1.30), (0.00, 0.00, 1.50), "spine"), + ("neck", (0.00, 0.00, 1.50), (0.00, 0.00, 1.58), "chest"), + ("head", (0.00, 0.00, 1.58), (0.00, 0.00, 1.80), "neck"), + ("shoulder_r", (0.00, 0.00, 1.46), (-0.17, 0.00, 1.45), "chest"), + ("upper_arm_r", (-0.19, 0.00, 1.44), (-0.20, 0.00, 1.15), "shoulder_r"), + ("forearm_r", (-0.20, 0.00, 1.15), (-0.21, 0.00, 0.89), "upper_arm_r"), + ("hand_r", (-0.21, 0.00, 0.89), (-0.21, 0.00, 0.80), "forearm_r"), + ("shoulder_l", (0.00, 0.00, 1.46), (0.17, 0.00, 1.45), "chest"), + ("upper_arm_l", (0.19, 0.00, 1.44), (0.20, 0.00, 1.15), "shoulder_l"), + ("forearm_l", (0.20, 0.00, 1.15), (0.21, 0.00, 0.89), "upper_arm_l"), + ("hand_l", (0.21, 0.00, 0.89), (0.21, 0.00, 0.80), "forearm_l"), + ("thigh_r", (-0.09, 0.00, 0.93), (-0.10, 0.00, 0.50), "hips"), + ("shin_r", (-0.10, 0.00, 0.50), (-0.10, 0.00, 0.08), "thigh_r"), + ("foot_r", (-0.10, 0.00, 0.08), (-0.10, -0.16, 0.02), "shin_r"), + ("thigh_l", (0.09, 0.00, 0.93), (0.10, 0.00, 0.50), "hips"), + ("shin_l", (0.10, 0.00, 0.50), (0.10, 0.00, 0.08), "thigh_l"), + ("foot_l", (0.10, 0.00, 0.08), (0.10, -0.16, 0.02), "shin_l"), +] + +# Персонаж смотрит в -Y (на камеру) — это направление `south`. +FACE = Vector((0.0, -1.0, 0.0)) + +ATTACHMENTS = { + "hand_r": (-0.21, -0.02, 0.87), + "hand_l": (0.21, -0.02, 0.87), + "chest": (0.00, 0.10, 1.36), + "hips": (-0.12, 0.02, 0.98), + "head": (0.00, 0.00, 1.70), +} + +MATERIALS = { + # name: (base color RGB, roughness, metallic) + "skin": ((0.42, 0.32, 0.27), 0.72, 0.00), + "bodysuit": ((0.035, 0.042, 0.052), 0.72, 0.02), + "webbing": ((0.06, 0.07, 0.08), 0.85, 0.00), + "cloth": ((0.13, 0.14, 0.15), 0.80, 0.00), + # Keep equipment inside the same near-black value family as the suit. + # Faction colour belongs on tiny markings; a bright whole torso destroys + # the low-resolution silhouette and reads as a cartoon T-shirt. + "ballistic": ((0.075, 0.085, 0.080), 0.64, 0.04), + "ceramic": ((0.17, 0.18, 0.19), 0.45, 0.02), + "shroud": ((0.07, 0.08, 0.10), 0.90, 0.00), + "gunmetal": ((0.08, 0.09, 0.10), 0.38, 0.75), + "polymer": ((0.10, 0.10, 0.11), 0.60, 0.00), + "blade": ((0.52, 0.55, 0.58), 0.22, 0.90), + "wood": ((0.16, 0.12, 0.09), 0.75, 0.00), + "helmet": ((0.055, 0.065, 0.075), 0.42, 0.18), + "visor": ((0.015, 0.20, 0.27), 0.14, 0.58), + "rubber": ((0.025, 0.028, 0.032), 0.88, 0.00), + "marking": ((0.32, 0.075, 0.055), 0.62, 0.04), +} + +# Каждая деталь: (имя, кость, материал, форма, параметры). +# Формы: capsule(p0, p1, r0, r1) | box(center, size, yaw) | sphere(center, r, scale) + +BODY_PARTS = [ + # Fully covered covert operator: a readable helmet/visor/respirator cluster + # replaces the bald mannequin head and remains distinct at 28 px/m. + ("head", "head", "rubber", ("sphere", ((0.00, -0.01, 1.68), 0.096, (0.90, 1.00, 1.08)))), + ("helmet", "head", "helmet", ("sphere", ((0.00, 0.00, 1.715), 0.112, (1.03, 1.02, 0.78)))), + ("visor", "head", "visor", ("box", ((0.00, -0.102, 1.695), (0.155, 0.035, 0.060), 0.0))), + ("respirator", "head", "rubber", ("box", ((0.00, -0.105, 1.625), (0.105, 0.045, 0.072), 0.0))), + ("neck", "neck", "rubber", ("capsule", ((0.00, 0.00, 1.49), (0.00, -0.01, 1.59), 0.052, 0.048))), + ("chest", "chest", "bodysuit", ("capsule", ((0.00, 0.00, 1.27), (0.00, 0.00, 1.47), 0.145, 0.165))), + ("shoulders", "chest", "bodysuit", ("capsule", ((-0.23, 0.00, 1.45), (0.23, 0.00, 1.45), 0.082, 0.082))), + ("chest_rig", "chest", "webbing", ("box", ((0.00, -0.125, 1.34), (0.255, 0.050, 0.235), 0.0))), + ("id_stripe", "chest", "marking", ("box", ((-0.105, -0.155, 1.40), (0.040, 0.018, 0.135), 0.0))), + ("shoulder_shell_r", "shoulder_r", "helmet", ("box", ((-0.205, 0.00, 1.43), (0.115, 0.145, 0.095), 0.0))), + ("shoulder_shell_l", "shoulder_l", "helmet", ("box", ((0.205, 0.00, 1.43), (0.115, 0.145, 0.095), 0.0))), + ("backpack", "chest", "helmet", ("box", ((0.00, 0.135, 1.31), (0.245, 0.105, 0.285), 0.0))), + ("abdomen", "spine", "bodysuit", ("capsule", ((0.00, 0.00, 1.09), (0.00, 0.00, 1.29), 0.122, 0.140))), + ("belt", "hips", "webbing", ("box", ((0.00, 0.00, 1.055), (0.285, 0.215, 0.060), 0.0))), + ("holster", "hips", "helmet", ("box", ((0.155, -0.015, 0.92), (0.085, 0.095, 0.185), 0.0))), + ("pelvis", "hips", "bodysuit", ("capsule", ((0.00, 0.00, 0.94), (0.00, 0.00, 1.10), 0.128, 0.122))), + ("upper_arm_r", "upper_arm_r", "bodysuit", ("capsule", ((-0.19, 0.00, 1.44), (-0.20, 0.00, 1.15), 0.072, 0.060))), + ("forearm_r", "forearm_r", "bodysuit", ("capsule", ((-0.20, 0.00, 1.15), (-0.21, 0.00, 0.89), 0.064, 0.052))), + ("hand_r", "hand_r", "rubber", ("capsule", ((-0.21, 0.00, 0.89), (-0.21, -0.01, 0.80), 0.042, 0.036))), + ("upper_arm_l", "upper_arm_l", "bodysuit", ("capsule", ((0.19, 0.00, 1.44), (0.20, 0.00, 1.15), 0.072, 0.060))), + ("forearm_l", "forearm_l", "bodysuit", ("capsule", ((0.20, 0.00, 1.15), (0.21, 0.00, 0.89), 0.064, 0.052))), + ("hand_l", "hand_l", "rubber", ("capsule", ((0.21, 0.00, 0.89), (0.21, -0.01, 0.80), 0.042, 0.036))), + ("thigh_r", "thigh_r", "bodysuit", ("capsule", ((-0.09, 0.00, 0.94), (-0.10, 0.00, 0.50), 0.094, 0.074))), + ("shin_r", "shin_r", "bodysuit", ("capsule", ((-0.10, 0.00, 0.50), (-0.10, 0.00, 0.09), 0.074, 0.056))), + ("knee_r", "shin_r", "helmet", ("box", ((-0.10, -0.055, 0.51), (0.105, 0.070, 0.105), 0.0))), + ("foot_r", "foot_r", "rubber", ("box", ((-0.10, -0.05, 0.050), (0.115, 0.255, 0.105), 0.0))), + ("thigh_l", "thigh_l", "bodysuit", ("capsule", ((0.09, 0.00, 0.94), (0.10, 0.00, 0.50), 0.094, 0.074))), + ("shin_l", "shin_l", "bodysuit", ("capsule", ((0.10, 0.00, 0.50), (0.10, 0.00, 0.09), 0.074, 0.056))), + ("knee_l", "shin_l", "helmet", ("box", ((0.10, -0.055, 0.51), (0.105, 0.070, 0.105), 0.0))), + ("foot_l", "foot_l", "rubber", ("box", ((0.10, -0.05, 0.050), (0.115, 0.255, 0.105), 0.0))), +] + +# Броня. Каждый слой — тонкая оболочка поверх тела, силуэт почти не растёт: +# отряд должен читаться по одному и тому же пятну в любой экипировке. +ARMOR_PARTS = { + "jacket": [ + ("torso", "chest", "cloth", ("capsule", ((0.00, 0.00, 1.12), (0.00, 0.00, 1.47), 0.140, 0.158))), + ("sleeve_r", "upper_arm_r", "cloth", ("capsule", ((-0.19, 0.00, 1.45), (-0.20, 0.00, 1.18), 0.058, 0.050))), + ("sleeve_l", "upper_arm_l", "cloth", ("capsule", ((0.19, 0.00, 1.45), (0.20, 0.00, 1.18), 0.058, 0.050))), + ("strap_v", "chest", "webbing", ("box", ((-0.05, -0.13, 1.32), (0.045, 0.030, 0.300), 0.0))), + ("strap_h", "chest", "webbing", ("box", ((0.00, 0.00, 1.19), (0.300, 0.230, 0.036), 0.0))), + ("pouch", "hips", "webbing", ("box", ((0.10, -0.10, 1.06), (0.085, 0.060, 0.080), 0.0))), + ], + "vest": [ + ("shell", "chest", "ballistic", ("capsule", ((0.00, 0.00, 1.18), (0.00, 0.00, 1.45), 0.138, 0.152))), + ("collar", "chest", "ballistic", ("capsule", ((-0.09, 0.00, 1.47), (0.09, 0.00, 1.47), 0.048, 0.048))), + ("mag_a", "chest", "polymer", ("box", ((-0.07, -0.14, 1.26), (0.070, 0.045, 0.110), 0.0))), + ("mag_b", "chest", "polymer", ("box", ((0.03, -0.14, 1.26), (0.070, 0.045, 0.110), 0.0))), + ("belt", "hips", "webbing", ("box", ((0.00, 0.00, 1.02), (0.250, 0.200, 0.045), 0.0))), + ], + "plate": [ + ("chest_up", "chest", "ceramic", ("box", ((0.00, -0.06, 1.40), (0.290, 0.130, 0.115), 0.0))), + ("chest_lo", "chest", "ceramic", ("box", ((0.00, -0.05, 1.26), (0.270, 0.130, 0.115), 0.0))), + ("back", "chest", "ceramic", ("box", ((0.00, 0.07, 1.33), (0.280, 0.100, 0.250), 0.0))), + ("cap_r", "shoulder_r", "ceramic", ("capsule", ((-0.13, 0.00, 1.47), (-0.21, 0.00, 1.42), 0.062, 0.052))), + ("cap_l", "shoulder_l", "ceramic", ("capsule", ((0.13, 0.00, 1.47), (0.21, 0.00, 1.42), 0.062, 0.052))), + ("skirt", "hips", "ceramic", ("box", ((0.00, 0.00, 1.02), (0.260, 0.215, 0.090), 0.0))), + ], + "shroud": [ + ("torso", "chest", "shroud", ("capsule", ((0.00, 0.00, 1.08), (0.00, 0.00, 1.48), 0.133, 0.150))), + ("hood", "head", "shroud", ("sphere", ((0.00, 0.02, 1.70), 0.112, (0.94, 1.06, 1.10)))), + ("mask", "head", "shroud", ("box", ((0.00, -0.08, 1.65), (0.130, 0.060, 0.090), 0.0))), + ("wrap_r", "forearm_r", "shroud", ("capsule", ((-0.20, 0.00, 1.16), (-0.21, 0.00, 0.94), 0.048, 0.040))), + ("wrap_l", "forearm_l", "shroud", ("capsule", ((0.20, 0.00, 1.16), (0.21, 0.00, 0.94), 0.048, 0.040))), + ], +} + +# Оружие. Всё висит на кости hand_r, координаты — мировые в позе покоя. +HAND_R = Vector((-0.21, -0.02, 0.86)) + +WEAPON_PARTS = { + "sword": [ + ("grip", "hand_r", "wood", ("capsule", ((-0.21, 0.02, 0.90), (-0.21, -0.06, 0.84), 0.020, 0.020))), + ("guard", "hand_r", "gunmetal", ("box", ((-0.21, -0.09, 0.83), (0.150, 0.030, 0.026), 0.0))), + ("blade", "hand_r", "blade", ("capsule", ((-0.21, -0.10, 0.82), (-0.23, -0.82, 0.72), 0.021, 0.010))), + ], + "dagger": [ + ("grip", "hand_r", "polymer", ("capsule", ((-0.21, 0.01, 0.89), (-0.21, -0.05, 0.85), 0.018, 0.018))), + ("blade", "hand_r", "blade", ("capsule", ((-0.21, -0.06, 0.84), (-0.22, -0.28, 0.79), 0.017, 0.007))), + ], + "rifle": [ + # The weapon crosses the torso by ~12 degrees instead of pointing exactly + # along local -Y. The deliberate cheat keeps it readable in north/south + # sprite directions where a physically straight rifle collapses to 1 px. + ("receiver", "hand_r", "gunmetal", ("box", ((-0.10, -0.20, 0.92), (0.090, 0.320, 0.110), -0.21))), + ("barrel", "hand_r", "gunmetal", ("capsule", ((-0.13, -0.34, 0.93), (-0.27, -0.68, 0.93), 0.030, 0.024))), + ("stock", "hand_r", "polymer", ("box", ((-0.02, 0.02, 0.90), (0.078, 0.210, 0.095), -0.21))), + ("mag", "hand_r", "polymer", ("box", ((-0.09, -0.16, 0.83), (0.060, 0.080, 0.150), -0.21))), + ("optic", "hand_r", "visor", ("capsule", ((-0.11, -0.24, 1.00), (-0.05, -0.09, 1.00), 0.024, 0.024))), + ], + "shotgun": [ + ("receiver", "hand_r", "gunmetal", ("box", ((-0.10, -0.18, 0.92), (0.070, 0.270, 0.090), -0.21))), + ("barrel", "hand_r", "gunmetal", ("capsule", ((-0.13, -0.30, 0.94), (-0.25, -0.61, 0.94), 0.026, 0.023))), + ("tube", "hand_r", "gunmetal", ("capsule", ((-0.13, -0.30, 0.89), (-0.23, -0.57, 0.89), 0.020, 0.017))), + ("stock", "hand_r", "wood", ("box", ((-0.02, 0.02, 0.89), (0.060, 0.180, 0.078), -0.21))), + ], + "pistol": [ + ("slide", "hand_r", "gunmetal", ("box", ((-0.20, -0.11, 0.92), (0.032, 0.150, 0.048), 0.0))), + ("can", "hand_r", "gunmetal", ("capsule", ((-0.20, -0.18, 0.92), (-0.20, -0.32, 0.92), 0.021, 0.021))), + ("grip", "hand_r", "polymer", ("box", ((-0.20, -0.04, 0.84), (0.030, 0.052, 0.110), 0.0))), + ], + "bow": [ + ("limb_up", "hand_r", "polymer", ("capsule", ((-0.21, -0.06, 0.90), (-0.24, -0.20, 1.14), 0.017, 0.012))), + ("limb_lo", "hand_r", "polymer", ("capsule", ((-0.21, -0.06, 0.84), (-0.24, -0.20, 0.60), 0.017, 0.012))), + ("riser", "hand_r", "gunmetal", ("capsule", ((-0.21, -0.05, 0.94), (-0.21, -0.05, 0.80), 0.021, 0.021))), + ("cam_up", "hand_r", "gunmetal", ("capsule", ((-0.245, -0.22, 1.15), (-0.235, -0.18, 1.15), 0.028, 0.028))), + ("cam_lo", "hand_r", "gunmetal", ("capsule", ((-0.245, -0.22, 0.59), (-0.235, -0.18, 0.59), 0.028, 0.028))), + ("string", "hand_r", "webbing", ("capsule", ((-0.24, -0.21, 1.15), (-0.24, -0.21, 0.59), 0.005, 0.005))), + ], +} + +# Fitted modules for the imported universal humanoid. Coordinates are metres +# in its authored T-pose; every object is rigidly attached to the deform rig, +# so the same source actions drive body, armor and held equipment. +SOURCE_BODY_PARTS = [ + ("helmet", "DEF-head", "helmet", ("sphere", ((0.00, -0.005, 1.735), 0.116, (0.92, 1.02, 0.86)))), + ("visor", "DEF-head", "visor", ("box", ((0.00, -0.108, 1.720), (0.158, 0.030, 0.052), 0.0))), + ("respirator", "DEF-head", "rubber", ("box", ((0.00, -0.112, 1.650), (0.105, 0.050, 0.070), 0.0))), + ("collar", "DEF-spine.003", "rubber", ("capsule", ((-0.105, 0.00, 1.515), (0.105, 0.00, 1.515), 0.045, 0.045))), +] + +SOURCE_ARMOR_PARTS = { + "jacket": [ + ("torso", "DEF-spine.002", "cloth", ("capsule", ((0.0, 0.0, 1.18), (0.0, 0.0, 1.45), 0.145, 0.175))), + ("harness_v", "DEF-spine.003", "webbing", ("box", ((-0.055, -0.145, 1.355), (0.045, 0.030, 0.285), 0.0))), + ("harness_h", "DEF-spine.002", "webbing", ("box", ((0.0, -0.138, 1.245), (0.310, 0.035, 0.040), 0.0))), + ], + "vest": [ + ("shell", "DEF-spine.002", "ballistic", ("capsule", ((0.0, 0.0, 1.235), (0.0, 0.0, 1.435), 0.125, 0.150))), + ("chest_plate", "DEF-spine.003", "ceramic", ("box", ((0.0, -0.118, 1.370), (0.240, 0.035, 0.145), 0.0))), + ("mag_l", "DEF-spine.002", "polymer", ("box", ((-0.052, -0.137, 1.270), (0.050, 0.035, 0.090), 0.0))), + ("mag_r", "DEF-spine.002", "polymer", ("box", ((0.025, -0.137, 1.270), (0.050, 0.035, 0.090), 0.0))), + ], + "plate": [ + ("chest", "DEF-spine.003", "ceramic", ("box", ((0.0, -0.125, 1.390), (0.330, 0.105, 0.205), 0.0))), + ("abdomen", "DEF-spine.002", "ceramic", ("box", ((0.0, -0.115, 1.210), (0.285, 0.095, 0.135), 0.0))), + ("pauldron_l", "DEF-upper_arm.L", "ceramic", ("sphere", ((0.225, 0.045, 1.445), 0.090, (1.20, 0.90, 0.68)))), + ("pauldron_r", "DEF-upper_arm.R", "ceramic", ("sphere", ((-0.225, 0.045, 1.445), 0.090, (1.20, 0.90, 0.68)))), + ], + "shroud": [ + ("torso", "DEF-spine.002", "shroud", ("capsule", ((0.0, 0.0, 1.16), (0.0, 0.0, 1.47), 0.145, 0.170))), + ("hood", "DEF-head", "shroud", ("sphere", ((0.0, 0.005, 1.735), 0.122, (0.98, 1.04, 0.98)))), + ("mask", "DEF-head", "rubber", ("box", ((0.0, -0.105, 1.675), (0.135, 0.040, 0.105), 0.0))), + ], +} + +SOURCE_WEAPON_PARTS = { + "pistol": [ + ("slide", "DEF-hand.R", "gunmetal", ("box", ((-0.775, -0.055, 1.445), (0.050, 0.210, 0.060), 0.0))), + ("suppressor", "DEF-hand.R", "gunmetal", ("capsule", ((-0.775, -0.145, 1.445), (-0.775, -0.330, 1.445), 0.025, 0.022))), + ("grip", "DEF-hand.R", "polymer", ("box", ((-0.775, 0.015, 1.385), (0.045, 0.065, 0.125), 0.0))), + ], + "rifle": [ + ("receiver", "DEF-hand.R", "gunmetal", ("box", ((-0.700, -0.105, 1.445), (0.085, 0.330, 0.105), -0.12))), + ("barrel", "DEF-hand.R", "gunmetal", ("capsule", ((-0.720, -0.250, 1.455), (-0.790, -0.650, 1.455), 0.028, 0.021))), + ("stock", "DEF-hand.R", "polymer", ("box", ((-0.650, 0.105, 1.430), (0.080, 0.230, 0.095), -0.12))), + ("optic", "DEF-hand.R", "visor", ("capsule", ((-0.690, -0.145, 1.520), (-0.665, -0.015, 1.520), 0.024, 0.024))), + ], + "shotgun": [ + ("receiver", "DEF-hand.R", "gunmetal", ("box", ((-0.700, -0.100, 1.445), (0.080, 0.300, 0.100), -0.10))), + ("barrel", "DEF-hand.R", "gunmetal", ("capsule", ((-0.720, -0.240, 1.455), (-0.780, -0.575, 1.455), 0.030, 0.025))), + ("tube", "DEF-hand.R", "gunmetal", ("capsule", ((-0.720, -0.235, 1.405), (-0.770, -0.535, 1.405), 0.020, 0.018))), + ], + "dagger": [ + ("grip", "DEF-hand.R", "polymer", ("capsule", ((-0.775, 0.02, 1.44), (-0.775, -0.07, 1.44), 0.020, 0.018))), + ("blade", "DEF-hand.R", "blade", ("capsule", ((-0.775, -0.08, 1.44), (-0.790, -0.33, 1.42), 0.018, 0.006))), + ], + "sword": [ + ("grip", "DEF-hand.R", "polymer", ("capsule", ((-0.775, 0.03, 1.44), (-0.775, -0.10, 1.44), 0.022, 0.020))), + ("blade", "DEF-hand.R", "blade", ("capsule", ((-0.775, -0.11, 1.44), (-0.800, -0.78, 1.36), 0.023, 0.008))), + ], + "bow": [ + ("riser", "DEF-hand.R", "gunmetal", ("capsule", ((-0.775, -0.05, 1.58), (-0.775, -0.05, 1.28), 0.020, 0.020))), + ("limb", "DEF-hand.R", "polymer", ("capsule", ((-0.775, -0.05, 1.58), (-0.800, -0.15, 1.80), 0.015, 0.010))), + ("limb_low", "DEF-hand.R", "polymer", ("capsule", ((-0.775, -0.05, 1.28), (-0.800, -0.15, 1.06), 0.015, 0.010))), + ], +} + + +# ---------------------------------------------------------------------------- +# Мелкие утилиты +# ---------------------------------------------------------------------------- + +def log(message): + print("[agent_rig] " + message) + + +def collection(name, parent): + existing = bpy.data.collections.get(name) + if existing is not None: + return existing + made = bpy.data.collections.new(name) + parent.children.link(made) + return made + + +def material(name): + existing = bpy.data.materials.get(name) + if existing is not None: + return existing + color, roughness, metallic = MATERIALS[name] + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + _set_socket(bsdf, "Base Color", (color[0], color[1], color[2], 1.0)) + _set_socket(bsdf, "Roughness", roughness) + _set_socket(bsdf, "Metallic", metallic) + # В 4.x «Specular» переименован; гасим блик мягко и молча, если сокета нет. + _set_socket(bsdf, "Specular IOR Level", 0.35) + _set_socket(bsdf, "Specular", 0.35) + # High-frequency material breakup survives the pre-render/downsample + # step as LoD-like grime and fabric/ceramic separation. Geometry still + # owns the silhouette; this is deliberately not noisy pixel confetti. + nodes = mat.node_tree.nodes + links = mat.node_tree.links + noise = nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 22.0 + noise.inputs["Detail"].default_value = 3.0 + noise.inputs["Roughness"].default_value = 0.72 + ramp = nodes.new("ShaderNodeValToRGB") + dark = tuple(max(0.0, channel * 0.58) for channel in color) + light = tuple(min(1.0, channel * 1.35 + 0.015) for channel in color) + ramp.color_ramp.elements[0].color = (*dark, 1.0) + ramp.color_ramp.elements[1].color = (*light, 1.0) + bump = nodes.new("ShaderNodeBump") + bump.inputs["Strength"].default_value = 0.16 + bump.inputs["Distance"].default_value = 0.035 + links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + links.new(noise.outputs["Fac"], bump.inputs["Height"]) + links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) + mat.diffuse_color = (color[0], color[1], color[2], 1.0) + return mat + + +def _set_socket(node, name, value): + socket = node.inputs.get(name) + if socket is None: + return + try: + socket.default_value = value + except (TypeError, ValueError): + pass + + +def mesh_object(name, bm, mat, coll): + mesh = bpy.data.meshes.new(name) + bm.to_mesh(mesh) + bm.free() + mesh.materials.append(mat) + for polygon in mesh.polygons: + polygon.use_smooth = False + obj = bpy.data.objects.new(name, mesh) + coll.objects.link(obj) + return obj + + +def build_shape(kind, params): + """Возвращает bmesh детали в МИРОВЫХ координатах позы покоя.""" + bm = bmesh.new() + if kind == "capsule": + p0, p1, r0, r1 = Vector(params[0]), Vector(params[1]), params[2], params[3] + axis = p1 - p0 + length = axis.length + bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=12, + radius1=r0, radius2=r1, depth=length) + rot = axis.to_track_quat("Z", "Y").to_matrix().to_4x4() + bmesh.ops.transform(bm, matrix=rot, verts=bm.verts) + bmesh.ops.translate(bm, vec=(p0 + p1) * 0.5, verts=bm.verts) + elif kind == "box": + center, size, yaw = Vector(params[0]), Vector(params[1]), params[2] + bmesh.ops.create_cube(bm, size=1.0) + matrix = (Matrix.Translation(center) + @ Matrix.Rotation(yaw, 4, "Z") + @ Matrix.Diagonal(size.to_4d())) + bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts) + elif kind == "sphere": + center, radius, scale = Vector(params[0]), params[1], Vector(params[2]) + bmesh.ops.create_uvsphere(bm, u_segments=14, v_segments=8, radius=radius) + matrix = Matrix.Translation(center) @ Matrix.Diagonal(scale.to_4d()) + bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts) + else: + raise ValueError("unknown shape: " + kind) + return bm + + +def bone_parent(obj, armature, bone_name): + """Жёстко цепляет объект к кости, сохраняя его положение в позе покоя. + + Присваивание matrix_world после установки родителя заставляет Blender + пересчитать matrix_basis относительно родителя — так деталь остаётся ровно + там, где её построили, но начинает ездить вместе с костью. + """ + world = obj.matrix_world.copy() + obj.parent = armature + obj.parent_type = "BONE" + obj.parent_bone = bone_name + bpy.context.view_layer.update() + obj.matrix_world = world + + +# ---------------------------------------------------------------------------- +# Сборка сцены +# ---------------------------------------------------------------------------- + +def wipe_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def build_armature(rig_coll): + data = bpy.data.armatures.new("agent_rig") + armature = bpy.data.objects.new("agent_rig", data) + rig_coll.objects.link(armature) + bpy.context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode="EDIT") + made = {} + for name, head, tail, parent in BONES: + bone = data.edit_bones.new(name) + bone.head = head + bone.tail = tail + bone.roll = 0.0 + bone.use_connect = False + if parent is not None: + bone.parent = made[parent] + made[name] = bone + bpy.ops.object.mode_set(mode="OBJECT") + for bone in armature.pose.bones: + bone.rotation_mode = "QUATERNION" + return armature + + +def import_source_body(source_path, source_cfg, rig_coll, body_coll, pivot): + """Import the authored CC0 humanoid and normalize it to the SpriteForge rig. + + The source uses Z-up but its shoulder axis is Y. A single alignment empty + turns it into the project convention (X right, -Y forward), places its feet + at Z=0 and scales the 1.94 m mesh to a lean 1.75 m operative. + """ + before = set(bpy.data.objects) + bpy.ops.import_scene.gltf(filepath=os.path.abspath(source_path)) + imported = [obj for obj in bpy.data.objects if obj not in before] + armatures = [obj for obj in imported if obj.type == "ARMATURE"] + meshes = [obj for obj in imported if obj.type == "MESH" and obj.parent in armatures] + if len(armatures) != 1 or len(meshes) != 1: + raise SystemExit("[agent_rig] source model must contain one skinned mesh and armature") + armature, body = armatures[0], meshes[0] + armature.name = "agent_rig" + body.name = "body_sleek_mesh" + + # The loose icosphere in the public source is a modelling reference, not + # part of the skinned character. + for obj in imported: + if obj not in {armature, body} and obj.type == "MESH": + bpy.data.objects.remove(obj, do_unlink=True) + + for owner in (armature, body): + for coll in list(owner.users_collection): + coll.objects.unlink(owner) + rig_coll.objects.link(armature) + body_coll.objects.link(body) + + align = bpy.data.objects.new("agent_source_align", None) + rig_coll.objects.link(align) + rotation = float(source_cfg.get("source_rotation_deg", -90.0)) + scale = float(source_cfg.get("source_scale", 0.90)) + align.rotation_euler = (0.0, 0.0, math.radians(rotation)) + align.scale = (scale, scale, scale) + align.location = (0.0, 0.0, float(source_cfg.get("source_ground_z", 0.897))) + align.parent = pivot + armature.parent = align + + # Dark textile base. Authored armor modules cover it later, while this + # mesh supplies real anatomy and joint deformation. + body.data.materials.clear() + body.data.materials.append(material("bodysuit")) + + # A physically thin limb can collapse to a single sub-pixel line in a + # 70x90 orthographic cell. Inflate the *deformed surface* by a small, + # configurable world-space amount instead of scaling the skeleton: joint + # positions and every imported action stay untouched while the silhouette + # survives palette quantisation. The modifier follows the Armature one. + silhouette = body.modifiers.new("SpriteForge silhouette thickness", "SOLIDIFY") + silhouette.thickness = float(source_cfg.get("source_silhouette_thickness", 0.018)) + silhouette.offset = 0.0 + silhouette.use_rim = True + for bone in armature.pose.bones: + bone.rotation_mode = "QUATERNION" + return armature + + +def install_source_actions(): + mapping = { + "idle": "Pistol_Idle_Loop", + "walk": "Walk_Loop", + "attack": "Pistol_Shoot", + } + for target, source in mapping.items(): + original = bpy.data.actions.get(source) + if original is None: + raise SystemExit("[agent_rig] source action missing: " + source) + existing = bpy.data.actions.get(target) + if existing is not None: + bpy.data.actions.remove(existing) + clone = original.copy() + clone.name = target + clone.use_fake_user = True + log("source action %s <- %s %.0f..%.0f" % + (target, source, clone.frame_range[0], clone.frame_range[1])) + + +def build_parts(parts, prefix, armature, coll): + for suffix, bone, mat, (kind, params) in parts: + obj = mesh_object("%s_%s" % (prefix, suffix), build_shape(kind, params), + material(mat), coll) + bone_parent(obj, armature, bone) + + +def build_attachments(armature, coll): + for bone, location in ATTACHMENTS.items(): + empty = bpy.data.objects.new("attach_" + bone, None) + empty.empty_display_type = "PLAIN_AXES" + empty.empty_display_size = 0.06 + empty.location = location + coll.objects.link(empty) + bone_parent(empty, armature, bone) + + +def build_camera(scene, camera_cfg, root_coll): + elevation = math.radians(float(camera_cfg["elevation_deg"])) + distance = float(camera_cfg["distance"]) + data = bpy.data.cameras.new("agent_cam") + data.type = "ORTHO" + data.clip_start = 0.01 + data.clip_end = distance * 4.0 + data.shift_x = 0.0 + data.shift_y = 0.0 + camera = bpy.data.objects.new("agent_cam", data) + # Смотрит строго в начало координат: ось взгляда проходит через (0,0,0), + # поэтому в ортографии оно всегда попадает точно в центр кадра. + camera.location = (0.0, -distance * math.cos(elevation), distance * math.sin(elevation)) + camera.rotation_euler = (math.pi / 2.0 - elevation, 0.0, 0.0) + root_coll.objects.link(camera) + scene.camera = camera + return camera + + +def build_lights(root_coll): + # Свет фиксирован в МИРЕ, а не привязан к фигуре: поворачивается модель, + # значит каждое направление освещено по-своему — как и должно быть. + key = bpy.data.lights.new("key_sun", type="SUN") + key.energy = 3.2 + key.color = (1.0, 0.96, 0.90) + key.angle = math.radians(6.0) + key_obj = bpy.data.objects.new("key_sun", key) + key_obj.rotation_euler = (math.radians(52.0), 0.0, math.radians(-38.0)) + root_coll.objects.link(key_obj) + + fill = bpy.data.lights.new("fill_sun", type="SUN") + fill.energy = 0.55 + fill.color = (0.62, 0.72, 1.0) + fill_obj = bpy.data.objects.new("fill_sun", fill) + fill_obj.rotation_euler = (math.radians(108.0), 0.0, math.radians(150.0)) + root_coll.objects.link(fill_obj) + + +def build_world(scene): + world = bpy.data.worlds.new("agent_world") + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + if background is not None: + _set_socket(background, "Color", (0.035, 0.042, 0.055, 1.0)) + _set_socket(background, "Strength", 1.0) + scene.world = world + + +def configure_render(scene): + for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"): + try: + scene.render.engine = engine + break + except (TypeError, ValueError): + continue + log("engine: " + scene.render.engine) + scene.render.film_transparent = True + scene.render.use_motion_blur = False + scene.render.resolution_percentage = 100 + scene.render.pixel_aspect_x = 1.0 + scene.render.pixel_aspect_y = 1.0 + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.image_settings.color_depth = "8" + scene.render.image_settings.compression = 0 + # Standard, а не AgX: спрайт должен получить ровно тот цвет, что в материале. + try: + scene.view_settings.view_transform = "Standard" + scene.view_settings.look = "None" + except (TypeError, ValueError): + pass + eevee = getattr(scene, "eevee", None) + if eevee is not None and hasattr(eevee, "taa_render_samples"): + eevee.taa_render_samples = 48 + if scene.render.engine == "CYCLES": + scene.cycles.samples = 64 + scene.cycles.use_denoising = True + + +# ---------------------------------------------------------------------------- +# Анимации +# ---------------------------------------------------------------------------- + +def world_axis_quat(armature, bone_name, axis, angle): + """Кватернион позы, вращающий кость вокруг МИРОВОЙ оси на angle радиан.""" + rest = armature.data.bones[bone_name].matrix_local.to_3x3() + # Imported rigs may be normalized by a rotated/scaled parent. Convert the + # requested scene axis through the full armature transform first; otherwise + # a forward elbow bend turns into a sideways T-pose rotation. + armature_axis = armature.matrix_world.to_3x3().inverted() @ Vector(axis) + local_axis = rest.inverted() @ armature_axis + if local_axis.length < 1e-9: + return Quaternion((1.0, 0.0, 0.0, 0.0)) + return Quaternion(local_axis.normalized(), angle) + + +def pose(armature, frame, swings, offset=None): + """swings: {bone: (axis, angle)}; offset — сдвиг корня в мировых координатах.""" + for bone in armature.pose.bones: + bone.rotation_quaternion = Quaternion((1.0, 0.0, 0.0, 0.0)) + armature.pose.bones["root"].location = Vector((0.0, 0.0, 0.0)) + for name, (axis, angle) in swings.items(): + armature.pose.bones[name].rotation_quaternion = world_axis_quat(armature, name, axis, angle) + if offset is not None: + root = armature.pose.bones["root"] + rest = armature.data.bones["root"].matrix_local.to_3x3() + root.location = rest.inverted() @ Vector(offset) + for bone in armature.pose.bones: + bone.keyframe_insert(data_path="rotation_quaternion", frame=frame) + armature.pose.bones["root"].keyframe_insert(data_path="location", frame=frame) + + +X = (1.0, 0.0, 0.0) # ось «шаг вперёд/назад» для фигуры, смотрящей в -Y +Y = (0.0, 1.0, 0.0) # ось «наклон вбок» +Z = (0.0, 0.0, 1.0) # ось «поворот корпуса» + +# Базовая стойка: low ready. Руки не по швам — иначе оружие в кисти висит в воздухе. +READY = { + "upper_arm_r": (X, math.radians(-22.0)), + "forearm_r": (X, math.radians(-58.0)), + "upper_arm_l": (X, math.radians(-18.0)), + "forearm_l": (X, math.radians(-62.0)), + "shoulder_r": (Y, math.radians(10.0)), + "shoulder_l": (Y, math.radians(-10.0)), + "thigh_r": (X, math.radians(8.0)), + "thigh_l": (X, math.radians(8.0)), + "shin_r": (X, math.radians(-14.0)), + "shin_l": (X, math.radians(-14.0)), + "spine": (X, math.radians(-9.0)), + "chest": (Z, math.radians(-5.0)), +} + + +def merged(base, extra): + out = dict(base) + out.update(extra) + return out + + +def make_action(armature, name): + if armature.animation_data is None: + armature.animation_data_create() + action = bpy.data.actions.new(name) + # Blender 5 may not count a layered action as used until a slot/channel is + # committed. Keep authored clips across save even when their first pose is + # identical to rest and therefore creates no curve. + action.use_fake_user = True + armature.animation_data.action = action + return action + + +def build_actions(armature, animations): + for name, spec in animations.items(): + frames = int(spec["frames"]) + make_action(armature, name) + if name == "idle": + for frame in range(frames): + pose(armature, frame + 1, READY) + elif name == "walk": + for frame in range(frames): + phase = 2.0 * math.pi * frame / frames + swing = math.radians(26.0) + pose(armature, frame + 1, merged(READY, { + "thigh_r": (X, swing * math.sin(phase)), + "thigh_l": (X, swing * math.sin(phase + math.pi)), + "shin_r": (X, -math.radians(30.0) * max(0.0, -math.sin(phase - 0.6))), + "shin_l": (X, -math.radians(30.0) * max(0.0, -math.sin(phase + math.pi - 0.6))), + "upper_arm_r": READY["upper_arm_r"], + "upper_arm_l": READY["upper_arm_l"], + "spine": (X, math.radians(-11.0)), + }), offset=(0.0, 0.0, -0.022 * abs(math.sin(phase)))) + elif name == "attack": + # Замах на 0..1, удар на 2..3, возврат на 4..5. + recoil = [0.0, -0.06, -0.14, -0.08, -0.03, 0.0] + twist = [0.0, -0.025, -0.055, -0.030, -0.010, 0.0] + for frame in range(frames): + k = recoil[frame % len(recoil)] + t = twist[frame % len(twist)] + pose(armature, frame + 1, merged(READY, { + "upper_arm_r": (X, math.radians(-22.0) + k), + "forearm_r": (X, math.radians(-58.0) - 0.25 * k), + "upper_arm_l": (X, math.radians(-18.0) + k * 0.65), + "forearm_l": (X, math.radians(-62.0) - 0.20 * k), + "chest": (Z, t), + "hips": (Z, t * 0.25), + })) + else: + for frame in range(frames): + pose(armature, frame + 1, READY) + log("action %s: %d frame(s)" % (name, frames)) + + +SOURCE_READY = { + # The source is an A-pose. Clavicles lower the arms around world Y; + # upper arms and elbows then close around the weapon platform in world Z. + "upper_arm.R": (Z, math.radians(-43.0)), + "forearm.R": (Z, math.radians(-67.0)), + "upper_arm.L": (Z, math.radians(43.0)), + "forearm.L": (Z, math.radians(67.0)), + "shoulder.R": (Y, math.radians(36.0)), + "shoulder.L": (Y, math.radians(-36.0)), + "thigh.R": (X, math.radians(7.0)), + "thigh.L": (X, math.radians(7.0)), + "shin.R": (X, math.radians(-12.0)), + "shin.L": (X, math.radians(-12.0)), + "spine.002": (X, math.radians(-8.0)), + "spine.003": (Z, math.radians(-4.0)), +} + + +def pose_source(armature, frame, swings, offset=None): + for bone in armature.pose.bones: + bone.rotation_quaternion = Quaternion((1.0, 0.0, 0.0, 0.0)) + for name, (axis, angle) in swings.items(): + if name in armature.pose.bones: + armature.pose.bones[name].rotation_quaternion = world_axis_quat( + armature, name, axis, angle) + armature.location = Vector(offset or (0.0, 0.0, 0.0)) + for bone in armature.pose.bones: + bone.keyframe_insert(data_path="rotation_quaternion", frame=frame) + armature.keyframe_insert(data_path="location", frame=frame) + + +def build_source_actions(armature, animations): + """Compact LoD-like cycles on the imported deforming humanoid.""" + for name, spec in animations.items(): + frames = int(spec["frames"]) + make_action(armature, name) + for frame in range(frames): + phase = 2.0 * math.pi * frame / max(1, frames) + swings = dict(SOURCE_READY) + offset = (0.0, 0.0, 0.0) + if name == "idle": + swings["spine.002"] = (X, math.radians(-8.0) + math.radians(1.2) * math.sin(phase)) + elif name == "walk": + stride = math.radians(27.0) + swings.update({ + "thigh.R": (X, stride * math.sin(phase)), + "thigh.L": (X, stride * math.sin(phase + math.pi)), + "shin.R": (X, -math.radians(34.0) * max(0.0, -math.sin(phase - 0.55))), + "shin.L": (X, -math.radians(34.0) * max(0.0, -math.sin(phase + math.pi - 0.55))), + "spine.003": (Z, math.radians(3.0) * math.sin(phase)), + }) + offset = (0.0, 0.0, -0.025 * abs(math.sin(phase))) + elif name == "attack": + recoil = (0.0, -0.04, -0.13, -0.075, -0.025, 0.0)[frame % 6] + swings.update({ + "upper_arm.R": (Z, math.radians(-43.0) + recoil), + "forearm.R": (Z, math.radians(-67.0) - recoil * 0.30), + "upper_arm.L": (Z, math.radians(43.0) - recoil * 0.65), + "forearm.L": (Z, math.radians(67.0) + recoil * 0.22), + "spine.003": (Z, recoil * 0.28), + }) + pose_source(armature, frame + 1, swings, offset) + log("source action %s: %d frame(s)" % (name, frames)) + + +# ---------------------------------------------------------------------------- +# build +# ---------------------------------------------------------------------------- + +def cmd_build(cfg, out_path): + wipe_scene() + scene = bpy.context.scene + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + + root = collection("AGENT", scene.collection) + rig_coll = collection("RIG", root) + body_coll = collection("BODY", root) + armor_coll = collection("ARMOR", root) + weapon_coll = collection("WEAPON", root) + attach_coll = collection("ATTACH", root) + + # Пустышка над ригом: направление задаётся её поворотом, камера не двигается. + pivot = bpy.data.objects.new("agent_root", None) + pivot.empty_display_type = "ARROWS" + pivot.empty_display_size = 0.3 + rig_coll.objects.link(pivot) + source_model = cfg.get("camera", {}).get("source_model") + if source_model: + armature = import_source_body(source_model, cfg.get("camera", {}), + rig_coll, body_coll, pivot) + build_parts(SOURCE_BODY_PARTS, "body_sleek", armature, body_coll) + for variant, parts in SOURCE_ARMOR_PARTS.items(): + build_parts(parts, "armor_" + variant, armature, armor_coll) + for variant, parts in SOURCE_WEAPON_PARTS.items(): + build_parts(parts, "weapon_" + variant, armature, weapon_coll) + else: + armature = build_armature(rig_coll) + armature.parent = pivot + build_parts(BODY_PARTS, "body_sleek", armature, body_coll) + + # Armor authored for the legacy skeleton is intentionally withheld from + # the imported body until its fitted replacement is built. Never mix + # coordinate systems and silently ship floating equipment. + if not source_model: + for variant, parts in ARMOR_PARTS.items(): + build_parts(parts, "armor_" + variant, armature, armor_coll) + for variant, parts in WEAPON_PARTS.items(): + if variant in {"rifle", "shotgun"}: + parts = [(suffix, "chest", mat, shape) + for suffix, _bone, mat, shape in parts] + build_parts(parts, "weapon_" + variant, armature, weapon_coll) + build_attachments(armature, attach_coll) + + build_camera(scene, cfg["camera"], scene.collection) + build_lights(scene.collection) + build_world(scene) + configure_render(scene) + if source_model: + install_source_actions() + else: + build_actions(armature, cfg["animations"]) + + scene.frame_start = 1 + scene.frame_end = max(int(a["frames"]) for a in cfg["animations"].values()) + + os.makedirs(os.path.dirname(out_path), exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=out_path) + meshes = [o.name for o in bpy.data.objects if o.type == "MESH"] + log("saved %s (%d mesh objects, %d bones)" % (out_path, len(meshes), len(BONES))) + return 0 + + +# ---------------------------------------------------------------------------- +# render +# ---------------------------------------------------------------------------- + +def assign_action(armature, name): + action = bpy.data.actions.get(name) + if action is None: + raise SystemExit("[agent_rig] no action '%s' in the .blend" % name) + if armature.animation_data is None: + armature.animation_data_create() + anim = armature.animation_data + anim.action = action + # Blender 4.4+: у Action появились слоты, и без выбранного слота она молчит. + slots = getattr(action, "slots", None) + if slots is not None and hasattr(anim, "action_slot") and anim.action_slot is None and len(slots): + anim.action_slot = slots[0] + return action + + +def cmd_render(cfg, component, animation, outdir, supersample): + scene = bpy.context.scene + armature = bpy.data.objects.get("agent_rig") + pivot = bpy.data.objects.get("agent_root") + if armature is None or pivot is None: + raise SystemExit("[agent_rig] the .blend has no agent_rig/agent_root; run `blender-init` first") + + prefix = component + "_" + shown = 0 + for obj in bpy.data.objects: + if obj.type != "MESH": + continue + obj.hide_render = not obj.name.startswith(prefix) + shown += 0 if obj.hide_render else 1 + if shown == 0: + raise SystemExit("[agent_rig] no objects named '%s*' in the .blend" % prefix) + + configure_render(scene) + scene.render.resolution_x = int(cfg["render_width"]) * supersample + scene.render.resolution_y = int(cfg["render_height"]) * supersample + camera = scene.camera or build_camera(scene, cfg["camera"], scene.collection) + camera.data.type = "ORTHO" + camera.data.shift_x = 0.0 + camera.data.shift_y = 0.0 + # ortho_scale ложится на БОЛЬШУЮ сторону кадра. + longest = max(int(cfg["render_width"]), int(cfg["render_height"])) + camera.data.ortho_scale = longest / float(cfg["camera"]["pixels_per_unit"]) + + action = assign_action(armature, animation) + frames = int(cfg["frames"]) + directions = cfg["directions"] + os.makedirs(outdir, exist_ok=True) + + for index, name in enumerate(directions): + # Порядок направлений в каталоге идёт по -45° от south: юг, юго-запад, … + pivot.rotation_euler = (0.0, 0.0, math.radians(-45.0 * index)) + bpy.context.view_layer.update() + start, end = action.frame_range + for frame in range(frames): + # Source mocap clips are 15-60 frames long; LoD-style sprites keep + # only 4-8 decisive poses. Sample the full clip and omit the + # duplicated loop endpoint. + source_frame = start + (end - start) * frame / max(1, frames) + scene.frame_set(int(round(source_frame))) + cell = index * frames + frame + scene.render.filepath = os.path.join(outdir, "frame_%03d.png" % cell) + bpy.ops.render.render(write_still=True) + log("direction %d/%d (%s) done" % (index + 1, len(directions), name)) + + log("rendered %d cell(s) at %dx%d into %s" + % (len(directions) * frames, scene.render.resolution_x, scene.render.resolution_y, outdir)) + return 0 + + +def main(argv): + if "--" in argv: + argv = argv[argv.index("--") + 1:] + else: + argv = [] + if not argv: + raise SystemExit("[agent_rig] usage: build|render --config ") + command = argv[0] + options = {} + index = 1 + while index < len(argv) - 1: + if argv[index].startswith("--"): + options[argv[index][2:]] = argv[index + 1] + index += 2 + else: + index += 1 + with open(options["config"], "r", encoding="utf-8") as handle: + cfg = json.load(handle) + if command == "build": + return cmd_build(cfg, options["out"]) + if command == "render": + return cmd_render(cfg, cfg["component"], cfg["animation"], cfg["outdir"], + int(cfg.get("supersample", 1))) + raise SystemExit("[agent_rig] unknown command: " + command) + + +if __name__ == "__main__": + sys.exit(main(list(sys.argv))) diff --git a/tools/blender/inspect_render_result.py b/tools/blender/inspect_render_result.py new file mode 100644 index 0000000..224856b --- /dev/null +++ b/tools/blender/inspect_render_result.py @@ -0,0 +1,65 @@ +"""Print Blender render-result/pass APIs without writing project assets.""" +import bpy + +bpy.ops.mesh.primitive_cube_add() +scene = bpy.context.scene +scene.view_layers[0].use_pass_z = True +scene.view_layers[0].use_pass_normal = True +print("SCENE_COMPOSITOR_ATTRS", [name for name in dir(scene) if "composit" in name.lower() or "node" in name.lower()]) +scene.use_nodes = True +tree = scene.compositing_node_group +if tree is None: + tree = bpy.data.node_groups.new("SpriteForge compositor", "CompositorNodeTree") + scene.compositing_node_group = tree +print("COMPOSITOR_TREE", tree, "NODE_TYPES", [node.bl_idname for node in tree.nodes]) +file_node = tree.nodes.new("CompositorNodeOutputFile") +print("FILE_NODE_ATTRS", [name for name in dir(file_node) if "file" in name.lower() or "slot" in name.lower() or "item" in name.lower()]) +print("FILE_NODE_PROPS", [prop.identifier for prop in file_node.bl_rna.properties]) +print("FILE_ITEMS_ATTRS", [name for name in dir(file_node.file_output_items) if not name.startswith("__")]) +print("FILE_ITEMS_NEW_DOC", file_node.file_output_items.new.__doc__) +print("FILE_FORMATS", file_node.format.bl_rna.properties["file_format"].enum_items.keys()) +render_node = tree.nodes.new("CompositorNodeRLayers") +scene.view_layers[0].update_render_passes() +print("RENDER_OUTPUTS", [socket.name for socket in render_node.outputs]) +file_node.format.file_format = "OPEN_EXR_MULTILAYER" +file_node.directory = bpy.app.tempdir +file_node.file_name = "spriteforge-passes" +file_node.file_output_items.clear() +for socket_type, name in (("RGBA", "Image"), ("FLOAT", "Depth"), + ("VECTOR", "Normal")): + file_node.file_output_items.new(socket_type, name) + tree.links.new(render_node.outputs[name], file_node.inputs[name]) +print("OUTPUT_INPUTS", [(socket.name, socket.bl_idname) for socket in file_node.inputs]) +print("OUTPUT_ITEM_PROPS", [[prop.identifier for prop in item.bl_rna.properties] + for item in file_node.file_output_items]) +scene.render.engine = "BLENDER_EEVEE" +scene.render.resolution_x = 8 +scene.render.resolution_y = 8 +scene.render.resolution_percentage = 100 +camera_data = bpy.data.cameras.new("camera") +camera = bpy.data.objects.new("camera", camera_data) +scene.collection.objects.link(camera) +camera.location = (3, -3, 2) +camera.rotation_euler = ((0, 0, 0)) +camera.rotation_euler = ((0, 0, 0)) +camera.rotation_euler = ((bpy.context.object.location-camera.location).to_track_quat("-Z", "Y").to_euler()) +scene.camera = camera +scene.render.image_settings.file_format = "PNG" +scene.render.filepath = bpy.app.tempdir + "spriteforge-pass-api.exr" +bpy.ops.render.render(write_still=True) +result = bpy.data.images.get("Render Result") +print("RESULT_ATTRS", [name for name in dir(result) if "layer" in name.lower() or "pass" in name.lower() or "slot" in name.lower()]) +print("RESULT_TYPE", type(result), "SIZE", tuple(result.size), "PIXELS", len(result.pixels)) +print("VIEW_LAYER_ATTRS", [name for name in dir(scene.view_layers[0]) if "pass" in name.lower()]) +try: + import OpenImageIO + print("OPENIMAGEIO", OpenImageIO.VERSION_STRING) + source = OpenImageIO.ImageInput.open(bpy.app.tempdir + "spriteforge-passes.exr") + index = 0 + while source.seek_subimage(index, 0): + spec = source.spec() + print("SUBIMAGE", index, spec.width, spec.height, list(spec.channelnames)) + index += 1 + source.close() +except Exception as error: + print("OPENIMAGEIO_MISSING", repr(error)) diff --git a/tools/blender/inspect_source_model.py b/tools/blender/inspect_source_model.py new file mode 100644 index 0000000..b10191a --- /dev/null +++ b/tools/blender/inspect_source_model.py @@ -0,0 +1,46 @@ +"""Print a compact inventory of a Blender-compatible source model.""" + +import sys +from pathlib import Path + +import bpy +from mathutils import Vector + + +def main() -> None: + marker = sys.argv.index("--") + source = Path(sys.argv[marker + 1]).resolve() + bpy.ops.wm.read_factory_settings(use_empty=True) + suffix = source.suffix.lower() + if suffix in {".glb", ".gltf"}: + bpy.ops.import_scene.gltf(filepath=str(source)) + elif suffix == ".fbx": + bpy.ops.import_scene.fbx(filepath=str(source)) + elif suffix == ".obj": + bpy.ops.wm.obj_import(filepath=str(source)) + else: + raise SystemExit(f"unsupported source format: {suffix}") + + for obj in sorted(bpy.context.scene.objects, key=lambda value: value.name): + if obj.type == "MESH": + corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + lo = tuple(round(min(point[index] for point in corners), 4) for index in range(3)) + hi = tuple(round(max(point[index] for point in corners), 4) for index in range(3)) + parent = obj.parent.name if obj.parent else "-" + print(f"MESH {obj.name} verts={len(obj.data.vertices)} mats={len(obj.data.materials)} parent={parent} hide={obj.hide_render} bbox={lo}..{hi}") + elif obj.type == "ARMATURE": + bones = ",".join(bone.name for bone in obj.data.bones) + print(f"ARMATURE {obj.name} bones={len(obj.data.bones)} [{bones}]") + for bone in obj.data.bones: + if bone.name in {"root","DEF-hips","DEF-spine.002","DEF-spine.003","DEF-head", + "DEF-hand.L","DEF-hand.R","DEF-upper_arm.L","DEF-upper_arm.R"}: + head=tuple(round(value,4) for value in bone.head_local) + tail=tuple(round(value,4) for value in bone.tail_local) + print(f"BONE {bone.name} {head}->{tail}") + for action in sorted(bpy.data.actions, key=lambda value: value.name): + start, end = action.frame_range + print(f"ACTION {action.name} frames={start:.0f}..{end:.0f}") + + +if __name__ == "__main__": + main() diff --git a/tools/blender/monster_rig.py b/tools/blender/monster_rig.py new file mode 100644 index 0000000..5331d1f --- /dev/null +++ b/tools/blender/monster_rig.py @@ -0,0 +1,1166 @@ +"""Blender-сторона конвейера тварей. Запускается ВНУТРИ Blender. + + blender --factory-startup -b --python tools/blender/monster_rig.py -- build --config cfg.json --out rig.blend + blender --factory-startup -b rig.blend --python tools/blender/monster_rig.py -- render --config cfg.json + +Родственник tools/blender/agent_rig.py, но задача обратная. У агента слои +бумажной куклы обязаны совпасть пиксель в пиксель, поэтому там ОДИН скелет на +всё. Здесь пять видов обязаны различаться силуэтом с одного взгляда, поэтому +здесь ПЯТЬ независимых скелетов — и ровно одна общая вещь: неподвижная +ортографическая изометрическая камера и начало координат в точке опоры. + +Правило выравнивания то же самое: камера СТОИТ, поворачивается модель. +Начало координат каждой твари (точка опоры на полу) всегда проецируется в +центр кадра, поэтому упаковщик на стороне Python режет все кадры одним и тем +же прямоугольником, и никакого подгона по bbox не существует в принципе. + +Анатомия задана таблицами, а не кодом: кости, детали и четыре клипа на вид. +Клипы описаны в НОРМИРОВАННОМ времени (список опорных значений на 0..1) и +сэмплируются под то число кадров, которое пришло из каталога, — менять +frames в каталоге можно, не трогая этот файл. + +PyYAML внутри Blender нет — конфиг приходит одним JSON-файлом. +""" +from __future__ import annotations + +import json +import math +import os +import sys + +import bmesh +import bpy +from mathutils import Matrix, Quaternion, Vector + +# Фигура смотрит в -Y (на камеру) — это направление `south`. +# Правая сторона твари — это -X, как и у агента. +X = (1.0, 0.0, 0.0) # наклон вперёд/назад +Y = (0.0, 1.0, 0.0) # завал вбок +Z = (0.0, 0.0, 1.0) # поворот вокруг вертикали + + +def d(degrees): + return math.radians(degrees) + + +# ---------------------------------------------------------------------------- +# Материалы: мокрое мясо, кость, хитин и вживлённое железо +# ---------------------------------------------------------------------------- + +MATERIALS = { + # name: (base color RGB, roughness, metallic) + "flesh": ((0.36, 0.15, 0.14), 0.44, 0.00), # низкий roughness = мокрый блик + "muscle": ((0.47, 0.10, 0.10), 0.38, 0.00), + "gut": ((0.26, 0.07, 0.09), 0.32, 0.00), + "membrane": ((0.54, 0.27, 0.31), 0.26, 0.00), + "sinew": ((0.57, 0.44, 0.36), 0.62, 0.00), + "bone": ((0.73, 0.69, 0.58), 0.58, 0.00), + "chitin": ((0.13, 0.12, 0.14), 0.34, 0.12), + "steel": ((0.31, 0.33, 0.36), 0.28, 0.85), + "rust": ((0.29, 0.16, 0.09), 0.82, 0.25), + "tube": ((0.19, 0.17, 0.20), 0.44, 0.08), + "ocellus": ((0.86, 0.78, 0.28), 0.18, 0.00), +} + + +# ---------------------------------------------------------------------------- +# Конструкторы форм — таблицы ниже читаются глазами, а не разбираются +# ---------------------------------------------------------------------------- + +def cap(p0, p1, r0, r1=None): + """Капсула (усечённый конус) между двумя мировыми точками позы покоя.""" + return ("capsule", (p0, p1, r0, r0 if r1 is None else r1)) + + +def box(center, size, yaw=0.0): + return ("box", (center, size, yaw)) + + +def ball(center, radius, scale=(1.0, 1.0, 1.0)): + return ("sphere", (center, radius, scale)) + + +def _flip_point(p): + return (-p[0], p[1], p[2]) + + +def _flip_shape(shape): + kind, params = shape + if kind == "capsule": + p0, p1, r0, r1 = params + return ("capsule", (_flip_point(p0), _flip_point(p1), r0, r1)) + if kind == "box": + center, size, yaw = params + return ("box", (_flip_point(center), size, -yaw)) + center, radius, scale = params + return ("sphere", (_flip_point(center), radius, scale)) + + +def _flip_name(name): + if name.endswith("_r"): + return name[:-2] + "_l" + if name.endswith("_l"): + return name[:-2] + "_r" + return name + + +def mirror_bones(bones): + """Правые кости -> левые. Симметрия описывается один раз, а не дважды.""" + out = list(bones) + for name, head, tail, parent in bones: + if not name.endswith("_r"): + continue + out.append((_flip_name(name), _flip_point(head), _flip_point(tail), + None if parent is None else _flip_name(parent))) + return out + + +def mirror_parts(parts): + """Зеркалит по имени ДЕТАЛИ, а не кости: накладка на непарной кости + (`hip_plate_r` на `hips`) тоже обязана получить пару.""" + out = list(parts) + for name, bone, mat, shape in parts: + if not name.endswith("_r"): + continue + out.append((_flip_name(name), _flip_name(bone), mat, _flip_shape(shape))) + return out + + +# ---------------------------------------------------------------------------- +# SHAMBLER — «освежёванный»: грудная клетка вскрыта наружу, правая рука +# доросла до колена. Медленный, кривой, читается как человек, которому +# что-то сделали. +# ---------------------------------------------------------------------------- + +SHAMBLER_BONES = mirror_bones([ + ("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None), + ("hips", (0.00, 0.00, 0.86), (0.00, 0.00, 0.99), "root"), + ("spine", (0.00, 0.00, 0.99), (0.00, -0.03, 1.17), "hips"), + ("chest", (0.00, -0.03, 1.17), (0.00, -0.09, 1.36), "spine"), + ("neck", (0.00, -0.09, 1.36), (0.00, -0.14, 1.44), "chest"), + ("head", (0.00, -0.14, 1.44), (0.00, -0.20, 1.56), "neck"), + ("thigh_r", (-0.10, 0.00, 0.86), (-0.12, 0.02, 0.48), "hips"), + ("shin_r", (-0.12, 0.02, 0.48), (-0.12, -0.01, 0.08), "thigh_r"), + ("foot_r", (-0.12, -0.01, 0.08), (-0.12, -0.15, 0.02), "shin_r"), +]) +# Руки НЕ зеркальны и потому объявлены вручную: правая доросла до колена, +# левая осталась человеческой. Асимметрия — половина силуэта этой твари. +SHAMBLER_BONES += [ + ("arm_r", (-0.19, -0.04, 1.30), (-0.24, 0.02, 0.96), "chest"), + ("fore_r", (-0.24, 0.02, 0.96), (-0.27, -0.04, 0.54), "arm_r"), + ("claw_r", (-0.27, -0.04, 0.54), (-0.28, -0.09, 0.38), "fore_r"), + ("arm_l", (0.18, -0.04, 1.30), (0.23, 0.02, 1.02), "chest"), + ("fore_l", (0.23, 0.02, 1.02), (0.26, -0.03, 0.74), "arm_l"), + ("claw_l", (0.26, -0.03, 0.74), (0.27, -0.07, 0.63), "fore_l"), +] + +SHAMBLER_PARTS = mirror_parts([ + ("rib_a_r", "chest", "bone", cap((-0.04, -0.03, 1.17), (-0.16, -0.21, 1.13), 0.020, 0.009)), + ("rib_b_r", "chest", "bone", cap((-0.04, -0.05, 1.25), (-0.18, -0.23, 1.23), 0.021, 0.009)), + ("rib_c_r", "chest", "bone", cap((-0.04, -0.07, 1.32), (-0.16, -0.20, 1.33), 0.019, 0.009)), + ("thigh_r", "thigh_r", "flesh", cap((-0.10, 0.00, 0.88), (-0.12, 0.02, 0.48), 0.075, 0.055)), + ("shin_r", "shin_r", "flesh", cap((-0.12, 0.02, 0.48), (-0.12, -0.01, 0.09), 0.052, 0.038)), + ("foot_r", "foot_r", "sinew", box((-0.12, -0.08, 0.045), (0.085, 0.215, 0.085))), +]) +SHAMBLER_PARTS += [ + ("pelvis", "hips", "flesh", cap((0.00, 0.00, 0.86), (0.00, 0.00, 1.00), 0.110, 0.100)), + ("gut", "spine", "gut", ball((0.00, -0.06, 1.05), 0.115, (1.05, 0.92, 0.86))), + ("thorax", "chest", "flesh", cap((0.00, -0.02, 1.11), (0.00, -0.09, 1.34), 0.112, 0.098)), + ("cavity", "chest", "gut", ball((0.00, -0.11, 1.23), 0.072, (1.00, 0.75, 1.25))), + ("shoulders", "chest", "flesh", cap((-0.19, -0.04, 1.31), (0.18, -0.04, 1.31), 0.058, 0.058)), + ("staple_a", "spine", "steel", box((0.00, 0.06, 1.03), (0.075, 0.030, 0.014))), + ("staple_b", "spine", "steel", box((0.00, 0.05, 1.11), (0.075, 0.030, 0.014))), + ("staple_c", "chest", "steel", box((0.00, 0.03, 1.20), (0.070, 0.030, 0.014))), + ("neck", "neck", "sinew", cap((0.00, -0.09, 1.35), (0.00, -0.14, 1.45), 0.046, 0.042)), + ("skull", "head", "flesh", ball((0.00, -0.17, 1.49), 0.086, (0.86, 1.06, 1.04))), + ("jaw", "head", "bone", box((0.00, -0.24, 1.425), (0.098, 0.115, 0.050))), + ("crown", "head", "steel", box((0.02, -0.14, 1.552), (0.115, 0.125, 0.026))), + ("bolt_r", "head", "rust", ball((-0.06, -0.20, 1.535), 0.017)), + ("bolt_l", "head", "rust", ball((0.07, -0.20, 1.535), 0.017)), + # Правая рука — освежёванная и длинная: мышца без кожи, кисть-крюк. + ("arm_r", "arm_r", "muscle", cap((-0.19, -0.04, 1.30), (-0.24, 0.02, 0.96), 0.052, 0.042)), + ("fore_r", "fore_r", "muscle", cap((-0.24, 0.02, 0.96), (-0.27, -0.04, 0.55), 0.043, 0.028)), + ("claw_r_a", "claw_r", "bone", cap((-0.27, -0.05, 0.54), (-0.30, -0.13, 0.37), 0.014, 0.000)), + ("claw_r_b", "claw_r", "bone", cap((-0.27, -0.04, 0.54), (-0.25, -0.12, 0.36), 0.013, 0.000)), + ("claw_r_c", "claw_r", "bone", cap((-0.27, -0.03, 0.54), (-0.28, -0.07, 0.34), 0.012, 0.000)), + ("arm_l", "arm_l", "flesh", cap((0.18, -0.04, 1.30), (0.23, 0.02, 1.02), 0.050, 0.040)), + ("fore_l", "fore_l", "flesh", cap((0.23, 0.02, 1.02), (0.26, -0.03, 0.75), 0.040, 0.030)), + ("hand_l", "claw_l", "sinew", cap((0.26, -0.03, 0.75), (0.27, -0.07, 0.63), 0.032, 0.022)), +] + +SHAMBLER_ANIM = { + "walk": { + # Хромота: правая нога шагает шире, левая волочится и почти не гнётся. + "cycle": [("thigh_r", X, d(24), 0.00, d(-4)), ("thigh_l", X, d(14), 0.50, d(3)), + ("shin_r", X, -d(22), 0.22, d(-12)), ("shin_l", X, -d(8), 0.72, d(-5)), + ("arm_r", X, d(9), 0.50, d(7)), ("arm_l", X, d(6), 0.00, d(4)), + ("fore_r", X, d(6), 0.50, d(5)), ("spine", Y, d(6), 0.10, 0.0), + ("chest", Z, d(7), 0.00, 0.0), ("head", Y, d(8), 0.30, d(7))], + "bob": (0.032, 0.0), + }, + "attack": { + "keys": [("arm_r", X, [d(8), d(-92), d(-112), d(44), d(62), d(26)]), + ("fore_r", X, [d(6), d(-38), d(-52), d(24), d(36), d(14)]), + ("claw_r", X, [0, d(-18), d(-24), d(30), d(22), d(8)]), + ("chest", Z, [0, d(26), d(32), d(-24), d(-16), d(-4)]), + ("hips", Z, [0, d(11), d(14), d(-10), d(-6), 0]), + ("head", X, [d(5), d(-9), d(-11), d(15), d(10), d(6)]), + ("thigh_l", X, [0, d(-8), d(-10), d(12), d(8), 0])], + "root_loc": [(0, 0.03, 0), (0, 0.06, -0.02), (0, 0.07, -0.02), + (0, -0.07, -0.04), (0, -0.04, -0.02), (0, 0, 0)], + }, + "hit": { + "keys": [("chest", X, [0, d(-24), d(-14), d(-4)]), + ("head", X, [0, d(-28), d(-16), d(-5)]), + ("arm_r", X, [0, d(26), d(15), d(4)]), + ("arm_l", X, [0, d(22), d(13), d(3)]), + ("thigh_r", X, [0, d(-12), d(-7), 0])], + "root_loc": [(0, 0, 0), (0, 0.06, -0.02), (0, 0.03, -0.01), (0, 0.01, 0)], + }, + "death": { + # Не падение во весь рост, а осадка в кучу: труп обязан помещаться + # в клетку при ЛЮБОМ повороте, а лежащий во весь рост — не помещается. + "keys": [("hips", X, [0, d(6), d(18), d(30), d(38), d(42), d(44), d(44)]), + ("spine", X, [0, d(8), d(24), d(40), d(52), d(58), d(60), d(60)]), + ("chest", X, [0, d(6), d(20), d(34), d(44), d(50), d(52), d(52)]), + ("head", X, [0, d(-14), d(10), d(28), d(36), d(40), d(42), d(42)]), + ("thigh_r", X, [0, d(20), d(52), d(78), d(92), d(96), d(98), d(98)]), + ("thigh_l", X, [0, d(16), d(46), d(72), d(88), d(94), d(96), d(96)]), + ("shin_r", X, [0, d(-24), d(-62), d(-96), d(-114), d(-120), d(-122), d(-122)]), + ("shin_l", X, [0, d(-20), d(-58), d(-92), d(-110), d(-118), d(-120), d(-120)]), + ("arm_r", X, [0, d(-20), d(10), d(46), d(64), d(70), d(72), d(72)]), + ("arm_l", X, [0, d(-16), d(8), d(40), d(58), d(64), d(66), d(66)])], + "root_rot": [("x", [0, d(-6), d(8), d(24), d(34), d(38), d(40), d(40)]), + ("y", [0, d(2), d(6), d(11), d(14), d(15), d(16), d(16)])], + "root_loc": [(0, 0, 0), (0, 0.01, -0.10), (0, 0.02, -0.26), (0, 0.02, -0.40), + (0, 0.02, -0.48), (0, 0.02, -0.52), (0, 0.02, -0.54), (0, 0.02, -0.54)], + }, +} + + +# ---------------------------------------------------------------------------- +# RUSHER — четвероногий спринтер: горизонтальный хребет, пальцеходящие задние +# ноги, косы вместо предплечий, вместо головы — раскрытый цветок челюстей. +# ---------------------------------------------------------------------------- + +RUSHER_BONES = mirror_bones([ + ("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None), + ("hips", (0.00, 0.30, 0.60), (0.00, 0.14, 0.63), "root"), + ("spine", (0.00, 0.14, 0.63), (0.00, -0.02, 0.65), "hips"), + ("chest", (0.00, -0.02, 0.65), (0.00, -0.20, 0.64), "spine"), + ("neck", (0.00, -0.20, 0.64), (0.00, -0.33, 0.60), "chest"), + ("head", (0.00, -0.33, 0.60), (0.00, -0.46, 0.55), "neck"), + ("fore_r", (-0.13, -0.12, 0.60), (-0.16, -0.04, 0.34), "chest"), + ("scythe_r", (-0.16, -0.04, 0.34), (-0.17, -0.16, 0.05), "fore_r"), + ("thigh_r", (-0.12, 0.26, 0.58), (-0.15, 0.38, 0.30), "hips"), + ("shin_r", (-0.15, 0.38, 0.30), (-0.15, 0.20, 0.14), "thigh_r"), + ("toe_r", (-0.15, 0.20, 0.14), (-0.15, 0.06, 0.02), "shin_r"), + ("tail_a", (0.00, 0.34, 0.60), (0.00, 0.56, 0.66), "hips"), + ("tail_b", (0.00, 0.56, 0.66), (0.00, 0.76, 0.58), "tail_a"), +]) + +RUSHER_PARTS = mirror_parts([ + ("fore_r", "fore_r", "muscle", cap((-0.13, -0.12, 0.61), (-0.16, -0.04, 0.35), 0.042, 0.034)), + ("scythe_r", "scythe_r", "bone", cap((-0.16, -0.05, 0.35), (-0.17, -0.17, 0.05), 0.030, 0.006)), + ("spur_r", "scythe_r", "bone", cap((-0.16, -0.06, 0.30), (-0.19, -0.20, 0.24), 0.014, 0.000)), + ("thigh_r", "thigh_r", "muscle", cap((-0.12, 0.25, 0.59), (-0.15, 0.38, 0.30), 0.070, 0.046)), + ("shin_r", "shin_r", "sinew", cap((-0.15, 0.38, 0.30), (-0.15, 0.20, 0.14), 0.034, 0.026)), + ("toe_r", "toe_r", "bone", cap((-0.15, 0.20, 0.14), (-0.15, 0.05, 0.02), 0.026, 0.012)), + ("hip_plate_r", "hips", "chitin", box((-0.10, 0.28, 0.66), (0.090, 0.150, 0.070))), + ("jaw_up_r", "head", "bone", cap((-0.03, -0.34, 0.60), (-0.17, -0.52, 0.62), 0.028, 0.005)), + ("jaw_lo_r", "head", "bone", cap((-0.03, -0.34, 0.56), (-0.14, -0.50, 0.47), 0.026, 0.005)), +]) +RUSHER_PARTS += [ + ("hips", "hips", "flesh", cap((0.00, 0.32, 0.60), (0.00, 0.14, 0.63), 0.095, 0.100)), + ("belly", "spine", "gut", cap((0.00, 0.16, 0.58), (0.00, -0.06, 0.58), 0.082, 0.074)), + ("spine", "spine", "flesh", cap((0.00, 0.14, 0.63), (0.00, -0.02, 0.66), 0.098, 0.094)), + ("thorax", "chest", "flesh", cap((0.00, -0.02, 0.66), (0.00, -0.20, 0.64), 0.094, 0.072)), + ("crest_a", "spine", "bone", box((0.00, 0.10, 0.73), (0.026, 0.090, 0.090))), + ("crest_b", "spine", "bone", box((0.00, -0.02, 0.75), (0.026, 0.090, 0.110))), + ("crest_c", "chest", "bone", box((0.00, -0.14, 0.73), (0.024, 0.080, 0.090))), + ("port", "chest", "steel", box((0.00, -0.10, 0.58), (0.100, 0.090, 0.040))), + ("neck", "neck", "sinew", cap((0.00, -0.20, 0.64), (0.00, -0.33, 0.60), 0.062, 0.050)), + ("stump", "head", "muscle", ball((0.00, -0.36, 0.59), 0.058, (1.00, 0.90, 0.95))), + ("jaw_up", "head", "bone", cap((0.00, -0.36, 0.63), (0.00, -0.55, 0.68), 0.028, 0.005)), + ("jaw_lo", "head", "bone", cap((0.00, -0.36, 0.55), (0.00, -0.53, 0.44), 0.026, 0.005)), + ("gullet", "head", "gut", ball((0.00, -0.39, 0.59), 0.034, (1.00, 1.20, 1.00))), + ("tail_a", "tail_a", "flesh", cap((0.00, 0.34, 0.60), (0.00, 0.56, 0.66), 0.060, 0.038)), + ("tail_b", "tail_b", "sinew", cap((0.00, 0.56, 0.66), (0.00, 0.76, 0.58), 0.036, 0.010)), + ("barb", "tail_b", "bone", cap((0.00, 0.72, 0.60), (0.00, 0.84, 0.52), 0.018, 0.000)), +] + +RUSHER_ANIM = { + "walk": { + # Галоп в два такта: передние вместе, задние вместе, хребет качает. + "cycle": [("fore_r", X, d(34), 0.00, d(-6)), ("fore_l", X, d(34), 0.12, d(-6)), + ("scythe_r", X, d(26), 0.30, d(14)), ("scythe_l", X, d(26), 0.42, d(14)), + ("thigh_r", X, d(32), 0.50, 0.0), ("thigh_l", X, d(32), 0.62, 0.0), + ("shin_r", X, d(26), 0.76, 0.0), ("shin_l", X, d(26), 0.88, 0.0), + ("spine", X, d(9), 0.25, 0.0), ("chest", X, d(7), 0.35, 0.0), + ("neck", X, d(8), 0.55, 0.0), + ("tail_a", X, d(12), 0.20, d(-6)), ("tail_b", Z, d(16), 0.45, 0.0)], + "bob": (0.055, 0.25), + }, + "attack": { + # Рывок: тело выстреливает вперёд, косы сходятся, челюсти распахнуты. + "keys": [("fore_r", X, [d(-10), d(-56), d(-64), d(46), d(30), d(-4)]), + ("fore_l", X, [d(-10), d(-56), d(-64), d(46), d(30), d(-4)]), + ("scythe_r", X, [d(10), d(48), d(56), d(-38), d(-18), d(6)]), + ("scythe_l", X, [d(10), d(48), d(56), d(-38), d(-18), d(6)]), + ("neck", X, [0, d(22), d(26), d(-30), d(-14), 0]), + ("head", X, [0, d(16), d(20), d(-24), d(-10), 0]), + ("spine", X, [0, d(12), d(14), d(-16), d(-8), 0]), + ("tail_a", X, [d(-4), d(-22), d(-26), d(18), d(10), 0])], + "root_loc": [(0, 0.04, 0), (0, 0.10, 0.04), (0, 0.12, 0.05), + (0, -0.16, 0.02), (0, -0.08, 0), (0, 0, 0)], + }, + "hit": { + "keys": [("spine", X, [0, d(-20), d(-11), d(-3)]), + ("neck", X, [0, d(-26), d(-15), d(-4)]), + ("fore_r", X, [0, d(30), d(17), d(5)]), + ("fore_l", X, [0, d(26), d(15), d(4)]), + ("tail_a", X, [0, d(24), d(13), d(3)])], + "root_loc": [(0, 0, 0), (0, 0.08, -0.03), (0, 0.04, -0.01), (0, 0.01, 0)], + }, + "death": { + # Ноги подламываются, туша садится на брюхо и заваливается набок. + "keys": [("thigh_r", X, [0, d(-18), d(-46), d(-70), d(-84), d(-90), d(-92), d(-92)]), + ("thigh_l", X, [0, d(-16), d(-42), d(-66), d(-80), d(-86), d(-88), d(-88)]), + ("fore_r", X, [0, d(24), d(58), d(84), d(96), d(100), d(102), d(102)]), + ("fore_l", X, [0, d(20), d(52), d(78), d(92), d(98), d(100), d(100)]), + ("scythe_r", X, [0, d(-18), d(-44), d(-64), d(-74), d(-78), d(-80), d(-80)]), + ("scythe_l", X, [0, d(-16), d(-40), d(-60), d(-70), d(-74), d(-76), d(-76)]), + ("neck", X, [0, d(-12), d(6), d(24), d(34), d(38), d(40), d(40)]), + ("head", Z, [0, d(10), d(24), d(36), d(42), d(44), d(46), d(46)]), + ("tail_a", Z, [0, d(-14), d(-30), d(-42), d(-48), d(-50), d(-52), d(-52)]), + ("tail_b", Z, [0, d(-16), d(-34), d(-48), d(-56), d(-60), d(-62), d(-62)])], + "root_rot": [("y", [0, d(6), d(18), d(32), d(42), d(48), d(50), d(50)])], + "root_loc": [(0, 0, 0), (0, 0.01, -0.08), (0, 0.02, -0.20), (0, 0.02, -0.30), + (0, 0.02, -0.36), (0, 0.02, -0.39), (0, 0.02, -0.40), (0, 0.02, -0.40)], + }, +} + + +# ---------------------------------------------------------------------------- +# SPITTER — сосуд под давлением: волочащийся железистый мешок, вживлённый в +# горло клапан и сопло вместо лица, руки атрофированы. +# ---------------------------------------------------------------------------- + +SPITTER_BONES = mirror_bones([ + ("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None), + ("hips", (0.00, 0.00, 0.52), (0.00, 0.00, 0.66), "root"), + ("sac", (0.00, 0.06, 0.58), (0.00, 0.34, 0.44), "hips"), + ("spine", (0.00, 0.00, 0.66), (0.00, -0.03, 0.84), "hips"), + ("chest", (0.00, -0.03, 0.84), (0.00, -0.05, 1.00), "spine"), + ("valve", (0.00, -0.05, 1.00), (0.00, -0.10, 1.09), "chest"), + ("head", (0.00, -0.10, 1.09), (0.00, -0.24, 1.14), "valve"), + ("arm_r", (-0.15, -0.04, 0.94), (-0.21, 0.00, 0.76), "chest"), + ("fore_r", (-0.21, 0.00, 0.76), (-0.24, -0.06, 0.62), "arm_r"), + ("thigh_r", (-0.13, 0.00, 0.52), (-0.20, 0.04, 0.28), "hips"), + ("shin_r", (-0.20, 0.04, 0.28), (-0.22, -0.02, 0.06), "thigh_r"), +]) + +SPITTER_PARTS = mirror_parts([ + ("arm_r", "arm_r", "flesh", cap((-0.15, -0.04, 0.94), (-0.21, 0.00, 0.77), 0.032, 0.024)), + ("fore_r", "fore_r", "sinew", cap((-0.21, 0.00, 0.77), (-0.24, -0.06, 0.62), 0.023, 0.014)), + ("thigh_r", "thigh_r", "flesh", cap((-0.13, 0.00, 0.53), (-0.20, 0.04, 0.28), 0.070, 0.050)), + ("shin_r", "shin_r", "flesh", cap((-0.20, 0.04, 0.28), (-0.22, -0.02, 0.07), 0.048, 0.036)), + ("foot_r", "shin_r", "sinew", box((-0.22, -0.07, 0.035), (0.110, 0.230, 0.070))), + ("gland_r", "sac", "tube", cap((-0.06, 0.16, 0.62), (-0.05, -0.02, 0.96), 0.016, 0.013)), +]) +SPITTER_PARTS += [ + ("pelvis", "hips", "flesh", cap((0.00, 0.00, 0.52), (0.00, 0.00, 0.68), 0.125, 0.110)), + # Мешок — главная читаемая деталь: он больше грудной клетки и волочится. + ("sac", "sac", "membrane", ball((0.00, 0.24, 0.44), 0.215, (0.95, 1.25, 0.90))), + ("sac_seam", "sac", "sinew", cap((0.00, 0.06, 0.56), (0.00, 0.42, 0.44), 0.030, 0.018)), + ("sac_ring", "sac", "rust", box((0.00, 0.10, 0.52), (0.230, 0.040, 0.190))), + ("gland_c", "sac", "tube", cap((0.00, 0.18, 0.66), (0.00, -0.02, 0.98), 0.018, 0.014)), + ("spine", "spine", "flesh", cap((0.00, 0.00, 0.66), (0.00, -0.03, 0.85), 0.100, 0.096)), + ("thorax", "chest", "flesh", cap((0.00, -0.03, 0.85), (0.00, -0.05, 1.00), 0.098, 0.086)), + ("clamp", "chest", "steel", box((0.00, -0.04, 0.92), (0.190, 0.180, 0.030))), + # Клапан в горле: фланец, корпус, два болта. Это имплант, а не кость. + ("valve", "valve", "steel", cap((0.00, -0.05, 1.00), (0.00, -0.10, 1.10), 0.050, 0.044)), + ("flange", "valve", "steel", box((0.00, -0.07, 1.05), (0.140, 0.130, 0.022))), + ("valve_bolt_r", "valve", "rust", ball((-0.055, -0.07, 1.05), 0.016)), + ("valve_bolt_l", "valve", "rust", ball((0.055, -0.07, 1.05), 0.016)), + # Сопло вместо лица: раструб, а не голова. + ("skull", "head", "flesh", ball((0.00, -0.14, 1.11), 0.070, (0.90, 1.00, 0.88))), + ("nozzle", "head", "steel", cap((0.00, -0.16, 1.12), (0.00, -0.30, 1.13), 0.038, 0.062)), + ("nozzle_lip", "head", "rust", cap((0.00, -0.29, 1.13), (0.00, -0.32, 1.13), 0.066, 0.060)), + ("ocellus_r", "head", "ocellus", ball((-0.045, -0.19, 1.17), 0.014)), + ("ocellus_l", "head", "ocellus", ball((0.045, -0.19, 1.17), 0.014)), + ("ocellus_c", "head", "ocellus", ball((0.000, -0.20, 1.19), 0.012)), +] + +SPITTER_ANIM = { + "walk": { + # Переваливается: мешок раскачивается противофазой к корпусу. + "cycle": [("thigh_r", X, d(19), 0.00, d(-3)), ("thigh_l", X, d(19), 0.50, d(-3)), + ("shin_r", X, -d(14), 0.20, d(-8)), ("shin_l", X, -d(14), 0.70, d(-8)), + ("hips", Y, d(9), 0.00, 0.0), ("sac", X, d(11), 0.50, d(-4)), + ("sac", Y, d(8), 0.25, 0.0), ("spine", Y, -d(7), 0.00, 0.0), + ("arm_r", X, d(10), 0.50, d(8)), ("arm_l", X, d(10), 0.00, d(8)), + ("head", X, d(5), 0.15, 0.0)], + "bob": (0.028, 0.0), + }, + "attack": { + # Присесть, накачать мешок, выпрямить шею — плевок на 3-4 кадре. + "keys": [("spine", X, [0, d(-18), d(-24), d(20), d(10), d(2)]), + ("chest", X, [0, d(-12), d(-16), d(16), d(8), d(2)]), + ("valve", X, [0, d(-14), d(-20), d(26), d(12), d(2)]), + ("head", X, [0, d(-16), d(-22), d(30), d(14), d(3)]), + ("sac", X, [0, d(-14), d(-20), d(16), d(8), 0]), + ("arm_r", X, [d(6), d(-24), d(-30), d(18), d(10), d(6)]), + ("arm_l", X, [d(6), d(-24), d(-30), d(18), d(10), d(6)]), + ("thigh_r", X, [0, d(16), d(22), d(-8), d(-4), 0]), + ("thigh_l", X, [0, d(16), d(22), d(-8), d(-4), 0])], + "root_loc": [(0, 0, 0), (0, 0.03, -0.06), (0, 0.04, -0.08), + (0, -0.04, 0.02), (0, -0.02, 0.01), (0, 0, 0)], + }, + "hit": { + "keys": [("spine", X, [0, d(-22), d(-13), d(-4)]), + ("head", X, [0, d(-26), d(-15), d(-4)]), + ("sac", X, [0, d(20), d(11), d(3)]), + ("arm_r", X, [0, d(24), d(14), d(4)]), + ("arm_l", X, [0, d(24), d(14), d(4)])], + "root_loc": [(0, 0, 0), (0, 0.05, -0.02), (0, 0.03, -0.01), (0, 0.01, 0)], + }, + "death": { + # Мешок сдувается первым, потом ноги разъезжаются и туша оседает. + "keys": [("sac", X, [0, d(10), d(26), d(38), d(44), d(46), d(48), d(48)]), + ("spine", X, [0, d(10), d(28), d(46), d(58), d(64), d(66), d(66)]), + ("chest", X, [0, d(8), d(22), d(36), d(46), d(50), d(52), d(52)]), + ("head", X, [0, d(-10), d(14), d(34), d(44), d(48), d(50), d(50)]), + ("thigh_r", Y, [0, d(-12), d(-28), d(-40), d(-46), d(-48), d(-50), d(-50)]), + ("thigh_l", Y, [0, d(12), d(28), d(40), d(46), d(48), d(50), d(50)]), + ("thigh_r", X, [0, d(18), d(44), d(64), d(74), d(78), d(80), d(80)]), + ("thigh_l", X, [0, d(18), d(44), d(64), d(74), d(78), d(80), d(80)]), + ("shin_r", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]), + ("shin_l", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]), + ("arm_r", X, [0, d(-14), d(10), d(38), d(52), d(58), d(60), d(60)]), + ("arm_l", X, [0, d(-14), d(10), d(38), d(52), d(58), d(60), d(60)])], + "root_rot": [("x", [0, d(-4), d(8), d(20), d(28), d(32), d(34), d(34)])], + "root_loc": [(0, 0, 0), (0, 0.01, -0.06), (0, 0.02, -0.16), (0, 0.02, -0.24), + (0, 0.02, -0.29), (0, 0.02, -0.32), (0, 0.02, -0.33), (0, 0.02, -0.33)], + }, +} + + +# ---------------------------------------------------------------------------- +# BRUTE — «сросшийся»: к плечу приварено второе, наполовину поглощённое тело; +# грудь забрана болтами; правая рука — молот из сросшейся кости. +# ---------------------------------------------------------------------------- + +BRUTE_BONES = mirror_bones([ + ("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.14), None), + ("hips", (0.00, 0.00, 0.98), (0.00, 0.00, 1.16), "root"), + ("spine", (0.00, 0.00, 1.16), (0.00, -0.02, 1.38), "hips"), + ("chest", (0.00, -0.02, 1.38), (0.00, -0.04, 1.60), "spine"), + ("neck", (0.00, -0.04, 1.60), (0.00, -0.08, 1.66), "chest"), + ("head", (0.00, -0.08, 1.66), (0.00, -0.12, 1.78), "neck"), + ("thigh_r", (-0.17, 0.00, 0.98), (-0.19, 0.02, 0.54), "hips"), + ("shin_r", (-0.19, 0.02, 0.54), (-0.19, -0.01, 0.10), "thigh_r"), + ("foot_r", (-0.19, -0.01, 0.10), (-0.19, -0.16, 0.03), "shin_r"), +]) +# Руки и привой не зеркальны: справа молот из сросшейся кости, слева — чужое +# тело, вросшее в плечо. Объявлены руками, иначе зеркало создало бы дубли. +BRUTE_BONES += [ + ("arm_r", (-0.32, -0.02, 1.54), (-0.44, 0.02, 1.18), "chest"), + ("fore_r", (-0.44, 0.02, 1.18), (-0.50, -0.04, 0.82), "arm_r"), + ("maul_r", (-0.50, -0.04, 0.82), (-0.52, -0.06, 0.64), "fore_r"), + ("arm_l", (0.28, -0.02, 1.52), (0.38, 0.02, 1.20), "chest"), + ("fore_l", (0.38, 0.02, 1.20), (0.42, -0.04, 0.90), "arm_l"), + ("graft", (0.20, 0.06, 1.60), (0.32, 0.10, 1.78), "chest"), + ("graft_head", (0.32, 0.10, 1.78), (0.40, 0.12, 1.90), "graft"), + ("graft_arm", (0.32, 0.08, 1.72), (0.50, 0.04, 1.50), "graft"), +] + +BRUTE_PARTS = mirror_parts([ + ("thigh_r", "thigh_r", "flesh", cap((-0.17, 0.00, 1.00), (-0.19, 0.02, 0.54), 0.115, 0.088)), + ("shin_r", "shin_r", "flesh", cap((-0.19, 0.02, 0.54), (-0.19, -0.01, 0.11), 0.085, 0.062)), + ("foot_r", "foot_r", "sinew", box((-0.19, -0.09, 0.055), (0.135, 0.290, 0.110))), + ("knee_r", "shin_r", "bone", ball((-0.19, -0.05, 0.54), 0.062, (0.90, 0.80, 0.90))), +]) +BRUTE_PARTS += [ + ("pelvis", "hips", "flesh", cap((0.00, 0.00, 0.98), (0.00, 0.00, 1.18), 0.180, 0.170)), + ("gut", "spine", "gut", ball((0.00, -0.08, 1.24), 0.165, (1.10, 0.92, 0.86))), + ("torso", "chest", "flesh", cap((0.00, -0.01, 1.28), (0.00, -0.04, 1.58), 0.215, 0.235)), + ("shoulders", "chest", "muscle", cap((-0.32, -0.02, 1.54), (0.30, -0.02, 1.54), 0.115, 0.115)), + # Броня не надета, а вбита: пластины утоплены в мясо и держатся болтами. + ("plate_a", "chest", "steel", box((0.00, -0.17, 1.50), (0.360, 0.130, 0.150))), + ("plate_b", "chest", "steel", box((0.00, -0.16, 1.32), (0.330, 0.130, 0.140))), + ("plate_back", "chest", "rust", box((0.00, 0.16, 1.42), (0.340, 0.110, 0.320))), + ("bolt_a", "chest", "rust", ball((-0.15, -0.23, 1.50), 0.026)), + ("bolt_b", "chest", "rust", ball((0.15, -0.23, 1.50), 0.026)), + ("bolt_c", "chest", "rust", ball((-0.13, -0.22, 1.32), 0.024)), + ("bolt_d", "chest", "rust", ball((0.13, -0.22, 1.32), 0.024)), + ("collar", "neck", "steel", cap((-0.09, -0.05, 1.62), (0.09, -0.05, 1.62), 0.055, 0.055)), + ("skull", "head", "flesh", ball((0.00, -0.10, 1.71), 0.078, (0.92, 1.00, 0.92))), + ("maw", "head", "bone", box((0.00, -0.16, 1.68), (0.100, 0.090, 0.050))), + # Рука-молот: предплечье срослось в костяную колотушку с шипами. + ("arm_r", "arm_r", "muscle", cap((-0.32, -0.02, 1.54), (-0.44, 0.02, 1.18), 0.115, 0.098)), + ("fore_r", "fore_r", "muscle", cap((-0.44, 0.02, 1.18), (-0.50, -0.04, 0.86), 0.100, 0.086)), + ("maul", "maul_r", "bone", ball((-0.51, -0.05, 0.74), 0.125, (0.95, 1.05, 1.00))), + ("maul_spike_a", "maul_r", "bone", cap((-0.51, -0.05, 0.74), (-0.68, -0.10, 0.66), 0.030, 0.000)), + ("maul_spike_b", "maul_r", "bone", cap((-0.51, -0.05, 0.74), (-0.50, -0.06, 0.56), 0.028, 0.000)), + ("maul_spike_c", "maul_r", "bone", cap((-0.51, -0.05, 0.74), (-0.56, -0.22, 0.72), 0.026, 0.000)), + ("arm_l", "arm_l", "flesh", cap((0.28, -0.02, 1.52), (0.38, 0.02, 1.20), 0.098, 0.082)), + ("fore_l", "fore_l", "flesh", cap((0.38, 0.02, 1.20), (0.42, -0.04, 0.92), 0.082, 0.064)), + ("fist_l", "fore_l", "sinew", ball((0.42, -0.05, 0.89), 0.070)), + # Привой: чужой торс, безвольная голова и рука, которая просто висит. + ("graft", "graft", "flesh", cap((0.20, 0.06, 1.60), (0.33, 0.10, 1.78), 0.105, 0.090)), + ("graft_seam", "graft", "steel", box((0.20, 0.06, 1.60), (0.130, 0.130, 0.030))), + ("graft_head", "graft_head", "flesh", ball((0.38, 0.12, 1.87), 0.070, (0.92, 1.00, 0.96))), + ("graft_jaw", "graft_head", "bone", box((0.40, 0.09, 1.83), (0.075, 0.070, 0.040))), + ("graft_arm", "graft_arm", "sinew", cap((0.33, 0.08, 1.73), (0.50, 0.04, 1.50), 0.052, 0.034)), + ("graft_hand", "graft_arm", "sinew", ball((0.51, 0.03, 1.47), 0.042)), +] + +BRUTE_ANIM = { + "walk": { + # Тяжёлый шаг вразвалку: плечи катаются, привой болтается отдельно. + "cycle": [("thigh_r", X, d(20), 0.00, d(-3)), ("thigh_l", X, d(20), 0.50, d(-3)), + ("shin_r", X, -d(16), 0.22, d(-9)), ("shin_l", X, -d(16), 0.72, d(-9)), + ("hips", Y, d(8), 0.00, 0.0), ("chest", Y, -d(7), 0.05, 0.0), + ("chest", Z, d(9), 0.25, 0.0), + ("arm_r", X, d(13), 0.50, d(6)), ("arm_l", X, d(13), 0.00, d(6)), + ("fore_r", X, d(7), 0.60, d(4)), + ("graft", Y, d(11), 0.35, 0.0), ("graft_head", X, d(13), 0.45, d(6)), + ("graft_arm", X, d(17), 0.15, 0.0), + ("head", X, d(4), 0.30, d(3))], + "bob": (0.045, 0.0), + }, + "attack": { + # Замах ВБОК, а не над головой: молот над головой вылезал бы из клетки. + "keys": [("arm_r", Z, [0, d(-52), d(-64), d(38), d(24), d(4)]), + ("arm_r", X, [d(4), d(-34), d(-42), d(26), d(16), d(4)]), + ("fore_r", X, [d(4), d(-24), d(-30), d(34), d(20), d(6)]), + ("maul_r", X, [0, d(-16), d(-20), d(26), d(14), d(4)]), + ("chest", Z, [0, d(30), d(36), d(-30), d(-18), d(-4)]), + ("hips", Z, [0, d(14), d(17), d(-14), d(-8), 0]), + ("graft", Z, [0, d(-16), d(-20), d(18), d(10), 0]), + ("graft_arm", X, [0, d(-20), d(-26), d(30), d(16), 0]), + ("thigh_l", X, [0, d(-10), d(-13), d(16), d(9), 0])], + "root_loc": [(0, 0.02, 0), (0, 0.05, 0.02), (0, 0.06, 0.02), + (0, -0.08, -0.05), (0, -0.05, -0.03), (0, 0, 0)], + }, + "hit": { + "keys": [("chest", X, [0, d(-18), d(-10), d(-3)]), + ("head", X, [0, d(-22), d(-13), d(-4)]), + ("arm_r", X, [0, d(20), d(11), d(3)]), + ("graft_head", X, [0, d(-26), d(-15), d(-4)]), + ("graft_arm", X, [0, d(28), d(16), d(4)])], + "root_loc": [(0, 0, 0), (0, 0.05, -0.03), (0, 0.03, -0.01), (0, 0.01, 0)], + }, + "death": { + # Оседает на колени и валится вперёд на молот. Куча, а не бревно. + "keys": [("hips", X, [0, d(8), d(20), d(32), d(40), d(44), d(46), d(46)]), + ("spine", X, [0, d(10), d(26), d(42), d(54), d(60), d(62), d(62)]), + ("chest", X, [0, d(8), d(22), d(36), d(46), d(52), d(54), d(54)]), + ("head", X, [0, d(-12), d(12), d(30), d(40), d(44), d(46), d(46)]), + ("thigh_r", X, [0, d(22), d(56), d(84), d(98), d(104), d(106), d(106)]), + ("thigh_l", X, [0, d(20), d(52), d(80), d(94), d(100), d(102), d(102)]), + ("shin_r", X, [0, d(-26), d(-66), d(-100), d(-118), d(-124), d(-126), d(-126)]), + ("shin_l", X, [0, d(-24), d(-62), d(-96), d(-114), d(-120), d(-122), d(-122)]), + ("arm_r", X, [0, d(-14), d(14), d(50), d(68), d(74), d(76), d(76)]), + ("arm_l", X, [0, d(-12), d(12), d(44), d(62), d(68), d(70), d(70)]), + ("graft", X, [0, d(-16), d(8), d(34), d(48), d(54), d(56), d(56)]), + ("graft_arm", X, [0, d(-20), d(10), d(44), d(62), d(70), d(72), d(72)])], + "root_rot": [("x", [0, d(-8), d(6), d(22), d(32), d(36), d(38), d(38)]), + ("y", [0, d(-2), d(-6), d(-11), d(-14), d(-16), d(-17), d(-17)])], + # Keep the fallen mass centred around the ground pivot. Driving the + # root farther below the floor only moves the projected corpse down + # screen and clips it; the limb rotations already sell the collapse. + "root_loc": [(0, 0, 0), (0, 0.01, -0.10), (0, 0.02, -0.22), (0, 0.02, -0.32), + (0, 0.02, -0.38), (0, 0.02, -0.40), (0, 0.02, -0.40), (0, 0.02, -0.40)], + }, +} + + +# ---------------------------------------------------------------------------- +# LURKER — жнец-паук: плоский зашитый панцирь подвешен под четырьмя длинными +# выгнутыми лапами, вместо головы — безглазый клин с крюками. +# ---------------------------------------------------------------------------- + +LURKER_BONES = mirror_bones([ + ("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.10), None), + ("core", (0.00, 0.10, 0.36), (0.00, -0.08, 0.38), "root"), + ("head", (0.00, -0.08, 0.38), (0.00, -0.26, 0.32), "core"), + ("fcoxa_r", (-0.09, -0.04, 0.40), (-0.26, -0.18, 0.66), "core"), + ("ffemur_r", (-0.26, -0.18, 0.66), (-0.44, -0.30, 0.28), "fcoxa_r"), + ("ftarsus_r", (-0.44, -0.30, 0.28), (-0.50, -0.36, 0.01), "ffemur_r"), + ("bcoxa_r", (-0.09, 0.12, 0.40), (-0.27, 0.24, 0.64), "core"), + ("bfemur_r", (-0.27, 0.24, 0.64), (-0.45, 0.36, 0.26), "bcoxa_r"), + ("btarsus_r", (-0.45, 0.36, 0.26), (-0.51, 0.42, 0.01), "bfemur_r"), + ("hook_r", (-0.05, -0.20, 0.34), (-0.10, -0.34, 0.24), "head"), +]) + +LURKER_PARTS = mirror_parts([ + ("fcoxa_r", "fcoxa_r", "chitin", cap((-0.09, -0.04, 0.40), (-0.26, -0.18, 0.66), 0.040, 0.028)), + ("ffemur_r", "ffemur_r", "chitin", cap((-0.26, -0.18, 0.66), (-0.44, -0.30, 0.28), 0.028, 0.017)), + ("ftarsus_r", "ftarsus_r", "bone", cap((-0.44, -0.30, 0.28), (-0.50, -0.36, 0.01), 0.017, 0.004)), + ("fjoint_r", "ffemur_r", "sinew", ball((-0.26, -0.18, 0.66), 0.036)), + ("bcoxa_r", "bcoxa_r", "chitin", cap((-0.09, 0.12, 0.40), (-0.27, 0.24, 0.64), 0.040, 0.028)), + ("bfemur_r", "bfemur_r", "chitin", cap((-0.27, 0.24, 0.64), (-0.45, 0.36, 0.26), 0.028, 0.017)), + ("btarsus_r", "btarsus_r", "bone", cap((-0.45, 0.36, 0.26), (-0.51, 0.42, 0.01), 0.017, 0.004)), + ("bjoint_r", "bfemur_r", "sinew", ball((-0.27, 0.24, 0.64), 0.036)), + ("hook_r", "hook_r", "bone", cap((-0.05, -0.20, 0.34), (-0.11, -0.36, 0.23), 0.020, 0.000)), +]) +LURKER_PARTS += [ + ("carapace", "core", "chitin", ball((0.00, 0.02, 0.38), 0.185, (0.85, 1.15, 0.42))), + ("belly", "core", "flesh", ball((0.00, 0.02, 0.31), 0.150, (0.82, 1.05, 0.30))), + # Шов: панцирь не вырос, его ЗАШИЛИ. Скобы идут по всей спине. + ("suture", "core", "sinew", box((0.00, 0.02, 0.455), (0.030, 0.290, 0.020))), + ("staple_a", "core", "steel", box((0.00, -0.10, 0.462), (0.110, 0.026, 0.014))), + ("staple_b", "core", "steel", box((0.00, 0.02, 0.468), (0.115, 0.026, 0.014))), + ("staple_c", "core", "steel", box((0.00, 0.14, 0.462), (0.110, 0.026, 0.014))), + ("vent_r", "core", "rust", box((-0.13, 0.16, 0.40), (0.060, 0.070, 0.070))), + ("vent_l", "core", "rust", box((0.13, 0.16, 0.40), (0.060, 0.070, 0.070))), + ("wedge", "head", "chitin", cap((0.00, -0.08, 0.38), (0.00, -0.28, 0.31), 0.085, 0.030)), + ("mouth", "head", "gut", ball((0.00, -0.26, 0.32), 0.036, (1.00, 0.70, 1.00))), +] + +LURKER_ANIM = { + "walk": { + # Семенит: лапы работают по диагонали, корпус почти не шевелится — + # именно это и должно читаться как «крадётся», а не «топает». + "cycle": [("fcoxa_r", X, d(20), 0.00, 0.0), ("fcoxa_l", X, d(20), 0.50, 0.0), + ("bcoxa_r", X, d(20), 0.50, 0.0), ("bcoxa_l", X, d(20), 0.00, 0.0), + ("ffemur_r", X, d(24), 0.18, 0.0), ("ffemur_l", X, d(24), 0.68, 0.0), + ("bfemur_r", X, d(24), 0.68, 0.0), ("bfemur_l", X, d(24), 0.18, 0.0), + ("ftarsus_r", X, d(16), 0.34, 0.0), ("ftarsus_l", X, d(16), 0.84, 0.0), + ("btarsus_r", X, d(16), 0.84, 0.0), ("btarsus_l", X, d(16), 0.34, 0.0), + ("core", Y, d(5), 0.00, 0.0), ("head", X, d(6), 0.25, 0.0)], + "bob": (0.022, 0.0), + }, + "attack": { + # Вздыбить перед, ударить крюками сверху вниз. + "keys": [("fcoxa_r", X, [0, d(-46), d(-56), d(34), d(18), d(2)]), + ("fcoxa_l", X, [0, d(-46), d(-56), d(34), d(18), d(2)]), + ("ffemur_r", X, [0, d(-30), d(-38), d(28), d(14), d(2)]), + ("ffemur_l", X, [0, d(-30), d(-38), d(28), d(14), d(2)]), + ("core", X, [0, d(-26), d(-32), d(22), d(10), 0]), + ("head", X, [0, d(-20), d(-26), d(38), d(16), 0]), + ("hook_r", X, [0, d(-18), d(-24), d(34), d(14), 0]), + ("hook_l", X, [0, d(-18), d(-24), d(34), d(14), 0]), + ("bfemur_r", X, [0, d(14), d(18), d(-10), d(-5), 0]), + ("bfemur_l", X, [0, d(14), d(18), d(-10), d(-5), 0])], + "root_loc": [(0, 0, 0), (0, 0.02, 0.06), (0, 0.03, 0.08), + (0, -0.07, -0.02), (0, -0.03, -0.01), (0, 0, 0)], + }, + "hit": { + "keys": [("core", X, [0, d(-18), d(-10), d(-3)]), + ("head", X, [0, d(-24), d(-14), d(-4)]), + ("fcoxa_r", X, [0, d(22), d(13), d(4)]), + ("fcoxa_l", X, [0, d(22), d(13), d(4)]), + ("bcoxa_r", X, [0, d(-14), d(-8), d(-2)]), + ("bcoxa_l", X, [0, d(-14), d(-8), d(-2)])], + "root_loc": [(0, 0, 0), (0, 0.06, -0.02), (0, 0.03, -0.01), (0, 0.01, 0)], + }, + "death": { + # Лапы поджимаются НАВЕРХ и внутрь, панцирь падает на пол — классика. + "keys": [("fcoxa_r", X, [0, d(-16), d(-38), d(-56), d(-66), d(-70), d(-72), d(-72)]), + ("fcoxa_l", X, [0, d(-16), d(-38), d(-56), d(-66), d(-70), d(-72), d(-72)]), + ("bcoxa_r", X, [0, d(-14), d(-34), d(-52), d(-62), d(-66), d(-68), d(-68)]), + ("bcoxa_l", X, [0, d(-14), d(-34), d(-52), d(-62), d(-66), d(-68), d(-68)]), + ("ffemur_r", X, [0, d(26), d(64), d(96), d(112), d(118), d(120), d(120)]), + ("ffemur_l", X, [0, d(26), d(64), d(96), d(112), d(118), d(120), d(120)]), + ("bfemur_r", X, [0, d(24), d(60), d(92), d(108), d(114), d(116), d(116)]), + ("bfemur_l", X, [0, d(24), d(60), d(92), d(108), d(114), d(116), d(116)]), + ("ftarsus_r", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]), + ("ftarsus_l", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]), + ("btarsus_r", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]), + ("btarsus_l", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]), + ("head", X, [0, d(8), d(20), d(30), d(36), d(38), d(40), d(40)])], + "root_rot": [("y", [0, d(6), d(16), d(26), d(32), d(35), d(36), d(36)])], + "root_loc": [(0, 0, 0), (0, 0.00, -0.05), (0, 0.01, -0.12), (0, 0.01, -0.18), + (0, 0.01, -0.22), (0, 0.01, -0.24), (0, 0.01, -0.25), (0, 0.01, -0.25)], + }, +} + + +KINDS = { + "shambler": {"bones": SHAMBLER_BONES, "parts": SHAMBLER_PARTS, "anim": SHAMBLER_ANIM}, + "rusher": {"bones": RUSHER_BONES, "parts": RUSHER_PARTS, "anim": RUSHER_ANIM}, + "spitter": {"bones": SPITTER_BONES, "parts": SPITTER_PARTS, "anim": SPITTER_ANIM}, + "brute": {"bones": BRUTE_BONES, "parts": BRUTE_PARTS, "anim": BRUTE_ANIM}, + "lurker": {"bones": LURKER_BONES, "parts": LURKER_PARTS, "anim": LURKER_ANIM}, +} + + +# ---------------------------------------------------------------------------- +# Мелкие утилиты (родня agent_rig.py; расходиться им нельзя по смыслу камеры) +# ---------------------------------------------------------------------------- + +def log(message): + print("[monster_rig] " + message) + + +def collection(name, parent): + existing = bpy.data.collections.get(name) + if existing is not None: + return existing + made = bpy.data.collections.new(name) + parent.children.link(made) + return made + + +def _set_socket(node, name, value): + socket = node.inputs.get(name) + if socket is None: + return + try: + socket.default_value = value + except (TypeError, ValueError): + pass + + +def material(name): + existing = bpy.data.materials.get(name) + if existing is not None: + return existing + color, roughness, metallic = MATERIALS[name] + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf is not None: + _set_socket(bsdf, "Base Color", (color[0], color[1], color[2], 1.0)) + _set_socket(bsdf, "Roughness", roughness) + _set_socket(bsdf, "Metallic", metallic) + _set_socket(bsdf, "Specular IOR Level", 0.5) + _set_socket(bsdf, "Specular", 0.5) + mat.diffuse_color = (color[0], color[1], color[2], 1.0) + return mat + + +def mesh_object(name, bm, mat, coll): + mesh = bpy.data.meshes.new(name) + bm.to_mesh(mesh) + bm.free() + mesh.materials.append(mat) + for polygon in mesh.polygons: + polygon.use_smooth = False + obj = bpy.data.objects.new(name, mesh) + coll.objects.link(obj) + return obj + + +def build_shape(kind, params): + """Возвращает bmesh детали в МИРОВЫХ координатах позы покоя.""" + bm = bmesh.new() + if kind == "capsule": + p0, p1, r0, r1 = Vector(params[0]), Vector(params[1]), params[2], params[3] + axis = p1 - p0 + length = axis.length + if length < 1e-6: + raise ValueError("degenerate capsule") + bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=10, + radius1=r0, radius2=r1, depth=length) + rot = axis.to_track_quat("Z", "Y").to_matrix().to_4x4() + bmesh.ops.transform(bm, matrix=rot, verts=bm.verts) + bmesh.ops.translate(bm, vec=(p0 + p1) * 0.5, verts=bm.verts) + elif kind == "box": + center, size, yaw = Vector(params[0]), Vector(params[1]), params[2] + bmesh.ops.create_cube(bm, size=1.0) + matrix = (Matrix.Translation(center) + @ Matrix.Rotation(yaw, 4, "Z") + @ Matrix.Diagonal(size.to_4d())) + bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts) + elif kind == "sphere": + center, radius, scale = Vector(params[0]), params[1], Vector(params[2]) + bmesh.ops.create_uvsphere(bm, u_segments=14, v_segments=8, radius=radius) + matrix = Matrix.Translation(center) @ Matrix.Diagonal(scale.to_4d()) + bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts) + else: + raise ValueError("unknown shape: " + kind) + return bm + + +def bone_parent(obj, armature, bone_name): + """Жёстко цепляет объект к кости, сохраняя его положение в позе покоя.""" + world = obj.matrix_world.copy() + obj.parent = armature + obj.parent_type = "BONE" + obj.parent_bone = bone_name + bpy.context.view_layer.update() + obj.matrix_world = world + + +# ---------------------------------------------------------------------------- +# Сборка сцены +# ---------------------------------------------------------------------------- + +def build_armature(name, bones, coll): + data = bpy.data.armatures.new(name) + armature = bpy.data.objects.new(name, data) + coll.objects.link(armature) + bpy.context.view_layer.objects.active = armature + # Режим EDIT входит для ВЫДЕЛЕННЫХ объектов: без сброса выделения второй + # риг открылся бы вместе с первым и кости уехали бы в чужой скелет. + for other in bpy.context.view_layer.objects: + other.select_set(other is armature) + bpy.ops.object.mode_set(mode="EDIT") + made = {} + for bone_name, head, tail, parent in bones: + bone = data.edit_bones.new(bone_name) + bone.head = head + bone.tail = tail + bone.roll = 0.0 + bone.use_connect = False + if parent is not None: + bone.parent = made[parent] + made[bone_name] = bone + bpy.ops.object.mode_set(mode="OBJECT") + for bone in armature.pose.bones: + bone.rotation_mode = "QUATERNION" + return armature + + +def build_parts(parts, prefix, armature, coll): + for suffix, bone, mat, (shape, params) in parts: + obj = mesh_object("%s_%s" % (prefix, suffix), build_shape(shape, params), + material(mat), coll) + bone_parent(obj, armature, bone) + + +def build_camera(scene, camera_cfg, root_coll): + elevation = math.radians(float(camera_cfg["elevation_deg"])) + distance = float(camera_cfg["distance"]) + data = bpy.data.cameras.new("monster_cam") + data.type = "ORTHO" + data.clip_start = 0.01 + data.clip_end = distance * 4.0 + data.shift_x = 0.0 + data.shift_y = 0.0 + camera = bpy.data.objects.new("monster_cam", data) + # Смотрит строго в начало координат: ось взгляда проходит через (0,0,0), + # поэтому в ортографии оно всегда попадает точно в центр кадра. + camera.location = (0.0, -distance * math.cos(elevation), distance * math.sin(elevation)) + camera.rotation_euler = (math.pi / 2.0 - elevation, 0.0, 0.0) + root_coll.objects.link(camera) + scene.camera = camera + return camera + + +def build_lights(root_coll): + # Свет фиксирован в МИРЕ: поворачивается тварь, значит каждое направление + # освещено по-своему. Ключ холодный сверху-слева, мясо подсвечено снизу + # тёплым — тот самый «мокрый» контур. + key = bpy.data.lights.new("key_sun", type="SUN") + key.energy = 3.0 + key.color = (0.92, 0.95, 1.0) + key.angle = math.radians(8.0) + key_obj = bpy.data.objects.new("key_sun", key) + key_obj.rotation_euler = (math.radians(50.0), 0.0, math.radians(-40.0)) + root_coll.objects.link(key_obj) + + fill = bpy.data.lights.new("fill_sun", type="SUN") + fill.energy = 0.7 + fill.color = (1.0, 0.52, 0.44) + fill_obj = bpy.data.objects.new("fill_sun", fill) + fill_obj.rotation_euler = (math.radians(112.0), 0.0, math.radians(155.0)) + root_coll.objects.link(fill_obj) + + rim = bpy.data.lights.new("rim_sun", type="SUN") + rim.energy = 1.1 + rim.color = (0.60, 0.78, 0.92) + rim_obj = bpy.data.objects.new("rim_sun", rim) + rim_obj.rotation_euler = (math.radians(74.0), 0.0, math.radians(196.0)) + root_coll.objects.link(rim_obj) + + +def build_world(scene): + world = bpy.data.worlds.new("monster_world") + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + if background is not None: + _set_socket(background, "Color", (0.030, 0.036, 0.046, 1.0)) + _set_socket(background, "Strength", 1.0) + scene.world = world + + +def configure_render(scene): + for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"): + try: + scene.render.engine = engine + break + except (TypeError, ValueError): + continue + scene.render.film_transparent = True + scene.render.use_motion_blur = False + scene.render.resolution_percentage = 100 + scene.render.pixel_aspect_x = 1.0 + scene.render.pixel_aspect_y = 1.0 + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.image_settings.color_depth = "8" + scene.render.image_settings.compression = 0 + # Standard, а не AgX: спрайт должен получить ровно тот цвет, что в материале. + try: + scene.view_settings.view_transform = "Standard" + scene.view_settings.look = "None" + except (TypeError, ValueError): + pass + eevee = getattr(scene, "eevee", None) + if eevee is not None and hasattr(eevee, "taa_render_samples"): + eevee.taa_render_samples = 48 + if scene.render.engine == "CYCLES": + scene.cycles.samples = 64 + scene.cycles.use_denoising = True + + +# ---------------------------------------------------------------------------- +# Анимации: нормированное время -> кадры каталога +# ---------------------------------------------------------------------------- + +def sample(values, t): + """Кусочно-линейная выборка списка опорных значений на отрезке 0..1.""" + if len(values) == 1: + return float(values[0]) + x = max(0.0, min(1.0, t)) * (len(values) - 1) + index = min(int(x), len(values) - 2) + frac = x - index + return float(values[index]) * (1.0 - frac) + float(values[index + 1]) * frac + + +def world_axis_quat(armature, bone_name, axis, angle): + """Кватернион позы, вращающий кость вокруг МИРОВОЙ оси на angle радиан.""" + rest = armature.data.bones[bone_name].matrix_local.to_3x3() + local_axis = rest.inverted() @ Vector(axis) + if local_axis.length < 1e-9: + return Quaternion((1.0, 0.0, 0.0, 0.0)) + return Quaternion(local_axis.normalized(), angle) + + +AXIS_BY_NAME = {"x": X, "y": Y, "z": Z} + + +def apply_pose(armature, frame, swings, root_offset, root_rot): + """swings: [(bone, axis, angle)] — накапливаются, если кость встретилась дважды.""" + for bone in armature.pose.bones: + bone.rotation_quaternion = Quaternion((1.0, 0.0, 0.0, 0.0)) + armature.pose.bones["root"].location = Vector((0.0, 0.0, 0.0)) + + accumulated = {} + for bone_name, axis, angle in swings: + if bone_name not in armature.pose.bones: + raise SystemExit("[monster_rig] no bone '%s' in %s" % (bone_name, armature.name)) + quat = world_axis_quat(armature, bone_name, axis, angle) + accumulated[bone_name] = accumulated.get(bone_name, Quaternion((1, 0, 0, 0))) @ quat + for bone_name, quat in accumulated.items(): + armature.pose.bones[bone_name].rotation_quaternion = quat + + root = armature.pose.bones["root"] + rest = armature.data.bones["root"].matrix_local.to_3x3() + # root_rot приходит списком (мировая ось, угол) — в локальную систему кости + # его переводит тот же world_axis_quat, что и все остальные вращения. + if root_rot: + quat = Quaternion((1.0, 0.0, 0.0, 0.0)) + for axis, angle in root_rot: + quat = quat @ world_axis_quat(armature, "root", axis, angle) + root.rotation_quaternion = quat + if root_offset is not None: + root.location = rest.inverted() @ Vector(root_offset) + + for bone in armature.pose.bones: + bone.keyframe_insert(data_path="rotation_quaternion", frame=frame) + root.keyframe_insert(data_path="location", frame=frame) + + +def make_action(armature, name): + if armature.animation_data is None: + armature.animation_data_create() + action = bpy.data.actions.new(name) + # Blender 5 не считает слоёное действие использованным, пока в нём нет + # каналов. Без fake user клип, совпавший с позой покоя, не переживёт save. + action.use_fake_user = True + armature.animation_data.action = action + return action + + +def build_clip(armature, kind, animation, spec, frames): + make_action(armature, "%s_%s" % (kind, animation)) + cyclic = "cycle" in spec + for frame in range(frames): + # Циклический клип замыкается: последний кадр НЕ дублирует первый. + t = frame / float(frames) if cyclic else (frame / float(max(1, frames - 1))) + swings = [] + offset = None + root_rot = [] + if cyclic: + for bone_name, axis, amp, phase, bias in spec["cycle"]: + swings.append((bone_name, axis, + bias + amp * math.sin(2.0 * math.pi * (t + phase)))) + bob, bob_phase = spec.get("bob", (0.0, 0.0)) + offset = (0.0, 0.0, -bob * abs(math.sin(2.0 * math.pi * (t + bob_phase)))) + else: + for bone_name, axis, values in spec.get("keys", []): + swings.append((bone_name, axis, sample(values, t))) + loc = spec.get("root_loc") + if loc is not None: + offset = (sample([v[0] for v in loc], t), + sample([v[1] for v in loc], t), + sample([v[2] for v in loc], t)) + for axis_name, values in spec.get("root_rot", []): + root_rot.append((AXIS_BY_NAME[axis_name], sample(values, t))) + apply_pose(armature, frame + 1, swings, offset, root_rot) + log("action %s_%s: %d frame(s)" % (kind, animation, frames)) + + +def build_actions(armature, kind, anim_specs, animations): + for animation, meta in animations.items(): + spec = anim_specs.get(animation) + if spec is None: + raise SystemExit("[monster_rig] %s has no '%s' clip" % (kind, animation)) + build_clip(armature, kind, animation, spec, int(meta["frames"])) + + +# ---------------------------------------------------------------------------- +# build +# ---------------------------------------------------------------------------- + +def wipe_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def cmd_build(cfg, out_path): + wipe_scene() + scene = bpy.context.scene + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + + root = collection("MONSTERS", scene.collection) + + # Пустышка над всеми ригами: направление задаётся её поворотом вокруг Z, + # камера при этом не двигается — иначе свет ездил бы вместе с тварью. + pivot = bpy.data.objects.new("monster_root", None) + pivot.empty_display_type = "ARROWS" + pivot.empty_display_size = 0.3 + scene.collection.objects.link(pivot) + + selected = cfg.get("kinds") or list(KINDS) + unknown = [kind for kind in selected if kind not in KINDS] + if unknown: + raise SystemExit("[monster_rig] unknown kind(s): " + ", ".join(unknown)) + total_parts = 0 + for kind in selected: + spec = KINDS[kind] + kind_coll = collection(kind.upper(), root) + armature = build_armature("rig_" + kind, spec["bones"], kind_coll) + armature.parent = pivot + build_parts(spec["parts"], kind, armature, kind_coll) + build_actions(armature, kind, spec["anim"], cfg["animations"]) + total_parts += len(spec["parts"]) + log("kind %s: %d bone(s), %d part(s)" % (kind, len(spec["bones"]), len(spec["parts"]))) + + build_camera(scene, cfg["camera"], scene.collection) + build_lights(scene.collection) + build_world(scene) + configure_render(scene) + + scene.frame_start = 1 + scene.frame_end = max(int(a["frames"]) for a in cfg["animations"].values()) + + os.makedirs(os.path.dirname(out_path), exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=out_path) + log("saved %s (%d kinds, %d mesh objects)" % (out_path, len(selected), total_parts)) + return 0 + + +# ---------------------------------------------------------------------------- +# render +# ---------------------------------------------------------------------------- + +def assign_action(armature, name): + action = bpy.data.actions.get(name) + if action is None: + raise SystemExit("[monster_rig] no action '%s' in the .blend" % name) + if armature.animation_data is None: + armature.animation_data_create() + anim = armature.animation_data + anim.action = action + # Blender 4.4+: у Action появились слоты, и без выбранного слота она молчит. + slots = getattr(action, "slots", None) + if slots is not None and hasattr(anim, "action_slot") and anim.action_slot is None and len(slots): + anim.action_slot = slots[0] + return action + + +def cmd_render(cfg, kind, animation, outdir, supersample): + scene = bpy.context.scene + armature = bpy.data.objects.get("rig_" + kind) + pivot = bpy.data.objects.get("monster_root") + if armature is None or pivot is None: + raise SystemExit("[monster_rig] the .blend has no rig_%s/monster_root; " + "run `monster-init` first" % kind) + + prefix = kind + "_" + shown = 0 + for obj in bpy.data.objects: + if obj.type != "MESH": + continue + obj.hide_render = not obj.name.startswith(prefix) + shown += 0 if obj.hide_render else 1 + if shown == 0: + raise SystemExit("[monster_rig] no objects named '%s*' in the .blend" % prefix) + + configure_render(scene) + scene.render.resolution_x = int(cfg["render_width"]) * supersample + scene.render.resolution_y = int(cfg["render_height"]) * supersample + camera = scene.camera or build_camera(scene, cfg["camera"], scene.collection) + camera.data.type = "ORTHO" + camera.data.shift_x = 0.0 + camera.data.shift_y = 0.0 + # ortho_scale ложится на БОЛЬШУЮ сторону кадра. + longest = max(int(cfg["render_width"]), int(cfg["render_height"])) + camera.data.ortho_scale = longest / float(cfg["camera"]["pixels_per_unit"]) + + assign_action(armature, "%s_%s" % (kind, animation)) + frames = int(cfg["frames"]) + directions = cfg["directions"] + os.makedirs(outdir, exist_ok=True) + + for index, name in enumerate(directions): + # Порядок направлений идёт по -45° от юга: юг, юго-запад, запад, … + pivot.rotation_euler = (0.0, 0.0, math.radians(-45.0 * index)) + bpy.context.view_layer.update() + for frame in range(frames): + scene.frame_set(frame + 1) + cell = index * frames + frame + scene.render.filepath = os.path.join(outdir, "frame_%03d.png" % cell) + bpy.ops.render.render(write_still=True) + log("direction %d/%d (%s) done" % (index + 1, len(directions), name)) + + log("rendered %d cell(s) at %dx%d into %s" + % (len(directions) * frames, scene.render.resolution_x, scene.render.resolution_y, outdir)) + return 0 + + +def main(argv): + if "--" in argv: + argv = argv[argv.index("--") + 1:] + else: + argv = [] + if not argv: + raise SystemExit("[monster_rig] usage: build|render --config ") + command = argv[0] + options = {} + index = 1 + while index < len(argv) - 1: + if argv[index].startswith("--"): + options[argv[index][2:]] = argv[index + 1] + index += 2 + else: + index += 1 + with open(options["config"], "r", encoding="utf-8") as handle: + cfg = json.load(handle) + if command == "build": + return cmd_build(cfg, options["out"]) + if command == "render": + return cmd_render(cfg, cfg["kind"], cfg["animation"], cfg["outdir"], + int(cfg.get("supersample", 1))) + raise SystemExit("[monster_rig] unknown command: " + command) + + +if __name__ == "__main__": + sys.exit(main(list(sys.argv))) diff --git a/tools/build_agent_assets.ps1 b/tools/build_agent_assets.ps1 new file mode 100644 index 0000000..8a4a8cd --- /dev/null +++ b/tools/build_agent_assets.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = 'Stop' +$env:BLENDER_BIN = 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' + +python tools/agent_assets.py validate +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python tools/agent_assets.py sync +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python -m spriteforge.cli validate assets/manifests/agents.generated.yaml +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python -m spriteforge.cli build assets/manifests/environment.yaml assets/manifests/enemies.yaml assets/manifests/agents.generated.yaml --output build/sprites --cache-dir .sfcache --jobs 1 +exit $LASTEXITCODE diff --git a/tools/build_assets.ps1 b/tools/build_assets.ps1 new file mode 100644 index 0000000..f6f3eb1 --- /dev/null +++ b/tools/build_assets.ps1 @@ -0,0 +1,41 @@ +param( + [int]$Jobs = 4, + [switch]$RenderMissing +) +$ErrorActionPreference = 'Stop' +$env:BLENDER_BIN = 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' + +if ($RenderMissing) { + python tools/agent_assets.py blender-render-all --animations idle walk attack + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/monster_assets.py monster-render-all + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +python tools/agent_assets.py sync +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python tools/monster_assets.py sync +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python tools/generate_static_assets.py +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +python tools/agent_assets.py validate --strict +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python tools/monster_assets.py validate --complete --strict +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python tools/asset_coverage.py --release +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$manifests = @( + 'assets/manifests/environment.yaml', + 'assets/manifests/enemies.yaml', + 'assets/manifests/agents.generated.yaml', + 'assets/manifests/static.generated.yaml', + 'assets/manifests/monsters.generated.yaml' +) +python -m spriteforge.cli validate @manifests +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python -m spriteforge.cli build @manifests --output build/sprites --cache-dir .sfcache --jobs $Jobs +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python -m spriteforge.cli codegen --build-dir build/sprites --output generated/spriteforge_assets.h +exit $LASTEXITCODE diff --git a/tools/build_monster_assets.ps1 b/tools/build_monster_assets.ps1 new file mode 100644 index 0000000..093e8f3 --- /dev/null +++ b/tools/build_monster_assets.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' +$env:BLENDER_BIN = 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' + +# Риг создаётся один раз; --force пересобирает его с нуля. +if (-not (Test-Path 'assets/monsters/monster_rig.blend')) { + python tools/monster_assets.py monster-init + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +python tools/monster_assets.py monster-render-all +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python tools/monster_assets.py validate --complete +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +python -m spriteforge.cli validate assets/manifests/monsters.generated.yaml +exit $LASTEXITCODE diff --git a/tools/generate_static_assets.py b/tools/generate_static_assets.py new file mode 100644 index 0000000..f6e487a --- /dev/null +++ b/tools/generate_static_assets.py @@ -0,0 +1,145 @@ +"""Deterministic sci-fi/body-horror environment, prop and inventory icon sources.""" +from __future__ import annotations + +from pathlib import Path +import random + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +WORLD = ROOT / "assets/sources/world" +ICONS = ROOT / "assets/sources/items" +MANIFEST = ROOT / "assets/manifests/static.generated.yaml" + +INK = (13, 17, 22, 255) +STEEL = (61, 72, 79, 255) +EDGE = (27, 34, 39, 255) +LIGHT = (103, 119, 121, 255) +FLESH = (91, 32, 42, 255) +VEIN = (159, 48, 55, 255) +SICK = (121, 145, 78, 255) + + +def save(image: Image.Image, root: Path, name: str) -> Path: + root.mkdir(parents=True, exist_ok=True) + path = root / f"{name}.png" + image.save(path) + return path + + +def floor(name: str, mode: str, seed: int) -> None: + rng = random.Random(seed) + im = Image.new("RGBA", (32, 16)); d = ImageDraw.Draw(im) + poly = [(15, 0), (31, 8), (15, 15), (0, 8)] + d.polygon(poly, fill=(49, 58, 63, 255), outline=EDGE) + for _ in range(30): + x, y = rng.randrange(32), rng.randrange(16) + if im.getpixel((x, y))[3]: + q = rng.randrange(-10, 9); c = im.getpixel((x, y)) + im.putpixel((x, y), tuple(max(0, min(255, c[i]+q)) for i in range(3))+(255,)) + if mode == "clean": + d.line([(7, 4), (23, 12)], fill=(75, 87, 91, 255)); d.point((15, 3), fill=LIGHT) + d.point((5, 8), fill=LIGHT); d.point((25, 8), fill=LIGHT) + elif mode == "cracked": + d.line([(18, 2), (15, 6), (18, 8), (12, 13)], fill=(12, 15, 18, 255), width=1) + d.line([(15, 6), (10, 7)], fill=(12, 15, 18, 255)) + elif mode == "grate": + d.polygon([(10, 5), (16, 2), (23, 6), (16, 11)], fill=(20, 27, 30, 255), outline=(91, 104, 104, 255)) + for x in range(11, 22, 3): d.line([(x, 4), (x-1, 9)], fill=(74, 91, 89, 255)) + else: + d.ellipse((6, 4, 25, 13), fill=(62, 27, 35, 255), outline=FLESH) + for _ in range(8): + x = rng.randrange(8, 24); y = rng.randrange(5, 12) + d.line([(16, 8), (x, y)], fill=VEIN) + d.ellipse((13, 6, 18, 10), fill=(137, 50, 57, 255)) + save(im, WORLD, name) + + +def wall(name: str, infested: bool) -> None: + im = Image.new("RGBA", (32, 32)); d = ImageDraw.Draw(im) + d.polygon([(15, 0), (31, 8), (15, 16), (0, 8)], fill=(72, 82, 85, 255), outline=EDGE) + d.polygon([(0, 8), (15, 16), (15, 31), (0, 23)], fill=(35, 43, 48, 255), outline=INK) + d.polygon([(15, 16), (31, 8), (31, 23), (15, 31)], fill=(48, 57, 61, 255), outline=INK) + d.line([(4, 10), (4, 21), (12, 25)], fill=(80, 92, 92, 255)) + d.line([(27, 11), (27, 20), (19, 25)], fill=(27, 34, 38, 255)) + for p in ((5, 11), (12, 15), (26, 12), (19, 16)): d.rectangle((p[0],p[1],p[0]+1,p[1]+1), fill=LIGHT) + if infested: + d.ellipse((7, 7, 24, 27), fill=(70, 27, 35, 235), outline=FLESH) + d.ellipse((11, 11, 20, 22), fill=(111, 36, 44, 255), outline=VEIN) + d.ellipse((14, 14, 17, 18), fill=(32, 14, 19, 255)) + for end in ((2,9),(29,9),(4,25),(27,25)): + d.line([(15,17), end], fill=VEIN, width=1) + save(im, WORLD, name) + + +def prop(name: str, kind: str) -> None: + sizes = {"crate": (24,22), "barrel": (18,25), "locker": (18,32)} + w,h=sizes[kind]; im=Image.new("RGBA",(w,h)); d=ImageDraw.Draw(im) + if kind == "crate": + d.polygon([(1,6),(11,1),(22,6),(12,11)],fill=(82,78,64,255),outline=INK) + d.polygon([(1,6),(12,11),(12,21),(1,16)],fill=(48,48,43,255),outline=INK) + d.polygon([(12,11),(22,6),(22,16),(12,21)],fill=(61,59,50,255),outline=INK) + d.line([(3,7),(10,10),(10,18),(3,15),(3,7)],fill=(112,105,78,255)) + elif kind == "barrel": + d.ellipse((2,1,15,7),fill=(77,82,77,255),outline=INK) + d.rectangle((2,4,15,20),fill=(50,59,57,255),outline=INK) + d.ellipse((2,17,15,24),fill=(42,48,47,255),outline=INK) + d.line([(2,9),(15,9)],fill=LIGHT); d.line([(2,17),(15,17)],fill=INK) + d.polygon([(6,10),(10,8),(12,12),(9,16),(5,14)],fill=SICK) + else: + d.polygon([(2,5),(9,1),(16,5),(9,9)],fill=(75,82,84,255),outline=INK) + d.polygon([(2,5),(9,9),(9,31),(2,27)],fill=(38,46,50,255),outline=INK) + d.polygon([(9,9),(16,5),(16,27),(9,31)],fill=(52,61,64,255),outline=INK) + d.line([(11,11),(11,27)],fill=INK); d.point((13,18),fill=(180,61,56,255)) + save(im,WORLD,"prop_"+name) + + +def icon(name: str) -> None: + im=Image.new("RGBA",(24,24)); d=ImageDraw.Draw(im) + d.rounded_rectangle((1,1,22,22),3,fill=(20,25,30,230),outline=(72,84,88,255)) + metal=(151,165,166,255); dark=(45,52,55,255); red=(164,48,54,255) + if name in {"rifle","smg","shotgun","pistol","magnum"}: + length={"pistol":11,"magnum":12,"smg":15,"shotgun":18,"rifle":18}[name] + d.rectangle((3,9,3+length,12),fill=metal); d.rectangle((7,12,10,17),fill=dark) + if name=="shotgun": d.line((4,14,19,14),fill=(113,77,50,255),width=2) + elif name == "bow": + d.arc((4,3,18,21),270,90,fill=metal,width=2); d.line((11,4,11,20),fill=(104,124,103,255)) + elif name in {"saber","knife","cleaver"}: + end=(18,4) if name!="knife" else (15,7); d.line((6,18,*end),fill=metal,width=3) + d.line((4,19,9,16),fill=(118,76,43,255),width=2) + elif name in {"jacket","vest","plate","shroud"}: + col={"jacket":(75,83,77,255),"vest":(82,91,91,255),"plate":(115,122,119,255),"shroud":(56,49,63,255)}[name] + d.polygon([(7,5),(4,9),(6,20),(18,20),(20,9),(17,5),(14,8),(10,8)],fill=col,outline=INK) + elif name in {"bandage","stimpack"}: + d.rectangle((7,4,16,20),fill=(178,188,175,255),outline=INK) + d.rectangle((9,10,14,14),fill=red); d.line((11,8,11,16),fill=(220,220,205,255)) + else: + colors={"lantern":(224,184,72,255),"charm":(142,69,101,255),"spurs":(143,151,151,255)} + d.ellipse((6,5,18,18),fill=colors[name],outline=INK); d.ellipse((10,9,14,13),fill=(235,221,159,255)) + save(im,ICONS,"item_icon_"+name) + + +def manifest() -> None: + entries=[] + for name in ("floor_clean","floor_cracked","floor_grate","floor_biomass","wall_industrial","wall_infested", + "prop_crate","prop_barrel","prop_locker"): + pivot = '"15,8"' if name.startswith("floor") else ("\"15,31\"" if name.startswith("wall") else "bottom_center") + entries += [f"{name}:", " backend: import", f" source: ../sources/world/{name}.png", f" pivot: {pivot}", + f" tags: [environment, {'floor' if name.startswith('floor') else 'prop' if name.startswith('prop') else 'wall'}]", ""] + names=("rifle","pistol","shotgun","smg","magnum","bow","saber","knife","cleaver","jacket","vest","plate", + "shroud","lantern","charm","spurs","bandage","stimpack") + for name in names: + aid="item_icon_"+name; entries += [f"{aid}:"," backend: import",f" source: ../sources/items/{aid}.png"," pivot: center"," tags: [ui, item]",""] + MANIFEST.write_text("\n".join(["version: 1","defaults:"," dirs: 1"," fps: 1",""]+entries),encoding="utf-8",newline="\n") + + +def main() -> None: + for i,mode in enumerate(("clean","cracked","grate","biomass")): floor("floor_"+mode,mode,100+i) + wall("wall_industrial",False); wall("wall_infested",True) + for kind in ("crate","barrel","locker"): prop(kind,kind) + for name in ("rifle","pistol","shotgun","smg","magnum","bow","saber","knife","cleaver","jacket","vest","plate", + "shroud","lantern","charm","spurs","bandage","stimpack"): icon(name) + manifest(); print("generated 27 static assets and assets/manifests/static.generated.yaml") + + +if __name__ == "__main__": main() diff --git a/tools/monster_assets.py b/tools/monster_assets.py new file mode 100644 index 0000000..49dd92f --- /dev/null +++ b/tools/monster_assets.py @@ -0,0 +1,753 @@ +"""Monster authoring bridge: Blender -> листы -> манифест SpriteForge. + +Родственник tools/agent_assets.py, но задача другая, и разница принципиальная. + +Агент — бумажная кукла: тело, броня и оружие обязаны совпасть пиксель в +пиксель, поэтому там один скелет, одна калибровка и растровый вход с чужих +генераторов. Тварь — цельная фигура: слоёв нет, склеивать нечего, зато пять +видов обязаны РАЗЛИЧАТЬСЯ силуэтом с одного взгляда. Поэтому здесь нет ни +ingest, ни калибровки, зато есть проверка «морфологии не совпали». + +Общее у двух конвейеров ровно одно и намеренно: неподвижная ортографическая +изометрическая камера и вырезка постоянным прямоугольником. Начало координат +рига проецируется в центр кадра, значит упаковщик режет все кадры одинаково и +подгона по bbox не существует в принципе — тварь не «дышит» при повороте. + + python tools/monster_assets.py monster-init + python tools/monster_assets.py monster-render shambler walk + python tools/monster_assets.py monster-render-all + python tools/monster_assets.py validate + python tools/monster_assets.py sync +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image, ImageChops +import yaml + +DEFAULT_BLENDER = r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" + +# Допуски проверок, в пикселях листа. +GROUND_SLACK = 4 # насколько низ силуэта может не дотягивать до pivot_y +CENTER_SLACK = 6 # снос центра «розы направлений» от pivot_x +# Порог зеркального совпадения силуэтов для проверки порядка направлений. +MIRROR_IOU_MIN = 0.55 +# Выше этого два направления (или два ВИДА) считаются одной картинкой. +DISTINCT_IOU_MAX = 0.98 +KIND_IOU_MAX = 0.90 + + +def fnv1a(text: str) -> int: + value = 0x811C9DC5 + for byte in text.encode("utf-8"): + value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF + return value + + +def symbol(text: str) -> str: + return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").upper() + + +def asset_id(kind: str, animation: str) -> str: + """ID ровно того вида, который требует tools/asset_coverage.py.""" + return f"{kind}_{animation}" + + +# --------------------------------------------------------------------------- +# Пути и конфигурация +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Workspace: + """Все пути конвейера в одном месте: тесты подставляют временный корень.""" + root: Path + + @property + def catalog(self) -> Path: return self.root / "assets/monsters/catalog.yaml" + + @property + def source_root(self) -> Path: return self.root / "assets/sources/monsters" + + @property + def manifest(self) -> Path: return self.root / "assets/manifests/monsters.generated.yaml" + + @property + def manifest_dir(self) -> Path: return self.root / "assets/manifests" + + @property + def header(self) -> Path: return self.root / "generated/monster_sprites.h" + + @property + def blend(self) -> Path: return self.root / "assets/monsters/monster_rig.blend" + + @property + def rig_script(self) -> Path: return self.root / "tools/blender/monster_rig.py" + + def source_path(self, kind: str, animation: str) -> Path: + return self.source_root / f"{kind}_{animation}.png" + + +WORKSPACE = Workspace(Path(__file__).resolve().parents[1]) + + +@dataclass(frozen=True) +class Config: + width: int + height: int + pivot_x: int + pivot_y: int + directions: tuple[str, ...] + animations: dict + kinds: dict + camera: dict + + # Кадр рендера симметричен относительно pivot: начало координат рига + # смотрит в его центр, значит вырезка — постоянный прямоугольник. + @property + def render_width(self) -> int: return 2 * max(self.pivot_x, self.width - self.pivot_x) + + @property + def render_height(self) -> int: return 2 * max(self.pivot_y, self.height - self.pivot_y) + + @property + def crop_x(self) -> int: return self.render_width // 2 - self.pivot_x + + @property + def crop_y(self) -> int: return self.render_height // 2 - self.pivot_y + + def frames(self, animation: str) -> int: + return int(self.animations[animation]["frames"]) + + def cells(self, animation: str) -> int: + return len(self.directions) * self.frames(animation) + + def sheet_size(self, animation: str) -> tuple[int, int]: + return (self.width * self.cells(animation), self.height) + + def fps(self, kind: str, animation: str) -> int: + """Темп клипа: общий fps анимации, растянутый под повадку вида.""" + scale = float(self.kinds[kind].get("fps_scale", 1.0)) + return max(1, int(round(float(self.animations[animation]["fps"]) * scale))) + + +def load_config(ws: Workspace = WORKSPACE) -> Config: + raw = yaml.safe_load(ws.catalog.read_text(encoding="utf-8")) + if raw.get("version") != 1: + raise ValueError("assets/monsters/catalog.yaml: only version 1 is supported") + frame = raw["frame"] + return Config(frame["width"], frame["height"], frame["pivot_x"], frame["pivot_y"], + tuple(raw["directions"]), raw["animations"], raw["kinds"], + raw.get("camera", {})) + + +def check_names(cfg: Config, kind: str, animation: str) -> None: + if kind not in cfg.kinds: + raise ValueError(f"unknown monster kind: {kind}") + if animation not in cfg.animations: + raise ValueError(f"unknown animation: {animation}") + + +# --------------------------------------------------------------------------- +# Работа с картинками +# --------------------------------------------------------------------------- + +def resize_rgba(image: Image.Image, size: tuple[int, int]) -> Image.Image: + """LANCZOS по предумноженной альфе: иначе по кромке ползёт чёрная кайма.""" + if image.size == size: + return image.copy() + r, g, b, a = image.convert("RGBA").split() + premultiplied = Image.merge("RGBA", (ImageChops.multiply(r, a), + ImageChops.multiply(g, a), + ImageChops.multiply(b, a), a)) + scaled = premultiplied.resize(size, Image.Resampling.LANCZOS) + px = scaled.load() + for y in range(scaled.height): + for x in range(scaled.width): + pr, pg, pb, pa = px[x, y] + if pa: + px[x, y] = (min(255, pr * 255 // pa), min(255, pg * 255 // pa), + min(255, pb * 255 // pa), pa) + else: + px[x, y] = (0, 0, 0, 0) + return scaled + + +def mask_of(image: Image.Image) -> Image.Image: + return image.convert("RGBA").getchannel("A").point(lambda v: 255 if v >= 24 else 0).convert("1") + + +def cell_at(cfg: Config, sheet: Image.Image, index: int) -> Image.Image: + x = index * cfg.width + return sheet.crop((x, 0, x + cfg.width, cfg.height)) + + +def paste_cell(cfg: Config, sheet: Image.Image, index: int, layer: Image.Image) -> None: + sheet.paste(layer, (index * cfg.width, 0)) + + +def bbox_of(cell: Image.Image): + return mask_of(cell).convert("L").getbbox() + + +# --------------------------------------------------------------------------- +# Blender: создание .blend и рендер +# --------------------------------------------------------------------------- + +def blender_binary(explicit: str | None = None) -> str: + candidate = explicit or os.environ.get("BLENDER_BIN") or DEFAULT_BLENDER + if Path(candidate).is_file(): + return candidate + found = shutil.which(candidate) or shutil.which("blender") + if found: + return found + raise ValueError(f"blender not found: {candidate} (set BLENDER_BIN or pass --blender)") + + +def run_blender(binary: str, args: list[str]) -> None: + process = subprocess.run([binary, *args], capture_output=True, text=True, + encoding="utf-8", errors="replace") + for line in (process.stdout or "").splitlines(): + if line.startswith("[monster_rig]") or line.startswith("Error"): + print(line) + if process.returncode != 0: + sys.stderr.write(process.stdout or "") + sys.stderr.write(process.stderr or "") + raise ValueError(f"blender exited with {process.returncode}") + + +def blender_config(cfg: Config) -> dict: + return {"width": cfg.width, "height": cfg.height, + "pivot_x": cfg.pivot_x, "pivot_y": cfg.pivot_y, + "render_width": cfg.render_width, "render_height": cfg.render_height, + "directions": list(cfg.directions), "animations": cfg.animations, + "kinds": list(cfg.kinds), "camera": cfg.camera} + + +def monster_init(ws: Workspace, cfg: Config, blender: str | None, force: bool, + kind: str | None = None) -> Path: + target = ws.root / "assets" / "monsters" / f"{kind}.blend" if kind else ws.blend + if target.is_file() and not force: + raise ValueError(f"{target.relative_to(ws.root).as_posix()} already exists; " + "pass --force to rebuild") + binary = blender_binary(blender) + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as work: + config_path = Path(work) / "config.json" + payload = blender_config(cfg) + if kind: payload["kinds"] = [kind] + config_path.write_text(json.dumps(payload), encoding="utf-8") + run_blender(binary, ["--factory-startup", "-b", "--python", str(ws.rig_script), "--", + "build", "--config", str(config_path), "--out", str(target)]) + if not target.is_file(): + raise ValueError("blender finished but wrote no .blend") + print(f"wrote {target.relative_to(ws.root).as_posix()}") + return target + + +def pack_render_frames(cfg: Config, animation: str, images: list[Image.Image], + supersample: int) -> Image.Image: + """Кадры Blender -> лист, direction-major. Одна вырезка на все ячейки. + + Здесь живёт гарантия общего pivot: прямоугольник вырезки зависит только от + каталога, а не от содержимого кадра. Поэтому громила и затаившийся стоят на + одной и той же строке, хотя габариты у них разные втрое. + """ + expected = cfg.cells(animation) + if len(images) != expected: + raise ValueError(f"{animation}: expected {expected} rendered cell(s), got {len(images)}") + if supersample < 1: + raise ValueError("supersample must be >= 1") + source = (cfg.render_width * supersample, cfg.render_height * supersample) + box = (cfg.crop_x * supersample, cfg.crop_y * supersample, + (cfg.crop_x + cfg.width) * supersample, (cfg.crop_y + cfg.height) * supersample) + sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) + for index, image in enumerate(images): + rgba = image.convert("RGBA") + if rgba.size != source: + raise ValueError(f"cell {index}: render is {rgba.size}, expected {source}") + paste_cell(cfg, sheet, index, resize_rgba(rgba.crop(box), (cfg.width, cfg.height))) + return sheet + + +def monster_render(ws: Workspace, cfg: Config, kind: str, animation: str, + blender: str | None, blend: Path | None, supersample: int, + keep: Path | None = None) -> Path: + check_names(cfg, kind, animation) + blend_path = blend or ws.blend + if not blend_path.is_file(): + raise ValueError(f"no rig at {blend_path}; run `monster_assets.py monster-init` first") + binary = blender_binary(blender) + with tempfile.TemporaryDirectory() as work: + outdir = Path(work) / "frames" + payload = blender_config(cfg) + payload.update({"kind": kind, "animation": animation, + "frames": cfg.frames(animation), "supersample": supersample, + "outdir": str(outdir)}) + config_path = Path(work) / "config.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + run_blender(binary, ["--factory-startup", "-b", str(blend_path), + "--python", str(ws.rig_script), "--", + "render", "--config", str(config_path)]) + images = [] + for index in range(cfg.cells(animation)): + frame = outdir / f"frame_{index:03d}.png" + if not frame.is_file(): + raise ValueError(f"blender did not write {frame.name}") + images.append(Image.open(frame).copy()) + if keep is not None: + keep.mkdir(parents=True, exist_ok=True) + for frame in sorted(outdir.glob("frame_*.png")): + shutil.copy2(frame, keep / frame.name) + sheet = pack_render_frames(cfg, animation, images, supersample) + target = ws.source_path(kind, animation) + target.parent.mkdir(parents=True, exist_ok=True) + sheet.save(target) + return target + + +# --------------------------------------------------------------------------- +# sync: манифест SpriteForge + заголовок с константами +# --------------------------------------------------------------------------- + +SYNC_BANNER = [ + "# Собрано `python tools/monster_assets.py sync`.", + "# Записи с backend: import перегенерируются по PNG в assets/sources/monsters/.", + "# Записи с любым другим backend sync СОХРАНЯЕТ как есть: правь их руками", + "# здесь, это единственное место, где такая правка переживает sync.", + "#", + "# ID ассета — _: ровно то, что требует", + "# tools/asset_coverage.py от группы enemy. Переименуешь — упадёт покрытие.", +] + + +def existing_specs(ws: Workspace) -> dict: + if not ws.manifest.is_file(): + return {} + raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} + return {key: value for key, value in raw.items() + if isinstance(value, dict) and "backend" in value} + + +def manual_specs(ws: Workspace) -> dict: + """Ручные спеки — всё, что не `import`: их sync не трогает.""" + return {key: value for key, value in existing_specs(ws).items() + if value.get("backend") != "import"} + + +def header_text(cfg: Config) -> str: + """Заголовок с константами. Строковых ID ассетов в C++ быть не должно.""" + anim_map = [("WALK", "walk"), ("ATTACK", "attack"), ("HURT", "hit"), ("DIE", "death")] + out = ["/* Generated by tools/monster_assets.py sync. Do not edit. */", + "#pragma once", + "#include ", + "", + "#include \"render/anim.h\"", + "#include \"sim/target.h\"", + ""] + for kind in cfg.kinds: + for animation in cfg.animations: + aid = asset_id(kind, animation) + out.append(f"constexpr uint32_t MONSTER_ASSET_{symbol(aid)} = " + f"UINT32_C(0x{fnv1a(aid):08X});") + out += ["", + "// AnimKind::IDLE у тварей нет: покоя у них не бывает, стоящая тварь", + "// показывает первый кадр шага.", + "constexpr uint32_t MonsterAsset(EnemyKind kind, AnimKind anim)", + "{", + " switch (kind) {"] + for kind in cfg.kinds: + out.append(f" case EnemyKind::{kind.upper()}:") + out.append(" switch (anim) {") + for enum_name, animation in anim_map: + if animation not in cfg.animations: + continue + out.append(f" case AnimKind::{enum_name}: " + f"return MONSTER_ASSET_{symbol(asset_id(kind, animation))};") + out.append(f" default: return MONSTER_ASSET_{symbol(asset_id(kind, 'walk'))};") + out.append(" }") + out += [" default: return 0;", " }", "}", ""] + return "\n".join(out) + + +def legacy_collisions(ws: Workspace, ids: set[str]) -> dict[str, list[str]]: + """ID, объявленные ЕЩЁ и в другом манифесте. + + Два манифеста с одним ID — это не «дубль», а неопределённость: какой файл + выиграет, решает порядок обхода каталога. Ловим текстом, до сборки. + """ + found: dict[str, list[str]] = {} + if not ws.manifest_dir.is_dir(): + return found + for path in sorted(ws.manifest_dir.glob("*.yaml")): + if path.resolve() == ws.manifest.resolve(): + continue + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + clash = sorted(key for key, value in raw.items() + if isinstance(value, dict) and key in ids) + if clash: + found[path.name] = clash + return found + + +def sync(ws: Workspace, cfg: Config) -> None: + manual = manual_specs(ws) + lines = list(SYNC_BANNER) + ["version: 1", "defaults:", f" dirs: {len(cfg.directions)}", + f" pivot: \"{cfg.pivot_x},{cfg.pivot_y}\"", ""] + for aid, spec in manual.items(): + lines.append(yaml.safe_dump({aid: spec}, sort_keys=False, allow_unicode=True, + default_flow_style=False).rstrip("\n")) + lines.append("") + + generated = {} + for kind in cfg.kinds: + for animation in cfg.animations: + aid = asset_id(kind, animation) + if aid in manual: + continue + path = ws.source_path(kind, animation) + if not path.is_file(): + continue + rel = Path(os.path.relpath(path, ws.manifest.parent)).as_posix() + lines += [f"{aid}:", " backend: import", f" source: {rel}", + f" fps: {cfg.fps(kind, animation)}", + " params: {frame_width: %d, frame_height: %d, frames: %d}" + % (cfg.width, cfg.height, cfg.cells(animation)), + f" tags: [enemy, {kind}, {animation}]", ""] + generated[aid] = path + + ws.manifest.parent.mkdir(parents=True, exist_ok=True) + ws.manifest.write_text("\n".join(lines), encoding="utf-8", newline="\n") + ws.header.parent.mkdir(parents=True, exist_ok=True) + ws.header.write_text(header_text(cfg), encoding="utf-8", newline="\n") + print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} " + f"({len(generated)} import asset(s), {len(manual)} hand-written spec(s) preserved)") + print(f"wrote {ws.header.relative_to(ws.root).as_posix()}") + clashes = legacy_collisions(ws, set(generated)) + for name, ids in clashes.items(): + print(f"NOTE: {name} also declares {', '.join(ids)} — retire the old entries " + "before the game switches to the rendered sheets") + + +# --------------------------------------------------------------------------- +# validate +# --------------------------------------------------------------------------- + +def mirror(cfg: Config, mask: Image.Image) -> Image.Image: + """Отражение вокруг колонки pivot_x, а не вокруг центра холста.""" + flipped = mask.convert("L").transform(mask.size, Image.AFFINE, + (-1, 0, 2 * cfg.pivot_x, 0, 1, 0), + resample=Image.Resampling.NEAREST) + return flipped.point(lambda v: 255 if v >= 128 else 0).convert("1") + + +def iou(a: Image.Image, b: Image.Image) -> float: + intersection = sum(ImageChops.logical_and(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) + union = sum(ImageChops.logical_or(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) + return intersection / union if union else 1.0 + + +def union_masks(cfg: Config, sheet: Image.Image, animation: str) -> list[Image.Image]: + """По одной маске на направление: объединение всех кадров клипа. + + Сравнивать покадрово нельзя — в середине шага у зеркальных направлений + вынесена вперёд разная нога, и честное отражение дало бы ложную тревогу. + """ + frames = cfg.frames(animation) + out = [] + for direction in range(len(cfg.directions)): + merged = Image.new("1", (cfg.width, cfg.height), 0) + for frame in range(frames): + merged = ImageChops.logical_or(merged, mask_of(cell_at(cfg, sheet, direction * frames + frame))) + out.append(merged) + return out + + +def total_mask(masks: list[Image.Image]) -> Image.Image: + merged = masks[0] + for mask in masks[1:]: + merged = ImageChops.logical_or(merged, mask) + return merged + + +@dataclass +class Report: + errors: list[str] + warnings: list[str] + + def fail(self, message: str) -> None: self.errors.append(message) + + def warn(self, message: str) -> None: self.warnings.append(message) + + +def check_sheet(cfg: Config, path: Path, animation: str, report: Report) -> Image.Image | None: + if not path.is_file(): + report.fail(f"missing: {path.name}") + return None + sheet = Image.open(path).convert("RGBA") + expected = cfg.sheet_size(animation) + if sheet.size != expected: + report.fail(f"{path.name}: canvas {sheet.size}, expected {expected} " + f"({len(cfg.directions)} directions x {cfg.frames(animation)} frame(s) of " + f"{cfg.width}x{cfg.height})") + return None + if sheet.getextrema()[3][0] != 0: + report.fail(f"{path.name}: no transparent pixels — the render was not keyed out") + for index in range(cfg.cells(animation)): + if bbox_of(cell_at(cfg, sheet, index)) is None: + direction = cfg.directions[index // cfg.frames(animation)] + report.fail(f"{path.name}: empty cell {index} " + f"({direction} frame {index % cfg.frames(animation)})") + return sheet + + +def check_framing(cfg: Config, name: str, sheet: Image.Image, animation: str, + report: Report) -> None: + """Силуэт не должен упираться в кромку клетки. + + Это главная ловушка ЭТОГО конвейера. Клетка 48x56 мала, а замах громилы и + осевшая туша занимают больше места, чем стойка; кадр, срезанный кромкой, + выглядит в игре как отрубленная лапа, и заметить это по цифрам иначе нельзя. + """ + for index in range(cfg.cells(animation)): + box = bbox_of(cell_at(cfg, sheet, index)) + if box is None: + continue + touched = [] + if box[0] <= 0: touched.append("left") + if box[1] <= 0: touched.append("top") + if box[2] >= cfg.width: touched.append("right") + if box[3] >= cfg.height: touched.append("bottom") + if touched: + direction = cfg.directions[index // cfg.frames(animation)] + report.warn(f"{name}: cell {index} ({direction} frame " + f"{index % cfg.frames(animation)}) is clipped by the " + f"{'/'.join(touched)} edge — the creature does not fit the cell") + + +def check_ground(cfg: Config, name: str, masks: list[Image.Image], animation: str, + report: Report) -> None: + """Тварь обязана стоять на общей линии пола, а не висеть над ней.""" + if animation == "death": + return # труп имеет право осесть куда угодно, лишь бы не за кромку + for index, mask in enumerate(masks): + box = mask.convert("L").getbbox() + if box is None: + continue + if box[3] < cfg.pivot_y - GROUND_SLACK: + report.fail(f"{name}: {cfg.directions[index]} floats — lowest pixel y={box[3]}, " + f"pivot_y is {cfg.pivot_y}") + + +def check_centering(cfg: Config, name: str, masks: list[Image.Image], report: Report) -> None: + """Роза из восьми направлений центрируется на pivot_x. + + Покадрово центр проверять нельзя: у бредущего одна рука длиннее другой, и + его силуэт честно несимметричен. А вот сумма восьми поворотов вокруг общей + оси обязана стоять на оси. + """ + box = total_mask(masks).convert("L").getbbox() + if box is None: + return + center = (box[0] + box[2]) / 2.0 + if abs(center - cfg.pivot_x) > CENTER_SLACK: + report.fail(f"{name}: the eight directions centre at x={center:.1f}, " + f"pivot_x is {cfg.pivot_x} — the rig origin is off the turn axis") + + +def check_direction_order(cfg: Config, name: str, masks: list[Image.Image], + report: Report) -> None: + """Порядок направлений — насколько его вообще видно текстом. + + Каталог обещает юг, юго-запад, запад, ... — значит пары (ЮЗ,ЮВ), (З,В), + (СЗ,СВ) обязаны совпадать при отражении вокруг pivot_x. Это не + доказательство порядка, но любую перестановку соседей оно ловит. + """ + names = cfg.directions + if len(names) != 8: + return + for left, right in ((1, 7), (2, 6), (3, 5)): + score = iou(masks[left], mirror(cfg, masks[right])) + if score < MIRROR_IOU_MIN: + report.warn(f"{name}: {names[left]} and {names[right]} are not mirror images " + f"(IoU {score:.2f} < {MIRROR_IOU_MIN}) — direction order or camera differs") + similarities = [iou(masks[0], masks[index]) for index in range(1, 8)] + if all(score > DISTINCT_IOU_MAX for score in similarities): + report.warn(f"{name}: all eight directions have the same silhouette " + "— one direction may have been rendered eight times") + + +def check_kinds_are_distinct(cfg: Config, silhouettes: dict, report: Report) -> None: + """Пять видов обязаны различаться пятном. + + Ради этой проверки конвейер и отделён от агентского: там слои обязаны + совпадать, здесь виды обязаны НЕ совпадать. Совпали — значит вместо + морфологий получились пять одинаковых капсул. + """ + kinds = sorted(silhouettes) + for i, left in enumerate(kinds): + for right in kinds[i + 1:]: + score = iou(silhouettes[left], silhouettes[right]) + if score > KIND_IOU_MAX: + report.warn(f"{left} and {right} have nearly the same walk silhouette " + f"(IoU {score:.2f} > {KIND_IOU_MAX}) — the morphologies are " + "not readable apart") + + +def check_manifest(ws: Workspace, cfg: Config, report: Report) -> None: + if not ws.manifest.is_file(): + return + raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} + defaults = raw.get("defaults", {}) + if defaults.get("dirs") != len(cfg.directions): + report.fail(f"manifest defaults.dirs is {defaults.get('dirs')}, " + f"catalog has {len(cfg.directions)}") + if defaults.get("pivot") != f"{cfg.pivot_x},{cfg.pivot_y}": + report.fail(f"manifest defaults.pivot is {defaults.get('pivot')!r}, " + f"catalog has '{cfg.pivot_x},{cfg.pivot_y}'") + for aid, spec in raw.items(): + if not isinstance(spec, dict) or spec.get("backend") != "import": + continue + params = spec.get("params", {}) + if params.get("frame_width") != cfg.width or params.get("frame_height") != cfg.height: + report.fail(f"manifest {aid}: frame {params.get('frame_width')}x" + f"{params.get('frame_height')}, catalog has {cfg.width}x{cfg.height}") + declared = {key for key, value in raw.items() + if isinstance(value, dict) and "backend" in value} + for name, ids in legacy_collisions(ws, declared).items(): + report.warn(f"{name} declares the same asset id(s) as the generated manifest: " + f"{', '.join(ids)} — which file wins is undefined") + + +def validate(ws: Workspace, cfg: Config, complete: bool = False, strict: bool = False) -> int: + report = Report([], []) + check_manifest(ws, cfg, report) + walk_silhouettes = {} + for kind in cfg.kinds: + for animation in cfg.animations: + path = ws.source_path(kind, animation) + if not (complete or path.is_file()): + continue + sheet = check_sheet(cfg, path, animation, report) + if sheet is None: + continue + name = path.name + masks = union_masks(cfg, sheet, animation) + check_framing(cfg, name, sheet, animation, report) + check_ground(cfg, name, masks, animation, report) + check_centering(cfg, name, masks, report) + if animation == "walk": + # Attack, hit and death poses are intentionally asymmetric. + # Only the cyclic locomotion silhouette is a useful camera / + # direction-order invariant. + check_direction_order(cfg, name, masks, report) + walk_silhouettes[kind] = total_mask(masks) + if len(walk_silhouettes) > 1: + check_kinds_are_distinct(cfg, walk_silhouettes, report) + + for warning in report.warnings: + print(f"{'ERROR' if strict else 'WARN'}: {warning}") + for error in report.errors: + print(f"ERROR: {error}") + failures = len(report.errors) + (len(report.warnings) if strict else 0) + print(f"monster assets: {len(report.errors)} error(s), {len(report.warnings)} warning(s)") + return 1 if failures else 0 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> int: + ws = WORKSPACE + cfg = load_config(ws) + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + + init = sub.add_parser("monster-init", help="create the monster rig .blend with Blender") + init.add_argument("--blender") + init.add_argument("--force", action="store_true") + init.add_argument("--kind", choices=list(cfg.kinds), + help="build a lightweight one-kind .blend instead of the combined authoring scene") + + render = sub.add_parser("monster-render", help="render one kind+animation into a sheet") + render.add_argument("kind", choices=list(cfg.kinds)) + render.add_argument("animation", choices=list(cfg.animations)) + render.add_argument("--blender") + render.add_argument("--blend", type=Path) + render.add_argument("--supersample", type=int, default=4) + render.add_argument("--keep-renders", type=Path) + + render_all = sub.add_parser("monster-render-all", help="batch-render catalog kinds") + render_all.add_argument("kinds", nargs="*", help="default: every catalog kind") + render_all.add_argument("--animations", nargs="+", choices=list(cfg.animations), + default=list(cfg.animations)) + render_all.add_argument("--blender") + render_all.add_argument("--blend", type=Path) + render_all.add_argument("--supersample", type=int, default=3) + render_all.add_argument("--force", action="store_true", help="replace existing sheets") + + sub.add_parser("sync", help="regenerate the SpriteForge manifest and the C++ header") + + val = sub.add_parser("validate", help="check canvas, framing, ground line, direction order") + val.add_argument("--complete", action="store_true", help="missing sheets are errors") + val.add_argument("--strict", action="store_true", help="warnings are errors") + + args = parser.parse_args() + if args.command == "monster-init": + monster_init(ws, cfg, args.blender, args.force, args.kind) + return 0 + if args.command == "monster-render": + print(monster_render(ws, cfg, args.kind, args.animation, args.blender, + args.blend, args.supersample, args.keep_renders)) + sync(ws, cfg) + return 0 + if args.command == "monster-render-all": + kinds = args.kinds or list(cfg.kinds) + for kind in kinds: + if kind not in cfg.kinds: + raise ValueError(f"unknown monster kind: {kind}") + done = skipped = 0 + for kind in kinds: + for animation in args.animations: + target = ws.source_path(kind, animation) + if target.is_file() and not args.force: + # A catalog frame-size change invalidates old sheets even + # though their filenames are unchanged. + try: + cached_size = Image.open(target).size + except OSError: + cached_size = None + if cached_size == cfg.sheet_size(animation): + print(f"skip {target.relative_to(ws.root).as_posix()}") + skipped += 1 + continue + print(f"rebuild {target.relative_to(ws.root).as_posix()}: " + f"canvas {cached_size}, expected {cfg.sheet_size(animation)}") + print(monster_render(ws, cfg, kind, animation, args.blender, + args.blend, args.supersample)) + done += 1 + sync(ws, cfg) + print(f"batch render: {done} rendered, {skipped} cached") + return 0 + if args.command == "sync": + sync(ws, cfg) + return 0 + return validate(ws, cfg, args.complete, args.strict) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/sprite_preview.py b/tools/sprite_preview.py new file mode 100644 index 0000000..4c4b7b2 --- /dev/null +++ b/tools/sprite_preview.py @@ -0,0 +1,83 @@ +"""Make a human-readable grid from a direction-major technical sprite sheet. + +This command is for the human art review loop. Agents should use the textual +validators instead of opening the generated preview. +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image, ImageDraw + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("sheet", type=Path) + parser.add_argument("--overlay", action="append", type=Path, default=[], + help="aligned paper-doll sheet to composite over the base; repeatable") + parser.add_argument("--cell", required=True, metavar="WIDTHxHEIGHT") + parser.add_argument("--frames", required=True, type=int, + help="frames per direction") + parser.add_argument("--dirs", type=int, default=8) + parser.add_argument("--scale", type=int, default=3) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + try: + cell_w, cell_h = (int(value) for value in args.cell.lower().split("x", 1)) + except (ValueError, TypeError) as error: + raise SystemExit("--cell must look like 70x90") from error + if min(cell_w, cell_h, args.frames, args.dirs, args.scale) < 1: + raise SystemExit("cell, frames, dirs and scale must be positive") + + source = Image.open(args.sheet).convert("RGBA") + expected = (cell_w * args.frames * args.dirs, cell_h) + if source.size != expected: + raise SystemExit(f"sheet is {source.size[0]}x{source.size[1]}, expected " + f"{expected[0]}x{expected[1]}") + layers = [source] + for path in args.overlay: + layer = Image.open(path).convert("RGBA") + if layer.size != expected: + raise SystemExit(f"overlay {path} is {layer.size}, expected {expected}") + layers.append(layer) + + label_h = 18 + sw, sh = cell_w * args.scale, cell_h * args.scale + out = Image.new("RGBA", (args.frames * sw, args.dirs * (sh + label_h)), + (22, 24, 29, 255)) + draw = ImageDraw.Draw(out) + for direction in range(args.dirs): + for frame in range(args.frames): + index = direction * args.frames + frame + box = (index * cell_w, 0, (index + 1) * cell_w, cell_h) + cell = layers[0].crop(box) + for layer in layers[1:]: + cell.alpha_composite(layer.crop(box)) + cell = cell.resize((sw, sh), Image.Resampling.NEAREST) + x, y = frame * sw, direction * (sh + label_h) + label_h + # Neutral checker makes transparent padding and the exact cell edge visible. + checker = Image.new("RGBA", (sw, sh), (36, 39, 45, 255)) + cd = ImageDraw.Draw(checker) + block = max(4, args.scale * 4) + for cy in range(0, sh, block): + for cx in range(0, sw, block): + if ((cx // block) + (cy // block)) & 1: + cd.rectangle((cx, cy, cx + block - 1, cy + block - 1), + fill=(48, 52, 59, 255)) + checker.alpha_composite(cell) + out.alpha_composite(checker, (x, y)) + draw.rectangle((x, y, x + sw - 1, y + sh - 1), outline=(105, 112, 126, 255)) + draw.text((x + 4, y - label_h + 3), f"dir {direction} frame {frame}", + fill=(220, 224, 232, 255)) + + target = args.output or args.sheet.with_name(args.sheet.stem + ".preview.png") + target.parent.mkdir(parents=True, exist_ok=True) + out.convert("RGB").save(target) + print(target.resolve()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/__pycache__/test_agent_assets.cpython-313-pytest-9.1.1.pyc b/tools/tests/__pycache__/test_agent_assets.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..fdadd23 Binary files /dev/null and b/tools/tests/__pycache__/test_agent_assets.cpython-313-pytest-9.1.1.pyc differ diff --git a/tools/tests/__pycache__/test_agent_assets.cpython-313.pyc b/tools/tests/__pycache__/test_agent_assets.cpython-313.pyc new file mode 100644 index 0000000..2c3ed19 Binary files /dev/null and b/tools/tests/__pycache__/test_agent_assets.cpython-313.pyc differ diff --git a/tools/tests/__pycache__/test_monster_assets.cpython-313-pytest-9.1.1.pyc b/tools/tests/__pycache__/test_monster_assets.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..1fd7cde Binary files /dev/null and b/tools/tests/__pycache__/test_monster_assets.cpython-313-pytest-9.1.1.pyc differ diff --git a/tools/tests/__pycache__/test_monster_assets.cpython-313.pyc b/tools/tests/__pycache__/test_monster_assets.cpython-313.pyc new file mode 100644 index 0000000..7221426 Binary files /dev/null and b/tools/tests/__pycache__/test_monster_assets.cpython-313.pyc differ diff --git a/tools/tests/test_agent_assets.py b/tools/tests/test_agent_assets.py new file mode 100644 index 0000000..3da7a44 --- /dev/null +++ b/tools/tests/test_agent_assets.py @@ -0,0 +1,434 @@ +"""Тесты конвейера бумажной куклы: упаковка, выравнивание слоёв, манифест. + +Blender здесь не запускается. Проверяется ровно то, что ломается молча: +кадры, разложенные не в том порядке; экипировка, подогнанная по собственному +bbox; sync, затирающий ручную blender-спеку. + + python -m unittest discover -s tools/tests +""" +from __future__ import annotations + +import importlib.util +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parents[2] + +_spec = importlib.util.spec_from_file_location("agent_assets", ROOT / "tools/agent_assets.py") +aa = importlib.util.module_from_spec(_spec) +sys.modules["agent_assets"] = aa +_spec.loader.exec_module(aa) + + +# --------------------------------------------------------------------------- +# Каркас: временный воркспейс с настоящим каталогом +# --------------------------------------------------------------------------- + +class Sandbox(unittest.TestCase): + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp(prefix="agent_assets_")) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.ws = aa.Workspace(self.dir) + self.ws.catalog.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / "assets/agents/catalog.yaml", self.ws.catalog) + self.cfg = aa.load_config(self.ws) + + # -- синтетические кадры Blender ------------------------------------- + def render_frame(self, marks, supersample=1): + """Кадр рендера: непрозрачные квадраты по координатам кадра рендера.""" + size = (self.cfg.render_width * supersample, self.cfg.render_height * supersample) + image = Image.new("RGBA", size, (0, 0, 0, 0)) + for x, y, w, h in marks: + block = Image.new("RGBA", (w * supersample, h * supersample), (200, 60, 40, 255)) + image.paste(block, (x * supersample, y * supersample)) + return image + + # -- синтетические доски 4x2 ----------------------------------------- + def board(self, cell_size, shapes, key=(255, 0, 255)): + """Доска 4 колонки x 2 строки; shapes[i] — список (x, y, w, h) внутри ячейки.""" + cw, ch = cell_size + board = Image.new("RGB", (cw * 4, ch * 2), key) + for index, boxes in enumerate(shapes): + ox, oy = (index % 4) * cw, (index // 4) * ch + for x, y, w, h in boxes: + board.paste(Image.new("RGB", (w, h), (30, 40, 60)), (ox + x, oy + y)) + return board + + def write_board(self, name, image): + path = self.dir / name + path.parent.mkdir(parents=True, exist_ok=True) + image.save(path) + return path + + def humanoid(self, cell_size, lean=0): + """Восемь ячеек «тела»: одинаковый прямоугольник, слегка гуляющий по X.""" + cw, ch = cell_size + w, h = cw // 6, int(ch * 0.6) + top = int(ch * 0.2) + return [[(cw // 2 - w // 2 + lean * (i - 4), top, w, h)] for i in range(8)] + + +# --------------------------------------------------------------------------- +# Упаковка кадров Blender +# --------------------------------------------------------------------------- + +class TestPacking(Sandbox): + def test_sheet_is_direction_major_with_catalog_geometry(self): + cfg = self.cfg + frames = [self.render_frame([(cfg.crop_x, cfg.crop_y, 1, 1)]) + for _ in range(cfg.cells("walk"))] + sheet = aa.pack_render_frames(cfg, "walk", frames, 1) + self.assertEqual(sheet.size, (cfg.width * 8 * 8, cfg.height)) + # колонка = направление * кадров + кадр + self.assertEqual(cfg.cells("walk"), 64) + + def test_origin_lands_exactly_on_the_pivot(self): + cfg = self.cfg + # Пиксель в центре кадра рендера — это начало координат рига. + centre = (cfg.render_width // 2, cfg.render_height // 2, 1, 1) + sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([centre])] * 8, 1) + for direction in range(8): + box = aa.bbox_of(aa.cell_at(cfg, sheet, direction)) + self.assertEqual((box[0], box[1]), (cfg.pivot_x, cfg.pivot_y), + f"direction {direction} origin moved off the pivot") + + def test_every_cell_uses_the_same_crop_regardless_of_content(self): + """Гарантия бумажной куклы: маленькое оружие не растягивается на клетку.""" + cfg = self.cfg + gear = (cfg.crop_x + 40, cfg.crop_y + 20, 6, 4) + body = (cfg.crop_x + 10, cfg.crop_y + 5, 50, 80) + gear_sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([gear])] * 8, 1) + body_sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([body])] * 8, 1) + self.assertEqual(aa.bbox_of(aa.cell_at(cfg, gear_sheet, 0)), (40, 20, 46, 24)) + self.assertEqual(aa.bbox_of(aa.cell_at(cfg, body_sheet, 0)), (10, 5, 60, 85)) + + def test_supersampled_crop_is_an_exact_integer_reduction(self): + cfg = self.cfg + mark = (cfg.crop_x + 12, cfg.crop_y + 30, 8, 8) + sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([mark], 4)] * 8, 4) + self.assertEqual(aa.bbox_of(aa.cell_at(cfg, sheet, 0)), (12, 30, 20, 38)) + + def test_wrong_render_resolution_is_refused(self): + cfg = self.cfg + frames = [self.render_frame([(0, 0, 2, 2)])] * 8 + with self.assertRaises(ValueError): + aa.pack_render_frames(cfg, "idle", frames, 2) + + def test_wrong_cell_count_is_refused(self): + with self.assertRaises(ValueError): + aa.pack_render_frames(self.cfg, "idle", [self.render_frame([(0, 0, 2, 2)])] * 7, 1) + + +# --------------------------------------------------------------------------- +# Растровый ingest +# --------------------------------------------------------------------------- + +class TestIngest(Sandbox): + CELL = (200, 240) + + def ingest_body(self, lean=0): + board = self.board(self.CELL, self.humanoid(self.CELL, lean)) + path = self.write_board("body.png", board) + return aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path]) + + def test_body_ingest_writes_calibration_and_sits_on_the_pivot(self): + self.ingest_body() + cal = aa.load_calibration(self.ws) + self.assertIsNotNone(cal) + self.assertEqual(cal.source, "body_sleek/idle") + sheet = Image.open(self.ws.source_path("body_sleek", "idle")) + box = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0)) + self.assertAlmostEqual(box[3], self.cfg.pivot_y, delta=1) + self.assertAlmostEqual((box[0] + box[2]) / 2, self.cfg.pivot_x, delta=1) + + def test_equipment_keeps_its_position_relative_to_the_full_cell(self): + """Кинжал в руке обязан остаться в руке, а не съехать к стопам.""" + self.ingest_body() + cw, ch = self.CELL + # Маленький блок высоко в ячейке — «оружие у пояса». + gear_x, gear_y, gear_w, gear_h = cw // 2 + 14, int(ch * 0.42), 10, 12 + gear = self.board(self.CELL, [[(gear_x, gear_y, gear_w, gear_h)] for _ in range(8)]) + path = self.write_board("gear.png", gear) + aa.ingest(self.ws, self.cfg, "weapon_dagger", "idle", [path]) + + cal = aa.load_calibration(self.ws) + scale, ox, oy = aa.cell_transform(self.cfg, cal, self.CELL) + sheet = Image.open(self.ws.source_path("weapon_dagger", "idle")) + box = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0)) + self.assertAlmostEqual(box[0], ox + gear_x * scale, delta=1.5) + self.assertAlmostEqual(box[1], oy + gear_y * scale, delta=1.5) + self.assertAlmostEqual(box[2] - box[0], gear_w * scale, delta=1.5) + self.assertAlmostEqual(box[3] - box[1], gear_h * scale, delta=1.5) + # И главное: НЕ прижат к pivot_y и НЕ растянут на клетку. + self.assertLess(box[3], self.cfg.pivot_y - 10) + self.assertLess(box[3] - box[1], (self.cfg.height - aa.FIT_MARGIN_Y) // 2) + + def test_equipment_before_body_is_refused(self): + gear = self.board(self.CELL, [[(10, 10, 8, 8)] for _ in range(8)]) + path = self.write_board("gear.png", gear) + with self.assertRaises(ValueError): + aa.ingest(self.ws, self.cfg, "weapon_dagger", "idle", [path]) + + def test_calibrate_flag_is_body_only(self): + self.ingest_body() + gear = self.board(self.CELL, [[(10, 10, 8, 8)] for _ in range(8)]) + path = self.write_board("gear.png", gear) + with self.assertRaises(ValueError): + aa.ingest(self.ws, self.cfg, "armor_vest", "idle", [path], calibrate=True) + + def test_one_scale_for_every_direction(self): + """Направление с более коротким силуэтом обязано остаться короче.""" + cw, ch = self.CELL + cells = self.humanoid(self.CELL) + cells[4] = [(cw // 2 - 8, int(ch * 0.2), 16, int(ch * 0.45))] # север ниже прочих + path = self.write_board("body.png", self.board(self.CELL, cells)) + aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path]) + sheet = Image.open(self.ws.source_path("body_sleek", "idle")) + south = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0)) + north = aa.bbox_of(aa.cell_at(self.cfg, sheet, 4)) + ratio = (north[3] - north[1]) / (south[3] - south[1]) + self.assertAlmostEqual(ratio, 0.45 / 0.6, delta=0.05) + + def test_boards_of_different_resolutions_share_one_transform(self): + """Калибровка нормирована на ячейку, значит разрешение доски не важно.""" + self.ingest_body() + cal = aa.load_calibration(self.ws) + small = aa.cell_transform(self.cfg, cal, (100, 120)) + large = aa.cell_transform(self.cfg, cal, (400, 480)) + self.assertAlmostEqual(small[0] * 100, large[0] * 400, places=6) + self.assertAlmostEqual(small[1], large[1], places=6) + self.assertAlmostEqual(small[2], large[2], places=6) + + def test_frame_count_must_match_the_catalog(self): + path = self.write_board("body.png", self.board(self.CELL, self.humanoid(self.CELL))) + with self.assertRaises(ValueError): + aa.ingest(self.ws, self.cfg, "body_sleek", "walk", [path]) + + def test_empty_cell_is_refused(self): + cells = self.humanoid(self.CELL) + cells[3] = [] + path = self.write_board("body.png", self.board(self.CELL, cells)) + with self.assertRaises(ValueError): + aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path]) + + +# --------------------------------------------------------------------------- +# Манифест +# --------------------------------------------------------------------------- + +class TestSync(Sandbox): + def put_sheet(self, component, animation): + path = self.ws.source_path(component, animation) + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", self.cfg.sheet_size(animation), (0, 0, 0, 0)).save(path) + return path + + def manifest(self): + import yaml + return yaml.safe_load(self.ws.manifest.read_text(encoding="utf-8")) + + def test_rendered_png_becomes_an_import_asset(self): + self.put_sheet("body_sleek", "idle") + aa.sync(self.ws, self.cfg) + raw = self.manifest() + spec = raw["agent_body_sleek_idle"] + self.assertEqual(spec["backend"], "import") + self.assertEqual(spec["source"], "../sources/agents/sleek/body_sleek_idle.png") + self.assertEqual(spec["params"], {"frame_width": 70, "frame_height": 90, "frames": 8}) + self.assertEqual(raw["defaults"], {"dirs": 8, "pivot": "35,87"}) + self.assertNotIn("agent_armor_vest_idle", raw) + + def test_walk_asset_counts_every_direction_and_frame(self): + self.put_sheet("body_sleek", "walk") + aa.sync(self.ws, self.cfg) + self.assertEqual(self.manifest()["agent_body_sleek_walk"]["params"]["frames"], 64) + + def test_hand_written_blender_spec_survives_sync(self): + self.put_sheet("weapon_rifle", "idle") + aa.sync(self.ws, self.cfg) + text = self.ws.manifest.read_text(encoding="utf-8") + self.ws.manifest.write_text(text.replace( + "agent_weapon_rifle_idle:\n backend: import\n" + " source: ../sources/agents/sleek/weapon_rifle_idle.png\n", + "agent_weapon_rifle_idle:\n backend: blender\n" + " source: ../sources/agents/sleek/rig.blend\n scene: WEAPON\n"), + encoding="utf-8", newline="\n") + aa.sync(self.ws, self.cfg) + spec = self.manifest()["agent_weapon_rifle_idle"] + self.assertEqual(spec["backend"], "blender") + self.assertEqual(spec["scene"], "WEAPON") + + def test_import_specs_are_regenerated_not_preserved(self): + self.put_sheet("body_sleek", "idle") + aa.sync(self.ws, self.cfg) + self.ws.manifest.write_text( + self.ws.manifest.read_text(encoding="utf-8").replace("fps: 8", "fps: 999"), + encoding="utf-8", newline="\n") + aa.sync(self.ws, self.cfg) + self.assertEqual(self.manifest()["agent_body_sleek_idle"]["fps"], 8) + + def test_removed_png_drops_out_of_the_manifest(self): + path = self.put_sheet("body_sleek", "idle") + aa.sync(self.ws, self.cfg) + path.unlink() + aa.sync(self.ws, self.cfg) + self.assertNotIn("agent_body_sleek_idle", self.manifest()) + + def test_header_ids_match_the_manifest_names(self): + self.put_sheet("body_sleek", "idle") + aa.sync(self.ws, self.cfg) + header = self.ws.header.read_text(encoding="utf-8") + self.assertIn(f"UINT32_C(0x{aa.fnv1a('agent_body_sleek_idle'):08X})", header) + self.assertIn(f"UINT32_C(0x{aa.fnv1a('agent_weapon_rifle_idle'):08X})", header) + + +# --------------------------------------------------------------------------- +# validate +# --------------------------------------------------------------------------- + +class TestValidate(Sandbox): + def sheet_from_cells(self, animation, boxes): + cfg = self.cfg + sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) + for index, cell_boxes in enumerate(boxes): + layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) + for x, y, w, h in cell_boxes: + layer.paste(Image.new("RGBA", (w, h), (180, 60, 40, 255)), (x, y)) + aa.paste_cell(cfg, sheet, index, layer) + return sheet + + def save(self, component, animation, sheet): + path = self.ws.source_path(component, animation) + path.parent.mkdir(parents=True, exist_ok=True) + sheet.save(path) + return path + + def good_body(self): + """Восемь направлений: зеркальные пары совпадают, юг и север различны.""" + cfg = self.cfg + wide, narrow = 22, 12 + # (ширина, сдвиг от pivot) по каталожному порядку + shape = [(wide, 0), (18, -4), (narrow, -7), (16, -4), + (wide - 2, 0), (16, 4), (narrow, 7), (18, 4)] + cells = [] + for width, dx in shape: + x = cfg.pivot_x - width // 2 + dx + cells.append([(x, 20, width, cfg.pivot_y - 20)]) + return self.sheet_from_cells("idle", cells) + + def test_clean_body_passes(self): + self.save("body_sleek", "idle", self.good_body()) + self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 0) + + def test_canvas_mismatch_is_an_error(self): + path = self.save("body_sleek", "idle", self.good_body()) + Image.open(path).resize((100, 100)).save(path) + self.assertEqual(aa.validate(self.ws, self.cfg), 1) + + def test_body_off_the_pivot_is_an_error(self): + cfg = self.cfg + cells = [[(cfg.pivot_x - 10, 5, 20, 40)] for _ in range(8)] + self.save("body_sleek", "idle", self.sheet_from_cells("idle", cells)) + self.assertEqual(aa.validate(self.ws, self.cfg), 1) + + def test_equipment_fitted_on_its_own_is_an_error(self): + self.save("body_sleek", "idle", self.good_body()) + cfg = self.cfg + # Классическая поломка: кинжал растянули на всю клетку и посадили на стопы. + stretched = [[(2, 3, cfg.width - 4, cfg.pivot_y - 3)] for _ in range(8)] + self.save("weapon_dagger", "idle", self.sheet_from_cells("idle", stretched)) + self.assertEqual(aa.validate(self.ws, self.cfg), 1) + + def test_equipment_on_the_body_passes(self): + self.save("body_sleek", "idle", self.good_body()) + cfg = self.cfg + gear = [[(cfg.pivot_x - 6, 40, 12, 14)] for _ in range(8)] + self.save("weapon_dagger", "idle", self.sheet_from_cells("idle", gear)) + self.assertEqual(aa.validate(self.ws, self.cfg), 0) + + def test_shuffled_direction_order_warns(self): + cells = [aa.cell_at(self.cfg, self.good_body(), i) for i in range(8)] + swapped = cells[:1] + [cells[2], cells[1]] + cells[3:] # ЮЗ и З местами + sheet = Image.new("RGBA", self.cfg.sheet_size("idle"), (0, 0, 0, 0)) + for index, cell in enumerate(swapped): + aa.paste_cell(self.cfg, sheet, index, cell) + self.save("body_sleek", "idle", sheet) + report = aa.Report([], []) + aa.check_direction_order(self.cfg, "body", sheet, "idle", report) + self.assertTrue(report.warnings) + self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 1) + + def test_one_direction_repeated_eight_times_warns(self): + cfg = self.cfg + cells = [[(cfg.pivot_x - 11, 20, 22, cfg.pivot_y - 20)] for _ in range(8)] + sheet = self.sheet_from_cells("idle", cells) + report = aa.Report([], []) + aa.check_direction_order(cfg, "body", sheet, "idle", report) + self.assertTrue(any("same silhouette" in w for w in report.warnings)) + + def test_walk_mirror_check_uses_the_whole_cycle(self): + """Зеркальным парам разрешено расходиться внутри шага, но не в сумме.""" + cfg = self.cfg + frames = cfg.frames("walk") + cells = [] + for direction in range(8): + for frame in range(frames): + lead = 6 if (direction in (1, 2, 3) and frame < frames // 2) else -6 + if direction in (5, 6, 7): + lead = -lead + x = cfg.pivot_x - 9 + (lead if direction not in (0, 4) else 0) + cells.append([(x, 20, 18, cfg.pivot_y - 20)]) + self.save("body_sleek", "walk", self.sheet_from_cells("walk", cells)) + self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 0) + + def test_complete_flag_reports_missing_layers(self): + self.save("body_sleek", "idle", self.good_body()) + self.assertEqual(aa.validate(self.ws, self.cfg, complete=True), 1) + + def test_manifest_pivot_drift_is_an_error(self): + self.save("body_sleek", "idle", self.good_body()) + aa.sync(self.ws, self.cfg) + self.ws.manifest.write_text( + self.ws.manifest.read_text(encoding="utf-8").replace('"35,87"', '"35,80"'), + encoding="utf-8", newline="\n") + self.assertEqual(aa.validate(self.ws, self.cfg), 1) + + +# --------------------------------------------------------------------------- +# Каталог и брифы +# --------------------------------------------------------------------------- + +class TestCatalog(Sandbox): + def test_render_frame_is_symmetric_about_the_pivot(self): + cfg = self.cfg + self.assertEqual(cfg.render_width, 70) + self.assertEqual(cfg.render_height, 174) + self.assertEqual((cfg.crop_x, cfg.crop_y), (0, 0)) + self.assertEqual(cfg.render_height // 2 - cfg.crop_y, cfg.pivot_y) + + def test_camera_matches_the_game_tile_projection(self): + import math + self.assertAlmostEqual(self.cfg.camera["elevation_deg"], + math.degrees(math.atan(16 / 32)), places=2) + + def test_prompt_names_the_catalog_geometry(self): + text = aa.prompt_for(self.cfg, "armor_vest", "walk") + self.assertIn("70x90", text) + self.assertIn("(35,87)", text) + self.assertIn("8 frame(s)", text) + self.assertIn("south, southwest, west", text) + + def test_prompt_rejects_unknown_names(self): + with self.assertRaises(ValueError): + aa.prompt_for(self.cfg, "armor_nope", "idle") + with self.assertRaises(ValueError): + aa.prompt_for(self.cfg, "armor_vest", "dance") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_monster_assets.py b/tools/tests/test_monster_assets.py new file mode 100644 index 0000000..064072a --- /dev/null +++ b/tools/tests/test_monster_assets.py @@ -0,0 +1,427 @@ +"""Тесты конвейера тварей: упаковка, манифест, проверки, покрытие. + +Blender здесь не запускается. Проверяется ровно то, что ломается молча: +кадры, разложенные не в том порядке; тварь, вылезшая за кромку клетки; sync, +затирающий ручную спеку; и — главное для ЭТОГО конвейера — расхождение имён +между манифестом и tools/asset_coverage.py. + + python -m unittest discover -s tools/tests +""" +from __future__ import annotations + +import importlib.util +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +def _load(name: str, relative: str): + spec = importlib.util.spec_from_file_location(name, ROOT / relative) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +ma = _load("monster_assets", "tools/monster_assets.py") +coverage = _load("asset_coverage", "tools/asset_coverage.py") + + +class Sandbox(unittest.TestCase): + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp(prefix="monster_assets_")) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.ws = ma.Workspace(self.dir) + self.ws.catalog.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / "assets/monsters/catalog.yaml", self.ws.catalog) + self.cfg = ma.load_config(self.ws) + + def render_frame(self, marks, supersample=1): + """Кадр Blender: непрозрачные квадраты в координатах кадра рендера.""" + size = (self.cfg.render_width * supersample, self.cfg.render_height * supersample) + image = Image.new("RGBA", size, (0, 0, 0, 0)) + for x, y, w, h in marks: + block = Image.new("RGBA", (w * supersample, h * supersample), (150, 40, 40, 255)) + image.paste(block, (x * supersample, y * supersample)) + return image + + def sheet_from_cells(self, animation, boxes): + cfg = self.cfg + sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) + for index, cell_boxes in enumerate(boxes): + layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) + for x, y, w, h in cell_boxes: + layer.paste(Image.new("RGBA", (w, h), (150, 40, 40, 255)), (x, y)) + ma.paste_cell(cfg, sheet, index, layer) + return sheet + + def save(self, kind, animation, sheet): + path = self.ws.source_path(kind, animation) + path.parent.mkdir(parents=True, exist_ok=True) + sheet.save(path) + return path + + def creature(self, animation, width=20, height=30, lean=None): + """Клип: зеркальные пары совпадают точно, соседние — заметно разные. + + Снос по X берётся от запаса до кромки: чем уже тварь, тем сильнее её + разносит по направлениям. Иначе прямоугольники соседей перекрываются + настолько, что перестановка направлений проходит проверку незамеченной. + """ + cfg = self.cfg + lean = (cfg.width - width) // 6 if lean is None else lean + # (ширина, снос от pivot_x) по каталожному порядку направлений + shape = [(width, 0), (width - 2, -lean), (width - 8, -3 * lean), (width - 2, -lean), + (width - 2, 0), (width - 2, lean), (width - 8, 3 * lean), (width - 2, lean)] + cells = [] + for w, dx in shape: + for _ in range(cfg.frames(animation)): + cells.append([(cfg.pivot_x - w // 2 + dx, cfg.pivot_y - height, w, height)]) + return self.sheet_from_cells(animation, cells) + + +# --------------------------------------------------------------------------- +# Геометрия каталога и упаковка кадров Blender +# --------------------------------------------------------------------------- + +class TestCatalog(Sandbox): + def test_render_frame_is_symmetric_about_the_pivot(self): + cfg = self.cfg + self.assertEqual((cfg.width, cfg.height), (96, 112)) + self.assertEqual(cfg.render_width, 96) + self.assertEqual(cfg.render_height, 176) + self.assertEqual((cfg.crop_x, cfg.crop_y), (0, 0)) + self.assertEqual(cfg.render_height // 2 - cfg.crop_y, cfg.pivot_y) + + def test_pivot_leaves_room_for_the_ground_plane_behind_the_creature(self): + """Под точкой опоры обязан быть запас: хвост и задние лапы уходят ВНИЗ.""" + self.assertGreaterEqual(self.cfg.height - self.cfg.pivot_y, 8) + + def test_camera_matches_the_agent_and_the_game_tile_projection(self): + import math + agent = yaml.safe_load((ROOT / "assets/agents/catalog.yaml").read_text(encoding="utf-8")) + self.assertAlmostEqual(self.cfg.camera["elevation_deg"], + agent["camera"]["elevation_deg"], places=3) + self.assertAlmostEqual(self.cfg.camera["elevation_deg"], + math.degrees(math.atan(16 / 32)), places=2) + + def test_every_kind_declares_all_four_clips(self): + self.assertEqual(set(self.cfg.animations), {"walk", "attack", "hit", "death"}) + self.assertEqual(list(self.cfg.kinds), + ["shambler", "rusher", "spitter", "brute", "lurker"]) + + def test_fps_follows_the_creature_tempo(self): + # Бегун частит, громила волочится: одинаковое число кадров, разный fps. + self.assertGreater(self.cfg.fps("rusher", "walk"), self.cfg.fps("brute", "walk")) + self.assertEqual(self.cfg.fps("shambler", "walk"), 8) + + +class TestPacking(Sandbox): + def test_sheet_is_direction_major(self): + cfg = self.cfg + frames = [self.render_frame([(cfg.crop_x, cfg.crop_y, 1, 1)]) + for _ in range(cfg.cells("walk"))] + sheet = ma.pack_render_frames(cfg, "walk", frames, 1) + self.assertEqual(cfg.cells("walk"), 64) + self.assertEqual(sheet.size, (cfg.width * 64, cfg.height)) + + def test_origin_lands_exactly_on_the_pivot(self): + cfg = self.cfg + centre = (cfg.render_width // 2, cfg.render_height // 2, 1, 1) + sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([centre])] * cfg.cells("hit"), 1) + for index in range(cfg.cells("hit")): + box = ma.bbox_of(ma.cell_at(cfg, sheet, index)) + self.assertEqual((box[0], box[1]), (cfg.pivot_x, cfg.pivot_y), + f"cell {index} origin moved off the pivot") + + def test_every_cell_uses_the_same_crop_regardless_of_content(self): + """Общий pivot: мелкая тварь не растягивается на клетку, крупная не жмётся.""" + cfg = self.cfg + small = (cfg.crop_x + 20, cfg.crop_y + 30, 6, 5) + large = (cfg.crop_x + 4, cfg.crop_y + 6, 40, 46) + cells = cfg.cells("hit") + small_sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([small])] * cells, 1) + large_sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([large])] * cells, 1) + self.assertEqual(ma.bbox_of(ma.cell_at(cfg, small_sheet, 0)), (20, 30, 26, 35)) + self.assertEqual(ma.bbox_of(ma.cell_at(cfg, large_sheet, 0)), (4, 6, 44, 52)) + + def test_supersampled_crop_is_an_exact_integer_reduction(self): + cfg = self.cfg + mark = (cfg.crop_x + 10, cfg.crop_y + 12, 8, 8) + sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([mark], 4)] * cfg.cells("hit"), 4) + self.assertEqual(ma.bbox_of(ma.cell_at(cfg, sheet, 0)), (10, 12, 18, 20)) + + def test_wrong_render_resolution_is_refused(self): + cfg = self.cfg + frames = [self.render_frame([(0, 0, 2, 2)])] * cfg.cells("hit") + with self.assertRaises(ValueError): + ma.pack_render_frames(cfg, "hit", frames, 2) + + def test_wrong_cell_count_is_refused(self): + with self.assertRaises(ValueError): + ma.pack_render_frames(self.cfg, "hit", [self.render_frame([(0, 0, 2, 2)])] * 7, 1) + + +# --------------------------------------------------------------------------- +# Манифест и заголовок +# --------------------------------------------------------------------------- + +class TestSync(Sandbox): + def put_sheet(self, kind, animation): + path = self.ws.source_path(kind, animation) + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", self.cfg.sheet_size(animation), (0, 0, 0, 0)).save(path) + return path + + def manifest(self): + return yaml.safe_load(self.ws.manifest.read_text(encoding="utf-8")) + + def test_asset_id_is_kind_underscore_animation(self): + self.put_sheet("shambler", "walk") + ma.sync(self.ws, self.cfg) + raw = self.manifest() + self.assertIn("shambler_walk", raw) + self.assertNotIn("monster_shambler_walk", raw) + self.assertNotIn("enemy_shambler_walk", raw) + + def test_rendered_png_becomes_an_import_asset(self): + self.put_sheet("brute", "attack") + ma.sync(self.ws, self.cfg) + spec = self.manifest()["brute_attack"] + self.assertEqual(spec["backend"], "import") + self.assertEqual(spec["source"], "../sources/monsters/brute_attack.png") + self.assertEqual(spec["params"], + {"frame_width": 96, "frame_height": 112, "frames": 48}) + self.assertEqual(self.manifest()["defaults"], {"dirs": 8, "pivot": "48,88"}) + + def test_frames_count_every_direction_and_frame(self): + self.put_sheet("lurker", "death") + ma.sync(self.ws, self.cfg) + self.assertEqual(self.manifest()["lurker_death"]["params"]["frames"], 64) + + def test_fps_is_scaled_per_kind(self): + self.put_sheet("rusher", "walk") + self.put_sheet("brute", "walk") + ma.sync(self.ws, self.cfg) + raw = self.manifest() + self.assertEqual(raw["rusher_walk"]["fps"], self.cfg.fps("rusher", "walk")) + self.assertGreater(raw["rusher_walk"]["fps"], raw["brute_walk"]["fps"]) + + def test_hand_written_spec_survives_sync(self): + self.put_sheet("spitter", "hit") + ma.sync(self.ws, self.cfg) + text = self.ws.manifest.read_text(encoding="utf-8") + self.ws.manifest.write_text(text.replace( + "spitter_hit:\n backend: import\n" + " source: ../sources/monsters/spitter_hit.png\n", + "spitter_hit:\n backend: blender\n" + " source: ../monsters/monster_rig.blend\n scene: SPITTER\n"), + encoding="utf-8", newline="\n") + ma.sync(self.ws, self.cfg) + spec = self.manifest()["spitter_hit"] + self.assertEqual(spec["backend"], "blender") + self.assertEqual(spec["scene"], "SPITTER") + + def test_import_specs_are_regenerated_not_preserved(self): + self.put_sheet("shambler", "walk") + ma.sync(self.ws, self.cfg) + self.ws.manifest.write_text( + self.ws.manifest.read_text(encoding="utf-8").replace("fps: 8", "fps: 999"), + encoding="utf-8", newline="\n") + ma.sync(self.ws, self.cfg) + self.assertEqual(self.manifest()["shambler_walk"]["fps"], 8) + + def test_removed_png_drops_out_of_the_manifest(self): + path = self.put_sheet("shambler", "walk") + ma.sync(self.ws, self.cfg) + path.unlink() + ma.sync(self.ws, self.cfg) + self.assertNotIn("shambler_walk", self.manifest()) + + def test_header_hashes_match_the_manifest_names(self): + self.put_sheet("shambler", "walk") + ma.sync(self.ws, self.cfg) + header = self.ws.header.read_text(encoding="utf-8") + for kind in self.cfg.kinds: + for animation in self.cfg.animations: + aid = ma.asset_id(kind, animation) + self.assertIn(f"MONSTER_ASSET_{ma.symbol(aid)} = " + f"UINT32_C(0x{ma.fnv1a(aid):08X});", header) + + def test_header_maps_engine_enums_onto_the_clips(self): + self.put_sheet("shambler", "walk") + ma.sync(self.ws, self.cfg) + header = self.ws.header.read_text(encoding="utf-8") + # AnimKind у движка: HURT/DIE, а клипы каталога — hit/death. + self.assertIn("case AnimKind::HURT: return MONSTER_ASSET_SHAMBLER_HIT;", header) + self.assertIn("case AnimKind::DIE: return MONSTER_ASSET_SHAMBLER_DEATH;", header) + self.assertIn("case EnemyKind::LURKER:", header) + self.assertNotIn("EnemyKind::DUMMY", header) + + def test_hash_uses_the_same_fnv1a_constants_as_the_engine(self): + """Разойдись хоть один бит — ассет молча не найдётся, без ошибки в логе.""" + source = (ROOT / "src/render/anim.h").read_text(encoding="utf-8") + self.assertIn("0x811C9DC5u", source) + self.assertIn("0x01000193u", source) + self.assertEqual(ma.fnv1a(""), 0x811C9DC5) + self.assertEqual(ma.fnv1a("a"), ((0x811C9DC5 ^ 0x61) * 0x01000193) & 0xFFFFFFFF) + + def test_collision_with_the_legacy_manifest_is_reported(self): + self.put_sheet("shambler", "walk") + legacy = self.ws.manifest_dir / "enemies.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("version: 1\nshambler_walk: {backend: import, source: x.png}\n", + encoding="utf-8") + ma.sync(self.ws, self.cfg) + report = ma.Report([], []) + ma.check_manifest(self.ws, self.cfg, report) + self.assertTrue(any("enemies.yaml" in w for w in report.warnings)) + + +# --------------------------------------------------------------------------- +# Покрытие: имена конвейера обязаны совпасть с тем, что требует гейт +# --------------------------------------------------------------------------- + +class TestCoverage(unittest.TestCase): + def setUp(self) -> None: + self.spec = yaml.safe_load( + (ROOT / "assets/content_coverage.yaml").read_text(encoding="utf-8")) + self.cfg = ma.load_config(ma.Workspace(ROOT)) + + def test_pipeline_ids_are_exactly_what_the_gate_asks_for(self): + wanted = set(coverage.requirements(self.spec)["enemy"]) + produced = {ma.asset_id(kind, animation) + for kind in self.cfg.kinds for animation in self.cfg.animations} + self.assertEqual(produced, wanted) + + def test_catalog_kinds_match_the_coverage_spec(self): + self.assertEqual(list(self.cfg.kinds), list(self.spec["enemies"]["kinds"])) + self.assertEqual(set(self.cfg.animations), set(self.spec["enemies"]["animations"])) + + def test_the_gate_reads_the_manifest_the_pipeline_writes(self): + """Гейт собирает ID из assets/manifests/*.yaml — sync пишет ровно туда.""" + ws = ma.Workspace(ROOT) + self.assertEqual(ws.manifest.parent, coverage.MANIFESTS) + self.assertTrue(ws.manifest.name.endswith(".yaml")) + self.assertEqual(len(coverage.requirements(self.spec)["enemy"]), + len(self.cfg.kinds) * len(self.cfg.animations)) + + +# --------------------------------------------------------------------------- +# validate +# --------------------------------------------------------------------------- + +class TestValidate(Sandbox): + def test_clean_sheet_passes(self): + self.save("shambler", "walk", self.creature("walk")) + self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 0) + + def test_canvas_mismatch_is_an_error(self): + path = self.save("shambler", "walk", self.creature("walk")) + Image.open(path).resize((100, 100)).save(path) + self.assertEqual(ma.validate(self.ws, self.cfg), 1) + + def test_empty_cell_is_an_error(self): + cfg = self.cfg + sheet = self.creature("hit") + ma.paste_cell(cfg, sheet, 5, Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0))) + self.save("rusher", "hit", sheet) + self.assertEqual(ma.validate(self.ws, self.cfg), 1) + + def test_creature_floating_above_the_ground_line_is_an_error(self): + cfg = self.cfg + cells = [[(cfg.pivot_x - 8, 4, 16, 20)] for _ in range(cfg.cells("walk"))] + self.save("lurker", "walk", self.sheet_from_cells("walk", cells)) + self.assertEqual(ma.validate(self.ws, self.cfg), 1) + + def test_origin_off_the_turn_axis_is_an_error(self): + cfg = self.cfg + cells = [[(cfg.pivot_x + 12, cfg.pivot_y - 30, 14, 30)] + for _ in range(cfg.cells("walk"))] + self.save("lurker", "walk", self.sheet_from_cells("walk", cells)) + self.assertEqual(ma.validate(self.ws, self.cfg), 1) + + def test_silhouette_clipped_by_the_cell_edge_warns(self): + """Замах, не влезший в клетку, в игре выглядит как отрубленная лапа.""" + cfg = self.cfg + sheet = self.creature("attack") + layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) + layer.paste(Image.new("RGBA", (cfg.width, 30), (150, 40, 40, 255)), (0, cfg.pivot_y - 30)) + ma.paste_cell(cfg, sheet, 2, layer) + self.save("brute", "attack", sheet) + report = ma.Report([], []) + ma.check_framing(cfg, "brute_attack.png", sheet, "attack", report) + self.assertTrue(any("clipped" in w for w in report.warnings)) + self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 1) + + def test_death_is_allowed_to_leave_the_ground_line(self): + cfg = self.cfg + # Осевшая туша: низкая, широкая, не достаёт до линии пола — это норма. + cells = [[(cfg.pivot_x - 14, cfg.pivot_y - 12, 28, 8)] + for _ in range(cfg.cells("death"))] + self.save("brute", "death", self.sheet_from_cells("death", cells)) + report = ma.Report([], []) + masks = ma.union_masks(cfg, self.sheet_from_cells("death", cells), "death") + ma.check_ground(cfg, "brute_death.png", masks, "death", report) + self.assertFalse(report.errors) + + def test_shuffled_direction_order_warns(self): + cfg = self.cfg + frames = cfg.frames("walk") + good = self.creature("walk") + cells = [ma.cell_at(cfg, good, i) for i in range(cfg.cells("walk"))] + # Меняем местами юго-запад и запад целиком. + a, b = slice(frames, 2 * frames), slice(2 * frames, 3 * frames) + cells[a], cells[b] = cells[b], cells[a] + sheet = Image.new("RGBA", cfg.sheet_size("walk"), (0, 0, 0, 0)) + for index, cell in enumerate(cells): + ma.paste_cell(cfg, sheet, index, cell) + report = ma.Report([], []) + ma.check_direction_order(cfg, "shambler_walk.png", ma.union_masks(cfg, sheet, "walk"), report) + self.assertTrue(report.warnings) + + def test_one_direction_repeated_eight_times_warns(self): + cfg = self.cfg + cells = [[(cfg.pivot_x - 10, cfg.pivot_y - 30, 20, 30)] + for _ in range(cfg.cells("walk"))] + sheet = self.sheet_from_cells("walk", cells) + report = ma.Report([], []) + ma.check_direction_order(cfg, "shambler_walk.png", ma.union_masks(cfg, sheet, "walk"), report) + self.assertTrue(any("same silhouette" in w for w in report.warnings)) + + def test_two_kinds_with_the_same_silhouette_warn(self): + """Пять видов обязаны различаться пятном, иначе это пять капсул.""" + self.save("shambler", "walk", self.creature("walk")) + self.save("brute", "walk", self.creature("walk")) + self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 1) + + def test_different_morphologies_pass(self): + # Узкий и высокий, широкий и высокий, широкий и плоский — ровно то, чем + # бредущий, громила и затаившийся обязаны различаться в игре. + self.save("shambler", "walk", self.creature("walk", width=10, height=40)) + self.save("brute", "walk", self.creature("walk", width=34, height=44)) + self.save("lurker", "walk", self.creature("walk", width=32, height=10)) + self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 0) + + def test_complete_flag_reports_missing_sheets(self): + self.save("shambler", "walk", self.creature("walk")) + self.assertEqual(ma.validate(self.ws, self.cfg, complete=True), 1) + + def test_manifest_pivot_drift_is_an_error(self): + self.save("shambler", "walk", self.creature("walk")) + ma.sync(self.ws, self.cfg) + self.ws.manifest.write_text( + self.ws.manifest.read_text(encoding="utf-8").replace('"48,88"', '"48,80"'), + encoding="utf-8", newline="\n") + self.assertEqual(ma.validate(self.ws, self.cfg), 1) + + +if __name__ == "__main__": + unittest.main()