diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 3e914c1..b6fa848 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -635,12 +635,13 @@ Completed: 11. `Simulation Garden 01` capture and review: a reproducible runtime capture tool writes debug and cinematic `main.tscn` baselines to `docs/baselines/` 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: -1. Improve the runtime first-read: camera staging, readable path/landmark - composition, and diegetic task communication without adding new simulation - mechanics. +1. Improve camera staging plus readable path and landmark composition without + adding new simulation mechanics. 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 diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index ade57b4..e06273d 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -683,6 +683,8 @@ Recently completed: - `Simulation Garden 01` is captured and reviewed from `main.tscn` with paired debug/cinematic baselines that verify live NPC visuals in the Terrain3D-backed 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 progress suitable for public development updates. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index beca44e..cb2ead3 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -361,8 +361,10 @@ A small village panel displays: - a Tab-cycled NPC inspector with profession, needs, task state, destination, decision reason, and utility scores. -NPC name/profession labels, definition-driven colors and props, and the -carried-food visual make active simulation state readable in the world. +NPC name/profession labels, definition-driven colors and props, carried-food +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 diff --git a/docs/baselines/SIMULATION_GARDEN_01.md b/docs/baselines/SIMULATION_GARDEN_01.md index 8cb944a..83b04ed 100644 --- a/docs/baselines/SIMULATION_GARDEN_01.md +++ b/docs/baselines/SIMULATION_GARDEN_01.md @@ -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 village, the selected-NPC reason inspector is fed by real task scoring, and the 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: @@ -37,8 +38,9 @@ The frame also makes the next presentation risks plain: prototype-grade; - terrain texture variation reads better than the old flat map, but paths, landmarks, water, and foreground silhouettes need stronger first-read staging; -- labels explain the system well, while the cinematic view still needs more - diegetic task communication. +- task glyphs help, but the cinematic view still needs clearer work-site props + 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 systems or expanding the terrain. diff --git a/docs/baselines/simulation_garden_01_cinematic.png b/docs/baselines/simulation_garden_01_cinematic.png index decf237..a1ed490 100644 Binary files a/docs/baselines/simulation_garden_01_cinematic.png and b/docs/baselines/simulation_garden_01_cinematic.png differ diff --git a/docs/baselines/simulation_garden_01_debug.png b/docs/baselines/simulation_garden_01_debug.png index c9790d5..1801106 100644 Binary files a/docs/baselines/simulation_garden_01_debug.png and b/docs/baselines/simulation_garden_01_debug.png differ diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 6f2ddd8..8688b10 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -16,6 +16,8 @@ const DEBUG_LOGS := true @onready var profession_prop: MeshInstance3D = $ProfessionProp @onready var profession_label: Label3D = $ProfessionLabel @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 sim_id: int = -1 @@ -51,6 +53,7 @@ func setup_from_sim(npc: SimNPC) -> void: name = "NPC_%s_%s" % [sim_id, npc_name] _apply_profession_presentation() 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: @@ -114,6 +117,18 @@ func set_carried_food_visible(is_visible: bool) -> void: 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: debug_overlay_visible = is_visible if profession_label != null: @@ -251,6 +266,7 @@ func apply_dead_visual_state() -> void: carried_food_visual.visible = false profession_prop.visible = false profession_label.visible = false + task_glyph_root.visible = false path_request_id += 1 velocity = Vector3.ZERO current_path = PackedVector3Array() @@ -269,3 +285,77 @@ func apply_dead_visual_state() -> void: collision_layer = 0 collision_mask = 0 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) diff --git a/player/npc/NpcVisual.tscn b/player/npc/NpcVisual.tscn index 619f12a..577bea2 100644 --- a/player/npc/NpcVisual.tscn +++ b/player/npc/NpcVisual.tscn @@ -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) 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="."] path_desired_distance = 0.3 target_desired_distance = 0.5 diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 2aaa339..24140cb 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -217,6 +217,7 @@ func simulate_tick() -> void: npc.current_task = SimulationIds.ACTION_IDLE npc.has_travel_target = false npc.target_id = &"" + npc_task_changed.emit(npc, completed_task, npc.current_task) if debug_logs: print( diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd index a0beb8c..c0a0e6a 100644 --- a/tests/npc_visual_lifecycle_test.gd +++ b/tests/npc_visual_lifecycle_test.gd @@ -32,6 +32,24 @@ func _run() -> void: and profession_definition.display_name in visual.get_node("ProfessionLabel").text, "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( SimulationIds.ACTION_GATHER_FOOD, -1.0, diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index cf5b674..c0dc1db 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -36,6 +36,10 @@ func initialize_world_view() -> void: simulation_manager.npc_inventory_changed.connect(_on_npc_inventory_changed) else: 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"): simulation_manager.state_restored.connect(_on_simulation_state_restored) 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") +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: for visual in active_npc_visuals.values(): visual.queue_free()