Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/development/machinery-interaction-hints-milestone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Machinery interaction hints milestone

The existing card now explains the currently bound mouse selection and cursor controls. Selecting a machine opens Construction for that exact observed assembly without submitting a machine intent. Keyboard/controller bindings truthfully state the existing static-body picking limit and direct the player to Construction's chooser; unbound selection retains that route. Unbound cursor controls describe release-camera or Controls settings routes.

Hints occupy a wrapping fifth row in the existing anchored card; no persistent world labels or new shortcuts are added. The hint is hidden while Construction is visible or GUI/settings owns input. Observed identity, energy, job details and fresh/stale/blocked/paused/outcome-unknown copy remain independent of the hint. The card fits its content height within the scene. Bindings are read live without preference writes.

## Ownership and contracts

MachineryPreview and MachineryContextCard own only local presentation and per-frame hint refresh. Canonical production state remains in ProductionSimulationSystem and persisted records. Tick cadence, schemas, authenticated command validation/transport, pending identities, receipts, persistence, paid resource accounting and collision/mesh construction are unchanged. Existing bounded activity telemetry remains the profiling signal; no new simulation work occurs.

Scope is machinery_context_card.gd, machinery_preview.gd, the existing focused machinery_context_shell_test.gd and this document. Inventory hints PR125, walker PR124, shared bootstrap/input/camera and authority boundaries are outside scope.

## Verification

Godot 4.7.2 on Windows: repository runner import and startup checks, paid no-port actual-client-shell regression and machinery_preview_test pass. The shell uses authenticated real-domain commands to acquire/assemble machines and exercise running, timeout-stale, host-blocked, saved/restored paused and outcome-unknown states. It injects mouse motion/button input through the actual root window and confirms exact-machine Construction selection and zero outbound machine intents. It covers hover/exit/reset/disconnect, GUI focus and suspended input, drawer/tab transitions, live in-memory mouse/keyboard/controller/unbound bindings, canonical snapshot and collider identity/transform/bounds invariance. Narrow widths include 640, 480 and 360 px with full wrapped hint and uncertainty line bounds.

Adjacent machinery_activity_animation_test and integrated_panel_layout_test regressions pass. Windows/Linux PR CI execution and exact tested synthetic merge SHA are pending; the documentation-only initial draft CI is not runtime evidence.

## Limitations

Keyboard/controller world selection currently routes only Area3D; machinery uses StaticBody3D. This PR describes the existing Construction chooser route and does not repair shared picking. Input/cursor descriptions are tested through bindings and injected mouse events; headless checks do not establish native capture, physical controller/VR or human gameplay acceptance. Screenshots are unnecessary where meaningful runtime/layout tests and source review establish this presentation change; no media, ports, releases or deployment are used. This task does not self-merge. Worktree remains active for coordinator review.
11 changes: 7 additions & 4 deletions src/presentation/production/machinery_context_card.gd
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ func _ready() -> void:
var rows := VBoxContainer.new()
rows.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_child(rows)
for index in range(4):
for index in range(5):
var line := Label.new()
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
line.add_theme_font_size_override("font_size", 16)
if index == 3:
if index >= 3:
line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
else:
line.clip_text = true
Expand All @@ -35,10 +35,12 @@ func _ready() -> void:
_lines.append(line)
visible = false

func present(proxy: Node3D, lines: PackedStringArray) -> void:
func present(proxy: Node3D, lines: PackedStringArray, hint: String = "") -> void:
target = proxy
for index in range(_lines.size()):
_lines[index].text = lines[index] if index < lines.size() else ""
_lines[4].text = hint
_lines[4].visible = not hint.is_empty()
_process(0.0)

func _process(_delta: float) -> void:
Expand All @@ -57,7 +59,8 @@ func _process(_delta: float) -> void:
visible = Rect2(Vector2.ZERO, extent).has_point(_anchor)
if not visible: return
size = extent
_panel.size = Vector2(minf(420, extent.x - MARGIN * 2), 116)
_panel.size = Vector2(minf(420, extent.x - MARGIN * 2), 0)
_panel.size.y = maxf(116, _panel.get_combined_minimum_size().y)
_panel.position = Vector2(MARGIN, extent.y - _panel.size.y - MARGIN)
queue_redraw()

Expand Down
26 changes: 25 additions & 1 deletion src/presentation/production/machinery_preview.gd
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,28 @@ func _present_context() -> void:
if not _labels.has(id):
_context_card.present(null, PackedStringArray())
return
_context_card.present(_labels[id]["body"], _context_lines.get(id, PackedStringArray()))
_context_card.present(_labels[id]["body"], _context_lines.get(id, PackedStringArray()), _interaction_hint())

