feat: add cinematic npc task glyphs

This commit is contained in:
2026-07-08 16:31:34 +02:00
parent e813a8b2d5
commit cd0309e224
11 changed files with 144 additions and 8 deletions
+4 -3
View File
@@ -635,12 +635,13 @@ Completed:
11. `Simulation Garden 01` capture and review: a reproducible runtime capture 11. `Simulation Garden 01` capture and review: a reproducible runtime capture
tool writes debug and cinematic `main.tscn` baselines to `docs/baselines/` tool writes debug and cinematic `main.tscn` baselines to `docs/baselines/`
and verifies live NPC visuals in the terrain-backed village. and verifies live NPC visuals in the terrain-backed village.
12. Runtime task glyphs: active NPCs now keep compact task-intent markers in
cinematic mode while debug names, labels, and inspector UI remain optional.
Next: Next:
1. Improve the runtime first-read: camera staging, readable path/landmark 1. Improve camera staging plus readable path and landmark composition without
composition, and diegetic task communication without adding new simulation adding new simulation mechanics.
mechanics.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+2
View File
@@ -683,6 +683,8 @@ Recently completed:
- `Simulation Garden 01` is captured and reviewed from `main.tscn` with paired - `Simulation Garden 01` is captured and reviewed from `main.tscn` with paired
debug/cinematic baselines that verify live NPC visuals in the Terrain3D-backed debug/cinematic baselines that verify live NPC visuals in the Terrain3D-backed
village. village.
- Runtime task glyphs preserve active NPC task intent in cinematic mode while
leaving the simulation task state authoritative.
This order strengthens the simulation while regularly producing visible This order strengthens the simulation while regularly producing visible
progress suitable for public development updates. progress suitable for public development updates.
+4 -2
View File
@@ -361,8 +361,10 @@ A small village panel displays:
- a Tab-cycled NPC inspector with profession, needs, task state, destination, - a Tab-cycled NPC inspector with profession, needs, task state, destination,
decision reason, and utility scores. decision reason, and utility scores.
NPC name/profession labels, definition-driven colors and props, and the NPC name/profession labels, definition-driven colors and props, carried-food
carried-food visual make active simulation state readable in the world. visuals, and compact task glyphs make active simulation state readable in the
world. The F10 cinematic mode hides debug labels while preserving the task
glyphs.
## Runtime architecture ## Runtime architecture
+5 -3
View File
@@ -28,7 +28,8 @@ The milestone is valid as a runtime integration checkpoint: the same Jajce
Terrain3D world is running in `main.tscn`, NPCs are alive in the terrain-backed Terrain3D world is running in `main.tscn`, NPCs are alive in the terrain-backed
village, the selected-NPC reason inspector is fed by real task scoring, and the village, the selected-NPC reason inspector is fed by real task scoring, and the
cinematic/debug toggle produces a shareable view without changing simulation cinematic/debug toggle produces a shareable view without changing simulation
state. state. A follow-up pass adds small task glyphs above active NPCs, so the
cinematic view now preserves task intent after the debug labels are hidden.
The frame also makes the next presentation risks plain: The frame also makes the next presentation risks plain:
@@ -37,8 +38,9 @@ The frame also makes the next presentation risks plain:
prototype-grade; prototype-grade;
- terrain texture variation reads better than the old flat map, but paths, - terrain texture variation reads better than the old flat map, but paths,
landmarks, water, and foreground silhouettes need stronger first-read staging; landmarks, water, and foreground silhouettes need stronger first-read staging;
- labels explain the system well, while the cinematic view still needs more - task glyphs help, but the cinematic view still needs clearer work-site props
diegetic task communication. and route composition so intent is readable before the viewer learns the icon
language.
Use this baseline as the first runtime comparison image before adding more Use this baseline as the first runtime comparison image before adding more
systems or expanding the terrain. systems or expanding the terrain.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 530 KiB

