Verbs (PLAN levels 1-3) - Eye contact: own Level 1 radial slice, logged as "eye contact". - Recognition is a player decision: "Recognize" sets NPCController.player_recognized, writes access evidence, and GameManager only then appends a RECOGNITION artifact. It is no longer implied by NPC type. - Body camera: real flag (off by default) driving the REC overlay; B toggles it and a formal warning arms it automatically, writing a body_camera incident event. - Blocking stations the guard 0.9 m from the person toward the entrance and turns to face them, instead of only emitting the escalation level. - Request reason: Level 3 ask answered from the NPC's seeded dialogue_lines. - Radial grows to 12 slices (174 px outer radius, centered labels). Incident recording fix - IncidentRecorder.start_incident() was never called, so add_event / add_access_evidence / add_escalation silently no-opped and the shift review always reported zero incidents. Incidents now open on focus (guarded by has_open_incident), verbs call _ensure_incident, and ShiftCoordinator closes leftovers as "unresolved" before building the review. Copy and docs - 9 new string keys (eye contact, body camera, recognition, reason, station). - README controls, 26-step smoke checklist, test count, status lists. - TODO section moved to DONE; MEMORY records the verb design and the recorder bug. Verification: headless 98/98, smoke_plan_verbs.gd + smoke_session_shell.gd + windowed smoke_focus_radial.gd all pass. Version bumped to 0.2.3.
183 lines
6.1 KiB
GDScript
183 lines
6.1 KiB
GDScript
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": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"},
|
|
{"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": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"},
|
|
{"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"},
|
|
{"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 := 174.0
|
|
var inner := 64.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"]))
|
|
# With 12 slices the label box is tight: center it on the slice midpoint.
|
|
var font := ThemeDB.fallback_font
|
|
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12)
|
|
_wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, 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)
|