## Describe only existing routes. Machinery bodies currently have native mouse
## picking; the shared keyboard/controller ray router accepts Area3D only.
func _interaction_hint() -> String:
if not WorldSelectionInput.gameplay_allowed(get_viewport()): return ""
var ancestor: Node = get_parent()
while ancestor != null:
if ancestor is FirstPlayableBootstrap:
var panel: MachineryPanel = ancestor._machinery_panel
if is_instance_valid(panel) and panel.is_visible_in_tree(): return ""
break
ancestor = ancestor.get_parent()
var controls := PlayerInputSettings.instance()
var events: Array[InputEvent] = InputMap.action_get_events("world_select")
if events.is_empty(): return "World select unbound · choose machine in Construction"
var binding: String = controls.binding_label("world_select")
if not events[0] is InputEventMouseButton:
return "%s cannot pick machinery · choose machine in Construction" % binding
var hint: String = "%s: select machine · opens Construction" % binding
var cursor_events: Array[InputEvent] = InputMap.action_get_events("ui_cursor")
if cursor_events.is_empty():
if InputMap.action_get_events("release_cursor").is_empty(): return "Cursor controls unbound · open Controls settings"
return "%s: release camera · then %s" % [controls.binding_label("release_cursor"), hint]
return "Hold %s for cursor · %s" % [controls.binding_label("ui_cursor"), hint]
100 changes: 99 additions & 1 deletion tests/godot/machinery_context_shell_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ func snap(name: String) -> void:
DirAccess.make_dir_recursive_absolute("res://var/ci/machinery-context-stills")
check(root.get_texture().get_image().save_png("res://var/ci/machinery-context-stills/" + name + ".png") == OK, "native shell still")

func click_machine() -> void:
var surface: SubViewportContainer = client._habitat.get_viewport().get_parent()
var point: Vector2 = surface.get_global_rect().get_center()
root.notify_mouse_entered()
var motion := InputEventMouseMotion.new()
motion.position = point
motion.global_position = point
root.push_input(motion, true)
for pressed in [true, false]:
var event := InputEventMouseButton.new()
event.position = point
event.global_position = point
event.window_id = root.get_window_id()
event.button_index = MOUSE_BUTTON_LEFT
event.pressed = pressed
root.push_input(event, true)
await settle()

