Pass the pre-build NPC review gate
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Skipped

- 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:
Gig Line Dev committed 2026-11-02 14:32:10 +01:00
1 parent 37f925c7ad
commit f71a36faa1
18 files changed
+335 -45

No files matched your search

+15 -1
View File
@@ -133,6 +133,10 @@ var _height_scale := 1.0
var loaded_model := false
var fallback_avatar := true
var character_model := ""
## QA/review override for the visual model. Empty keeps role/seed selection;
## `capture_npc_review.gd` uses it to stage the one library model (guard) that
## has no spawned NPC role.
var character_override := ""
## Tests can force the offline rig; the game always tries the committed model.
var use_skinned_model := true
var _skeleton: Skeleton3D = null
@@ -167,6 +171,13 @@ const ACCESSORIES := {
const BLENDER_NPC_DIR := "res://assets/models/blender/npc/"
const BLENDER_GEAR_DIR := "res://assets/models/blender/npc_gear/"
## Final-context adjustments applied inside the attach node. The lanyard was
## authored under the jacket's front surface; a small forward offset keeps the
## ribbon and badge readable without changing the attach-point contract.
const GEAR_OFFSETS := {
"lanyard": Vector3(0, 0, 0.04),
"firefighter_helmet": Vector3(0, 0.03, 0),
}
const GEAR_ATTACH := {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "head",
@@ -335,7 +346,8 @@ static func reaction_label(mode: String, escalation: int) -> String:
## Instantiate the Blender-authored skinned base mesh. Returns false when the
## GLB is missing/not imported, so setup falls back to the procedural rig.
func _build_skinned_body(npc_type: int) -> bool:
character_model = character_model_for(npc_type, _visual_seed)
character_model = character_override if character_override != "" \
else character_model_for(npc_type, _visual_seed)
var path := BLENDER_NPC_DIR + character_model + ".glb"
if not ResourceLoader.exists(path):
character_model = "npc_base"
@@ -731,6 +743,8 @@ func _attach_gear(gear_name: String) -> bool:
_:
node.free()
return false
if GEAR_OFFSETS.has(gear_name):
node.position += GEAR_OFFSETS[gear_name]
_detail_nodes.append(node)
var ao := Materials.load_ao(BLENDER_GEAR_DIR + gear_name + "_ao.png")
for child in node.find_children("*", "MeshInstance3D", true, false):
+213
View File
@@ -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])
+21 -2
View File
@@ -6,12 +6,13 @@ const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _avatar(npc_type: int, seed_value: int) -> NpcAvatar:
func _avatar(npc_type: int, seed_value: int, character: String = "") -> NpcAvatar:
var profile := NpcProfile.new()
profile.id = "char_test"
profile.npc_type = npc_type
profile.display_name = "Test Person"
var avatar := NpcAvatar.new()
avatar.character_override = character
avatar.setup(profile, "enter", seed_value,
Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "char_test")
return avatar
@@ -167,7 +168,23 @@ func test_character_selection_is_deterministic_and_complete() -> bool:
return TestUtils.check(problems.is_empty(),
"every role selects an existing library character (%s)" % str(problems))
func test_review_override_can_stage_the_guard_model() -> bool:
## The guard character has no spawned NPC role, so the final-context review
## forces it explicitly instead of remapping a role.
var avatar := _avatar(Rules.NpcType.CONFUSED_VISITOR, 12, "npc_guard")
var a := TestUtils.check_eq(avatar.character_model, "npc_guard",
"override selects the guard model")
var b := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar,
"the guard GLB loads through the normal skinned path")
avatar.free()
return a and b
func test_library_gear_attaches_to_the_contract_points() -> bool:
var guest := _avatar(Rules.NpcType.AUTHORIZED_GUEST, 12)
var lanyard := guest.find_child("Gear_lanyard", true, false) as Node3D
var e := TestUtils.check(lanyard != null and lanyard.position.z > 0.03,
"the lanyard badge clears the jacket front")
guest.free()
var tourist := _avatar(Rules.NpcType.TOURIST, 12)
var backpack := tourist.find_child("Gear_backpack", true, false) as Node3D
var camera := tourist.find_child("Gear_camera", true, false) as Node3D
@@ -188,8 +205,10 @@ func test_library_gear_attaches_to_the_contract_points() -> bool:
"firefighter helmet attaches to Rig/Head")
var d := TestUtils.check(tank != null and flashlight != null,
"responder gets SCBA tank and flashlight")
var e2 := TestUtils.check(helmet != null and helmet.position.y > 0.02,
"the firefighter helmet clears the brow")
responder.free()
return a and b and c and d
return a and b and c and d and e and e2
## Horizontal bounds of a gear GLB in its attach-node space. The head/back
## contract is measured against the Blender fit report (head width 0.315 m,