Implement control pass: focus cone, action wheel, named inputs, boot fixes
- 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:
1 parent
69484866db
commit
f11b441c07
28 files changed
+1433
-249
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user