Pass the pre-build NPC review gate
- Add a review harness that stages all eight library characters at portrait, conversation and crowd distance on both maps in day and night. - Fix the final-context lanyard badge and firefighter helmet fit with runtime attach offsets; the asset-space gear contract is unchanged. - Record the gate sign-off, refresh the gallery, and move the completed review to DONE.
This commit is contained in:
1 parent
37f925c7ad
commit
f71a36faa1
18 files changed
+335
-45
No files matched your search
@@ -0,0 +1,213 @@
|
||||
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])
|
||||
Reference in new issue
Block a user