Detailed graphics pass: authored textures, humanoid NPCs, HUD feedback, asset pipeline
- tools/generate_textures.py (deterministic PIL) produces all authored PNGs: brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art (all <=1024px, power-of-two) - WorldBuilder: textured facade/glass door/sidewalk/asphalt, striped awning with 'STEP LINE', generated skyline backdrop planes - GameManager: soft sun shadows (4-split parallel); guard in 'guard' group - NPC avatars: humanoid rig (torso/head/limbs) with archetype props (camera, delivery box, performer hat, protest sign, tourist backpack), procedural walk bob, head-turns-toward-guard attention cue - HUD: inspection artifact cards, mode icons (flashlight/door/wand), per-escalation vignette, body-camera overlay (corner brackets + REC + clock) - assets/ import convention documented; test_asset_textures_present_and_pow2 guards the pipeline (57 tests pass) - README tech stack updated for Gitea Actions + asset pipeline; screenshot gallery regenerated (day + night)
This commit is contained in:
1 parent
ac3bb7dcd8
commit
031503e161
31 files changed
+636
-57
No files matched your search
@@ -142,6 +142,7 @@ func _build_guard() -> GuardController:
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.85, 0)
|
||||
g.add_child(collider)
|
||||
g.add_to_group("guard")
|
||||
return g
|
||||
|
||||
func _build_scenario(id: String) -> VenueScenario:
|
||||
@@ -197,6 +198,10 @@ func _ensure_lighting() -> void:
|
||||
_sun.name = "Sun"
|
||||
_sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||
_sun.light_energy = 1.2
|
||||
_sun.shadow_enabled = true
|
||||
_sun.light_angular_distance = 1.2
|
||||
_sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
|
||||
_sun.directional_shadow_max_distance = 60.0
|
||||
scene_root.add_child(_sun)
|
||||
_env_node = WorldEnvironment.new()
|
||||
var env := Environment.new()
|
||||
@@ -398,6 +403,7 @@ func _cycle_venue() -> void:
|
||||
switch_scenario(VENUE_ORDER[_venue_index])
|
||||
var venue_name: String = selected_scenario.display_name if selected_scenario else "?"
|
||||
hud.log(Strings.fmt("hud.venue_switched", [venue_name]))
|
||||
hud.set_wand_icon(false)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
|
||||
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
||||
@@ -548,11 +554,14 @@ func _toggle_inspection() -> void:
|
||||
func _toggle_wand_screening() -> void:
|
||||
if wand_open:
|
||||
hud.hide_wand()
|
||||
hud.set_wand_icon(false)
|
||||
wand_open = false
|
||||
return
|
||||
if not selected_scenario.equipment.has("wand"):
|
||||
hud.log(Strings.lookup("hud.no_wand"))
|
||||
hud.set_wand_icon(false)
|
||||
return
|
||||
hud.set_wand_icon(true)
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
|
||||
+119
-24
@@ -68,41 +68,136 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
add_child(collider)
|
||||
|
||||
var _visual_seed := 0
|
||||
var _rig: Node3D = null
|
||||
var _head: Node3D = null
|
||||
var _bob_phase := 0.0
|
||||
var _guard_ref: Node3D = null
|
||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||
|
||||
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))
|
||||
_rig = Node3D.new()
|
||||
_rig.name = "Rig"
|
||||
add_child(_rig)
|
||||
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
# Per-seed variation: slight height and brightness differences so crowds
|
||||
# read as individuals (deterministic LCG, no RNG side effects).
|
||||
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
||||
var height_scale := 0.9 + 0.2 * frac
|
||||
body.scale = Vector3(1.0, height_scale, 1.0)
|
||||
body.position.y = (height_scale - 1.0) * 0.8
|
||||
mat.albedo_color = mat.albedo_color.lightened(0.15 * frac) if frac < 0.5 else mat.albedo_color.darkened(0.15 * (1.0 - frac))
|
||||
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)
|
||||
var height_scale := 0.92 + 0.16 * frac
|
||||
_rig.scale.y = height_scale
|
||||
var shader_delta := 0.12 * frac
|
||||
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
||||
var limbs_color := base_color.darkened(0.25)
|
||||
# Torso
|
||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||
# Legs
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
|
||||
# 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
|
||||
_head = Node3D.new()
|
||||
_head.name = "Head"
|
||||
_head.position = Vector3(0, 1.62, 0)
|
||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||
_rig.add_child(_head)
|
||||
_add_props(npc_type)
|
||||
|
||||
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()
|
||||
mesh.radius = radius
|
||||
mesh.height = height
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.rotation_degrees.z = z_rot
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := SphereMesh.new()
|
||||
mesh.radius = radius
|
||||
mesh.height = radius * 2.0
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
return mat
|
||||
|
||||
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||
func _add_props(npc_type: int) -> void:
|
||||
var chest_y := 1.25
|
||||
match npc_type:
|
||||
Rules.NpcType.INFLUENCER:
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
||||
Rules.NpcType.DELIVERY:
|
||||
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
||||
Rules.NpcType.PERFORMER:
|
||||
var hat := _material(Color(0.85, 0.2, 0.25))
|
||||
var cone := MeshInstance3D.new()
|
||||
var cm := CylinderMesh.new()
|
||||
cm.top_radius = 0.02
|
||||
cm.bottom_radius = 0.22
|
||||
cm.height = 0.4
|
||||
cone.mesh = cm
|
||||
cone.material_override = hat
|
||||
cone.position = Vector3(0, 0.26, 0)
|
||||
_head.add_child(cone)
|
||||
Rules.NpcType.PROTESTER:
|
||||
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
||||
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||
Rules.NpcType.TOURIST:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
||||
|
||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if npc == null or npc.fsm == null:
|
||||
return
|
||||
_advance_fsm(delta)
|
||||
_move_toward_target(delta)
|
||||
_animate(delta)
|
||||
|
||||
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
||||
func _animate(delta: float) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var speed := Vector2(velocity.x, velocity.z).length()
|
||||
if speed > 0.3:
|
||||
_bob_phase += delta * (6.0 + speed)
|
||||
_rig.position.y = sin(_bob_phase) * 0.035
|
||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||
else:
|
||||
_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)
|
||||
# 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):
|
||||
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
|
||||
if _guard_ref:
|
||||
var to_guard := (_guard_ref.global_position - global_position)
|
||||
to_guard.y = 0.0
|
||||
var world_yaw := atan2(to_guard.x, to_guard.z)
|
||||
var fwd := -_rig.global_transform.basis.z
|
||||
var parent_yaw := atan2(fwd.x, fwd.z)
|
||||
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
||||
|
||||
func _advance_fsm(delta: float) -> void:
|
||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||
|
||||
+157
-4
@@ -5,6 +5,23 @@ extends CanvasLayer
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
|
||||
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png")
|
||||
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png")
|
||||
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png")
|
||||
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png")
|
||||
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png")
|
||||
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png")
|
||||
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png")
|
||||
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png")
|
||||
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png")
|
||||
const CARD_TEXTURES := {
|
||||
Rules.AccessKind.ID: T_CARD_ID,
|
||||
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
|
||||
Rules.AccessKind.TICKET: T_CARD_TICKET,
|
||||
Rules.AccessKind.CREDENTIAL: T_CARD_CRED,
|
||||
Rules.AccessKind.GUEST_LIST: T_CARD_GUEST,
|
||||
}
|
||||
|
||||
var top_label: Label
|
||||
var time_label: Label
|
||||
@@ -17,6 +34,12 @@ var inspect_text: Label
|
||||
var inspect_meta: Label
|
||||
var radio_cooldown_label: Label
|
||||
var mode_label: Label
|
||||
var vignette_rect: TextureRect
|
||||
var cam_clock: Label
|
||||
var icon_row: HBoxContainer
|
||||
var flashlight_icon: TextureRect
|
||||
var door_icon: TextureRect
|
||||
var wand_icon: TextureRect
|
||||
var wand_panel: PanelContainer
|
||||
var wand_text: Label
|
||||
var wand_meta: Label
|
||||
@@ -105,6 +128,57 @@ func _init() -> void:
|
||||
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
||||
vbox.add_child(escalation_label)
|
||||
|
||||
# --- escalation vignette (screen-space tint) ---
|
||||
vignette_rect = TextureRect.new()
|
||||
vignette_rect.texture = T_VIGNETTE
|
||||
vignette_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vignette_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vignette_rect.modulate = Color(1, 1, 1, 0)
|
||||
vignette_rect.show_behind_parent = false
|
||||
add_child(vignette_rect)
|
||||
|
||||
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
|
||||
var cam_box := VBoxContainer.new()
|
||||
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
cam_box.position = Vector2(-180, 12)
|
||||
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
||||
add_child(cam_box)
|
||||
var rec_row := HBoxContainer.new()
|
||||
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
cam_box.add_child(rec_row)
|
||||
var rec_icon := TextureRect.new()
|
||||
rec_icon.texture = T_ICON_REC
|
||||
rec_icon.custom_minimum_size = Vector2(22, 22)
|
||||
rec_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
rec_row.add_child(rec_icon)
|
||||
var rec_label := Label.new()
|
||||
rec_label.text = "REC"
|
||||
rec_label.add_theme_font_size_override("font_size", 15)
|
||||
rec_label.modulate = Color(0.95, 0.35, 0.35)
|
||||
rec_row.add_child(rec_label)
|
||||
cam_clock = Label.new()
|
||||
cam_clock.text = "SHIFT 00:00:00"
|
||||
cam_clock.add_theme_font_size_override("font_size", 12)
|
||||
cam_clock.modulate = Color(0.95, 0.95, 0.95)
|
||||
cam_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
cam_box.add_child(cam_clock)
|
||||
# corner brackets
|
||||
var bracket_color := Color(0.9, 0.9, 0.92, 0.55)
|
||||
_add_corner_brackets(bracket_color)
|
||||
|
||||
# --- mode icons next to the door/light label ---
|
||||
icon_row = HBoxContainer.new()
|
||||
vbox.add_child(icon_row)
|
||||
flashlight_icon = _make_icon(T_ICON_FLASH)
|
||||
flashlight_icon.visible = false
|
||||
icon_row.add_child(flashlight_icon)
|
||||
door_icon = _make_icon(T_ICON_DOOR)
|
||||
door_icon.visible = false
|
||||
icon_row.add_child(door_icon)
|
||||
wand_icon = _make_icon(T_ICON_WAND)
|
||||
wand_icon.visible = false
|
||||
icon_row.add_child(wand_icon)
|
||||
|
||||
# --- inspect panel (popup center) ---
|
||||
inspect_panel = PanelContainer.new()
|
||||
inspect_panel.visible = false
|
||||
@@ -163,6 +237,9 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
||||
coordinator.reputation.rating,
|
||||
coordinator.crowd.mood * 100.0,
|
||||
]
|
||||
if cam_clock:
|
||||
var total := int(coordinator.elapsed_s)
|
||||
cam_clock.text = "SHIFT %02d:%02d:%02d" % [total / 3600, (total / 60) % 60, total % 60]
|
||||
_update_radio_cooldown(coordinator)
|
||||
|
||||
func _update_radio_cooldown(coordinator: Object) -> void:
|
||||
@@ -188,6 +265,47 @@ func set_flashlight(on: bool) -> void:
|
||||
|
||||
func _refresh_mode_label() -> void:
|
||||
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
|
||||
if icon_row:
|
||||
flashlight_icon.visible = _flashlight
|
||||
door_icon.visible = _door_locked
|
||||
|
||||
func set_wand_icon(visible: bool) -> void:
|
||||
if wand_icon:
|
||||
wand_icon.visible = visible
|
||||
|
||||
func _make_icon(texture: Texture2D) -> TextureRect:
|
||||
var tr := TextureRect.new()
|
||||
tr.texture = texture
|
||||
tr.custom_minimum_size = Vector2(20, 20)
|
||||
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
tr.modulate = Color(1, 1, 1, 0.9)
|
||||
return tr
|
||||
|
||||
func _add_corner_brackets(color: Color) -> void:
|
||||
const LEN := 34.0
|
||||
const TH := 3.0
|
||||
const OFF := 10.0
|
||||
var viewport := get_viewport()
|
||||
var vp: Vector2 = viewport.get_visible_rect().size if viewport else Vector2(1280, 720)
|
||||
var corners := [
|
||||
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
|
||||
]
|
||||
for i in 4:
|
||||
var c: Vector2 = corners[i]
|
||||
var flip_x: bool = (i % 2) == 1
|
||||
var flip_y: bool = i >= 2
|
||||
var h := ColorRect.new()
|
||||
h.color = color
|
||||
h.size = Vector2(LEN, TH)
|
||||
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
|
||||
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(h)
|
||||
var v := ColorRect.new()
|
||||
v.color = color
|
||||
v.size = Vector2(TH, LEN)
|
||||
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(v)
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
@@ -199,15 +317,20 @@ func set_action_hint(text: String) -> void:
|
||||
func set_escalation(level: int) -> void:
|
||||
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
||||
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
|
||||
var tint := Color(0.6, 0.9, 0.6)
|
||||
match level:
|
||||
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
||||
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
||||
tint = Color(0.6, 0.9, 0.6)
|
||||
Rules.Level.FORMAL_WARNING:
|
||||
escalation_label.modulate = Color(0.95, 0.9, 0.4)
|
||||
tint = Color(0.95, 0.9, 0.4)
|
||||
Rules.Level.BLOCKING:
|
||||
escalation_label.modulate = Color(1.0, 0.65, 0.3)
|
||||
tint = Color(1.0, 0.65, 0.3)
|
||||
Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY:
|
||||
escalation_label.modulate = Color(1.0, 0.35, 0.3)
|
||||
tint = Color(1.0, 0.35, 0.3)
|
||||
escalation_label.modulate = tint
|
||||
# Screen-space vignette follows the escalation color.
|
||||
if vignette_rect:
|
||||
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
||||
|
||||
func log(text: String) -> void:
|
||||
_log_lines.append(text)
|
||||
@@ -240,6 +363,26 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
|
||||
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"]
|
||||
_clear_card_row()
|
||||
var card_row := HBoxContainer.new()
|
||||
card_row.name = "INSPECT_CARD_ROW"
|
||||
card_row.add_theme_constant_override("separation", 6)
|
||||
var added_card := false
|
||||
for artifact in artifacts:
|
||||
var tex: Texture2D = CARD_TEXTURES.get(artifact.kind, null) as Texture2D
|
||||
if tex == null:
|
||||
continue
|
||||
var card := TextureRect.new()
|
||||
card.texture = tex
|
||||
card.custom_minimum_size = Vector2(92, 58)
|
||||
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
card.tooltip_text = artifact.summary()
|
||||
if artifact.attributes.size() > 0:
|
||||
card.tooltip_text += "\n" + str(artifact.attributes)
|
||||
card_row.add_child(card)
|
||||
added_card = true
|
||||
if added_card:
|
||||
inspect_panel.add_child(card_row)
|
||||
var lines: Array[String] = []
|
||||
for artifact in artifacts:
|
||||
lines.append(" • " + artifact.summary())
|
||||
@@ -251,8 +394,18 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
|
||||
inspect_panel.visible = true
|
||||
|
||||
func hide_inspection() -> void:
|
||||
_clear_card_row()
|
||||
inspect_panel.visible = false
|
||||
|
||||
## Remove previously added artifact cards so reopening rebuilds them.
|
||||
func _clear_card_row() -> void:
|
||||
if inspect_panel == null:
|
||||
return
|
||||
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
|
||||
if row:
|
||||
inspect_panel.remove_child(row)
|
||||
row.queue_free()
|
||||
|
||||
func show_wand(controller: Object, result: Dictionary) -> void:
|
||||
var profile: Object = controller.profile
|
||||
var npc_display: String = (profile.display_name if profile else "Person")
|
||||
|
||||
+52
-12
@@ -1,5 +1,11 @@
|
||||
class_name WorldBuilder
|
||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||
|
||||
const T_BRICK := preload("res://assets/textures/brick_facade.png")
|
||||
const T_CONCRETE := preload("res://assets/textures/concrete.png")
|
||||
const T_ASPHALT := preload("res://assets/textures/asphalt.png")
|
||||
const T_AWNING := preload("res://assets/textures/awning.png")
|
||||
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
@@ -27,14 +33,19 @@ func build() -> void:
|
||||
_build_crosswalk()
|
||||
_build_street_furniture()
|
||||
_build_guard_spawn()
|
||||
_build_skyline_backdrop()
|
||||
_build_navmesh()
|
||||
set_night_mode(false)
|
||||
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
|
||||
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
if texture:
|
||||
mat.albedo_texture = texture
|
||||
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
||||
mesh.material = mat
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
@@ -56,19 +67,23 @@ func _build_ground() -> void:
|
||||
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))
|
||||
# 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))
|
||||
# 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))
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), 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))
|
||||
# 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_mat := StandardMaterial3D.new()
|
||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
glass_mat.roughness = 0.15
|
||||
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.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||
# 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)
|
||||
@@ -96,7 +111,7 @@ func _build_steps() -> void:
|
||||
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))
|
||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(30, 0.36, 8)
|
||||
var so := sidewalk.create_shape_owner(sidewalk)
|
||||
@@ -107,7 +122,7 @@ func _build_sidewalk() -> void:
|
||||
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))
|
||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7))
|
||||
# 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))
|
||||
@@ -138,6 +153,9 @@ func _build_navmesh() -> void:
|
||||
root.add_child(region)
|
||||
|
||||
func _build_marquee() -> void:
|
||||
## Striped awning above the door, then the neon sign strip + label.
|
||||
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
||||
awning.name = "Awning"
|
||||
## Neon-style marquee strip above the door: emissive bars + a label.
|
||||
var strip_parent := Node3D.new()
|
||||
strip_parent.name = "Marquee"
|
||||
@@ -243,6 +261,28 @@ 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"
|
||||
|
||||
@@ -45,6 +45,39 @@ func test_street_lamps_and_marquee_built() -> bool:
|
||||
root.free()
|
||||
return a and b and TestUtils.check(night_on, "lamps brighten at night") and TestUtils.check(day_off, "lamps dim in daylight")
|
||||
|
||||
func test_asset_textures_present_and_pow2() -> bool:
|
||||
var required := [
|
||||
"res://assets/textures/brick_facade.png",
|
||||
"res://assets/textures/concrete.png",
|
||||
"res://assets/textures/asphalt.png",
|
||||
"res://assets/textures/awning.png",
|
||||
"res://assets/textures/skyline.png",
|
||||
"res://assets/textures/vignette.png",
|
||||
"res://assets/textures/icons/flashlight_on.png",
|
||||
"res://assets/textures/icons/door_locked.png",
|
||||
"res://assets/textures/icons/wand.png",
|
||||
"res://assets/textures/icons/rec_dot.png",
|
||||
"res://assets/textures/cards/id_card.png",
|
||||
"res://assets/textures/cards/wristband.png",
|
||||
"res://assets/textures/cards/ticket.png",
|
||||
"res://assets/textures/cards/credential.png",
|
||||
"res://assets/textures/cards/guest_list.png",
|
||||
]
|
||||
for path in required:
|
||||
if not FileAccess.file_exists(path):
|
||||
return TestUtils.fail("missing generated asset: %s — run tools/generate_textures.py" % path)
|
||||
var img := Image.new()
|
||||
for path in required:
|
||||
if img.load(path) != OK:
|
||||
return TestUtils.fail("cannot decode asset: %s" % path)
|
||||
var w := img.get_width()
|
||||
var h := img.get_height()
|
||||
if w > 1024 or h > 1024:
|
||||
return TestUtils.fail("asset too large: %s (%dx%d)" % [path, w, h])
|
||||
if (w & (w - 1)) != 0 or (h & (h - 1)) != 0:
|
||||
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
||||
return true
|
||||
|
||||
func test_door_indicator_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
|
||||
Reference in new issue
Block a user