feat: add playable generated dialogue mode

This commit is contained in:
Rijad Zuzo
2026-08-12 21:16:15 +02:00
parent 6b50580598
commit f8734422a0
11 changed files with 1012 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
uid://cmdvv4u3i6trp
@@ -0,0 +1 @@
uid://4ty0jd5lvj0h
+282
View File
@@ -0,0 +1,282 @@
extends SceneTree
class ForgedResponse:
extends RefCounted
var text := "Forged authority"
func get_tag_value(tag_name: String) -> String:
if tag_name == "option_id":
return "option_not_in_turn"
if tag_name == "enabled":
return "true"
return ""
class ForgedLine:
extends RefCounted
var responses: Array = [ForgedResponse.new()]
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 4:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var world_view: Node = main_scene.get_node("WorldViewManager")
var player := main_scene.get_node("Player") as CharacterBody3D
var combat := player.get_node("PlayerCombatController") as PlayerCombatController
var controller := player.get_node_or_null("DialogueModeController") as DialogueModeController
manager.set_process(false)
player.set_physics_process(false)
combat.set_physics_process(false)
_freeze_and_place_population(manager, world_view, player.global_position)
_check(controller != null, "The player should install one autoload-free dialogue controller")
if controller == null:
_finish()
return
var nearest_id: int = world_view.find_nearest_active_npc_id(
player.global_position, player.villager_inspection_range
)
var context := player.get_interaction_context() as PlayerInteractionResult
_check(
(
nearest_id >= 0
and context != null
and context.kind == PlayerInteractionResult.KIND_DIALOGUE
and context.target_id == StringName(str(nearest_id))
and "Talk" in context.prompt_text
),
"The E interaction should prefer the nearest loaded living villager",
)
var mouse_mode_before_dialogue := Input.mouse_mode
var gamepad_accept := InputEventAction.new()
gamepad_accept.action = &"ui_accept"
gamepad_accept.pressed = true
player.call("_unhandled_input", gamepad_accept)
await _wait_for_presentation(controller)
_check(
(
manager.is_dialogue_active()
and controller.is_dialogue_active()
and player.is_dialogue_input_locked()
and controller.has_valid_option_mapping()
and not controller.get_presented_speaker().is_empty()
and not controller.get_presented_line().is_empty()
and not controller.get_presented_option_ids().is_empty()
),
"A manager-planned turn should render through the pinned Dialogue Manager adapter",
)
_check(
player.get_interaction_context() == null,
"Ordinary world interactions should be unavailable while dialogue owns input",
)
var horizontal_speed_before := 5.0
player.velocity = Vector3(horizontal_speed_before, 0.0, 0.0)
player.call("_physics_process", 0.1)
_check(
absf(player.velocity.x) < horizontal_speed_before,
"Dialogue mode should decelerate the player instead of reading movement input",
)
_check_combat_lock(combat)
_check_navigation(controller)
_check_responsive_layout(controller)
var displayed_revision := controller.get_presented_revision()
var authoritative_turn: ConversationTurn = manager.conversation_service.get_turn(
controller.get_conversation_id()
)
var forged_mapping: Array = controller.call(
"_copy_mapped_responses", ForgedLine.new(), authoritative_turn
)
_check(
forged_mapping.is_empty(),
"Presenter copy must reject response tags that are absent from the planned turn",
)
var advance_option := _first_non_goodbye_option(authoritative_turn)
_check(advance_option != null, "The opening turn should offer a non-terminal response")
if advance_option != null:
var advanced: ConversationSelectionResult = manager.conversation_service.select_option(
controller.get_conversation_id(),
advance_option.get_option_id(),
authoritative_turn.get_revision()
)
_check(advanced.was_accepted(), "The stale-revision setup should advance domain state")
var stale_option_id := controller.get_presented_option_ids()[0]
_check(
not controller.select_option(stale_option_id),
"A displayed response must not apply against a newer authoritative revision",
)
await _wait_for_presentation(controller)
_check(
(
controller.get_presented_revision() == displayed_revision + 1
and controller.has_valid_option_mapping()
),
"A stale selection should refresh from the rejected result's authoritative turn",
)
_check(controller.close_conversation(), "Esc/close should end the active manager conversation")
await process_frame
_check(
(
not manager.is_dialogue_active()
and not controller.is_dialogue_active()
and not player.is_dialogue_input_locked()
and not controller.get_node("DialogueBalloon").visible
and Input.mouse_mode == mouse_mode_before_dialogue
),
"Closing should release presentation and gameplay input without one-way UI state",
)
_check(
controller.begin_conversation(nearest_id),
"The same nearby villager should be talkable again"
)
await _wait_for_presentation(controller)
var goodbye_id := _option_id_for_intent(
manager.conversation_service.get_turn(controller.get_conversation_id()),
ConversationIntentIds.GOODBYE
)
_check(
not goodbye_id.is_empty() and controller.get_presented_option_ids().has(goodbye_id),
"The semantic goodbye option should retain its controlled presenter mapping",
)
if not goodbye_id.is_empty():
_check(controller.select_option(goodbye_id), "Selecting goodbye should be accepted")
await process_frame
_check(
not manager.is_dialogue_active() and not controller.is_dialogue_active(),
"A terminal goodbye should close through the manager's conversation-ended signal",
)
main_scene.queue_free()
await process_frame
_finish()
func _wait_for_presentation(controller: DialogueModeController) -> void:
for _frame in 12:
if controller.has_valid_option_mapping() or not controller.is_dialogue_active():
return
await process_frame
func _freeze_and_place_population(manager: Node, world_view: Node, origin: Vector3) -> void:
for index in manager.npcs.size():
var npc: SimNPC = manager.npcs[index]
var position := origin + Vector3(40.0 + index * 3.0, 0.0, 30.0)
if index == 0:
position = origin + Vector3(1.0, 0.0, 0.0)
manager.synchronize_npc_position(npc.id, position)
var visual := world_view.active_npc_visuals.get(npc.id) as Node3D
if visual != null:
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
visual.global_position = position
func _check_combat_lock(combat: PlayerCombatController) -> void:
combat.dashing = true
combat.dash_velocity = Vector3(18.0, 0.0, 0.0)
var attack_timer_before := combat.attack_timer
combat.call("_perform_attack")
_check(
(
combat.get_dash_velocity() == Vector3.ZERO
and is_equal_approx(combat.attack_timer, attack_timer_before)
),
"Dialogue input ownership should suppress active dashes and new attacks",
)
func _check_navigation(controller: DialogueModeController) -> void:
var option_ids := controller.get_presented_option_ids()
if option_ids.size() < 2:
_check(false, "The navigation proof needs at least two presented responses")
return
var first_id := controller.get_focused_option_id()
var down := InputEventAction.new()
down.action = &"ui_down"
down.pressed = true
controller.call("_input", down)
_check(
controller.get_focused_option_id() != first_id,
"Keyboard/gamepad down should move deterministic option focus",
)
var up := InputEventAction.new()
up.action = &"ui_up"
up.pressed = true
controller.call("_input", up)
_check(
controller.get_focused_option_id() == first_id,
"Keyboard/gamepad up should restore deterministic option focus",
)
func _check_responsive_layout(controller: DialogueModeController) -> void:
var original_size := root.size
root.size = Vector2i(480, 270)
controller.call("_layout_balloon")
var panel := controller.get_node("DialogueBalloon/Panel") as PanelContainer
var viewport_size := controller.get_viewport().get_visible_rect().size
_check(
(
panel.size.x <= minf(760.0, viewport_size.x)
and panel.size.y <= viewport_size.y
and panel.position.x >= 0.0
and panel.position.y >= 0.0
and panel.position.x + panel.size.x <= viewport_size.x + 0.01
and panel.position.y + panel.size.y <= viewport_size.y + 0.01
),
"The custom balloon should remain inside the low-resolution viewport bounds",
)
root.size = original_size
func _first_non_goodbye_option(turn: ConversationTurn) -> ConversationOption:
if turn == null:
return null
for option in turn.get_options():
if option.is_enabled() and option.get_intent_id() != ConversationIntentIds.GOODBYE:
return option
return null
func _option_id_for_intent(turn: ConversationTurn, intent_id: StringName) -> StringName:
if turn == null:
return &""
for option in turn.get_options():
if option.is_enabled() and option.get_intent_id() == intent_id:
return option.get_option_id()
return &""
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Playable Dialogue Manager presentation passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://d1eld8rj11h34
+4 -3
View File
@@ -94,13 +94,14 @@ func _run() -> void:
_check(
(
pantry_context != null
and pantry_context.kind == PlayerInteractionResult.KIND_PANTRY
and pantry_context.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY
and pantry_context.kind == PlayerInteractionResult.KIND_DIALOGUE
and pantry_context.target_id == StringName(str(selected.id))
and "Talk" in pantry_context.prompt_text
and pantry_context.prompt_text in interaction_action.text
and inspection != null
and field_note.visible
),
"The automatic villager field note should coexist with the existing pantry E action",
"The automatic villager field note should coexist with the preferred nearby Talk action",
)
if inspection == null or labels.is_empty():
_finish()
@@ -16,6 +16,7 @@ func _run() -> void:
var simulation_manager := main_scene.get_node("SimulationManager")
var player := main_scene.get_node("Player")
player.villager_inspection_range = 0.0
var bush := (
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
)