feat: add playable generated dialogue mode
This commit is contained in:
@@ -7,6 +7,7 @@ const KIND_PANTRY := &"pantry"
|
|||||||
const KIND_STORAGE_DEPOSIT := &"storage_deposit"
|
const KIND_STORAGE_DEPOSIT := &"storage_deposit"
|
||||||
const KIND_GUARD := &"guard"
|
const KIND_GUARD := &"guard"
|
||||||
const KIND_STUDY := &"study"
|
const KIND_STUDY := &"study"
|
||||||
|
const KIND_DIALOGUE := &"dialogue"
|
||||||
|
|
||||||
var kind: StringName
|
var kind: StringName
|
||||||
var action_id: StringName
|
var action_id: StringName
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ func _physics_process(delta: float) -> void:
|
|||||||
if simulation_manager != null and simulation_manager.has_method("update_player_combatant"):
|
if simulation_manager != null and simulation_manager.has_method("update_player_combatant"):
|
||||||
simulation_manager.update_player_combatant(player.global_position)
|
simulation_manager.update_player_combatant(player.global_position)
|
||||||
_advance_timers(delta)
|
_advance_timers(delta)
|
||||||
|
if _input_is_locked():
|
||||||
|
dash_duration_remaining = 0.0
|
||||||
|
dashing = false
|
||||||
|
dash_velocity = Vector3.ZERO
|
||||||
|
return
|
||||||
if _player_is_downed():
|
if _player_is_downed():
|
||||||
attack_timer = 0.0
|
attack_timer = 0.0
|
||||||
dash_duration_remaining = 0.0
|
dash_duration_remaining = 0.0
|
||||||
@@ -81,7 +86,7 @@ func _player_is_downed() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func _perform_attack() -> void:
|
func _perform_attack() -> void:
|
||||||
if attack_timer > 0.0 or not _simulation_attack_ready():
|
if _input_is_locked() or attack_timer > 0.0 or not _simulation_attack_ready():
|
||||||
return
|
return
|
||||||
attack_timer = attack_cooldown_seconds
|
attack_timer = attack_cooldown_seconds
|
||||||
_play_swing()
|
_play_swing()
|
||||||
@@ -100,7 +105,7 @@ func _perform_attack() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _perform_dash() -> void:
|
func _perform_dash() -> void:
|
||||||
if dash_cooldown_remaining > 0.0 or _player_is_downed():
|
if _input_is_locked() or dash_cooldown_remaining > 0.0 or _player_is_downed():
|
||||||
return
|
return
|
||||||
dash_duration_remaining = dash_duration
|
dash_duration_remaining = dash_duration
|
||||||
dash_cooldown_remaining = maxf(dash_cooldown_seconds, dash_duration)
|
dash_cooldown_remaining = maxf(dash_cooldown_seconds, dash_duration)
|
||||||
@@ -144,7 +149,15 @@ func is_dashing() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func get_dash_velocity() -> Vector3:
|
func get_dash_velocity() -> Vector3:
|
||||||
return dash_velocity if dashing else Vector3.ZERO
|
return dash_velocity if dashing and not _input_is_locked() else Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _input_is_locked() -> bool:
|
||||||
|
return (
|
||||||
|
player != null
|
||||||
|
and player.has_method("is_dialogue_input_locked")
|
||||||
|
and bool(player.call("is_dialogue_input_locked"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func get_dash_cooldown_remaining() -> float:
|
func get_dash_cooldown_remaining() -> float:
|
||||||
|
|||||||
+77
-2
@@ -2,6 +2,8 @@ extends CharacterBody3D
|
|||||||
|
|
||||||
signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
||||||
|
|
||||||
|
const DIALOGUE_MODE_CONTROLLER_SCRIPT := preload("res://world/dialogue/DialogueModeController.gd")
|
||||||
|
|
||||||
@export var move_speed := 7.0
|
@export var move_speed := 7.0
|
||||||
@export var acceleration := 18.0
|
@export var acceleration := 18.0
|
||||||
@export var rotation_speed := 12.0
|
@export var rotation_speed := 12.0
|
||||||
@@ -21,12 +23,24 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
|||||||
|
|
||||||
@onready var combat_controller: Node = get_node_or_null("PlayerCombatController")
|
@onready var combat_controller: Node = get_node_or_null("PlayerCombatController")
|
||||||
|
|
||||||
|
var dialogue_mode_controller: DialogueModeController
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("grass_interactors")
|
add_to_group("grass_interactors")
|
||||||
|
_install_dialogue_mode_controller()
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
|
if is_dialogue_input_locked():
|
||||||
|
velocity.x = move_toward(velocity.x, 0.0, acceleration * delta)
|
||||||
|
velocity.z = move_toward(velocity.z, 0.0, acceleration * delta)
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= 30.0 * delta
|
||||||
|
else:
|
||||||
|
velocity.y = 0.0
|
||||||
|
move_and_slide()
|
||||||
|
return
|
||||||
if _is_player_downed():
|
if _is_player_downed():
|
||||||
velocity.x = move_toward(velocity.x, 0.0, acceleration * delta)
|
velocity.x = move_toward(velocity.x, 0.0, acceleration * delta)
|
||||||
velocity.z = move_toward(velocity.z, 0.0, acceleration * delta)
|
velocity.z = move_toward(velocity.z, 0.0, acceleration * delta)
|
||||||
@@ -79,16 +93,26 @@ func _is_player_downed() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event.is_action_pressed("interact"):
|
if is_dialogue_input_locked():
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
event.is_action_pressed("interact")
|
||||||
|
or (event.is_action_pressed("ui_accept") and _get_nearby_dialogue_npc_id() >= 0)
|
||||||
|
):
|
||||||
try_interact()
|
try_interact()
|
||||||
|
|
||||||
|
|
||||||
func try_interact() -> void:
|
func try_interact() -> void:
|
||||||
|
if is_dialogue_input_locked():
|
||||||
|
return
|
||||||
var context := get_interaction_context()
|
var context := get_interaction_context()
|
||||||
if context == null:
|
if context == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
match context.kind:
|
match context.kind:
|
||||||
|
PlayerInteractionResult.KIND_DIALOGUE:
|
||||||
|
if dialogue_mode_controller != null:
|
||||||
|
dialogue_mode_controller.begin_conversation(int(String(context.target_id)))
|
||||||
PlayerInteractionResult.KIND_ANIMAL:
|
PlayerInteractionResult.KIND_ANIMAL:
|
||||||
_execute_animal_interaction(context)
|
_execute_animal_interaction(context)
|
||||||
PlayerInteractionResult.KIND_RESOURCE:
|
PlayerInteractionResult.KIND_RESOURCE:
|
||||||
@@ -110,8 +134,19 @@ func try_interact() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func get_interaction_context() -> PlayerInteractionResult:
|
func get_interaction_context() -> PlayerInteractionResult:
|
||||||
if simulation_manager == null:
|
if simulation_manager == null or is_dialogue_input_locked():
|
||||||
return null
|
return null
|
||||||
|
var villager := _find_npc(_get_nearby_dialogue_npc_id())
|
||||||
|
if villager != null:
|
||||||
|
return PlayerInteractionResult.new(
|
||||||
|
PlayerInteractionResult.KIND_DIALOGUE,
|
||||||
|
DialogueModeController.TALK_ACTION_ID,
|
||||||
|
StringName(str(villager.id)),
|
||||||
|
villager.npc_name,
|
||||||
|
"Talk with %s" % villager.npc_name,
|
||||||
|
"Ask what is happening nearby",
|
||||||
|
null
|
||||||
|
)
|
||||||
var animal := _find_feed_animal()
|
var animal := _find_feed_animal()
|
||||||
if animal != null:
|
if animal != null:
|
||||||
return _build_animal_context(animal)
|
return _build_animal_context(animal)
|
||||||
@@ -148,6 +183,46 @@ func get_interaction_context() -> PlayerInteractionResult:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func is_dialogue_input_locked() -> bool:
|
||||||
|
if (
|
||||||
|
simulation_manager != null
|
||||||
|
and simulation_manager.has_method("is_dialogue_active")
|
||||||
|
and bool(simulation_manager.call("is_dialogue_active"))
|
||||||
|
):
|
||||||
|
return true
|
||||||
|
return (
|
||||||
|
dialogue_mode_controller != null
|
||||||
|
and is_instance_valid(dialogue_mode_controller)
|
||||||
|
and dialogue_mode_controller.is_dialogue_active()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _install_dialogue_mode_controller() -> void:
|
||||||
|
var existing := get_node_or_null("DialogueModeController") as DialogueModeController
|
||||||
|
if existing != null:
|
||||||
|
dialogue_mode_controller = existing
|
||||||
|
else:
|
||||||
|
dialogue_mode_controller = DIALOGUE_MODE_CONTROLLER_SCRIPT.new()
|
||||||
|
dialogue_mode_controller.name = "DialogueModeController"
|
||||||
|
dialogue_mode_controller.configure(self, simulation_manager, world_view_manager)
|
||||||
|
add_child(dialogue_mode_controller)
|
||||||
|
if existing != null:
|
||||||
|
dialogue_mode_controller.configure(self, simulation_manager, world_view_manager)
|
||||||
|
|
||||||
|
|
||||||
|
func _get_nearby_dialogue_npc_id() -> int:
|
||||||
|
if (
|
||||||
|
world_view_manager == null
|
||||||
|
or not world_view_manager.has_method("find_nearest_active_npc_id")
|
||||||
|
):
|
||||||
|
return -1
|
||||||
|
return int(
|
||||||
|
world_view_manager.call(
|
||||||
|
"find_nearest_active_npc_id", global_position, villager_inspection_range
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func get_nearby_villager_inspection() -> VillagerInspectionResult:
|
func get_nearby_villager_inspection() -> VillagerInspectionResult:
|
||||||
if (
|
if (
|
||||||
simulation_manager == null
|
simulation_manager == null
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cmdvv4u3i6trp
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://4ty0jd5lvj0h
|
||||||
@@ -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
|
||||||
@@ -94,13 +94,14 @@ func _run() -> void:
|
|||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
pantry_context != null
|
pantry_context != null
|
||||||
and pantry_context.kind == PlayerInteractionResult.KIND_PANTRY
|
and pantry_context.kind == PlayerInteractionResult.KIND_DIALOGUE
|
||||||
and pantry_context.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY
|
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 pantry_context.prompt_text in interaction_action.text
|
||||||
and inspection != null
|
and inspection != null
|
||||||
and field_note.visible
|
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():
|
if inspection == null or labels.is_empty():
|
||||||
_finish()
|
_finish()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
var simulation_manager := main_scene.get_node("SimulationManager")
|
var simulation_manager := main_scene.get_node("SimulationManager")
|
||||||
var player := main_scene.get_node("Player")
|
var player := main_scene.get_node("Player")
|
||||||
|
player.villager_inspection_range = 0.0
|
||||||
var bush := (
|
var bush := (
|
||||||
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
|
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
class_name DialogueModeController
|
||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
signal mode_started(conversation_id: StringName, revision: int)
|
||||||
|
signal turn_presented(conversation_id: StringName, revision: int)
|
||||||
|
signal mode_ended(conversation_id: StringName)
|
||||||
|
signal presentation_failed(reason: StringName)
|
||||||
|
|
||||||
|
const TALK_ACTION_ID := &"talk"
|
||||||
|
const MIN_PANEL_WIDTH := 280.0
|
||||||
|
const MAX_PANEL_WIDTH := 760.0
|
||||||
|
const MIN_PANEL_HEIGHT := 210.0
|
||||||
|
const MAX_PANEL_HEIGHT := 460.0
|
||||||
|
|
||||||
|
var _player: Node3D
|
||||||
|
var _simulation_manager: Node
|
||||||
|
var _world_view_manager: Node
|
||||||
|
var _presenter: ConversationPresenter
|
||||||
|
var _dialogue_runtime: Object
|
||||||
|
|
||||||
|
var _conversation_id: StringName
|
||||||
|
var _presented_turn: ConversationTurn
|
||||||
|
var _presentation_request := 0
|
||||||
|
var _presented_option_ids: Array[StringName] = []
|
||||||
|
var _option_buttons: Array[Button] = []
|
||||||
|
var _focused_option_index := -1
|
||||||
|
var _mapping_valid := false
|
||||||
|
var _mouse_mode_before_dialogue := Input.MOUSE_MODE_CAPTURED
|
||||||
|
var _owns_visible_mouse := false
|
||||||
|
|
||||||
|
var _root_control: Control
|
||||||
|
var _backdrop: ColorRect
|
||||||
|
var _panel: PanelContainer
|
||||||
|
var _speaker_label: Label
|
||||||
|
var _line_label: Label
|
||||||
|
var _options_scroll: ScrollContainer
|
||||||
|
var _options_box: VBoxContainer
|
||||||
|
var _status_label: Label
|
||||||
|
var _close_button: Button
|
||||||
|
|
||||||
|
|
||||||
|
func configure(
|
||||||
|
player: Node3D,
|
||||||
|
simulation_manager: Node,
|
||||||
|
world_view_manager: Node,
|
||||||
|
presenter: ConversationPresenter = null,
|
||||||
|
dialogue_runtime: Object = null
|
||||||
|
) -> void:
|
||||||
|
_player = player
|
||||||
|
_simulation_manager = simulation_manager
|
||||||
|
_world_view_manager = world_view_manager
|
||||||
|
_presenter = presenter
|
||||||
|
_dialogue_runtime = dialogue_runtime
|
||||||
|
_connect_manager_signals()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 20
|
||||||
|
_build_balloon()
|
||||||
|
_resolve_dependencies()
|
||||||
|
_connect_manager_signals()
|
||||||
|
set_process_input(false)
|
||||||
|
_root_control.visible = false
|
||||||
|
get_viewport().size_changed.connect(_layout_balloon)
|
||||||
|
_layout_balloon()
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
_presentation_request += 1
|
||||||
|
if (
|
||||||
|
is_dialogue_active()
|
||||||
|
and _simulation_manager != null
|
||||||
|
and is_instance_valid(_simulation_manager)
|
||||||
|
and _simulation_manager.has_method("end_player_conversation")
|
||||||
|
):
|
||||||
|
_simulation_manager.call(
|
||||||
|
"end_player_conversation", _conversation_id, get_presented_revision()
|
||||||
|
)
|
||||||
|
_restore_mouse_mode()
|
||||||
|
|
||||||
|
|
||||||
|
func begin_nearest_conversation() -> bool:
|
||||||
|
if _player == null or not is_instance_valid(_player):
|
||||||
|
return false
|
||||||
|
return begin_conversation(_nearest_active_npc_id())
|
||||||
|
|
||||||
|
|
||||||
|
func begin_conversation(npc_id: int) -> bool:
|
||||||
|
_resolve_dependencies()
|
||||||
|
if (
|
||||||
|
npc_id < 0
|
||||||
|
or is_dialogue_active()
|
||||||
|
or _simulation_manager == null
|
||||||
|
or not _simulation_manager.has_method("begin_player_conversation")
|
||||||
|
or npc_id != _nearest_active_npc_id()
|
||||||
|
):
|
||||||
|
return false
|
||||||
|
var started_id := StringName(_simulation_manager.call("begin_player_conversation", npc_id))
|
||||||
|
return not started_id.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func close_conversation() -> bool:
|
||||||
|
if not is_dialogue_active():
|
||||||
|
return false
|
||||||
|
if (
|
||||||
|
_simulation_manager == null
|
||||||
|
or not is_instance_valid(_simulation_manager)
|
||||||
|
or not _simulation_manager.has_method("end_player_conversation")
|
||||||
|
):
|
||||||
|
_close_local(_conversation_id)
|
||||||
|
return false
|
||||||
|
var expected_revision := get_presented_revision()
|
||||||
|
var ended := bool(
|
||||||
|
_simulation_manager.call("end_player_conversation", _conversation_id, expected_revision)
|
||||||
|
)
|
||||||
|
if not ended:
|
||||||
|
_show_status("The conversation changed. Choose again or press close once more.", true)
|
||||||
|
return ended
|
||||||
|
|
||||||
|
|
||||||
|
func select_option(option_id: StringName) -> bool:
|
||||||
|
if (
|
||||||
|
not is_dialogue_active()
|
||||||
|
or not _mapping_valid
|
||||||
|
or _presented_turn == null
|
||||||
|
or not _presented_option_ids.has(option_id)
|
||||||
|
or _simulation_manager == null
|
||||||
|
or not _simulation_manager.has_method("select_player_conversation_option")
|
||||||
|
):
|
||||||
|
return false
|
||||||
|
var option := _presented_turn.get_option(option_id)
|
||||||
|
if option == null or not option.is_enabled():
|
||||||
|
return false
|
||||||
|
var expected_revision := _presented_turn.get_revision()
|
||||||
|
_set_options_enabled(false)
|
||||||
|
var result := (
|
||||||
|
_simulation_manager.call(
|
||||||
|
"select_player_conversation_option", _conversation_id, option_id, expected_revision
|
||||||
|
)
|
||||||
|
as ConversationSelectionResult
|
||||||
|
)
|
||||||
|
if result == null:
|
||||||
|
_show_status("That response is unavailable.", true)
|
||||||
|
_set_options_enabled(true)
|
||||||
|
return false
|
||||||
|
if result.was_accepted():
|
||||||
|
return true
|
||||||
|
var current_turn := result.get_turn()
|
||||||
|
if current_turn != null and current_turn.is_valid():
|
||||||
|
_show_status("The conversation changed; choices were refreshed.", false)
|
||||||
|
_queue_turn(current_turn)
|
||||||
|
else:
|
||||||
|
_show_status("That response is no longer available.", true)
|
||||||
|
_set_options_enabled(true)
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func is_dialogue_active() -> bool:
|
||||||
|
return not _conversation_id.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func get_conversation_id() -> StringName:
|
||||||
|
return _conversation_id
|
||||||
|
|
||||||
|
|
||||||
|
func get_presented_revision() -> int:
|
||||||
|
return _presented_turn.get_revision() if _presented_turn != null else -1
|
||||||
|
|
||||||
|
|
||||||
|
func get_presented_option_ids() -> Array[StringName]:
|
||||||
|
return _presented_option_ids.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
func get_presented_line() -> String:
|
||||||
|
return _line_label.text if _line_label != null else ""
|
||||||
|
|
||||||
|
|
||||||
|
func get_presented_speaker() -> String:
|
||||||
|
return _speaker_label.text if _speaker_label != null else ""
|
||||||
|
|
||||||
|
|
||||||
|
func has_valid_option_mapping() -> bool:
|
||||||
|
return _mapping_valid
|
||||||
|
|
||||||
|
|
||||||
|
func get_focused_option_id() -> StringName:
|
||||||
|
if _focused_option_index < 0 or _focused_option_index >= _presented_option_ids.size():
|
||||||
|
return &""
|
||||||
|
return _presented_option_ids[_focused_option_index]
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if not is_dialogue_active() or event.is_echo():
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
close_conversation()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("ui_down"):
|
||||||
|
_move_option_focus(1)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("ui_up"):
|
||||||
|
_move_option_focus(-1)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("ui_accept") and _focused_option_index >= 0:
|
||||||
|
select_option(_presented_option_ids[_focused_option_index])
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_dependencies() -> void:
|
||||||
|
if _simulation_manager == null and is_inside_tree():
|
||||||
|
_simulation_manager = get_tree().get_first_node_in_group("simulation_manager")
|
||||||
|
if _dialogue_runtime == null and is_inside_tree():
|
||||||
|
_dialogue_runtime = get_tree().root.get_node_or_null("DialogueManager")
|
||||||
|
if _presenter == null:
|
||||||
|
_presenter = DialogueManagerConversationPresenter.new(_dialogue_runtime)
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_manager_signals() -> void:
|
||||||
|
if _simulation_manager == null or not is_instance_valid(_simulation_manager):
|
||||||
|
return
|
||||||
|
_connect_once(&"conversation_started", _on_conversation_started)
|
||||||
|
_connect_once(&"conversation_turn_changed", _on_conversation_turn_changed)
|
||||||
|
_connect_once(&"conversation_ended", _on_conversation_ended)
|
||||||
|
_connect_once(&"state_restored", _on_state_restored)
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_once(signal_name: StringName, callback: Callable) -> void:
|
||||||
|
if not _simulation_manager.has_signal(signal_name):
|
||||||
|
return
|
||||||
|
if not _simulation_manager.is_connected(signal_name, callback):
|
||||||
|
_simulation_manager.connect(signal_name, callback)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_conversation_started(conversation_id: StringName, turn: ConversationTurn) -> void:
|
||||||
|
if turn == null or not turn.is_valid() or turn.get_conversation_id() != conversation_id:
|
||||||
|
return
|
||||||
|
_conversation_id = conversation_id
|
||||||
|
_capture_mouse_mode()
|
||||||
|
_root_control.visible = true
|
||||||
|
set_process_input(true)
|
||||||
|
mode_started.emit(conversation_id, turn.get_revision())
|
||||||
|
_queue_turn(turn)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_conversation_turn_changed(conversation_id: StringName, turn: ConversationTurn) -> void:
|
||||||
|
if conversation_id != _conversation_id or turn == null or not turn.is_valid():
|
||||||
|
return
|
||||||
|
_queue_turn(turn)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_conversation_ended(conversation_id: StringName) -> void:
|
||||||
|
if conversation_id == _conversation_id:
|
||||||
|
_close_local(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_state_restored() -> void:
|
||||||
|
if is_dialogue_active():
|
||||||
|
_close_local(_conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _queue_turn(turn: ConversationTurn) -> void:
|
||||||
|
_presentation_request += 1
|
||||||
|
var request := _presentation_request
|
||||||
|
_presented_turn = turn.copy()
|
||||||
|
_mapping_valid = false
|
||||||
|
_presented_option_ids.clear()
|
||||||
|
_clear_option_buttons()
|
||||||
|
_speaker_label.text = "Conversation"
|
||||||
|
_line_label.text = "Listening…"
|
||||||
|
_show_status("", false)
|
||||||
|
call_deferred("_present_turn", _presented_turn.copy(), request)
|
||||||
|
|
||||||
|
|
||||||
|
func _present_turn(turn: ConversationTurn, request: int) -> void:
|
||||||
|
_resolve_dependencies()
|
||||||
|
if request != _presentation_request or not is_dialogue_active():
|
||||||
|
return
|
||||||
|
if _presenter == null or _dialogue_runtime == null:
|
||||||
|
_fail_presentation(&"runtime_unavailable")
|
||||||
|
return
|
||||||
|
var presentation_context := _build_presentation_context(turn)
|
||||||
|
var resource: Variant = _presenter.present_turn(turn, presentation_context)
|
||||||
|
if not _resource_matches_turn(resource, turn):
|
||||||
|
_release_ephemeral_resource(resource, null)
|
||||||
|
_fail_presentation(&"resource_mismatch")
|
||||||
|
return
|
||||||
|
var line: Variant = await _dialogue_runtime.call("get_next_dialogue_line", resource, "start")
|
||||||
|
if request != _presentation_request or not is_dialogue_active():
|
||||||
|
_release_ephemeral_resource(resource, line)
|
||||||
|
return
|
||||||
|
if not _line_matches_turn(line, turn):
|
||||||
|
_release_ephemeral_resource(resource, line)
|
||||||
|
_fail_presentation(&"line_mismatch")
|
||||||
|
return
|
||||||
|
var mapped := _copy_mapped_responses(line, turn)
|
||||||
|
var speaker_text := String(line.get("character"))
|
||||||
|
var line_text := String(line.get("text"))
|
||||||
|
_release_ephemeral_resource(resource, line)
|
||||||
|
if mapped.is_empty() and not turn.is_terminal():
|
||||||
|
_fail_presentation(&"option_mapping_invalid")
|
||||||
|
return
|
||||||
|
_speaker_label.text = speaker_text if not speaker_text.is_empty() else "Villager"
|
||||||
|
_line_label.text = line_text
|
||||||
|
for copied_response in mapped:
|
||||||
|
_add_option_button(
|
||||||
|
StringName(copied_response["option_id"]), String(copied_response["text"])
|
||||||
|
)
|
||||||
|
_mapping_valid = true
|
||||||
|
_focus_option(0)
|
||||||
|
turn_presented.emit(_conversation_id, turn.get_revision())
|
||||||
|
|
||||||
|
|
||||||
|
func _resource_matches_turn(resource: Variant, turn: ConversationTurn) -> bool:
|
||||||
|
return (
|
||||||
|
resource is Resource
|
||||||
|
and resource.has_meta("conversation_id")
|
||||||
|
and resource.has_meta("conversation_revision")
|
||||||
|
and String(resource.get_meta("conversation_id")) == String(turn.get_conversation_id())
|
||||||
|
and int(resource.get_meta("conversation_revision")) == turn.get_revision()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _line_matches_turn(line: Variant, turn: ConversationTurn) -> bool:
|
||||||
|
return (
|
||||||
|
line is Object
|
||||||
|
and line.has_method("get_tag_value")
|
||||||
|
and line.get("responses") is Array
|
||||||
|
and (
|
||||||
|
String(line.call("get_tag_value", "conversation_id"))
|
||||||
|
== String(turn.get_conversation_id())
|
||||||
|
)
|
||||||
|
and int(String(line.call("get_tag_value", "revision"))) == turn.get_revision()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _copy_mapped_responses(line: Variant, turn: ConversationTurn) -> Array[Dictionary]:
|
||||||
|
var expected_ids: Array[StringName] = []
|
||||||
|
for option in turn.get_options():
|
||||||
|
if option.is_enabled():
|
||||||
|
expected_ids.append(option.get_option_id())
|
||||||
|
var mapped: Array[Dictionary] = []
|
||||||
|
var seen: Dictionary = {}
|
||||||
|
for response in line.get("responses"):
|
||||||
|
if response is not Object or not response.has_method("get_tag_value"):
|
||||||
|
return []
|
||||||
|
var option_id := StringName(response.call("get_tag_value", "option_id"))
|
||||||
|
var option := turn.get_option(option_id)
|
||||||
|
if (
|
||||||
|
option == null
|
||||||
|
or not option.is_enabled()
|
||||||
|
or seen.has(option_id)
|
||||||
|
or String(response.call("get_tag_value", "enabled")) != "true"
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
seen[option_id] = true
|
||||||
|
mapped.append({"option_id": option_id, "text": String(response.get("text"))})
|
||||||
|
if mapped.size() != expected_ids.size():
|
||||||
|
return []
|
||||||
|
for expected_id in expected_ids:
|
||||||
|
if not seen.has(expected_id):
|
||||||
|
return []
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
func _build_presentation_context(turn: ConversationTurn) -> Dictionary:
|
||||||
|
var entity_names := {}
|
||||||
|
for entity in [turn.get_speaker(), turn.get_listener()]:
|
||||||
|
var label := _entity_display_name(entity)
|
||||||
|
entity_names[String(entity.get_entity_id())] = label
|
||||||
|
entity_names[entity.index_key()] = label
|
||||||
|
var topic_labels := {}
|
||||||
|
for topic_id in turn.get_causal_topic_ids():
|
||||||
|
topic_labels[String(topic_id)] = String(topic_id).replace("_", " ")
|
||||||
|
return {"entity_names": entity_names, "topic_labels": topic_labels}
|
||||||
|
|
||||||
|
|
||||||
|
func _entity_display_name(entity: WorldEntityRef) -> String:
|
||||||
|
if String(entity.get_entity_type()) == "player":
|
||||||
|
return "You"
|
||||||
|
var entity_id := String(entity.get_entity_id())
|
||||||
|
if String(entity.get_entity_type()) == "npc" and entity_id.is_valid_int():
|
||||||
|
var npc_name := _npc_name(int(entity_id))
|
||||||
|
if not npc_name.is_empty():
|
||||||
|
return npc_name
|
||||||
|
return entity_id.replace("_", " ").capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
func _npc_name(npc_id: int) -> String:
|
||||||
|
if _simulation_manager == null or not ("npcs" in _simulation_manager):
|
||||||
|
return ""
|
||||||
|
for npc in _simulation_manager.get("npcs"):
|
||||||
|
if int(npc.get("id")) == npc_id:
|
||||||
|
return String(npc.get("npc_name"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_active_npc_id() -> int:
|
||||||
|
if (
|
||||||
|
_player == null
|
||||||
|
or _world_view_manager == null
|
||||||
|
or not _world_view_manager.has_method("find_nearest_active_npc_id")
|
||||||
|
):
|
||||||
|
return -1
|
||||||
|
var maximum_distance := float(_player.get("villager_inspection_range"))
|
||||||
|
return int(
|
||||||
|
_world_view_manager.call(
|
||||||
|
"find_nearest_active_npc_id", _player.global_position, maximum_distance
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _fail_presentation(reason: StringName) -> void:
|
||||||
|
_mapping_valid = false
|
||||||
|
_clear_option_buttons()
|
||||||
|
_speaker_label.text = "Conversation unavailable"
|
||||||
|
_line_label.text = "The dialogue could not be displayed safely."
|
||||||
|
_show_status("Close and try speaking again.", true)
|
||||||
|
presentation_failed.emit(reason)
|
||||||
|
|
||||||
|
|
||||||
|
func _close_local(conversation_id: StringName) -> void:
|
||||||
|
_presentation_request += 1
|
||||||
|
_conversation_id = &""
|
||||||
|
_presented_turn = null
|
||||||
|
_presented_option_ids.clear()
|
||||||
|
_mapping_valid = false
|
||||||
|
_focused_option_index = -1
|
||||||
|
_clear_option_buttons()
|
||||||
|
set_process_input(false)
|
||||||
|
if _root_control != null:
|
||||||
|
_root_control.visible = false
|
||||||
|
_restore_mouse_mode()
|
||||||
|
mode_ended.emit(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _capture_mouse_mode() -> void:
|
||||||
|
_mouse_mode_before_dialogue = Input.mouse_mode
|
||||||
|
_owns_visible_mouse = Input.mouse_mode != Input.MOUSE_MODE_VISIBLE
|
||||||
|
if _owns_visible_mouse:
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
|
||||||
|
|
||||||
|
func _restore_mouse_mode() -> void:
|
||||||
|
if _owns_visible_mouse and Input.mouse_mode == Input.MOUSE_MODE_VISIBLE:
|
||||||
|
Input.mouse_mode = _mouse_mode_before_dialogue
|
||||||
|
_owns_visible_mouse = false
|
||||||
|
|
||||||
|
|
||||||
|
func _release_ephemeral_resource(resource: Variant, line: Variant) -> void:
|
||||||
|
if line is Object:
|
||||||
|
var extra_states: Variant = line.get("extra_game_states")
|
||||||
|
if extra_states is Array:
|
||||||
|
extra_states.clear()
|
||||||
|
var responses: Variant = line.get("responses")
|
||||||
|
if responses is Array:
|
||||||
|
responses.clear()
|
||||||
|
if resource is Resource:
|
||||||
|
var lines: Variant = resource.get("lines")
|
||||||
|
if lines is Dictionary:
|
||||||
|
resource.set("lines", {})
|
||||||
|
resource.set_script(null)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_balloon() -> void:
|
||||||
|
_root_control = Control.new()
|
||||||
|
_root_control.name = "DialogueBalloon"
|
||||||
|
_root_control.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root_control.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
add_child(_root_control)
|
||||||
|
|
||||||
|
_backdrop = ColorRect.new()
|
||||||
|
_backdrop.name = "Backdrop"
|
||||||
|
_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_backdrop.color = Color(0.025, 0.018, 0.012, 0.28)
|
||||||
|
_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
_root_control.add_child(_backdrop)
|
||||||
|
|
||||||
|
_panel = PanelContainer.new()
|
||||||
|
_panel.name = "Panel"
|
||||||
|
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
var panel_style := StyleBoxFlat.new()
|
||||||
|
panel_style.bg_color = Color(0.075, 0.055, 0.037, 0.97)
|
||||||
|
panel_style.border_color = Color(0.75, 0.52, 0.25, 0.92)
|
||||||
|
panel_style.set_border_width_all(2)
|
||||||
|
panel_style.set_corner_radius_all(10)
|
||||||
|
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.45)
|
||||||
|
panel_style.shadow_size = 8
|
||||||
|
_panel.add_theme_stylebox_override("panel", panel_style)
|
||||||
|
_root_control.add_child(_panel)
|
||||||
|
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 18)
|
||||||
|
margin.add_theme_constant_override("margin_top", 14)
|
||||||
|
margin.add_theme_constant_override("margin_right", 18)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
_panel.add_child(margin)
|
||||||
|
|
||||||
|
var content := VBoxContainer.new()
|
||||||
|
content.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(content)
|
||||||
|
|
||||||
|
_speaker_label = Label.new()
|
||||||
|
_speaker_label.name = "Speaker"
|
||||||
|
_speaker_label.add_theme_color_override("font_color", Color(0.95, 0.72, 0.38))
|
||||||
|
_speaker_label.add_theme_font_size_override("font_size", 18)
|
||||||
|
content.add_child(_speaker_label)
|
||||||
|
|
||||||
|
_line_label = Label.new()
|
||||||
|
_line_label.name = "Line"
|
||||||
|
_line_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_line_label.add_theme_color_override("font_color", Color(0.96, 0.91, 0.82))
|
||||||
|
_line_label.add_theme_font_size_override("font_size", 16)
|
||||||
|
_line_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
content.add_child(_line_label)
|
||||||
|
|
||||||
|
_options_scroll = ScrollContainer.new()
|
||||||
|
_options_scroll.name = "OptionsScroll"
|
||||||
|
_options_scroll.custom_minimum_size.y = 76.0
|
||||||
|
_options_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
_options_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
content.add_child(_options_scroll)
|
||||||
|
|
||||||
|
_options_box = VBoxContainer.new()
|
||||||
|
_options_box.name = "Options"
|
||||||
|
_options_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_options_box.add_theme_constant_override("separation", 5)
|
||||||
|
_options_scroll.add_child(_options_box)
|
||||||
|
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.name = "Status"
|
||||||
|
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_status_label.add_theme_font_size_override("font_size", 13)
|
||||||
|
content.add_child(_status_label)
|
||||||
|
|
||||||
|
var footer := HBoxContainer.new()
|
||||||
|
footer.alignment = BoxContainer.ALIGNMENT_END
|
||||||
|
content.add_child(footer)
|
||||||
|
|
||||||
|
var hint := Label.new()
|
||||||
|
hint.text = "↑/↓ Navigate · Enter Select · Esc Close"
|
||||||
|
hint.add_theme_color_override("font_color", Color(0.68, 0.62, 0.53))
|
||||||
|
hint.add_theme_font_size_override("font_size", 12)
|
||||||
|
hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
footer.add_child(hint)
|
||||||
|
|
||||||
|
_close_button = Button.new()
|
||||||
|
_close_button.name = "Close"
|
||||||
|
_close_button.text = "Close"
|
||||||
|
_close_button.focus_mode = Control.FOCUS_NONE
|
||||||
|
_close_button.pressed.connect(close_conversation)
|
||||||
|
footer.add_child(_close_button)
|
||||||
|
|
||||||
|
|
||||||
|
func _layout_balloon() -> void:
|
||||||
|
if _panel == null:
|
||||||
|
return
|
||||||
|
var viewport_size := get_viewport().get_visible_rect().size
|
||||||
|
var panel_width := minf(MAX_PANEL_WIDTH, maxf(MIN_PANEL_WIDTH, viewport_size.x - 24.0))
|
||||||
|
var available_height := maxf(viewport_size.y - 24.0, 120.0)
|
||||||
|
var panel_height := minf(
|
||||||
|
MAX_PANEL_HEIGHT, maxf(minf(MIN_PANEL_HEIGHT, available_height), viewport_size.y * 0.5)
|
||||||
|
)
|
||||||
|
panel_height = minf(panel_height, available_height)
|
||||||
|
_panel.size = Vector2(panel_width, panel_height)
|
||||||
|
_panel.position = Vector2(
|
||||||
|
(viewport_size.x - panel_width) * 0.5, maxf(12.0, viewport_size.y - panel_height - 18.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_option_button(option_id: StringName, copy: String) -> void:
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = copy
|
||||||
|
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
button.add_theme_font_size_override("font_size", 15)
|
||||||
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
button.set_meta("option_id", option_id)
|
||||||
|
button.pressed.connect(select_option.bind(option_id))
|
||||||
|
_options_box.add_child(button)
|
||||||
|
_option_buttons.append(button)
|
||||||
|
_presented_option_ids.append(option_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_option_buttons() -> void:
|
||||||
|
for button in _option_buttons:
|
||||||
|
if button != null and is_instance_valid(button):
|
||||||
|
if button.get_parent() != null:
|
||||||
|
button.get_parent().remove_child(button)
|
||||||
|
button.queue_free()
|
||||||
|
_option_buttons.clear()
|
||||||
|
_focused_option_index = -1
|
||||||
|
|
||||||
|
|
||||||
|
func _set_options_enabled(enabled: bool) -> void:
|
||||||
|
for button in _option_buttons:
|
||||||
|
if button != null and is_instance_valid(button):
|
||||||
|
button.disabled = not enabled
|
||||||
|
|
||||||
|
|
||||||
|
func _move_option_focus(direction: int) -> void:
|
||||||
|
if _option_buttons.is_empty():
|
||||||
|
return
|
||||||
|
_focus_option(posmod(_focused_option_index + direction, _option_buttons.size()))
|
||||||
|
|
||||||
|
|
||||||
|
func _focus_option(index: int) -> void:
|
||||||
|
if _option_buttons.is_empty():
|
||||||
|
_focused_option_index = -1
|
||||||
|
return
|
||||||
|
_focused_option_index = clampi(index, 0, _option_buttons.size() - 1)
|
||||||
|
var button := _option_buttons[_focused_option_index]
|
||||||
|
if button != null and is_instance_valid(button):
|
||||||
|
button.grab_focus()
|
||||||
|
_options_scroll.ensure_control_visible(button)
|
||||||
|
|
||||||
|
|
||||||
|
func _show_status(message: String, is_error: bool) -> void:
|
||||||
|
if _status_label == null:
|
||||||
|
return
|
||||||
|
_status_label.text = message
|
||||||
|
_status_label.visible = not message.is_empty()
|
||||||
|
_status_label.add_theme_color_override(
|
||||||
|
"font_color", Color(0.96, 0.5, 0.36) if is_error else Color(0.82, 0.72, 0.56)
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://chfg7yr7w6trx
|
||||||
Reference in New Issue
Block a user