extends SceneTree ## Final-context NPC review harness (pre-build gate). ## ## Stages all eight authored character models at portrait, conversation and ## crowd distance on both promoted maps, in day and night lighting. The normal ## crowd is hidden so each frame isolates the character and the venue behind it. ## Output is local QA art (put it under `tmp/` or `docs/screens/realism/`). ## ## Usage: ## godot --path . --script res://src/test/capture_npc_review.gd -- \ ## --out=res://tmp/npc-review-1 ## ## The guard character has no spawned NPC role; it uses the review-only ## `character_override` on NpcAvatar. 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") const VARIANTS := [ {"name": "npc_casual", "type": Rules.NpcType.TOURIST, "seed": 4242, "character": "npc_casual"}, {"name": "npc_jacket", "type": Rules.NpcType.AUTHORIZED_GUEST, "seed": 4242, "character": "npc_jacket"}, {"name": "npc_coat", "type": Rules.NpcType.INFLUENCER, "seed": 4242, "character": "npc_coat"}, {"name": "npc_vest", "type": Rules.NpcType.DELIVERY, "seed": 4242, "character": "npc_vest"}, {"name": "npc_dress", "type": Rules.NpcType.PERFORMER, "seed": 4242, "character": "npc_dress"}, {"name": "npc_hoodie", "type": Rules.NpcType.VIOLENT_THREAT, "seed": 4242, "character": "npc_hoodie"}, {"name": "npc_firefighter", "type": Rules.NpcType.RESPONDER, "seed": 4242, "character": "npc_firefighter"}, {"name": "npc_guard", "type": Rules.NpcType.CONFUSED_VISITOR, "seed": 4242, "character": "npc_guard"}, ] const VENUES := [ {"name": "hollywood_day", "venue": "hollywood_building", "elapsed": 0.0}, {"name": "hollywood_night", "venue": "hollywood_building", "elapsed": 760.0}, {"name": "joke_shop_day", "venue": "joke_shop", "elapsed": 0.0}, {"name": "joke_shop_night", "venue": "joke_shop", "elapsed": 700.0}, ] const CONTEXTS := [ {"name": "portrait", "distance": 2.0, "pitch": 0.0}, {"name": "conversation", "distance": 2.8, "pitch": 1.0}, {"name": "crowd", "distance": 7.5, "pitch": 1.0}, ] const SETTLE_FRAMES := 18 const MAX_FRAMES := 30000 var gm: Node = null var frames := 0 var out_dir := "user://screenshots/npc_review" var _phase := "boot" var _wait := 0 var _venue_i := 0 var _variant_i := 0 var _context_i := 0 var _subject: NpcAvatar = null var _subject_pos := Vector3.ZERO ## Captured at the start of a venue, before the review camera moves the guard. var _venue_spawn := Vector3.ZERO func _initialize() -> void: root.size = Vector2i(1280, 720) for arg in OS.get_cmdline_user_args(): var text := str(arg) if text.begins_with("--out="): out_dir = text.substr(6) var packed: PackedScene = load("res://scenes/main.tscn") root.add_child(packed.instantiate()) func _process(_delta: float) -> bool: frames += 1 if frames > MAX_FRAMES: push_error("capture_npc_review timed out") quit(1) return true if gm == null: gm = root.find_child("GameManager", true, false) return false match _phase: "boot": var menu = gm.get("start_menu") if menu != null and menu.visible: gm.call("_on_venue_selected", "hollywood_building") _wait = 50 _phase = "boot_wait" "boot_wait": _wait -= 1 if _wait <= 0: _begin_venue() "settle": _hide_crowd() _wait -= 1 if _wait <= 0: _shoot() _context_i += 1 if _context_i >= CONTEXTS.size(): _context_i = 0 _variant_i += 1 if _variant_i >= VARIANTS.size(): _variant_i = 0 _venue_i += 1 if _venue_i >= VENUES.size(): _phase = "done" print("capture_npc_review done -> %s" % out_dir) quit(0) return true _begin_venue() else: _stage_variant() else: _frame_subject() _wait = SETTLE_FRAMES "done": print("capture_npc_review done -> %s" % out_dir) quit(0) return true return false func _begin_venue() -> void: var venue: Dictionary = VENUES[_venue_i] gm.call("switch_scenario", str(venue["venue"])) var coordinator = gm.get("coordinator") coordinator.elapsed_s = float(venue["elapsed"]) coordinator.current_phase = coordinator._phase_for_time(coordinator.elapsed_s) gm.set("_last_phase", -1) gm.call("_apply_phase_lighting") coordinator.scheduler.spawn_rate_multiplier = 0.0 var hud = gm.get("hud") if hud != null: hud.visible = false hud.clear_log() hud.set_target_label("") hud.set_action_hint("") var guard = gm.get("guard") _venue_spawn = Vector3(0.0, 0.1, 8.0) \ if str(venue["venue"]) == "hollywood_building" else Vector3(-2.0, 0.1, 4.5) guard.set_physics_process(false) var night := str(venue["name"]).ends_with("night") if bool(gm.get("flashlight_on")) != night: gm.call("_toggle_flashlight") _variant_i = 0 _context_i = 0 _wait = 15 _phase = "settle" _stage_variant() print("capture_npc_review: venue %s at %d ms" % [venue["name"], Time.get_ticks_msec()]) func _stage_variant() -> void: if _subject != null and is_instance_valid(_subject): _subject.queue_free() _subject = null var variant: Dictionary = VARIANTS[_variant_i] var pos := _venue_spawn + Vector3(0.0, 0.0, -2.0) _subject_pos = pos var profile := NpcProfile.new() profile.id = "review_" + str(variant["name"]) profile.display_name = str(variant["name"]).to_upper() profile.npc_type = int(variant["type"]) var avatar := NpcAvatar.new() avatar.name = "ReviewSubject" avatar.character_override = str(variant["character"]) avatar.setup(profile, "roam", int(variant["seed"]), pos, pos + Vector3(0, 0, 2), Vector3(0, 0.5, 1.2), str(profile.id)) gm.scene_root.add_child(avatar) avatar.global_position = pos avatar.rotation.y = 0.0 avatar.set_physics_process(false) avatar.set_process(false) for node in avatar.find_children("*", "Label3D", true, false): (node as Label3D).visible = false for node in avatar.find_children("FocusCircle", "MeshInstance3D", true, false): (node as MeshInstance3D).visible = false _subject = avatar _frame_subject() _wait = SETTLE_FRAMES _phase = "settle" func _frame_subject() -> void: if _subject == null or not is_instance_valid(_subject): return var context: Dictionary = CONTEXTS[_context_i] var distance := float(context["distance"]) var guard = gm.get("guard") guard.global_position = _subject_pos + Vector3(0, 0.1, distance) guard.rotation.y = 0.0 guard.head.rotation.x = deg_to_rad(float(context["pitch"])) _hide_crowd() func _hide_crowd() -> void: for node in get_nodes_in_group("npc"): var npc := node as Node3D if npc == null or npc == _subject: continue npc.visible = false npc.set_physics_process(false) npc.global_position = Vector3(0, -100, 0) if gm != null: var hud = gm.get("hud") if hud != null: hud.clear_log() hud.set_target_label("") func _shoot() -> void: var venue: Dictionary = VENUES[_venue_i] var variant: Dictionary = VARIANTS[_variant_i] var context: Dictionary = CONTEXTS[_context_i] var dir := ProjectSettings.globalize_path(out_dir) DirAccess.make_dir_recursive_absolute(dir) var base := "%s__%s__%s" % [variant["name"], venue["name"], context["name"]] var path := dir.path_join(base + ".png") var index := 2 while FileAccess.file_exists(path): path = dir.path_join("%s-v%d.png" % [base, index]) index += 1 var img: Image = root.get_texture().get_image() var err := img.save_png(path) print("%s -> err=%d" % [base, err])