+90
View File
@@ -16,6 +16,8 @@ const DEBUG_LOGS := true
@onready var profession_prop: MeshInstance3D = $ProfessionProp @onready var profession_prop: MeshInstance3D = $ProfessionProp
@onready var profession_label: Label3D = $ProfessionLabel @onready var profession_label: Label3D = $ProfessionLabel
@onready var carried_food_visual: MeshInstance3D = $CarriedFood @onready var carried_food_visual: MeshInstance3D = $CarriedFood
@onready var task_glyph_root: Node3D = $TaskGlyphRoot
@onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph
var has_reported_arrival := false var has_reported_arrival := false
var sim_id: int = -1 var sim_id: int = -1
@@ -51,6 +53,7 @@ func setup_from_sim(npc: SimNPC) -> void:
name = "NPC_%s_%s" % [sim_id, npc_name] name = "NPC_%s_%s" % [sim_id, npc_name]
_apply_profession_presentation() _apply_profession_presentation()
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0) set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
set_task_presentation(npc.current_task, npc.task_state)
func _apply_profession_presentation() -> void: func _apply_profession_presentation() -> void:
@@ -114,6 +117,18 @@ func set_carried_food_visible(is_visible: bool) -> void:
carried_food_visual.visible = is_visible and not is_dead_visual carried_food_visual.visible = is_visible and not is_dead_visual
func set_task_presentation(action_id: StringName, task_state: StringName) -> void:
if task_glyph_root == null or task_glyph == null:
return
if is_dead_visual or not _should_show_task_glyph(action_id, task_state):
task_glyph_root.visible = false
return
task_glyph.mesh = _create_task_glyph_mesh(action_id)
task_glyph.material_override = _create_task_glyph_material(action_id)
task_glyph_root.visible = task_glyph.mesh != null
func set_debug_overlay_visible(is_visible: bool) -> void: func set_debug_overlay_visible(is_visible: bool) -> void:
debug_overlay_visible = is_visible debug_overlay_visible = is_visible
if profession_label != null: if profession_label != null:
@@ -251,6 +266,7 @@ func apply_dead_visual_state() -> void:
carried_food_visual.visible = false carried_food_visual.visible = false
profession_prop.visible = false profession_prop.visible = false
profession_label.visible = false profession_label.visible = false
task_glyph_root.visible = false
path_request_id += 1 path_request_id += 1
velocity = Vector3.ZERO velocity = Vector3.ZERO
current_path = PackedVector3Array() current_path = PackedVector3Array()
@@ -269,3 +285,77 @@ func apply_dead_visual_state() -> void:
collision_layer = 0 collision_layer = 0
collision_mask = 0 collision_mask = 0
debug_log("Applied dead visual state") debug_log("Applied dead visual state")
func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool:
if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
return false
return action_id not in [
SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD,
SimulationIds.ACTION_WANDER,
]
func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
task_glyph.rotation_degrees = Vector3.ZERO
match action_id:
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
var food := SphereMesh.new()
food.radius = 0.18
food.height = 0.26
return food
SimulationIds.ACTION_GATHER_WOOD:
var wood := BoxMesh.new()
wood.size = Vector3(0.18, 0.42, 0.18)
task_glyph.rotation_degrees = Vector3(0, 0, -18)
return wood
SimulationIds.ACTION_DEPOSIT_FOOD:
var crate := BoxMesh.new()
crate.size = Vector3(0.34, 0.22, 0.34)
return crate
SimulationIds.ACTION_PATROL:
var shield := CylinderMesh.new()
shield.top_radius = 0.2
shield.bottom_radius = 0.2
shield.height = 0.1
task_glyph.rotation_degrees = Vector3(90, 0, 0)
return shield
SimulationIds.ACTION_STUDY:
var book := BoxMesh.new()
book.size = Vector3(0.36, 0.08, 0.24)
return book
SimulationIds.ACTION_REST:
var rest := SphereMesh.new()
rest.radius = 0.2
rest.height = 0.12
return rest
return null
func _create_task_glyph_material(action_id: StringName) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.roughness = 0.65
material.emission_enabled = true
material.emission_energy_multiplier = 0.35
var color := _get_task_glyph_color(action_id)
material.albedo_color = color
material.emission = color
return material
func _get_task_glyph_color(action_id: StringName) -> Color:
match action_id:
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
return Color(0.95, 0.42, 0.25, 1.0)
SimulationIds.ACTION_GATHER_WOOD:
return Color(0.58, 0.34, 0.16, 1.0)
SimulationIds.ACTION_DEPOSIT_FOOD:
return Color(0.95, 0.72, 0.32, 1.0)
SimulationIds.ACTION_PATROL:
return Color(0.36, 0.58, 0.95, 1.0)
SimulationIds.ACTION_STUDY:
return Color(0.62, 0.46, 0.9, 1.0)
SimulationIds.ACTION_REST:
return Color(0.4, 0.78, 0.72, 1.0)
return Color(0.9, 0.9, 0.85, 1.0)
+6
View File
@@ -55,6 +55,12 @@ visible = false
transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0) transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0)
mesh = SubResource("SphereMesh_food") mesh = SubResource("SphereMesh_food")
[node name="TaskGlyphRoot" type="Node3D" parent="."]
visible = false
position = Vector3(0, 2.25, 0)
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="."] [node name="NavigationAgent3D" type="NavigationAgent3D" parent="."]
path_desired_distance = 0.3 path_desired_distance = 0.3
target_desired_distance = 0.5 target_desired_distance = 0.5
+1
View File
@@ -217,6 +217,7 @@ func simulate_tick() -> void:
npc.current_task = SimulationIds.ACTION_IDLE npc.current_task = SimulationIds.ACTION_IDLE
npc.has_travel_target = false npc.has_travel_target = false
npc.target_id = &"" npc.target_id = &""
npc_task_changed.emit(npc, completed_task, npc.current_task)
if debug_logs: if debug_logs:
print( print(
+18
View File
@@ -32,6 +32,24 @@ func _run() -> void:
and profession_definition.display_name in visual.get_node("ProfessionLabel").text, and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
"NPC visual should expose a profession prop and readable label" "NPC visual should expose a profession prop and readable label"
) )
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
_check(
visual.get_node("TaskGlyphRoot").visible
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh,
"NPC visual should expose a compact task glyph for cinematic readability"
)
visual.set_debug_overlay_visible(false)
_check(
not visual.get_node("ProfessionLabel").visible
and visual.get_node("TaskGlyphRoot").visible,
"Cinematic mode should hide debug labels while preserving task glyphs"
)
visual.set_debug_overlay_visible(true)
visual.set_task_presentation(SimulationIds.ACTION_WANDER, SimNPC.TASK_STATE_TRAVELING)
_check(
not visual.get_node("TaskGlyphRoot").visible,
"Ambient wander should not display a task glyph"
)
var test_decision := ActionSelectionResult.new( var test_decision := ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_FOOD,
-1.0, -1.0,
+14
View File
@@ -36,6 +36,10 @@ func initialize_world_view() -> void:
simulation_manager.npc_inventory_changed.connect(_on_npc_inventory_changed) simulation_manager.npc_inventory_changed.connect(_on_npc_inventory_changed)
else: else:
push_error("WorldViewManager: SimulationManager has no npc_inventory_changed signal") push_error("WorldViewManager: SimulationManager has no npc_inventory_changed signal")
if simulation_manager.has_signal("npc_task_changed"):
simulation_manager.npc_task_changed.connect(_on_npc_task_changed)
else:
push_error("WorldViewManager: SimulationManager has no npc_task_changed signal")
if simulation_manager.has_signal("state_restored"): if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_simulation_state_restored) simulation_manager.state_restored.connect(_on_simulation_state_restored)
else: else:
@@ -168,6 +172,16 @@ func _on_npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
push_error("WorldViewManager: NPCVisual has no set_carried_food_visible method") push_error("WorldViewManager: NPCVisual has no set_carried_food_visible method")
func _on_npc_task_changed(npc: SimNPC, _old_task: StringName, _new_task: StringName) -> void:
var visual = active_npc_visuals.get(npc.id)
if visual == null:
return
if visual.has_method("set_task_presentation"):
visual.set_task_presentation(npc.current_task, npc.task_state)
else:
push_error("WorldViewManager: NPCVisual has no set_task_presentation method")
func _on_simulation_state_restored() -> void: func _on_simulation_state_restored() -> void:
for visual in active_npc_visuals.values(): for visual in active_npc_visuals.values():
visual.queue_free() visual.queue_free()