func finish() -> void:
PlayerInputSettings.instance()._reset_map()
root.content_scale_mode = Window.CONTENT_SCALE_MODE_DISABLED
Expand Down Expand Up @@ -55,6 +73,9 @@ func finish() -> void:
check(hit.get("collider") == body, "actual center ray picks unchanged exact machine body")
var card: MachineryContextCard = preview._context_card
var before: String = world.capture_world_snapshot_json().value
var geometry: Array = []
for shape: CollisionShape3D in preview.find_children("*", "CollisionShape3D", true, false):
geometry.append([shape.get_instance_id(), shape.global_transform, shape.shape.get_debug_mesh().get_aabb()])
preview.clear_context()
check(not card.visible, "no persistent card without context")
for entry in preview._labels.values(): check(not entry["label"].visible, "machine world captions hidden")
Expand All @@ -63,18 +84,75 @@ func finish() -> void:
body.mouse_entered.emit()
check(card.target == body and card.visible, "native collider hover discovers exact machine")
check(card._lines[0].text.contains(id.right(8)), "stable identity discoverable")
check(card._lines[4].visible and card._lines[4].text.contains(PlayerInputSettings.instance().binding_label("world_select")) and card._lines[4].text.contains("opens Construction"), "default mouse binding describes existing inspection route")
var controls := PlayerInputSettings.instance()
var remapped := InputEventMouseButton.new()
remapped.button_index = MOUSE_BUTTON_MIDDLE
PlayerInputSettings._set_binding("world_select", remapped)
preview._process(0.0)
check(card._lines[4].text.contains(controls.binding_label("world_select")) and not card._lines[4].text.contains("Left"), "visible card refreshes remapped mouse binding without observation")
var keyboard := InputEventKey.new()
keyboard.physical_keycode = KEY_H
PlayerInputSettings._set_binding("world_select", keyboard)
preview._process(0.0)
check(card._lines[4].text.contains(controls.binding_label("world_select")) and card._lines[4].text.contains("cannot pick machinery") and card._lines[4].text.contains("Construction"), "keyboard guidance states current static-body picking limit")
var controller := InputEventJoypadButton.new()
controller.button_index = JOY_BUTTON_A
PlayerInputSettings._set_binding("world_select", controller)
preview._process(0.0)
check(card._lines[4].text.contains(controls.binding_label("world_select")) and card._lines[4].text.contains("cannot pick machinery"), "controller guidance does not promise unsupported activation")
InputMap.action_erase_events("world_select")
preview._process(0.0)
check(card._lines[4].text == "World select unbound · choose machine in Construction", "unbound selection keeps existing chooser route")
controls._reset_map()
PlayerInputSettings._set_binding("ui_cursor", keyboard)
preview._set_hover(id)
preview._process(0.0)
check(card._lines[4].text.contains("Hold " + controls.binding_label("ui_cursor")), "guidance refreshes live cursor binding")
InputMap.action_erase_events("ui_cursor")
preview._process(0.0)
check(card._lines[4].text.contains(controls.binding_label("release_cursor")) and card._lines[4].text.contains("release camera"), "unbound cursor describes existing capture release")
InputMap.action_erase_events("release_cursor")
preview._process(0.0)
check(card._lines[4].text == "Cursor controls unbound · open Controls settings", "fully unbound cursor gives settings route")
controls._reset_map()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
preview._process(0.0)
check(world.capture_world_snapshot_json().value == before and remote.sent.is_empty(), "binding advice queues no commands and changes no canonical state")
var shapes: Array = preview.find_children("*", "CollisionShape3D", true, false)
await snap("hover")
body.mouse_exited.emit()
check(not card.visible, "hover exit clears unselected details")
preview.machine_selected.emit(id)
await click_machine()
check(client._machinery_panel._selected(client._machinery_panel._machine) == id and client._machinery_panel.is_visible_in_tree(), "native shell mouse selection opens Construction for exact machine")
check(not card._lines[4].visible and card._lines[4].text.is_empty(), "open Construction suppresses duplicate hint")
check(remote.sent.is_empty(), "selection opens inspection without submitting machine intent")
client._machinery_panel._refresh_placement()
preview._process(0.0)
check(card.target == body and card.visible, "existing panel selection displays exact picked assembly")
client._machinery_panel._machine.grab_focus()
body.mouse_entered.emit()
check(preview._hovered_id.is_empty() and card.target == body, "GUI focus blocks hover while selected identity persists")
check(not card._lines[4].visible and not card._lines[3].text.is_empty(), "GUI ownership hides hints while observed uncertainty remains")
client._machinery_panel._machine.release_focus()
client._set_drawer_open(false)
preview._set_hover(id)
preview._process(0.0)
check(card._lines[4].visible, "closing Construction restores hover guidance")
client._open_panel(1)
preview._process(0.0)
check(card._lines[4].visible, "unrelated open panel does not suppress machinery guidance")
client._drawer_toggle.grab_focus()
preview._process(0.0)
check(not card._lines[4].visible, "GUI focus suppresses actionable gameplay hint")
client._drawer_toggle.release_focus()
client._set_drawer_open(false)
preview._set_hover(id)
PlayerInputSettings.instance().suspend_navigation(true)
preview._process(0.0)
check(not card._lines[4].visible, "suspended input cannot advertise activation")
PlayerInputSettings.instance().suspend_navigation(false)
preview._set_hover(id)
move([-6000, 0, -1000])
command("machine_run", {"machine_id": id, "recipe": "extract.ice"})
show_observation()
Expand Down Expand Up @@ -105,10 +183,30 @@ func finish() -> void:
await settle()
preview._process(0.0)
card._process(0.0)
check(card._lines[4].visible, "narrow hover retains interaction guidance")
check(card._lines[4].get_line_count() == card._lines[4].get_visible_line_count(), "narrow guidance is fully readable")
check(card.visible and Rect2(Vector2.ZERO, card.size).encloses(card._panel.get_rect()), "card remains bounded in actual narrow shell viewport")
check(card._lines[3].get_line_count() == card._lines[3].get_visible_line_count(), "narrow uncertainty statement remains fully readable")
await snap("narrow")
for width in [480, 360]:
root.size = Vector2i(width, 720)
for mode in ["mouse", "keyboard", "controller", "unbound"]:
controls._reset_map()
if mode == "keyboard": PlayerInputSettings._set_binding("world_select", keyboard)
elif mode == "controller": PlayerInputSettings._set_binding("world_select", controller)
elif mode == "unbound": InputMap.action_erase_events("world_select")
preview._set_hover(id)
preview._process(0.0)
await settle()
check(card.visible and Rect2(Vector2.ZERO, card.size).encloses(card._panel.get_rect()), "narrow %d %s hint and card bounded" % [width, mode])
for line: Label in [card._lines[3], card._lines[4]]:
check(line.get_line_count() == line.get_visible_line_count() and card._panel.get_global_rect().encloses(line.get_global_rect()), "narrow %d %s uncertainty and hint fully readable" % [width, mode])
controls._reset_map()
check(preview.find_children("*", "CollisionShape3D", true, false) == shapes, "context preserves exact colliders")
var after_geometry: Array = []
for shape: CollisionShape3D in preview.find_children("*", "CollisionShape3D", true, false):
after_geometry.append([shape.get_instance_id(), shape.global_transform, shape.shape.get_debug_mesh().get_aabb()])
check(after_geometry == geometry, "guidance preserves collider identities transforms and bounds")
var snapshot: String = world.capture_world_snapshot_json().value
preview.clear_context()
body.mouse_entered.emit()
Expand Down
Loading