Implement control pass: focus cone, action wheel, named inputs, boot fixes
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped

- Venue-correct access artifacts (AccessLoadout) and screened-aware verify
- Cone focus on NPC heads with billboarded ring + display name (ADR-0008)
- Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel
- Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions
- Fix GameManager parse error (radial typed as ActionRadial)
- Fix first-person camera height and guard spawning inside the building
- Move building volume back so the door/awning/marquee are visible
- Pull the event scheduler once per simulated second; cap live NPCs
- Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests

Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
This commit is contained in:
zwitschi committed 2026-10-31 15:58:01 +01:00
1 parent 69484866db
commit f11b441c07
28 files changed
+1433 -249

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
extends CanvasLayer
## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space,
## cancel Esc/RMB. Emergency is not a slice.
signal confirmed(slice_id: String)
signal cancelled()
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const SLICE_DEFS := [
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
{"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"},
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
]
var selected := 0
var wand_available := false
var person_name := ""
var is_open := false
var _opened_ms := 0
var _wheel: Control = null
var _center_label: Label = null
func _init() -> void:
name = "ActionRadial"
layer = 25
var wheel := Control.new()
wheel.name = "Wheel"
wheel.set_anchors_preset(Control.PRESET_FULL_RECT)
wheel.mouse_filter = Control.MOUSE_FILTER_STOP
wheel.draw.connect(_on_wheel_draw)
wheel.gui_input.connect(_on_wheel_input)
add_child(wheel)
_wheel = wheel
_center_label = Label.new()
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_center_label.set_anchors_preset(Control.PRESET_CENTER)
_center_label.offset_left = -120
_center_label.offset_right = 120
_center_label.offset_top = -24
_center_label.offset_bottom = 24
_center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_center_label)
hide()
func slice_count() -> int:
return SLICE_DEFS.size()
func slice_id(index: int) -> String:
if index < 0 or index >= SLICE_DEFS.size():
return ""
return str(SLICE_DEFS[index]["id"])
func slice_def(index: int) -> Dictionary:
if index < 0 or index >= SLICE_DEFS.size():
return {}
return SLICE_DEFS[index]
func is_slice_enabled(index: int) -> bool:
if slice_id(index) == "wand":
return wand_available
return true
func open_menu(p_name: String, p_wand: bool) -> void:
person_name = p_name
wand_available = p_wand
selected = 0
is_open = true
_opened_ms = Time.get_ticks_msec()
_center_label.text = person_name
show()
_wheel.queue_redraw()
func close_menu() -> void:
is_open = false
hide()
func step(direction: int) -> void:
if not is_open or SLICE_DEFS.is_empty():
return
var n := SLICE_DEFS.size()
var next := selected
for i in n:
next = (next + direction) % n
if next < 0:
next += n
if is_slice_enabled(next):
selected = next
_wheel.queue_redraw()
return
func confirm_selected() -> String:
if not is_open:
return ""
if not is_slice_enabled(selected):
return ""
return slice_id(selected)
func angle_to_index(angle: float) -> int:
# angle from atan2, 0 = +x, we want 0 = top (-PI/2).
var n := float(SLICE_DEFS.size())
var shifted := angle + PI / 2.0
while shifted < 0.0:
shifted += TAU
while shifted >= TAU:
shifted -= TAU
var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size()
return idx
func _on_wheel_input(event: InputEvent) -> void:
if not is_open:
return
if event is InputEventMouseMotion:
var idx := _index_at_mouse(event.position)
if idx >= 0 and is_slice_enabled(idx):
selected = idx
_wheel.queue_redraw()
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
if Time.get_ticks_msec() - _opened_ms < 120:
return
var idx := _index_at_mouse(event.position)
if idx >= 0 and is_slice_enabled(idx):
selected = idx
confirmed.emit(slice_id(selected))
elif event.button_index == MOUSE_BUTTON_RIGHT:
cancelled.emit()
func _index_at_mouse(local_pos: Vector2) -> int:
var center := _wheel.size * 0.5
var delta := local_pos - center
if delta.length() < 36.0:
return -1
return angle_to_index(atan2(delta.y, delta.x))
func _on_wheel_draw() -> void:
if not is_open:
return
var center := _wheel.size * 0.5
var n := SLICE_DEFS.size()
var outer := 150.0
var inner := 58.0
for i in n:
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
var col := Color(0.12, 0.14, 0.18, 0.82)
if i == selected:
col = Color(0.92, 0.78, 0.28, 0.9)
if not is_slice_enabled(i):
col = Color(0.2, 0.2, 0.22, 0.45)
_draw_slice(center, inner, outer, a0, a1, col)
var mid := (a0 + a1) * 0.5
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
_wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE)
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
var pts := PackedVector2Array()
var steps := 8
for s in steps + 1:
var t := float(s) / float(steps)
var ang := lerpf(a0, a1, t)
pts.append(center + Vector2(cos(ang), sin(ang)) * outer)
for s in steps + 1:
var t := float(steps - s) / float(steps)
var ang := lerpf(a0, a1, t)
pts.append(center + Vector2(cos(ang), sin(ang)) * inner)
_wheel.draw_colored_polygon(pts, color)