Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s
CI / headless-tests (push) Canceled after 49s
- Godot 4.3 project scaffold with first-person guard, procedural world, interaction HUD, access inspection, radio forms, debug overlay, shift review - Simulation core: ShiftCoordinator, VenueScenario, EventScheduler, NPCController/FSM, AccessVerifier, EscalationManager, RadioSystem, IncidentRecorder, ReputationEvaluator, CrowdSystem, EventBus - Three authored scenarios (Hollywood Blvd building, The Joke Shop, TV studio) - Custom headless test runner (25 tests) replacing editor-dependent GUT, plus GitHub Actions CI - Documentation workflow live: README, PLAN (tech stack), TODO/DONE, MEMORY
This commit is contained in:
1 parent
e23f284ad4
commit
439e0a4281
45 files changed
+4368
-7
No files matched your search
@@ -0,0 +1,402 @@
|
||||
class_name GameManager
|
||||
extends Node
|
||||
## Wireframe: connects a scenario to a ShiftCoordinator, spawns NPC avatars,
|
||||
## and routes player input into the interaction and access flows.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const Hud := preload("res://src/game/ui/hud.gd")
|
||||
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
|
||||
var coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var hud: Hud = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
var verifier := AccessVerifier.new()
|
||||
|
||||
var active_scenario_id := "hollywood_building"
|
||||
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
|
||||
var scene_root: Node3D = null
|
||||
var npcs: Array = []
|
||||
var npc_counter := 0
|
||||
var targeted_npc: Object = null
|
||||
var inspection_open := false
|
||||
var current_incident_npc_id := ""
|
||||
var current_incident_active := false
|
||||
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
||||
|
||||
func _ready() -> void:
|
||||
scene_root = Node3D.new()
|
||||
scene_root.name = "SceneRoot"
|
||||
add_child(scene_root)
|
||||
|
||||
WorldBuilder.new(scene_root).build()
|
||||
_ensure_lighting()
|
||||
|
||||
guard = _build_guard()
|
||||
scene_root.add_child(guard)
|
||||
guard.global_position = Vector3(0, 1.6, -2.5)
|
||||
guard.picked_target.connect(_on_guard_target)
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
|
||||
selected_scenario = _build_scenario(active_scenario_id)
|
||||
coordinator = ShiftCoordinator.new()
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
|
||||
_build_ui()
|
||||
coordinator.start()
|
||||
|
||||
var selected_scenario: VenueScenario = null
|
||||
|
||||
func _build_guard() -> GuardController:
|
||||
var g := GuardController.new()
|
||||
g.name = "Guard"
|
||||
var head := Node3D.new()
|
||||
head.name = "Head"
|
||||
g.add_child(head)
|
||||
var camera := Camera3D.new()
|
||||
camera.name = "Camera3D"
|
||||
camera.current = true
|
||||
head.add_child(camera)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.4
|
||||
shape.height = 1.7
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.85, 0)
|
||||
g.add_child(collider)
|
||||
return g
|
||||
|
||||
func _build_scenario(id: String) -> VenueScenario:
|
||||
match id:
|
||||
"joke_shop":
|
||||
return ScenarioFactory.joke_shop()
|
||||
"tv_studio":
|
||||
return ScenarioFactory.tv_studio()
|
||||
_:
|
||||
return ScenarioFactory.hollywood_building()
|
||||
|
||||
func switch_scenario(id: String) -> void:
|
||||
active_scenario_id = id
|
||||
selected_scenario = _build_scenario(id)
|
||||
if coordinator:
|
||||
restart_shift()
|
||||
|
||||
func restart_shift() -> void:
|
||||
shift_review_panel.hide()
|
||||
for n in npcs:
|
||||
n.queue_free()
|
||||
npcs.clear()
|
||||
npc_counter = 0
|
||||
inspector_current_events = []
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.start()
|
||||
|
||||
var inspector_current_events: Array = []
|
||||
|
||||
func _build_ui() -> void:
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
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(""))
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(restart_shift)
|
||||
|
||||
func _ensure_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||
sun.light_energy = 1.2
|
||||
scene_root.add_child(sun)
|
||||
var env_node := WorldEnvironment.new()
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.7, 0.7, 0.75)
|
||||
env.ambient_light_energy = 0.5
|
||||
env_node.environment = env
|
||||
scene_root.add_child(env_node)
|
||||
|
||||
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
npc_counter += 1
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.name = "Npc_%d" % npc_counter
|
||||
var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0
|
||||
var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18))
|
||||
var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16))
|
||||
var target_pos := Vector3(0.0, 0.5, 1.2)
|
||||
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
shift_review_panel.show_review(review, selected_scenario)
|
||||
|
||||
func _on_guard_target(target: Node3D) -> void:
|
||||
targeted_npc = target
|
||||
_begin_incident(target)
|
||||
|
||||
func _on_guard_clear() -> void:
|
||||
targeted_npc = null
|
||||
hud.set_target_label("")
|
||||
hud.set_action_hint("")
|
||||
|
||||
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:
|
||||
return
|
||||
current_incident_npc_id = controller.npc_id
|
||||
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), "?"),
|
||||
profile.display_name if profile else "?",
|
||||
controller.intent,
|
||||
]
|
||||
hud.set_target_label(label)
|
||||
var hint := ""
|
||||
if profile and profile.npc_type == Rules.NpcType.VIOLENT_THREAT:
|
||||
hint = "High risk: threaten/force-entry likely. Emergency radio (R) is justified."
|
||||
elif profile and profile.npc_type == Rules.NpcType.MENTAL_CRISIS:
|
||||
hint = "Requires patience. Avoid aggressive confrontation."
|
||||
elif profile and profile.npc_type == Rules.NpcType.AUTHORIZED_GUEST:
|
||||
hint = "Likely authorized. Press E to verify access."
|
||||
hud.set_action_hint(hint)
|
||||
|
||||
# ------------------------------------------------------------------ input ---
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
_handle_key(event.keycode)
|
||||
|
||||
func _handle_key(keycode: Key) -> void:
|
||||
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, Rules.Tone.PROFESSIONAL, "polite direction")
|
||||
KEY_F:
|
||||
hud.toggle_debug()
|
||||
_:
|
||||
_handle_action_key(keycode)
|
||||
|
||||
func _handle_action_key(keycode: Key) -> void:
|
||||
match keycode:
|
||||
KEY_1:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand")
|
||||
KEY_2:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point")
|
||||
KEY_3:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake")
|
||||
KEY_4:
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning")
|
||||
KEY_5:
|
||||
_apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking")
|
||||
KEY_6:
|
||||
_open_radio(false)
|
||||
KEY_R:
|
||||
_open_radio(true)
|
||||
KEY_A:
|
||||
if inspection_open:
|
||||
decide_access(true)
|
||||
KEY_D:
|
||||
if inspection_open:
|
||||
decide_access(false)
|
||||
_:
|
||||
pass
|
||||
|
||||
func _apply_interaction(level: int, tone: int, action_name: String) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Aim at someone first.")
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
var outcome: Dictionary = controller.apply_player_action(level, tone)
|
||||
var npc_id: String = controller.npc_id
|
||||
coordinator.recorder.add_event(npc_id, {
|
||||
"type": "player_action",
|
||||
"action": action_name,
|
||||
"level": level,
|
||||
"tone": Rules.TONE_NAMES.get(tone, "?"),
|
||||
})
|
||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||
if level >= Rules.Level.FORMAL_WARNING:
|
||||
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
|
||||
_react_to_outcome(controller, outcome)
|
||||
|
||||
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
var reaction: int = outcome.get("reaction", -1)
|
||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||
match reaction:
|
||||
Rules.Reaction.COMPLY:
|
||||
hud.log("%s complies and steps back." % name)
|
||||
coordinator.crowd.apply_event("de_escalation")
|
||||
coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully")
|
||||
_close_incident("peaceful")
|
||||
Rules.Reaction.LEAVE:
|
||||
hud.log("%s turns and leaves." % name)
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
_close_incident("left")
|
||||
Rules.Reaction.ARGUE:
|
||||
hud.log("%s argues with you." % name)
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.RECORD:
|
||||
hud.log("%s starts filming you." % name)
|
||||
coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.IGNORE:
|
||||
hud.log("%s ignores you." % name)
|
||||
Rules.Reaction.THREATEN:
|
||||
hud.log("%s is threatening you. Emergency radio (R) is justified now." % name)
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
hud.log("%s is forcing the door!" % name)
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
hud.log("%s leaves but may come back." % name)
|
||||
_:
|
||||
hud.log("No reaction (yet).")
|
||||
|
||||
func _open_radio(emergency: bool) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Address someone first before calling.")
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
|
||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
hud.set_action_hint("")
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
if not result.get("ok", false):
|
||||
hud.log("Radio blocked: %s" % result.get("reason", "unknown"))
|
||||
return
|
||||
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
||||
if hits > 0:
|
||||
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
|
||||
hud.log("Dispatch flags inaccuracies in your report (-%d)." % (4 * hits))
|
||||
else:
|
||||
hud.log("Dispatch received your report. Assistance en route.")
|
||||
if emergency:
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
hud.log("Emergency responder dispatched.")
|
||||
else:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
|
||||
# ----------------------------------------------------------------- access --
|
||||
|
||||
func _toggle_inspection() -> void:
|
||||
if inspection_open:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
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)
|
||||
hud.show_inspection(controller, artifacts, result)
|
||||
|
||||
func decide_access(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
return
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
var granted: bool = result.get("granted", false)
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"decided": allowed,
|
||||
"verified_granted": granted,
|
||||
"issues": result.get("issues", []),
|
||||
"checked": result.get("checked_artifacts", []),
|
||||
})
|
||||
if allowed == granted:
|
||||
if allowed:
|
||||
hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied"))
|
||||
coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
|
||||
else:
|
||||
hud.log("Entry denied: %s" % "; ".join(result.get("issues", [])))
|
||||
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
|
||||
else:
|
||||
if allowed:
|
||||
hud.log("You granted entry despite verification failure!")
|
||||
else:
|
||||
hud.log("You denied entry despite valid verification!")
|
||||
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
|
||||
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
|
||||
|
||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||
var profile: Object = controller.profile
|
||||
var npc_type: int = profile.npc_type if profile else -1
|
||||
return {
|
||||
"weapons_seen": npc_type == Rules.NpcType.VIOLENT_THREAT,
|
||||
"danger_level": 3 if npc_type == Rules.NpcType.VIOLENT_THREAT else 1,
|
||||
}
|
||||
|
||||
func _targeted_controller() -> Object:
|
||||
if targeted_npc == null:
|
||||
return null
|
||||
if targeted_npc.has_method("get_npc_controller"):
|
||||
return targeted_npc.get_npc_controller()
|
||||
return null
|
||||
|
||||
func _close_incident(outcome: String) -> void:
|
||||
if current_incident_active and not current_incident_npc_id.is_empty():
|
||||
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if coordinator == null or not coordinator.running:
|
||||
return
|
||||
coordinator.tick(delta)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
@@ -0,0 +1,83 @@
|
||||
class_name GuardController
|
||||
extends CharacterBody3D
|
||||
## First-person guard controller: WASD movement, mouse look, interact raycast.
|
||||
|
||||
signal picked_target(target: Node)
|
||||
signal cleared_target()
|
||||
|
||||
var move_speed: float = 3.2
|
||||
var look_sensitivity: float = 0.0022
|
||||
var is_ready := false
|
||||
var targeted_npc: Node3D = null
|
||||
|
||||
@onready var camera: Camera3D = $Head/Camera3D
|
||||
@onready var head: Node3D = $Head
|
||||
|
||||
func _ready() -> void:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
is_ready = true
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
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.
|
||||
var forward := -global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
var right := global_transform.basis.x
|
||||
right.y = 0.0
|
||||
right = right.normalized()
|
||||
|
||||
var input_dir := Vector2(
|
||||
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
|
||||
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)
|
||||
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 new_target != targeted_npc:
|
||||
targeted_npc = new_target
|
||||
if new_target != null:
|
||||
picked_target.emit(new_target)
|
||||
else:
|
||||
cleared_target.emit()
|
||||
|
||||
func _find_avatar(obj: Object) -> Node3D:
|
||||
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
extends Node
|
||||
## Top-level game entry point. Instantiates the GameManager which owns the world,
|
||||
## guard, simulation coordinator, and UI.
|
||||
|
||||
const GameManager := preload("res://src/game/game_manager.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
var manager := GameManager.new()
|
||||
manager.name = "GameManager"
|
||||
add_child(manager)
|
||||
print("[Step Line] game started")
|
||||
@@ -0,0 +1,118 @@
|
||||
class_name NpcAvatar
|
||||
extends CharacterBody3D
|
||||
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
|
||||
## Gives the controller a body, color-coded by archetype, and simple movement.
|
||||
|
||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
var npc: NPCController = null
|
||||
var spawn_position: Vector3 = Vector3.ZERO
|
||||
var target_position: Vector3 = Vector3.ZERO
|
||||
var awaiting_interaction := false
|
||||
var arrived := false
|
||||
|
||||
const TYPE_COLORS := {
|
||||
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
|
||||
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
|
||||
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
|
||||
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
|
||||
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
|
||||
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
|
||||
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
|
||||
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
|
||||
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
|
||||
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
|
||||
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
|
||||
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
|
||||
}
|
||||
|
||||
func _init() -> void:
|
||||
npc = NPCController.new()
|
||||
|
||||
func get_npc_controller() -> NPCController:
|
||||
return npc
|
||||
|
||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3) -> void:
|
||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
||||
spawn_position = p_spawn
|
||||
target_position = p_target
|
||||
position = p_spawn
|
||||
npc.fsm.state_changed.connect(_on_state_changed)
|
||||
_build_body(profile.npc_type)
|
||||
|
||||
# Normalize intent targets: approach the entrance, stop near the door.
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.35
|
||||
shape.height = 1.6
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.8, 0)
|
||||
add_child(collider)
|
||||
|
||||
func _build_body(npc_type: int) -> void:
|
||||
var body := MeshInstance3D.new()
|
||||
var capsule := CapsuleMesh.new()
|
||||
capsule.radius = 0.35
|
||||
capsule.height = 1.6
|
||||
body.mesh = capsule
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
body.material_override = mat
|
||||
add_child(body)
|
||||
var id_tag := MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.12
|
||||
sphere.height = 0.24
|
||||
var tag_mat := StandardMaterial3D.new()
|
||||
tag_mat.albedo_color = Color(1, 1, 1)
|
||||
id_tag.mesh = sphere
|
||||
id_tag.material_override = tag_mat
|
||||
id_tag.position = Vector3(0, 1.05, 0)
|
||||
id_tag.scale = Vector3(1, 0.4, 1)
|
||||
add_child(id_tag)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if npc == null or npc.fsm == null:
|
||||
return
|
||||
_advance_fsm()
|
||||
_move_toward_target(delta)
|
||||
|
||||
func _advance_fsm() -> void:
|
||||
if npc.leave_requested:
|
||||
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
||||
away.y = 0.5
|
||||
target_position = away
|
||||
return
|
||||
if arrived and not awaiting_interaction:
|
||||
awaiting_interaction = true
|
||||
|
||||
func _move_toward_target(delta: float) -> void:
|
||||
var to_target := target_position - global_position
|
||||
to_target.y = 0.0
|
||||
var dist := to_target.length()
|
||||
if dist < 0.7:
|
||||
_arrive()
|
||||
return
|
||||
var dir := to_target.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
|
||||
func _arrive() -> void:
|
||||
arrived = true
|
||||
velocity = Vector3.ZERO
|
||||
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
|
||||
target_position = Vector3(0, 0.5, 1.2)
|
||||
if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8:
|
||||
awaiting_interaction = true
|
||||
|
||||
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
|
||||
match to_state:
|
||||
"LEAVING":
|
||||
npc.leave_requested = true
|
||||
"THREATENING", "FORCING_ENTRY":
|
||||
# Advance toward the door posture; keep it simple for the prototype.
|
||||
pass
|
||||
@@ -0,0 +1,168 @@
|
||||
class_name Hud
|
||||
extends CanvasLayer
|
||||
## Minimal on-screen HUD: top status bar, target info, action hints,
|
||||
## event log, access inspection panel, and a debug overlay.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
var top_label: Label
|
||||
var time_label: Label
|
||||
var target_label: Label
|
||||
var hint_label: Label
|
||||
var log_label: Label
|
||||
var debug_label: Label
|
||||
var inspect_panel: PanelContainer
|
||||
var inspect_text: Label
|
||||
var inspect_meta: Label
|
||||
|
||||
var _debug_enabled := false
|
||||
var _log_lines: Array[String] = []
|
||||
|
||||
func _init() -> void:
|
||||
name = "HUD"
|
||||
layer = 10
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
margin.add_child(vbox)
|
||||
|
||||
var top := HBoxContainer.new()
|
||||
vbox.add_child(top)
|
||||
|
||||
top_label = Label.new()
|
||||
top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
top.add_child(top_label)
|
||||
|
||||
time_label = Label.new()
|
||||
top.add_child(time_label)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 6)
|
||||
vbox.add_child(spacer)
|
||||
|
||||
target_label = Label.new()
|
||||
target_label.add_theme_font_size_override("font_size", 16)
|
||||
vbox.add_child(target_label)
|
||||
|
||||
hint_label = Label.new()
|
||||
hint_label.modulate = Color(1, 0.9, 0.6)
|
||||
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
hint_label.custom_minimum_size = Vector2(0, 40)
|
||||
vbox.add_child(hint_label)
|
||||
|
||||
var spacer2 := Control.new()
|
||||
spacer2.custom_minimum_size = Vector2(0, 4)
|
||||
vbox.add_child(spacer2)
|
||||
|
||||
log_label = Label.new()
|
||||
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
log_label.custom_minimum_size = Vector2(0, 120)
|
||||
log_label.add_theme_font_size_override("font_size", 13)
|
||||
vbox.add_child(log_label)
|
||||
|
||||
var bottom_spacer := Control.new()
|
||||
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
vbox.add_child(bottom_spacer)
|
||||
|
||||
var controls := Label.new()
|
||||
controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug"
|
||||
controls.add_theme_font_size_override("font_size", 12)
|
||||
controls.modulate = Color(0.8, 0.8, 0.85)
|
||||
vbox.add_child(controls)
|
||||
|
||||
# --- inspect panel (popup center) ---
|
||||
inspect_panel = PanelContainer.new()
|
||||
inspect_panel.visible = false
|
||||
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
inspect_panel.modulate.a = 1.0
|
||||
var ivbox := VBoxContainer.new()
|
||||
inspect_panel.add_child(ivbox)
|
||||
inspect_text = Label.new()
|
||||
inspect_text.add_theme_font_size_override("font_size", 15)
|
||||
ivbox.add_child(inspect_text)
|
||||
inspect_meta = Label.new()
|
||||
inspect_meta.add_theme_font_size_override("font_size", 12)
|
||||
inspect_meta.modulate = Color(0.8, 0.85, 1.0)
|
||||
ivbox.add_child(inspect_meta)
|
||||
var insp_hint := Label.new()
|
||||
insp_hint.text = "A = allow entry D = deny entry"
|
||||
insp_hint.add_theme_font_size_override("font_size", 12)
|
||||
ivbox.add_child(insp_hint)
|
||||
add_child(inspect_panel)
|
||||
|
||||
# --- debug overlay ---
|
||||
debug_label = Label.new()
|
||||
debug_label.visible = false
|
||||
debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
debug_label.position = Vector2(16, 0)
|
||||
debug_label.add_theme_font_size_override("font_size", 11)
|
||||
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(debug_label)
|
||||
|
||||
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
||||
top_label.text = scenario.display_name if scenario else "Step Line"
|
||||
var mins := int(coordinator.elapsed_s) / 60
|
||||
var secs := int(coordinator.elapsed_s) % 60
|
||||
time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [
|
||||
mins, secs,
|
||||
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"),
|
||||
coordinator.reputation.rating,
|
||||
coordinator.crowd.mood * 100.0,
|
||||
]
|
||||
|
||||
func set_target_label(text: String) -> void:
|
||||
target_label.text = text
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
|
||||
func log(text: String) -> void:
|
||||
_log_lines.append(text)
|
||||
if _log_lines.size() > 8:
|
||||
_log_lines.pop_front()
|
||||
log_label.text = "\n".join(_log_lines)
|
||||
|
||||
func toggle_debug() -> void:
|
||||
_debug_enabled = not _debug_enabled
|
||||
debug_label.visible = _debug_enabled
|
||||
|
||||
func refresh_debug(coordinator: Object, npcs: Array) -> void:
|
||||
if not _debug_enabled:
|
||||
return
|
||||
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
|
||||
coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]]
|
||||
for npc in npcs:
|
||||
var c: Object = npc.get_npc_controller()
|
||||
if c == null:
|
||||
continue
|
||||
var profile: Object = c.profile
|
||||
lines.append(" [%s] %s intent=%s state=%s esc=%d" % [
|
||||
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
|
||||
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
|
||||
])
|
||||
debug_label.text = "\n".join(lines)
|
||||
|
||||
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
|
||||
var profile: Object = controller.profile
|
||||
var npc_display: String = (profile.display_name if profile else "Person")
|
||||
var granted: bool = result.get("granted", false)
|
||||
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
|
||||
var lines: Array[String] = []
|
||||
for artifact in artifacts:
|
||||
lines.append(" • " + artifact.summary())
|
||||
if result.get("reasons", []).size() > 0:
|
||||
lines.append("Reasons: " + ", ".join(result.get("reasons", [])))
|
||||
if result.get("issues", []).size() > 0:
|
||||
lines.append("Issues: " + "; ".join(result.get("issues", [])))
|
||||
inspect_meta.text = "\n".join(lines)
|
||||
inspect_panel.visible = true
|
||||
|
||||
func hide_inspection() -> void:
|
||||
inspect_panel.visible = false
|
||||
@@ -0,0 +1,159 @@
|
||||
class_name RadioPanel
|
||||
extends CanvasLayer
|
||||
## Radio report form (PLAN.md Level 4 assist / Level 5 emergency).
|
||||
## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent
|
||||
## accidental activation.
|
||||
|
||||
signal submitted(report: Dictionary, emergency: bool)
|
||||
signal cancelled()
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
var panel: PanelContainer
|
||||
var npc_name_label: Label
|
||||
var location_edit: LineEdit
|
||||
var desc_edit: LineEdit
|
||||
var weapons_check: CheckBox
|
||||
var danger_option: OptionButton
|
||||
var action_button: Button
|
||||
var hold_hint: Label
|
||||
var status_label: Label
|
||||
|
||||
var _emergency := false
|
||||
var _npc_id := ""
|
||||
var _holding := false
|
||||
var _hold_time := 0.0
|
||||
const HOLD_NEEDED := 1.6
|
||||
var _truth := {"weapons_seen": false, "danger_level": 0}
|
||||
|
||||
func _init() -> void:
|
||||
name = "RadioPanel"
|
||||
layer = 20
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.55)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(dim)
|
||||
|
||||
panel = PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
add_child(panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
panel.add_child(vbox)
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
|
||||
var title := Label.new()
|
||||
title.name = "RadioTitle"
|
||||
title.add_theme_font_size_override("font_size", 18)
|
||||
vbox.add_child(title)
|
||||
npc_name_label = Label.new()
|
||||
vbox.add_child(npc_name_label)
|
||||
|
||||
location_edit = LineEdit.new()
|
||||
location_edit.placeholder_text = "Location (e.g. front steps)"
|
||||
location_edit.text = "front entrance"
|
||||
vbox.add_child(location_edit)
|
||||
|
||||
desc_edit = LineEdit.new()
|
||||
desc_edit.placeholder_text = "Describe the person and behavior"
|
||||
vbox.add_child(desc_edit)
|
||||
|
||||
weapons_check = CheckBox.new()
|
||||
weapons_check.text = "Weapon seen"
|
||||
vbox.add_child(weapons_check)
|
||||
|
||||
var danger_row := HBoxContainer.new()
|
||||
vbox.add_child(danger_row)
|
||||
var danger_label := Label.new()
|
||||
danger_label.text = "Danger level: "
|
||||
danger_row.add_child(danger_label)
|
||||
danger_option = OptionButton.new()
|
||||
for i in 4:
|
||||
danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i)
|
||||
danger_option.select(1)
|
||||
danger_row.add_child(danger_option)
|
||||
|
||||
hold_hint = Label.new()
|
||||
hold_hint.text = ""
|
||||
hold_hint.modulate = Color(1, 0.85, 0.55)
|
||||
vbox.add_child(hold_hint)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.text = ""
|
||||
vbox.add_child(status_label)
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
vbox.add_child(buttons)
|
||||
action_button = Button.new()
|
||||
action_button.text = "Send request"
|
||||
action_button.button_down.connect(func(): _start_hold())
|
||||
action_button.button_up.connect(func(): _stop_hold(false))
|
||||
buttons.add_child(action_button)
|
||||
var cancel_btn := Button.new()
|
||||
cancel_btn.text = "Cancel"
|
||||
cancel_btn.pressed.connect(func(): cancelled.emit())
|
||||
buttons.add_child(cancel_btn)
|
||||
|
||||
var _title_label: Label
|
||||
|
||||
func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
|
||||
_emergency = emergency
|
||||
_truth = truth
|
||||
_npc_id = controller.npc_id if controller else ""
|
||||
npc_name_label.text = "Target: %s" % (controller.profile.display_name if controller else "?")
|
||||
var title: Label = panel.find_child("RadioTitle", true, false)
|
||||
title.text = "EMERGENCY CALL" if emergency else "Security Assistance"
|
||||
weapons_check.button_pressed = truth.get("weapons_seen", false)
|
||||
danger_option.select(truth.get("danger_level", 1))
|
||||
hold_hint.text = "Hold the button for %.1fs" % HOLD_NEEDED if emergency else ""
|
||||
status_label.text = ""
|
||||
action_button.text = "SEND EMERGENCY" if emergency else "Send request"
|
||||
_holding = false
|
||||
_hold_time = 0.0
|
||||
visible = true
|
||||
|
||||
func _start_hold() -> void:
|
||||
_holding = _emergency
|
||||
_hold_time = 0.0
|
||||
|
||||
func _stop_hold(cancelled_early: bool) -> void:
|
||||
if _holding:
|
||||
_holding = false
|
||||
if not cancelled_early and _hold_time >= HOLD_NEEDED:
|
||||
_submit()
|
||||
else:
|
||||
status_label.text = "Call cancelled - must hold the button."
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _holding:
|
||||
_hold_time += delta
|
||||
status_label.text = "Holding... %.0f%%" % (minf(1.0, _hold_time / HOLD_NEEDED) * 100.0)
|
||||
if _hold_time >= HOLD_NEEDED:
|
||||
var was := _holding
|
||||
_holding = false
|
||||
if was:
|
||||
_submit()
|
||||
|
||||
func _submit() -> void:
|
||||
if not visible:
|
||||
return
|
||||
var report := {
|
||||
"npc_id": _npc_id,
|
||||
"location": location_edit.text,
|
||||
"description": desc_edit.text,
|
||||
"behavior": desc_edit.text,
|
||||
"weapons_seen": weapons_check.button_pressed,
|
||||
"danger_level": danger_option.get_selected_id(),
|
||||
}
|
||||
visible = false
|
||||
submitted.emit(report, _emergency)
|
||||
|
||||
func _danger_name(level: int) -> String:
|
||||
match level:
|
||||
0: return "none"
|
||||
1: return "low"
|
||||
2: return "elevated"
|
||||
3: return "immediate threat"
|
||||
_: return "unknown"
|
||||
@@ -0,0 +1,77 @@
|
||||
class_name ShiftReview
|
||||
extends CanvasLayer
|
||||
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
||||
|
||||
signal restart_requested()
|
||||
|
||||
var report_label: Label
|
||||
|
||||
func _init() -> void:
|
||||
name = "ShiftReview"
|
||||
layer = 30
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0.04, 0.05, 0.08, 0.92)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(dim)
|
||||
|
||||
var center := VBoxContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_CENTER)
|
||||
center.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
center.add_theme_constant_override("separation", 12)
|
||||
add_child(center)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Shift Review"
|
||||
title.add_theme_font_size_override("font_size", 28)
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
center.add_child(title)
|
||||
|
||||
report_label = Label.new()
|
||||
report_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
report_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
report_label.custom_minimum_size = Vector2(720, 0)
|
||||
center.add_child(report_label)
|
||||
|
||||
var restart := Button.new()
|
||||
restart.text = "Run another shift"
|
||||
restart.pressed.connect(func(): restart_requested.emit())
|
||||
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
center.add_child(restart)
|
||||
|
||||
var quit_btn := Button.new()
|
||||
quit_btn.text = "Quit"
|
||||
quit_btn.pressed.connect(func(): get_tree().quit())
|
||||
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
center.add_child(quit_btn)
|
||||
|
||||
func show_review(review: Dictionary, scenario: Resource) -> void:
|
||||
var reputation: Dictionary = review.get("reputation", {})
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var crowd: Dictionary = review.get("crowd", {})
|
||||
var radio: Dictionary = review.get("radio", {})
|
||||
var incident_list: Array = incidents.get("incidents", [])
|
||||
var peaceful := 0
|
||||
for inc in incident_list:
|
||||
if inc.get("outcome", "") in ["peaceful", "left"]:
|
||||
peaceful += 1
|
||||
|
||||
var lines: Array[String] = [
|
||||
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
|
||||
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
|
||||
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
|
||||
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
||||
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
||||
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
|
||||
"",
|
||||
"Highlights:",
|
||||
]
|
||||
var mods: Array = reputation.get("modifiers", [])
|
||||
var shown := 0
|
||||
for mod in mods:
|
||||
var delta: float = float(mod.get("delta", 0.0))
|
||||
lines.append(" %s (%+.0f) %s" % [mod.get("category", ""), delta, mod.get("reason", "")])
|
||||
shown += 1
|
||||
if shown >= 12:
|
||||
break
|
||||
report_label.text = "\n".join(lines)
|
||||
visible = true
|
||||
@@ -0,0 +1,110 @@
|
||||
class_name WorldBuilder
|
||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
var root: Node3D = null
|
||||
|
||||
func _init(root_node: Node3D) -> void:
|
||||
root = root_node
|
||||
|
||||
func build() -> void:
|
||||
_build_ground()
|
||||
_build_building()
|
||||
_build_steps()
|
||||
_build_sidewalk()
|
||||
_build_street()
|
||||
_build_guard_spawn()
|
||||
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mesh.material = mat
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.cast_shadow = (1 if cast_shadow else 0) as GeometryInstance3D.ShadowCastingSetting
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
func _build_ground() -> void:
|
||||
var ground := StaticBody3D.new()
|
||||
ground.name = "Ground"
|
||||
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(160, 1, 160)
|
||||
var shape_owner := ground.create_shape_owner(ground)
|
||||
ground.shape_owner_add_shape(shape_owner, col)
|
||||
root.add_child(ground)
|
||||
|
||||
func _build_building() -> void:
|
||||
var building := StaticBody3D.new()
|
||||
building.name = "Building"
|
||||
# Main facade
|
||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
|
||||
# Entrance recess + door recess frame
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10))
|
||||
# Door
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
|
||||
# Door window
|
||||
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
|
||||
# Awning sign bar
|
||||
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
|
||||
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)))
|
||||
root.add_child(building)
|
||||
|
||||
func _build_steps() -> void:
|
||||
var steps := StaticBody3D.new()
|
||||
steps.name = "Steps"
|
||||
var step_w := 8.0
|
||||
var tread := 0.6
|
||||
var height := 0.16
|
||||
for i in 4:
|
||||
var y: float = i * height
|
||||
var z: float = 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))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(step_w, 0.65, tread * 4 + 3)
|
||||
var so := steps.create_shape_owner(steps)
|
||||
steps.shape_owner_add_shape(so, col)
|
||||
steps.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0.32, 0.8)))
|
||||
root.add_child(steps)
|
||||
|
||||
func _build_sidewalk() -> void:
|
||||
var sidewalk := StaticBody3D.new()
|
||||
sidewalk.name = "Sidewalk"
|
||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(30, 0.36, 8)
|
||||
var so := sidewalk.create_shape_owner(sidewalk)
|
||||
sidewalk.shape_owner_add_shape(so, col)
|
||||
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8)))
|
||||
root.add_child(sidewalk)
|
||||
|
||||
func _build_street() -> void:
|
||||
var street := StaticBody3D.new()
|
||||
street.name = "Street"
|
||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14))
|
||||
# Center dashes
|
||||
for i in range(-40, 41, 4):
|
||||
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(120, 0.2, 26)
|
||||
var so := street.create_shape_owner(street)
|
||||
street.shape_owner_add_shape(so, col)
|
||||
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
|
||||
root.add_child(street)
|
||||
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
marker.position = Vector3(0, 1.6, -2.5)
|
||||
root.add_child(marker)
|
||||
Reference in new issue
Block a user