diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 2489b1c..e077411 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -20,6 +20,8 @@ [ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"] [ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"] [ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"] +[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"] +[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -265,6 +267,9 @@ shape = SubResource("BoxShape3D_floor") [node name="InteractablesRoot" type="Node3D" parent="World/NavigationRegion3D" unique_id=1700011] +[node name="DistrictArt" parent="World/NavigationRegion3D" instance=ExtResource("22_district_env")] +script = ExtResource("23_district_art") + [node name="MoveRejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) collision_mask = 3 diff --git a/client/scripts/interactable_world_builder.gd b/client/scripts/interactable_world_builder.gd index c4968bd..99d1ab3 100644 --- a/client/scripts/interactable_world_builder.gd +++ b/client/scripts/interactable_world_builder.gd @@ -3,6 +3,10 @@ extends Object ## NEO-25: spawns walkable interactable props + glow marker pairs from server descriptor dicts. ## Returns glow groups for [method InteractionRadiusIndicators.setup_glow_groups]. +const PrototypeDistrictVisualCatalog := preload( + "res://scripts/prototype_district_visual_catalog.gd" +) + const _PROP_SIZE := Vector3(0.9, 1.0, 0.4) const _MARKER_MESH_SIZE := Vector3(0.22, 0.35, 0.22) const _MARKER_OFFSET_A := Vector3(1.1, 0.25, 0.9) @@ -35,18 +39,7 @@ static func build_from_catalog( interactables_root.add_child(body) body.global_position = anchor - var prop_mesh := MeshInstance3D.new() - var box_mesh := BoxMesh.new() - box_mesh.size = _PROP_SIZE - prop_mesh.mesh = box_mesh - prop_mesh.position = Vector3.ZERO - var prop_mat := StandardMaterial3D.new() - if kind == "resource_node": - prop_mat.albedo_color = Color(0.18, 0.55, 0.32, 1.0) - else: - prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0) - prop_mesh.material_override = prop_mat - body.add_child(prop_mesh) + _add_prop_visual(body, id, kind, anchor.y) var col := CollisionShape3D.new() var box_shape := BoxShape3D.new() @@ -99,3 +92,29 @@ static func _make_marker_mesh() -> MeshInstance3D: m.size = _MARKER_MESH_SIZE mi.mesh = m return mi + + +static func _add_prop_visual( + body: StaticBody3D, interactable_id: String, kind: String, anchor_y: float +) -> void: + var packed: Variant = PrototypeDistrictVisualCatalog.INTERACTABLE_PROPS.get(interactable_id, null) + if packed is PackedScene: + var visual: Node3D = PrototypeDistrictVisualCatalog.instantiate_prop( + packed as PackedScene, anchor_y + ) + visual.name = "PropVisual" + body.add_child(visual) + return + + var prop_mesh := MeshInstance3D.new() + var box_mesh := BoxMesh.new() + box_mesh.size = _PROP_SIZE + prop_mesh.mesh = box_mesh + prop_mesh.position = Vector3.ZERO + var prop_mat := StandardMaterial3D.new() + if kind == "resource_node": + prop_mat.albedo_color = Color(0.18, 0.55, 0.32, 1.0) + else: + prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0) + prop_mesh.material_override = prop_mat + body.add_child(prop_mesh) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 8301d0b..411be45 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -970,7 +970,7 @@ func _render_combat_target_hp_label() -> void: return var lock_id := _locked_target_id_from_state(_last_target_state) if lock_id.is_empty(): - _combat_target_hp_label.text = ("Target HP: — (Tab → orange/purple dummy near spawn)") + _combat_target_hp_label.text = ("Target HP: — (Tab → elite/melee/ranged near spawn)") return var row: Dictionary = {} if ( diff --git a/client/scripts/prototype_district_art.gd b/client/scripts/prototype_district_art.gd new file mode 100644 index 0000000..4891c9d --- /dev/null +++ b/client/scripts/prototype_district_art.gd @@ -0,0 +1,29 @@ +extends Node3D + +## Visual overlay for the prototype district (`prototype_district_environment.glb`). +## Hides placeholder box meshes under the navigation region while keeping collision shapes. +## Blender export mirrored +Z relative to `main.tscn`; flip art so visuals line up with colliders. + + +func _ready() -> void: + scale = Vector3(1.0, 1.0, -1.0) + var nav: NavigationRegion3D = get_parent() as NavigationRegion3D + if nav == null: + return + _hide_placeholder_meshes(nav) + + +static func _hide_placeholder_meshes(nav: NavigationRegion3D) -> void: + for child in nav.get_children(): + if child is Node3D and child.name == "DistrictArt": + continue + if child.name == "InteractablesRoot": + continue + _hide_mesh_instances(child) + + +static func _hide_mesh_instances(node: Node) -> void: + if node is MeshInstance3D: + (node as MeshInstance3D).visible = false + for child in node.get_children(): + _hide_mesh_instances(child) diff --git a/client/scripts/prototype_district_art.gd.uid b/client/scripts/prototype_district_art.gd.uid new file mode 100644 index 0000000..832dc33 --- /dev/null +++ b/client/scripts/prototype_district_art.gd.uid @@ -0,0 +1 @@ +uid://brx4j9m2n8k6v diff --git a/client/scripts/prototype_district_visual_catalog.gd b/client/scripts/prototype_district_visual_catalog.gd new file mode 100644 index 0000000..887b61e --- /dev/null +++ b/client/scripts/prototype_district_visual_catalog.gd @@ -0,0 +1,61 @@ +extends Object + +## Blender-exported prototype district art (visual-only). Collision/nav stay on `main.tscn` boxes. +## Prop GLBs import as a wrapper root plus one child mesh node that still carries baked +## world transforms from Blender — see [method normalize_prop_visual]. + +const ENVIRONMENT: PackedScene = preload("res://assets/district/prototype_district_environment.glb") + +const INTERACTABLE_PROPS: Dictionary = { + "prototype_terminal": preload("res://assets/district/props/terminal.glb"), + "prototype_resource_node_alpha": preload("res://assets/district/props/salvage_alpha.glb"), + "prototype_subsurface_vein_beta": preload("res://assets/district/props/subsurface_beta.glb"), + "prototype_bio_mat_gamma": preload("res://assets/district/props/bio_gamma.glb"), + "prototype_urban_bulk_delta": preload("res://assets/district/props/urban_delta.glb"), +} + +const TARGET_PROPS: Dictionary = { + "prototype_npc_melee": preload("res://assets/district/props/target_alpha.glb"), + "prototype_npc_ranged": preload("res://assets/district/props/target_beta.glb"), + "prototype_npc_elite": preload("res://assets/district/props/target_alpha.glb"), +} + + +static func instantiate_prop(packed: PackedScene, anchor_y: float = 0.5) -> Node3D: + var visual: Node3D = packed.instantiate() as Node3D + normalize_prop_visual(visual, anchor_y) + return visual + + +static func normalize_prop_visual(visual: Node3D, anchor_y: float) -> void: + _zero_node3d_tree(visual) + var meshes: Array = _collect_mesh_instances(visual) + if meshes.is_empty(): + visual.position = Vector3.ZERO + return + var min_y: float = INF + for mesh_variant: Variant in meshes: + var mesh: MeshInstance3D = mesh_variant as MeshInstance3D + min_y = min(min_y, mesh.get_aabb().position.y) + if min_y == INF: + visual.position = Vector3.ZERO + return + visual.position = Vector3(0.0, -anchor_y - min_y, 0.0) + + +static func _zero_node3d_tree(node: Node3D) -> void: + node.position = Vector3.ZERO + node.rotation = Vector3.ZERO + node.scale = Vector3.ONE + for child in node.get_children(): + if child is Node3D: + _zero_node3d_tree(child as Node3D) + + +static func _collect_mesh_instances(node: Node) -> Array: + var meshes: Array = [] + if node is MeshInstance3D: + meshes.append(node) + for child in node.get_children(): + meshes.append_array(_collect_mesh_instances(child)) + return meshes diff --git a/client/scripts/prototype_district_visual_catalog.gd.uid b/client/scripts/prototype_district_visual_catalog.gd.uid new file mode 100644 index 0000000..aba2a61 --- /dev/null +++ b/client/scripts/prototype_district_visual_catalog.gd.uid @@ -0,0 +1 @@ +uid://dqw8h2k5m3n7p diff --git a/client/scripts/prototype_target_constants.gd b/client/scripts/prototype_target_constants.gd index db28123..e0d46a7 100644 --- a/client/scripts/prototype_target_constants.gd +++ b/client/scripts/prototype_target_constants.gd @@ -1,33 +1,41 @@ extends Object -## NEO-24: prototype combat target ids in **tab cycle order** — must match -## `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs` -## (`PrototypeTargetAlphaId`, `PrototypeTargetBetaId`; ascending id order). -## Anchors + radii are a **display-only** mirror of the C# registry so the scene markers +## NEO-24 / NEO-91: prototype combat NPC ids in **tab cycle order** — must match +## `server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs` +## (`GetInstanceIdsInOrder()` ascending id order). +## Anchors + radii are a **display-only** mirror of the C# registry so scene markers ## (`prototype_target_markers.gd`) and HUD distance readouts match server-authoritative ## range decisions. If the server registry moves, update this file in the same commit — ## the two must stay in lock-step to avoid confusing "ok at 10 m / denied at 5 m" bugs. ## Static accessors only (no `class_name`; headless / CI loads before editor import — see client ## README Godot CLI section). -const ORDERED_IDS: Array[String] = ["prototype_target_alpha", "prototype_target_beta"] +const ORDERED_IDS: Array[String] = [ + "prototype_npc_elite", + "prototype_npc_melee", + "prototype_npc_ranged", +] -## All prototype targets share a single radius (`PrototypeTargetRegistry.SharedLockRadius` -## on the server). Per-target radii will come back with real combat design (E5.M1). +## All prototype NPCs share a single radius (`PrototypeNpcRegistry.SharedLockRadius` +## on the server). Per-instance radii will come back with real combat design (E5.M1). const SHARED_LOCK_RADIUS: float = 6.0 -## Keys match [constant ORDERED_IDS]; values mirror `PrototypeTargetRegistry.cs` anchors -## (world space) and horizontal lock radii (XZ meters). Anchors are placed so the two rings -## overlap at origin (~3.5 m wide), letting Tab flip between targets without locomotion. -## Keep in lock-step with the C# registry. +## Keys match [constant ORDERED_IDS]; values mirror `PrototypeNpcRegistry.cs` anchors +## (world space) and horizontal lock radii (XZ meters). const ANCHORS: Dictionary = { - "prototype_target_alpha": + "prototype_npc_elite": + { + "position": Vector3(0.0, 0.5, 0.0), + "radius": SHARED_LOCK_RADIUS, + "color": Color(1.0, 0.85, 0.2, 1.0), + }, + "prototype_npc_melee": { "position": Vector3(-3.0, 0.5, -3.0), "radius": SHARED_LOCK_RADIUS, "color": Color(1.0, 0.55, 0.2, 1.0), }, - "prototype_target_beta": + "prototype_npc_ranged": { "position": Vector3(3.0, 0.0, 3.0), "radius": SHARED_LOCK_RADIUS, @@ -118,10 +126,12 @@ static func next_in_range_id_after(current: Variant, world: Vector3) -> String: ## Player-facing short label for world [Label3D] markers (NEO-85 combat UX). static func display_short_name(target_id: String) -> String: match target_id: - "prototype_target_alpha": - return "Dummy α" - "prototype_target_beta": - return "Dummy β" + "prototype_npc_elite": + return "Elite" + "prototype_npc_melee": + return "Melee" + "prototype_npc_ranged": + return "Ranged" _: return target_id.strip_edges() diff --git a/client/scripts/prototype_target_markers.gd b/client/scripts/prototype_target_markers.gd index a8e73ae..faa8e20 100644 --- a/client/scripts/prototype_target_markers.gd +++ b/client/scripts/prototype_target_markers.gd @@ -1,14 +1,17 @@ extends Node3D -## NEO-24 UX: combat dummy anchors (`prototype_target_alpha`, `prototype_target_beta`) need -## obvious world markers — a capsule body, lock ring, and [Label3D] name per -## `PrototypeTargetConstants.ANCHORS`. NEO-85: highlight the server-locked dummy via +## NEO-24 / NEO-91 UX: prototype NPC anchors need obvious world markers — imported +## prop mesh (or capsule fallback), lock ring, and [Label3D] name per +## `PrototypeTargetConstants.ANCHORS`. NEO-85: highlight the server-locked NPC via ## [method set_locked_target]. ## ## Display-only; no game logic hangs off these nodes. If the server registry moves, ## update `prototype_target_constants.gd` — markers respawn on next scene load. const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") +const PrototypeDistrictVisualCatalog := preload( + "res://scripts/prototype_district_visual_catalog.gd" +) ## Visual tuning — readable from the default isometric camera at spawn. const _DUMMY_CAPSULE_HEIGHT: float = 1.75 @@ -46,17 +49,29 @@ func _spawn_marker(target_id: String) -> void: group.position = anchor add_child(group) - var body := MeshInstance3D.new() + var body := Node3D.new() body.name = "DummyBody" - var capsule := CapsuleMesh.new() - capsule.radius = _DUMMY_CAPSULE_RADIUS - capsule.height = _DUMMY_CAPSULE_HEIGHT - body.mesh = capsule - body.position = Vector3(0.0, _DUMMY_CAPSULE_HEIGHT * 0.5, 0.0) - var body_mat := _make_unshaded_material(tint, 0.88) - body.material_override = body_mat group.add_child(body) + var body_mat: StandardMaterial3D = null + var packed: Variant = PrototypeDistrictVisualCatalog.TARGET_PROPS.get(target_id, null) + if packed is PackedScene: + var visual: Node3D = PrototypeDistrictVisualCatalog.instantiate_prop( + packed as PackedScene, anchor.y + ) + visual.name = "PropVisual" + body.add_child(visual) + else: + var fallback := MeshInstance3D.new() + var capsule := CapsuleMesh.new() + capsule.radius = _DUMMY_CAPSULE_RADIUS + capsule.height = _DUMMY_CAPSULE_HEIGHT + fallback.mesh = capsule + fallback.position = Vector3(0.0, _DUMMY_CAPSULE_HEIGHT * 0.5, 0.0) + body_mat = _make_unshaded_material(tint, 0.88) + fallback.material_override = body_mat + body.add_child(fallback) + var ring: MeshInstance3D = _make_radius_ring(radius, tint) ring.name = "RadiusRing" ring.position = Vector3(0.0, _RING_Y - anchor.y, 0.0) @@ -86,16 +101,19 @@ func _apply_highlight_states() -> void: var target_id: String = id_variant as String var entry: Dictionary = _markers_by_id[target_id] var tint: Color = entry["tint"] - var body_mat: StandardMaterial3D = entry["body_mat"] + var body_mat: Variant = entry.get("body_mat") var ring_mat: StandardMaterial3D = entry["ring_mat"] var highlighted: bool = target_id == _locked_target_id and not _locked_target_id.is_empty() + if body_mat is StandardMaterial3D: + if highlighted: + (body_mat as StandardMaterial3D).albedo_color = tint.lightened(0.25) + else: + (body_mat as StandardMaterial3D).albedo_color = Color(tint.r, tint.g, tint.b, 0.88) if highlighted: - body_mat.albedo_color = tint.lightened(0.25) var ring_c := tint ring_c.a = 0.78 ring_mat.albedo_color = ring_c else: - body_mat.albedo_color = Color(tint.r, tint.g, tint.b, 0.88) var ring_c2 := tint ring_c2.a = 0.45 ring_mat.albedo_color = ring_c2