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
+272
-35
@@ -6,6 +6,7 @@ extends Node
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const DoorState := preload("res://src/core/door_state.gd")
|
||||
const WandScreener := preload("res://src/core/wand_screener.gd")
|
||||
@@ -19,6 +20,7 @@ const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
||||
const Hud := preload("res://src/game/ui/hud.gd")
|
||||
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
|
||||
@@ -57,8 +59,10 @@ const PHASE_LIGHTING := {
|
||||
},
|
||||
}
|
||||
var hud: Hud = null
|
||||
var radial: ActionRadial = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
var _focused_avatar: Node3D = null
|
||||
var audio: AudioFeedback = null
|
||||
var verifier := AccessVerifier.new()
|
||||
var wand_screener := WandScreener.new()
|
||||
@@ -88,6 +92,9 @@ var _door_rng := RandomNumberGenerator.new()
|
||||
|
||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
||||
const NEARBY_RADIUS := 12.0
|
||||
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
||||
## events evict the oldest unfocused NPC so authored moments still spawn.
|
||||
const MAX_LIVE_NPCS := 16
|
||||
var _nearby_index := -1
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -102,7 +109,10 @@ func _ready() -> void:
|
||||
|
||||
guard = _build_guard()
|
||||
scene_root.add_child(guard)
|
||||
guard.global_position = Vector3(0, 1.6, -2.5)
|
||||
# Stand on the landing in front of the door, facing the street where guests
|
||||
# arrive. The building volume now starts behind the door (world_builder).
|
||||
guard.global_position = Vector3(0, 0.1, -2.0)
|
||||
guard.rotation.y = PI
|
||||
guard.picked_target.connect(_on_guard_target)
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
|
||||
@@ -126,6 +136,9 @@ func _build_guard() -> GuardController:
|
||||
g.name = "Guard"
|
||||
var head := Node3D.new()
|
||||
head.name = "Head"
|
||||
# Eye height. The guard root sits on the floor, so this keeps the camera at
|
||||
# ~1.7 m while gravity/floor snapping handles the steps (ADR-0007).
|
||||
head.position = Vector3(0, 1.6, 0)
|
||||
g.add_child(head)
|
||||
var camera := Camera3D.new()
|
||||
camera.name = "Camera3D"
|
||||
@@ -185,20 +198,52 @@ var inspector_current_events: Array = []
|
||||
func _build_ui() -> void:
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
_add_crosshair()
|
||||
audio = AudioFeedback.new()
|
||||
add_child(audio)
|
||||
radial = ActionRadial.new()
|
||||
add_child(radial)
|
||||
radial.confirmed.connect(_on_radial_confirmed)
|
||||
radial.cancelled.connect(_cancel_radial)
|
||||
radio_panel = RadioPanel.new()
|
||||
add_child(radio_panel)
|
||||
radio_panel.hide()
|
||||
radio_panel.submitted.connect(_on_radio_submitted)
|
||||
radio_panel.cancelled.connect(func():
|
||||
radio_panel.hide()
|
||||
hud.set_action_hint(""))
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze())
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(restart_shift)
|
||||
|
||||
func _add_crosshair() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.layer = 9
|
||||
layer.name = "Crosshair"
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
layer.add_child(center)
|
||||
var box := Control.new()
|
||||
box.custom_minimum_size = Vector2(16, 16)
|
||||
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
center.add_child(box)
|
||||
var h := ColorRect.new()
|
||||
h.color = Color(1, 1, 1, 0.75)
|
||||
h.size = Vector2(12, 2)
|
||||
h.position = Vector2(2, 7)
|
||||
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_child(h)
|
||||
var v := ColorRect.new()
|
||||
v.color = Color(1, 1, 1, 0.75)
|
||||
v.size = Vector2(2, 12)
|
||||
v.position = Vector2(7, 2)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
box.add_child(v)
|
||||
add_child(layer)
|
||||
|
||||
func _ensure_lighting() -> void:
|
||||
_sun = DirectionalLight3D.new()
|
||||
_sun.name = "Sun"
|
||||
@@ -239,7 +284,12 @@ func _apply_phase_lighting() -> void:
|
||||
if world_builder:
|
||||
world_builder.set_night_mode(cfg.get("night", false))
|
||||
|
||||
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
|
||||
_purge_npcs()
|
||||
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
|
||||
return
|
||||
if npcs.size() >= MAX_LIVE_NPCS:
|
||||
_evict_oldest_npc()
|
||||
npc_counter += 1
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.name = "Npc_%d" % npc_counter
|
||||
@@ -250,10 +300,35 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
|
||||
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
|
||||
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos)
|
||||
var spawn_profile: NpcProfile = profile.duplicate(true) if profile else profile
|
||||
var override: Dictionary = event.get("override_access", {})
|
||||
var seed_value := npc_counter * 991 + 17
|
||||
if spawn_profile and selected_scenario.access_policy.wand_screening_required:
|
||||
var items: Array[String] = []
|
||||
for item in AccessLoadout.roll_carried_items(seed_value, override):
|
||||
items.append(str(item))
|
||||
spawn_profile.carried_items = items
|
||||
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
|
||||
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
||||
func _purge_npcs() -> void:
|
||||
for i in range(npcs.size() - 1, -1, -1):
|
||||
if not is_instance_valid(npcs[i]):
|
||||
npcs.remove_at(i)
|
||||
|
||||
## Make room for a scripted event by removing the oldest NPC the player is not
|
||||
## currently focused on.
|
||||
func _evict_oldest_npc() -> void:
|
||||
for i in npcs.size():
|
||||
var npc: Node = npcs[i]
|
||||
if npc == targeted_npc or npc == _focused_avatar:
|
||||
continue
|
||||
npcs.remove_at(i)
|
||||
npc.queue_free()
|
||||
return
|
||||
|
||||
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
|
||||
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
|
||||
## entrance and resolves the incident with command/restraint/evacuation/arrest.
|
||||
@@ -291,16 +366,27 @@ func _on_shift_ended(review: Dictionary) -> void:
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
||||
_refresh_freeze()
|
||||
|
||||
func _on_guard_target(target: Node3D) -> void:
|
||||
targeted_npc = target
|
||||
_set_focused_avatar(target)
|
||||
_begin_incident(target)
|
||||
|
||||
func _on_guard_clear() -> void:
|
||||
targeted_npc = null
|
||||
_set_focused_avatar(null)
|
||||
hud.set_target_label("")
|
||||
hud.set_action_hint("")
|
||||
|
||||
func _set_focused_avatar(avatar: Node3D) -> void:
|
||||
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
||||
_focused_avatar.set_focus_visual(false, false)
|
||||
_focused_avatar = avatar
|
||||
var menu_open := radial != null and radial.is_open
|
||||
if avatar and avatar.has_method("set_focus_visual"):
|
||||
avatar.set_focus_visual(true, menu_open)
|
||||
|
||||
func _begin_incident(avatar: Node3D) -> void:
|
||||
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
||||
if controller == null:
|
||||
@@ -309,8 +395,7 @@ func _begin_incident(avatar: Node3D) -> void:
|
||||
current_incident_active = true
|
||||
current_npc_truth = _read_npc_truth(controller)
|
||||
var profile: Object = controller.profile
|
||||
var label := "[%s] %s — %s" % [
|
||||
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
|
||||
var label := "%s — %s" % [
|
||||
profile.display_name if profile else "?",
|
||||
controller.intent,
|
||||
]
|
||||
@@ -327,20 +412,62 @@ func _begin_incident(avatar: Node3D) -> void:
|
||||
# ------------------------------------------------------------------ input ---
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
_handle_key(event.keycode)
|
||||
if event is InputEventKey and event.echo:
|
||||
return
|
||||
if event.is_action_pressed("cancel"):
|
||||
_handle_cancel(event is InputEventMouseButton)
|
||||
return
|
||||
if event is InputEventMouseButton and event.pressed:
|
||||
_handle_mouse_button(event)
|
||||
return
|
||||
if event is InputEventKey and event.pressed:
|
||||
_handle_key(event)
|
||||
|
||||
func _handle_key(keycode: Key) -> void:
|
||||
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
if radial and radial.is_open:
|
||||
return
|
||||
if not _modal_blocks_radial() and targeted_npc != null:
|
||||
_open_radial()
|
||||
|
||||
func _modal_blocks_radial() -> bool:
|
||||
if inspection_open or wand_open:
|
||||
return true
|
||||
if radio_panel and radio_panel.visible:
|
||||
return true
|
||||
if shift_review_panel and shift_review_panel.visible:
|
||||
return true
|
||||
return false
|
||||
|
||||
func _handle_key(event: InputEvent) -> void:
|
||||
var keycode: Key = event.keycode
|
||||
if radial and radial.is_open:
|
||||
if event.is_action_pressed("confirm"):
|
||||
_confirm_radial()
|
||||
return
|
||||
match keycode:
|
||||
KEY_A, KEY_LEFT:
|
||||
radial.step(-1)
|
||||
KEY_D, KEY_RIGHT:
|
||||
radial.step(1)
|
||||
return
|
||||
if event.is_action_pressed("action_menu"):
|
||||
if targeted_npc != null and not _modal_blocks_radial():
|
||||
_open_radial()
|
||||
return
|
||||
if event.is_action_pressed("inspect"):
|
||||
if targeted_npc != null and not inspection_open and not wand_open:
|
||||
_toggle_inspection()
|
||||
return
|
||||
if event.is_action_pressed("wand"):
|
||||
if targeted_npc != null and not inspection_open and not wand_open:
|
||||
_toggle_wand_screening()
|
||||
return
|
||||
match keycode:
|
||||
KEY_E:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_toggle_inspection()
|
||||
KEY_Q:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
||||
KEY_W:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_toggle_wand_screening()
|
||||
KEY_TAB:
|
||||
_cycle_nearby_target()
|
||||
KEY_F:
|
||||
@@ -383,6 +510,113 @@ func _handle_action_key(keycode: Key) -> void:
|
||||
_:
|
||||
pass
|
||||
|
||||
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse
|
||||
## capture; RMB does nothing so it never grabs the mouse by accident.
|
||||
func _handle_cancel(from_mouse: bool) -> void:
|
||||
if _close_top_modal():
|
||||
return
|
||||
if from_mouse:
|
||||
return
|
||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func _close_top_modal() -> bool:
|
||||
if radial and radial.is_open:
|
||||
_cancel_radial()
|
||||
return true
|
||||
if inspection_open:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
_refresh_freeze()
|
||||
return true
|
||||
if wand_open:
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
_refresh_freeze()
|
||||
return true
|
||||
if radio_panel and radio_panel.visible:
|
||||
radio_panel.hide()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _open_radial() -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log(Strings.lookup("hud.aim_first"))
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
var p_name := "Person"
|
||||
if controller and controller.profile:
|
||||
p_name = str(controller.profile.display_name)
|
||||
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
|
||||
radial.open_menu(p_name, wand_ok)
|
||||
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||
targeted_npc.set_focus_visual(true, true)
|
||||
_refresh_freeze()
|
||||
|
||||
func _confirm_radial() -> void:
|
||||
if radial == null or not radial.is_open:
|
||||
return
|
||||
var slice := radial.confirm_selected()
|
||||
radial.close_menu()
|
||||
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||
targeted_npc.set_focus_visual(true, false)
|
||||
_refresh_freeze()
|
||||
if slice != "":
|
||||
_on_radial_confirmed(slice)
|
||||
|
||||
func _cancel_radial() -> void:
|
||||
if radial == null or not radial.is_open:
|
||||
return
|
||||
radial.close_menu()
|
||||
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||
targeted_npc.set_focus_visual(true, false)
|
||||
_refresh_freeze()
|
||||
|
||||
func _on_radial_confirmed(slice_id: String) -> void:
|
||||
if radial and radial.is_open:
|
||||
radial.close_menu()
|
||||
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||
targeted_npc.set_focus_visual(true, false)
|
||||
_refresh_freeze()
|
||||
match slice_id:
|
||||
"raise_hand":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||
"point":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||
"head_shake":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
|
||||
"polite":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
||||
"inspect":
|
||||
_toggle_inspection()
|
||||
"wand":
|
||||
_toggle_wand_screening()
|
||||
"warning":
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||
"block":
|
||||
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||
"radio":
|
||||
_open_radio(false)
|
||||
|
||||
func _refresh_freeze() -> void:
|
||||
var freeze := inspection_open or wand_open
|
||||
if radial and radial.is_open:
|
||||
freeze = true
|
||||
if radio_panel and radio_panel.visible:
|
||||
freeze = true
|
||||
if shift_review_panel and shift_review_panel.visible:
|
||||
freeze = true
|
||||
if guard:
|
||||
guard.input_locked = freeze
|
||||
if freeze:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
## Toggle the guard's flashlight. In evening/late phases it widens NPC
|
||||
## perception radius (PLAN.md: Level 3 — flashlight at night).
|
||||
func _toggle_flashlight() -> void:
|
||||
@@ -429,8 +663,13 @@ func _cycle_nearby_target() -> void:
|
||||
_nearby_index = (_nearby_index + 1) % nearby.size()
|
||||
var next_target: Node3D = nearby[_nearby_index]
|
||||
targeted_npc = next_target
|
||||
_set_focused_avatar(next_target)
|
||||
_begin_incident(next_target)
|
||||
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
|
||||
var controller: Object = _targeted_controller()
|
||||
var who: String = next_target.name
|
||||
if controller and controller.profile and controller.profile.display_name != "":
|
||||
who = str(controller.profile.display_name)
|
||||
hud.log(Strings.fmt("hud.targeting", [who]))
|
||||
|
||||
func _apply_interaction(level: int, action_name: String) -> void:
|
||||
if targeted_npc == null:
|
||||
@@ -506,9 +745,11 @@ func _open_radio(emergency: bool) -> void:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
_refresh_freeze()
|
||||
|
||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
if not result.get("ok", false):
|
||||
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
|
||||
@@ -540,14 +781,16 @@ func _toggle_inspection() -> void:
|
||||
if inspection_open:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
_refresh_freeze()
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
inspection_open = true
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
|
||||
hud.show_inspection(controller, artifacts, result)
|
||||
_refresh_freeze()
|
||||
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
|
||||
var line := _door_context_line()
|
||||
if line != "":
|
||||
@@ -565,6 +808,7 @@ func _toggle_wand_screening() -> void:
|
||||
hud.hide_wand()
|
||||
hud.set_wand_icon(false)
|
||||
wand_open = false
|
||||
_refresh_freeze()
|
||||
return
|
||||
if not selected_scenario.equipment.has("wand"):
|
||||
hud.log(Strings.lookup("hud.no_wand"))
|
||||
@@ -579,6 +823,7 @@ func _toggle_wand_screening() -> void:
|
||||
var result: Dictionary = wand_screener.screen(items)
|
||||
wand_open = true
|
||||
hud.show_wand(controller, result)
|
||||
_refresh_freeze()
|
||||
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
|
||||
if wand_line != "":
|
||||
hud.log(wand_line)
|
||||
@@ -589,6 +834,7 @@ func decide_wand(allowed: bool) -> void:
|
||||
if controller == null:
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
_refresh_freeze()
|
||||
return
|
||||
var profile: Object = controller.profile
|
||||
var items: Array = profile.carried_items if profile else []
|
||||
@@ -624,6 +870,7 @@ func decide_wand(allowed: bool) -> void:
|
||||
controller.screened = true
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
_refresh_freeze()
|
||||
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
|
||||
_wand_cooldown = WAND_COOLDOWN_S
|
||||
|
||||
@@ -632,15 +879,17 @@ func decide_access(allowed: bool) -> void:
|
||||
if controller == null:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
_refresh_freeze()
|
||||
return
|
||||
# Screening-before-entry: at wand venues, a guest must be screened first.
|
||||
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
|
||||
hud.log(Strings.lookup("hud.screen_first"))
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
_refresh_freeze()
|
||||
return
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
|
||||
var granted: bool = result.get("granted", false)
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"decided": allowed,
|
||||
@@ -672,6 +921,7 @@ func decide_access(allowed: bool) -> void:
|
||||
audio.play_deny()
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
_refresh_freeze()
|
||||
|
||||
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
|
||||
## ID / ticket check lines). Returns "" when the venue has no matching lines.
|
||||
@@ -685,23 +935,10 @@ func _door_context_line() -> String:
|
||||
return ""
|
||||
|
||||
func _artifacts_for(controller: Object) -> Array:
|
||||
var profile: Object = controller.profile
|
||||
var npc_type: int = profile.npc_type if profile else -1
|
||||
var out: Array = []
|
||||
match npc_type:
|
||||
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
|
||||
Rules.NpcType.DELIVERY:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
|
||||
Rules.NpcType.TRESPASSER:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"}))
|
||||
Rules.NpcType.TOURIST:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
|
||||
_:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||
return out
|
||||
var profile: NpcProfile = controller.profile as NpcProfile
|
||||
var override: Dictionary = controller.access_override
|
||||
var policy = selected_scenario.access_policy if selected_scenario else null
|
||||
return AccessLoadout.build(profile, policy, override)
|
||||
|
||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||
var profile: Object = controller.profile
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
class_name GuardController
|
||||
extends CharacterBody3D
|
||||
## First-person guard controller: WASD movement, mouse look, interact raycast.
|
||||
## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008).
|
||||
|
||||
signal picked_target(target: Node)
|
||||
signal cleared_target()
|
||||
|
||||
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||
|
||||
var move_speed: float = 3.2
|
||||
var look_sensitivity: float = 0.0022
|
||||
var accel: float = 12.0
|
||||
var is_ready := false
|
||||
var targeted_npc: Node3D = null
|
||||
var input_locked := false
|
||||
var _miss_frames := 0
|
||||
const MISS_GRACE := 8
|
||||
|
||||
@onready var camera: Camera3D = $Head/Camera3D
|
||||
@onready var head: Node3D = $Head
|
||||
|
||||
func _ready() -> void:
|
||||
floor_snap_length = 0.3
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
is_ready = true
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if input_locked:
|
||||
return
|
||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
rotate_y(-event.relative.x * look_sensitivity)
|
||||
head.rotate_x(-event.relative.y * look_sensitivity)
|
||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
# Movement relative to the head's yaw only.
|
||||
if input_locked:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
if not is_on_floor():
|
||||
velocity.y -= 9.8 * _delta
|
||||
else:
|
||||
velocity.y = 0.0
|
||||
move_and_slide()
|
||||
return
|
||||
var forward := -global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
@@ -41,28 +53,49 @@ func _physics_process(_delta: float) -> void:
|
||||
Input.get_axis("move_left", "move_right"),
|
||||
Input.get_axis("move_forward", "move_back")
|
||||
)
|
||||
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
|
||||
velocity.x = direction.x * move_speed
|
||||
velocity.z = direction.z * move_speed
|
||||
velocity.y -= 9.8 * _delta
|
||||
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||
input_dir = Vector2.ZERO
|
||||
var desired := (forward * input_dir.y + right * input_dir.x)
|
||||
if desired.length() > 1.0:
|
||||
desired = desired.normalized()
|
||||
desired *= move_speed
|
||||
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
|
||||
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
|
||||
if is_on_floor():
|
||||
velocity.y = 0.0
|
||||
else:
|
||||
velocity.y -= 9.8 * _delta
|
||||
move_and_slide()
|
||||
_update_target()
|
||||
|
||||
func _update_target() -> void:
|
||||
var space := get_world_3d().direct_space_state
|
||||
var from := camera.global_position
|
||||
var to := from + -camera.global_transform.basis.z * 8.0
|
||||
var query := PhysicsRayQueryParameters3D.create(from, to)
|
||||
query.collide_with_areas = true
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = [get_rid()]
|
||||
var hit := space.intersect_ray(query)
|
||||
if camera == null:
|
||||
return
|
||||
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
||||
var heads: Array = []
|
||||
var avatars: Array = []
|
||||
for node in npcs:
|
||||
if not (node is Node3D) or not is_instance_valid(node):
|
||||
continue
|
||||
var avatar := node as Node3D
|
||||
var head_node := avatar.get_node_or_null("Rig/Head") as Node3D
|
||||
var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0)
|
||||
heads.append(pos)
|
||||
avatars.append(avatar)
|
||||
var cam_origin := camera.global_position
|
||||
var cam_forward := -camera.global_transform.basis.z
|
||||
var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads)
|
||||
var new_target: Node3D = null
|
||||
if hit:
|
||||
var obj: Object = hit.get("collider")
|
||||
var parent_avatar: Node3D = _find_avatar(obj)
|
||||
if parent_avatar != null:
|
||||
new_target = parent_avatar
|
||||
if idx >= 0:
|
||||
var candidate: Node3D = avatars[idx]
|
||||
if _head_visible(cam_origin, heads[idx]):
|
||||
new_target = candidate
|
||||
if new_target == null and targeted_npc != null:
|
||||
_miss_frames += 1
|
||||
if _miss_frames < MISS_GRACE:
|
||||
return
|
||||
else:
|
||||
_miss_frames = 0
|
||||
if new_target != targeted_npc:
|
||||
targeted_npc = new_target
|
||||
if new_target != null:
|
||||
@@ -70,14 +103,21 @@ func _update_target() -> void:
|
||||
else:
|
||||
cleared_target.emit()
|
||||
|
||||
func _find_avatar(obj: Object) -> Node3D:
|
||||
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
|
||||
func _head_visible(from: Vector3, to: Vector3) -> bool:
|
||||
var space := get_world_3d().direct_space_state
|
||||
if space == null:
|
||||
return true
|
||||
var query := PhysicsRayQueryParameters3D.create(from, to)
|
||||
query.collide_with_areas = false
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = [get_rid()]
|
||||
var hit := space.intersect_ray(query)
|
||||
if hit.is_empty():
|
||||
return true
|
||||
var obj: Object = hit.get("collider")
|
||||
var n := obj as Node
|
||||
if n == null:
|
||||
return null
|
||||
var current: Node = n
|
||||
while current != null:
|
||||
if current.has_method("get_npc_controller"):
|
||||
return current as Node3D
|
||||
current = current.get_parent()
|
||||
return null
|
||||
while n != null:
|
||||
if n.is_in_group("npc"):
|
||||
return true
|
||||
n = n.get_parent()
|
||||
return false
|
||||
+62
-5
@@ -39,14 +39,17 @@ func _init() -> void:
|
||||
func get_npc_controller() -> NPCController:
|
||||
return npc
|
||||
|
||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
|
||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
|
||||
var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
|
||||
npc.setup(id, profile, intent, seed_value)
|
||||
npc.access_override = p_override
|
||||
_visual_seed = seed_value
|
||||
spawn_position = p_spawn
|
||||
target_position = p_target
|
||||
door_position = p_door
|
||||
position = p_spawn
|
||||
npc.fsm.state_changed.connect(_on_state_changed)
|
||||
add_to_group("npc")
|
||||
_build_body(profile.npc_type)
|
||||
_nav_usable = false
|
||||
_nav_try_frames = 0
|
||||
@@ -62,9 +65,10 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.35
|
||||
shape.height = 1.6
|
||||
var height_scale: float = _rig.scale.y if _rig else 1.0
|
||||
shape.height = 1.6 * height_scale
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.8, 0)
|
||||
collider.position = Vector3(0, 0.8 * height_scale, 0)
|
||||
add_child(collider)
|
||||
|
||||
var _visual_seed := 0
|
||||
@@ -72,8 +76,11 @@ var _rig: Node3D = null
|
||||
var _head: Node3D = null
|
||||
var _bob_phase := 0.0
|
||||
var _guard_ref: Node3D = null
|
||||
var _ring: MeshInstance3D = null
|
||||
var _name_tag: Label3D = null
|
||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||
|
||||
func _build_body(npc_type: int) -> void:
|
||||
_rig = Node3D.new()
|
||||
@@ -96,14 +103,60 @@ func _build_body(npc_type: int) -> void:
|
||||
# Arms
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||
# Head
|
||||
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
||||
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
|
||||
_head = Node3D.new()
|
||||
_head.name = "Head"
|
||||
_head.position = Vector3(0, 1.62, 0)
|
||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
||||
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
|
||||
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||
_rig.add_child(_head)
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||
_build_focus_ring()
|
||||
_add_props(npc_type)
|
||||
|
||||
func _build_focus_ring() -> void:
|
||||
_ring = MeshInstance3D.new()
|
||||
_ring.name = "FocusRing"
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = 0.16
|
||||
torus.outer_radius = 0.20
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 16
|
||||
_ring.mesh = torus
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
_ring.material_override = mat
|
||||
_ring.position = Vector3(0, 0.28, 0)
|
||||
_ring.visible = false
|
||||
_head.add_child(_ring)
|
||||
_name_tag = Label3D.new()
|
||||
_name_tag.name = "NameTag"
|
||||
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
||||
_name_tag.font_size = 28
|
||||
_name_tag.pixel_size = 0.004
|
||||
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_name_tag.position = Vector3(0, 0.42, 0)
|
||||
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
||||
_name_tag.visible = false
|
||||
_head.add_child(_name_tag)
|
||||
|
||||
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
||||
if _ring:
|
||||
_ring.visible = focused
|
||||
var mat := _ring.material_override as StandardMaterial3D
|
||||
if mat:
|
||||
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85)
|
||||
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
||||
if _name_tag:
|
||||
_name_tag.visible = focused
|
||||
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
@@ -187,6 +240,10 @@ func _animate(delta: float) -> void:
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||
if _ring and _ring.visible:
|
||||
var cam := get_viewport().get_camera_3d() if get_viewport() else null
|
||||
if cam:
|
||||
_ring.look_at(cam.global_position, Vector3.UP)
|
||||
# Attention cue: head turns toward the guard when awareness was raised.
|
||||
if _head != null and npc.guard_noticed:
|
||||
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
extends RefCounted
|
||||
## World-space focus cone (ADR-0008). Picks the head closest to the camera
|
||||
## forward vector inside range and angle. No viewport needed, so tests run headless.
|
||||
|
||||
const MAX_RANGE := 10.0
|
||||
const MAX_ANGLE_RAD := 0.175 # ~10 degrees
|
||||
|
||||
static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vector3, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> float:
|
||||
var to := head_pos - cam_origin
|
||||
var dist := to.length()
|
||||
if dist < 0.05 or dist > max_range:
|
||||
return -1.0
|
||||
var dir := to / dist
|
||||
var dot := cam_forward.dot(dir)
|
||||
if dot <= 0.0:
|
||||
return -1.0
|
||||
var ang := acos(clampf(dot, -1.0, 1.0))
|
||||
if ang > max_angle:
|
||||
return -1.0
|
||||
return ang
|
||||
|
||||
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
|
||||
var best_i := -1
|
||||
var best_score := 999.0
|
||||
for i in head_positions.size():
|
||||
var pos: Vector3 = head_positions[i]
|
||||
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
||||
if score < 0.0:
|
||||
continue
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_i = i
|
||||
return best_i
|
||||
@@ -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)
|
||||
+11
-33
@@ -5,7 +5,6 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||
const T_SKYLINE := preload("res://assets/textures/ai/game_ready/skyline.png")
|
||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
@@ -33,7 +32,6 @@ func build() -> void:
|
||||
_build_crosswalk()
|
||||
_build_street_furniture()
|
||||
_build_guard_spawn()
|
||||
_build_skyline_backdrop()
|
||||
_build_navmesh()
|
||||
set_night_mode(false)
|
||||
|
||||
@@ -68,13 +66,13 @@ func _build_building() -> void:
|
||||
var building := StaticBody3D.new()
|
||||
building.name = "Building"
|
||||
# Main facade (textured brick)
|
||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
||||
# Entrance recess + door recess frame
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09))
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09))
|
||||
# Door frame and dark door
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11))
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11))
|
||||
# Door glass (semi-transparent)
|
||||
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55))
|
||||
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
|
||||
var glass_mat := StandardMaterial3D.new()
|
||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
@@ -82,13 +80,13 @@ func _build_building() -> void:
|
||||
glass_mat.metallic = 0.1
|
||||
glass.material_override = glass_mat
|
||||
# Door lock indicator (green = open, red = locked), placed beside the handle
|
||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
|
||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(40, 18, 4)
|
||||
var so := building.create_shape_owner(building)
|
||||
building.shape_owner_add_shape(so, col)
|
||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2)))
|
||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
||||
root.add_child(building)
|
||||
|
||||
func _build_steps() -> void:
|
||||
@@ -102,7 +100,7 @@ func _build_steps() -> void:
|
||||
var height := 0.16
|
||||
for i in 4:
|
||||
var y: float = i * height
|
||||
var z: float = i * tread
|
||||
var z: float = 4.0 * tread - i * tread
|
||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||
# Landing at the top
|
||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
||||
@@ -206,8 +204,10 @@ func _build_street_furniture() -> void:
|
||||
# Low-poly trees along the street front.
|
||||
var trunk_color := Color(0.35, 0.25, 0.18)
|
||||
var leaf_color := Color(0.16, 0.42, 0.2)
|
||||
# Flank the walkway; keep the sight line to the door clear.
|
||||
for i in 6:
|
||||
var x := -11.0 + i * 4.4
|
||||
var side := -1.0 if i % 2 == 0 else 1.0
|
||||
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
||||
var z := 4.0 + float((i * 7) % 5)
|
||||
var tree := Node3D.new()
|
||||
tree.name = "Tree_%d" % (i + 1)
|
||||
@@ -261,32 +261,10 @@ func _plain_material(color: Color) -> StandardMaterial3D:
|
||||
mat.albedo_color = color
|
||||
return mat
|
||||
|
||||
func _build_skyline_backdrop() -> void:
|
||||
## Distant procedural skyline on four unshaded planes around the block.
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_texture = T_SKYLINE
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
const DIST := 95.0
|
||||
const HEIGHT := 90.0
|
||||
for i in 4:
|
||||
var plane := MeshInstance3D.new()
|
||||
plane.name = "Skyline_%d" % i
|
||||
var pm := PlaneMesh.new()
|
||||
pm.size = Vector2(300, HEIGHT)
|
||||
plane.mesh = pm
|
||||
plane.material_override = mat
|
||||
var angle := i * 90.0
|
||||
plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST)
|
||||
plane.rotation_degrees = Vector3(0, -angle + 90.0, 0)
|
||||
plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting)
|
||||
root.add_child(plane)
|
||||
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
marker.position = Vector3(0, 1.6, -2.5)
|
||||
marker.position = Vector3(0, 0.1, -2.0)
|
||||
root.add_child(marker)
|
||||
|
||||
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
||||
|
||||
Reference in new issue
Block a user