Detailed graphics pass: authored textures, humanoid NPCs, HUD feedback, asset pipeline
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped

- 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:
zwitschi committed 2026-10-31 13:33:43 +01:00
1 parent ac3bb7dcd8
commit 031503e161
31 files changed
+636 -57

No files matched your search

+157 -4
View File
@@ -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")