extends Node signal pantry_crisis_staged(helper_id: int, interested_id: int) signal pantry_crisis_completed(helper_id: int, resolution_event_id: int) signal pantry_crisis_failed(reason: String) const FEATURED_HELPER_INDEX := 0 const INTERESTED_VILLAGER_INDEX := 1 const SHORTAGE_ACTOR_INDEX := 2 const VALLEY_ESTABLISH_SECONDS := 2.4 const ESTABLISH_SECONDS := 2.4 const RECOVERY_SECONDS := 5.7 const DEMO_TIMEOUT_SECONDS := 32.0 static var start_pantry_crisis_after_reload := false @export var ui: CanvasLayer @export var active_npcs_parent: Node @export var simulation_manager: Node @export var world_view_manager: Node @export var camera_rig: Node3D @export var player: CharacterBody3D @export var pantry_storage: StorageNode @export var crisis_caption: Label @export var debug_overlay_visible := true var _refresh_accumulator := 0.0 var _caption_tween: Tween var _demo_running := false var _featured_helper_id := -1 var _interested_villager_id := -1 func _ready() -> void: _apply_debug_overlay_visibility() _hide_caption() if start_pantry_crisis_after_reload: start_pantry_crisis_after_reload = false call_deferred("start_pantry_crisis_demo") func _process(delta: float) -> void: _refresh_accumulator += delta if _refresh_accumulator < 0.5: return _refresh_accumulator = 0.0 _apply_debug_overlay_visibility() func _unhandled_key_input(event: InputEvent) -> void: if not event is InputEventKey or not event.pressed or event.echo: return if event.keycode == KEY_F10: toggle_debug_overlay() get_viewport().set_input_as_handled() elif event.keycode == KEY_F11: request_pantry_crisis_demo() get_viewport().set_input_as_handled() elif event.keycode == KEY_F12: reset_demo_scene() get_viewport().set_input_as_handled() func toggle_debug_overlay() -> void: set_debug_overlay_visible(not debug_overlay_visible) func set_debug_overlay_visible(is_visible: bool) -> void: debug_overlay_visible = is_visible _apply_debug_overlay_visibility() print( ( "[DemoController] %s" % ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden") ) ) func request_pantry_crisis_demo() -> void: start_pantry_crisis_after_reload = true print("[DemoController] Reloading the repeatable pantry-crisis demo") get_tree().reload_current_scene() func reset_demo_scene() -> void: start_pantry_crisis_after_reload = false print("[DemoController] Resetting simulation garden demo") get_tree().reload_current_scene() func start_pantry_crisis_demo() -> void: if _demo_running: return _demo_running = true set_debug_overlay_visible(false) if player != null: player.set_physics_process(false) player.velocity = Vector3.ZERO _focus_camera_on_village() _show_caption("Morning settles over the village.") await get_tree().create_timer(VALLEY_ESTABLISH_SECONDS).timeout if not _demo_running or not is_inside_tree(): return var staged := stage_pantry_crisis() if staged.is_empty(): _fail_pantry_crisis("Could not stage the pantry shortage") return var helper: SimNPC = staged["helper"] var interested: SimNPC = staged["interested"] _show_caption("The pantry is empty. %s is worried." % interested.npc_name) pantry_crisis_staged.emit(helper.id, interested.id) await get_tree().create_timer(ESTABLISH_SECONDS).timeout if not _demo_running or not is_inside_tree(): return if not begin_pantry_restock(): _fail_pantry_crisis("No villager could begin the food run") return _show_caption("%s goes to help." % helper.npc_name) _focus_camera_on_npc(helper.id) var elapsed := 0.0 var carrying_was_shown := false while elapsed < DEMO_TIMEOUT_SECONDS: if not is_inside_tree(): return var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() if opportunity == null: var resolved: OpportunityStateRecord = ( simulation_manager.opportunity_system.get_latest() ) var resolution_event_id := -1 if resolved != null: resolution_event_id = resolved.get_resolution_event_id() _show_caption("%s restocks the pantry." % helper.npc_name) await get_tree().create_timer(RECOVERY_SECONDS).timeout _demo_running = false pantry_crisis_completed.emit(helper.id, resolution_event_id) return if ( not carrying_was_shown and helper.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0 ): carrying_was_shown = true _show_caption("Fresh berries, carried all the way home.") await get_tree().process_frame elapsed += get_process_delta_time() _fail_pantry_crisis("The food route did not finish in time") func stage_pantry_crisis() -> Dictionary: if not _has_demo_dependencies() or simulation_manager.npcs.size() < 3: return {} var helper: SimNPC = simulation_manager.npcs[FEATURED_HELPER_INDEX] var interested: SimNPC = simulation_manager.npcs[INTERESTED_VILLAGER_INDEX] var shortage_actor: SimNPC = simulation_manager.npcs[SHORTAGE_ACTOR_INDEX] var pantry_position := pantry_storage.get_interaction_position() for npc in simulation_manager.npcs: _park_npc_for_demo(npc) _place_npc_for_demo(helper, pantry_position + Vector3(-1.25, 0.0, 0.15)) _place_npc_for_demo(interested, pantry_position + Vector3(0.0, 0.0, 0.2)) _place_npc_for_demo(shortage_actor, pantry_position + Vector3(1.25, 0.0, 0.15)) var relationship: RelationshipStateRecord = ( simulation_manager.relationship_system.get_relationship(helper.id, interested.id) ) if relationship == null: return {} if relationship.get_trust() < 0.6: helper.hunger = 85.0 interested.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) if ( simulation_manager.economy.deposit_inventory(interested, SimulationIds.RESOURCE_FOOD) <= 0.0 ): return {} if relationship.get_trust() < 0.6: return {} var pantry: StorageStateRecord = simulation_manager.get_pantry() if pantry == null: return {} var current_food := pantry.get_amount(SimulationIds.RESOURCE_FOOD) if current_food > 1.0: pantry.withdraw(SimulationIds.RESOURCE_FOOD, current_food - 1.0) elif current_food < 1.0: pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0 - current_food) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) _set_demo_needs(helper, 45.0, false) _set_demo_needs(interested, 92.0, true) _set_demo_needs(shortage_actor, 45.0, false) var withdrawn: float = simulation_manager.economy.withdraw_to_inventory( shortage_actor, SimulationIds.RESOURCE_FOOD, 1.0 ) var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() if ( not is_equal_approx(withdrawn, 1.0) or opportunity == null or opportunity.get_interested_npc_id() != interested.id ): return {} if not simulation_manager.economy.consume_npc_food(shortage_actor): return {} _featured_helper_id = helper.id _interested_villager_id = interested.id _focus_camera_on_pantry(interested.id) return { "helper": helper, "interested": interested, "shortage_actor": shortage_actor, "opportunity": opportunity, } func begin_pantry_restock() -> bool: var helper := _get_npc(_featured_helper_id) var interested := _get_npc(_interested_villager_id) if helper == null or interested == null or simulation_manager.get_active_opportunity() == null: return false var opportunity_helper: OpportunityHelperResult = ( simulation_manager.get_active_opportunity_helper() ) if opportunity_helper == null or opportunity_helper.helper_npc_id != helper.id: return false var selection: ActionSelectionResult = simulation_manager.action_selector.select_action( helper, simulation_manager.village, simulation_manager.clock.time_of_day(), simulation_manager.npcs, opportunity_helper ) if ( selection == null or selection.action_id != SimulationIds.ACTION_GATHER_FOOD or interested.npc_name not in selection.reason ): return false var previous_task := helper.current_task simulation_manager.latest_decisions[helper.id] = selection simulation_manager.npc_decision_recorded.emit(helper, selection) helper.set_task(selection.action_id, selection.duration_override) simulation_manager.npc_task_changed.emit(helper, previous_task, selection.action_id) simulation_manager.npc_target_requested.emit(helper) return ( helper.task_state == SimNPC.TASK_STATE_TRAVELING and not helper.target_id.is_empty() and helper.has_travel_target ) func is_pantry_crisis_running() -> bool: return _demo_running func _has_demo_dependencies() -> bool: return ( simulation_manager != null and world_view_manager != null and camera_rig != null and pantry_storage != null and crisis_caption != null and "npcs" in simulation_manager and "active_npc_visuals" in world_view_manager ) func _park_npc_for_demo(npc: SimNPC) -> void: var previous_task := npc.current_task simulation_manager.release_npc_reservation(npc.id) npc.set_task(SimulationIds.ACTION_WANDER, 1000.0) npc.start_working() simulation_manager.npc_task_changed.emit(npc, previous_task, npc.current_task) func _place_npc_for_demo(npc: SimNPC, position: Vector3) -> void: var visual := world_view_manager.active_npc_visuals.get(npc.id) as Node3D if visual == null: return if visual.has_method("stop_travel"): visual.stop_travel() visual.global_position = position simulation_manager.synchronize_npc_position(npc.id, position) func _set_demo_needs(npc: SimNPC, hunger: float, starving: bool) -> void: npc.hunger = hunger npc.energy = 90.0 npc.is_starving = starving npc.starvation_ticks = 0 func _focus_camera_on_pantry(interested_id: int) -> void: var interested_visual := world_view_manager.active_npc_visuals.get(interested_id) as Node3D if interested_visual == null: return camera_rig.target = interested_visual camera_rig.pitch_degrees = -44.0 camera_rig.apply_presentation_preset( pantry_storage.global_position + Vector3(0.0, 0.9, 0.5), 128.0, 0.28 ) func _focus_camera_on_village() -> void: camera_rig.target = player camera_rig.pitch_degrees = -42.0 camera_rig.apply_presentation_preset(Vector3(-1.5, 1.2, -4.0), 128.0, 0.7) func _focus_camera_on_npc(npc_id: int) -> void: var visual := world_view_manager.active_npc_visuals.get(npc_id) as Node3D if visual != null: camera_rig.target = visual func _show_caption(message: String) -> void: if crisis_caption == null: return if _caption_tween != null: _caption_tween.kill() crisis_caption.text = message crisis_caption.visible = true crisis_caption.modulate.a = 0.0 _caption_tween = create_tween() _caption_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) _caption_tween.tween_property(crisis_caption, "modulate:a", 1.0, 0.28) func _hide_caption() -> void: if crisis_caption == null: return crisis_caption.visible = false crisis_caption.modulate.a = 1.0 func _fail_pantry_crisis(reason: String) -> void: _demo_running = false _show_caption("The food route was interrupted.") push_error("DemoController: %s" % reason) pantry_crisis_failed.emit(reason) func _get_npc(npc_id: int) -> SimNPC: for npc in simulation_manager.npcs: if npc.id == npc_id: return npc return null func _apply_debug_overlay_visibility() -> void: if ui != null and ui.has_method("set_debug_overlay_visible"): ui.set_debug_overlay_visible(debug_overlay_visible) for node in ResourceNode.get_all(): if node.has_method("set_debug_label_enabled"): node.set_debug_label_enabled(debug_overlay_visible) for node in StorageNode.get_all(): if node.has_method("set_debug_label_enabled"): node.set_debug_label_enabled(debug_overlay_visible) for node in ActivitySite.get_all(): if node.has_method("set_debug_label_enabled"): node.set_debug_label_enabled(debug_overlay_visible) if active_npcs_parent == null: return for child in active_npcs_parent.get_children(): if child.has_method("set_debug_overlay_visible"): child.set_debug_overlay_visible(debug_overlay_visible)