NEO-99: wire district GLB overlay and migrate tab targets to PrototypeNpcRegistry.

Add visual catalog/art scripts, instance environment art in main, spawn GLB
interactable and NPC prop meshes at server anchors, and replace legacy
prototype_target_* ids with prototype_npc_elite/melee/ranged for Tab lock.
pull/134/head
VinPropane 2026-05-29 20:03:45 -04:00
parent e19ecdb390
commit fa3d48a6aa
9 changed files with 188 additions and 44 deletions

View File

@ -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

View File

@ -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)

View File

@ -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 (

View File

@ -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)

View File

@ -0,0 +1 @@
uid://brx4j9m2n8k6v

View File

@ -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

View File

@ -0,0 +1 @@
uid://dqw8h2k5m3n7p

View File

@ -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()

View File

@ -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