178 lines
5.9 KiB
GDScript
178 lines
5.9 KiB
GDScript
extends SceneTree
|
|
## Gear-on-character capture: one frame per gear piece, worn by a real avatar.
|
|
##
|
|
## The gear library is authored in attach-point space, so the only way to judge a
|
|
## piece is to see it on the rig it ships with. Each shot stages `npc_casual`,
|
|
## attaches exactly one gear item through the runtime path (`_attach_gear`, which
|
|
## applies the same offsets the game uses) and frames the region that gear lives in.
|
|
##
|
|
## Usage (windowed):
|
|
## godot --path . --script res://src/test/capture_npc_gear.gd -- \
|
|
## --out=res://tmp/npc-gear
|
|
##
|
|
## Then compose the sheet:
|
|
## python3 tools/npc_baseline.py sheet --dir tmp/npc-gear --out tmp/npc-gear/sheet.jpg
|
|
|
|
const Rules := preload("res://src/core/rules.gd")
|
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
|
|
|
## Gear order matches `NpcAvatar.GEAR_ATTACH`; the crop frames where the piece sits.
|
|
const SHOTS := [
|
|
{"gear": "firefighter_helmet", "view": "head"},
|
|
{"gear": "sunglasses", "view": "head"},
|
|
{"gear": "lanyard", "view": "chest"},
|
|
{"gear": "backpack", "view": "back"},
|
|
{"gear": "shoulder_bag", "view": "back"},
|
|
{"gear": "scba_tank", "view": "back"},
|
|
{"gear": "camera", "view": "hand"},
|
|
{"gear": "clipboard", "view": "hand"},
|
|
{"gear": "phone", "view": "hand"},
|
|
{"gear": "cup", "view": "hand"},
|
|
{"gear": "umbrella", "view": "hand"},
|
|
{"gear": "protest_sign", "view": "hand"},
|
|
{"gear": "flashlight", "view": "hand"},
|
|
{"gear": "wand", "view": "hand"},
|
|
{"gear": "radio", "view": "hand"},
|
|
]
|
|
const VIEWS := {
|
|
"head": {"yaw": 18.0, "distance": 0.95, "height": 1.42, "target_y": 1.72, "fov": 34.0},
|
|
"chest": {"yaw": 18.0, "distance": 1.10, "height": 1.45, "target_y": 1.32, "fov": 34.0},
|
|
"back": {"yaw": 158.0, "distance": 1.80, "height": 1.50, "target_y": 1.25, "fov": 36.0},
|
|
"hand": {"yaw": 22.0, "distance": 1.25, "height": 1.35, "target_y": 0.95, "fov": 34.0},
|
|
}
|
|
const SETTLE_FRAMES := 3
|
|
const MAX_FRAMES := 3000
|
|
|
|
var out_dir := "user://screenshots/npc_gear"
|
|
var _stage: Node3D
|
|
var _camera: Camera3D
|
|
var _avatar: NpcAvatar
|
|
var _shot := 0
|
|
var _frames := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
for arg in OS.get_cmdline_user_args():
|
|
if str(arg).begins_with("--out="):
|
|
out_dir = str(arg).substr(6)
|
|
|
|
|
|
func _process(_delta: float) -> bool:
|
|
_frames += 1
|
|
if _frames > MAX_FRAMES:
|
|
push_error("capture_npc_gear timed out")
|
|
quit(1)
|
|
return true
|
|
if _stage == null:
|
|
_build_stage()
|
|
_stage_shot()
|
|
return false
|
|
if _frames < SETTLE_FRAMES:
|
|
return false
|
|
_shoot()
|
|
_shot += 1
|
|
if _shot >= SHOTS.size():
|
|
print("capture_npc_gear done -> %s" % out_dir)
|
|
quit(0)
|
|
return true
|
|
_stage_shot()
|
|
return false
|
|
|
|
|
|
func _build_stage() -> void:
|
|
_stage = Node3D.new()
|
|
_stage.name = "NpcGearStage"
|
|
root.add_child(_stage)
|
|
|
|
var env := Environment.new()
|
|
env.background_mode = Environment.BG_COLOR
|
|
env.background_color = Color(0.56, 0.59, 0.63)
|
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
env.ambient_light_color = Color(0.72, 0.75, 0.80)
|
|
env.ambient_light_energy = 0.55
|
|
env.tonemap_mode = Environment.TONE_MAPPER_ACES
|
|
var world_env := WorldEnvironment.new()
|
|
world_env.environment = env
|
|
_stage.add_child(world_env)
|
|
|
|
var sun := DirectionalLight3D.new()
|
|
sun.rotation_degrees = Vector3(-38.0, -32.0, 0.0)
|
|
sun.light_energy = 1.35
|
|
sun.light_color = Color(1.0, 0.97, 0.92)
|
|
sun.shadow_enabled = true
|
|
_stage.add_child(sun)
|
|
var fill := DirectionalLight3D.new()
|
|
fill.rotation_degrees = Vector3(-12.0, 145.0, 0.0)
|
|
fill.light_energy = 0.45
|
|
fill.light_color = Color(0.75, 0.82, 1.0)
|
|
_stage.add_child(fill)
|
|
|
|
var ground := MeshInstance3D.new()
|
|
var plane := PlaneMesh.new()
|
|
plane.size = Vector2(24.0, 24.0)
|
|
ground.mesh = plane
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = Color(0.30, 0.32, 0.35)
|
|
mat.roughness = 0.92
|
|
ground.material_override = mat
|
|
_stage.add_child(ground)
|
|
|
|
_camera = Camera3D.new()
|
|
_camera.current = true
|
|
_stage.add_child(_camera)
|
|
|
|
|
|
func _stage_shot() -> void:
|
|
if _avatar != null and is_instance_valid(_avatar):
|
|
_avatar.queue_free()
|
|
_avatar = null
|
|
var shot: Dictionary = SHOTS[_shot]
|
|
var profile := NpcProfile.new()
|
|
profile.id = "gear_" + str(shot["gear"])
|
|
profile.display_name = str(shot["gear"]).to_upper()
|
|
profile.npc_type = Rules.NpcType.TOURIST
|
|
var avatar := NpcAvatar.new()
|
|
avatar.name = "GearSubject"
|
|
# The hoodie base has short sleeves and no gear of its own beyond the outfit's,
|
|
# so the attach point of the tested piece is unobstructed.
|
|
avatar.character_override = "npc_hoodie"
|
|
avatar.setup(profile, "roam", 4242, Vector3.ZERO, Vector3(0, 0, 2),
|
|
Vector3(0, 0.5, 1.2), str(profile.id))
|
|
_stage.add_child(avatar)
|
|
avatar.global_position = Vector3.ZERO
|
|
avatar.rotation.y = 0.0
|
|
avatar.set_physics_process(false)
|
|
avatar.set_process(false)
|
|
# Clear the gear the outfit attached for itself, or the shot shows two items in
|
|
# the same hand and the tested piece is unreadable.
|
|
for node in avatar.find_children("Gear_*", "Node3D", true, false):
|
|
(node as Node3D).queue_free()
|
|
# Attach the tested piece through the real runtime path, so the offsets under
|
|
# test are exactly the ones the game applies.
|
|
var gear := str(shot["gear"])
|
|
if not avatar._attach_gear(gear):
|
|
push_warning("gear %s did not attach" % gear)
|
|
avatar._animate(1.0 / 60.0)
|
|
_avatar = avatar
|
|
_frame_view(VIEWS[str(shot["view"])])
|
|
|
|
|
|
func _frame_view(view: Dictionary) -> void:
|
|
var yaw := deg_to_rad(float(view["yaw"]))
|
|
var distance := float(view["distance"])
|
|
var target := Vector3(0.0, float(view["target_y"]), 0.0)
|
|
_camera.position = target + Vector3(sin(yaw) * distance,
|
|
float(view["height"]) - target.y, cos(yaw) * distance)
|
|
_camera.fov = float(view["fov"])
|
|
_camera.look_at(target, Vector3.UP)
|
|
|
|
|
|
func _shoot() -> void:
|
|
var dir := ProjectSettings.globalize_path(out_dir)
|
|
DirAccess.make_dir_recursive_absolute(dir)
|
|
var path := dir.path_join("%02d-%s.png" % [_shot, SHOTS[_shot]["gear"]])
|
|
var img: Image = root.get_texture().get_image()
|
|
var err := img.save_png(path)
|
|
print("%s -> err=%d" % [path.get_file(), err])
|