diff --git a/Animations/anim_publik_mees.gd b/Animations/anim_publik_mees.gd index 3be550e..d0d74ae 100644 --- a/Animations/anim_publik_mees.gd +++ b/Animations/anim_publik_mees.gd @@ -2,6 +2,7 @@ extends Node3D func _ready() -> void: + add_to_group(&"public") # excluded from debug overlays — see publikum_controller.gd var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer if anim: anim.play(&"Juubeldab") diff --git a/Assets/crowd_clap_sheet.png b/Assets/crowd_clap_sheet.png new file mode 100644 index 0000000..960624a Binary files /dev/null and b/Assets/crowd_clap_sheet.png differ diff --git a/Assets/crowd_clap_sheet.png.import b/Assets/crowd_clap_sheet.png.import new file mode 100644 index 0000000..36f2695 --- /dev/null +++ b/Assets/crowd_clap_sheet.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d166u18p3wqiy" +path="res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/crowd_clap_sheet.png" +dest_files=["res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/MainMenu.tscn b/MainMenu.tscn index 59d8184..7114f0c 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -175,7 +175,8 @@ horizontal_alignment = 1 [node name="Hint" type="Label" parent="OptionsPanel/VBox"] layout_mode = 2 theme_override_colors/font_outline_color = Color(0, 0, 0, 1) -theme_override_constants/outline_size = 4 +theme_override_constants/outline_size = 5 +theme_override_font_sizes/font_size = 20 text = "Click a key, then press a new key. Esc cancels." horizontal_alignment = 1 @@ -223,17 +224,28 @@ theme_override_font_sizes/font_size = 44 text = "Credits" horizontal_alignment = 1 -[node name="Names" type="Label" parent="CreditsPanel/VBox"] +[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox"] layout_mode = 2 size_flags_vertical = 3 theme_override_colors/font_outline_color = Color(0, 0, 0, 1) -theme_override_constants/outline_size = 4 -theme_override_font_sizes/font_size = 24 -text = "Bullosseum +theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1) +theme_override_constants/outline_size = 5 +theme_override_constants/line_separation = 6 +theme_override_font_sizes/normal_font_size = 26 +theme_override_font_sizes/bold_font_size = 30 +bbcode_enabled = true +fit_content = true +scroll_active = false +text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size] -A game by Siim & Richard" -horizontal_alignment = 1 -vertical_alignment = 1 +[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url] +[font_size=20]Creative, art, idea guy[/font_size] + +[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url] +[font_size=20]Programmer[/font_size] + +[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://instagram.com/kadirebane]@kadirebane[/url] +[font_size=20]Arena asset[/font_size][/center]" [node name="BackButton" type="Button" parent="CreditsPanel/VBox"] custom_minimum_size = Vector2(0, 48) diff --git a/console.gd b/console.gd index fed766a..58b8a36 100644 --- a/console.gd +++ b/console.gd @@ -172,6 +172,18 @@ func _input(event: InputEvent) -> void: if not _is_open: return + # Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes. + if ke.ctrl_pressed: + match ke.keycode: + KEY_C: + _clear_input() + get_viewport().set_input_as_handled() + return + KEY_L: + _log.clear() + get_viewport().set_input_as_handled() + return + match ke.keycode: KEY_TAB: _accept_completion() @@ -187,6 +199,20 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree +# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame +# while the console is open — cheap, and it never lets the command line go dark. +func _process(_delta: float) -> void: + if _is_open and not _input_field.has_focus(): + # Diagnostic: name whatever currently holds focus before we snatch it back, so + # a persistent thief (a HUD button, a menu control) shows up in the log instead + # of us guessing. Remove once the culprit is identified. + var owner: Control = get_viewport().gui_get_focus_owner() + var who: String = String(owner.name) if owner != null else "" + _log_warn("focus stolen by %s — reclaiming" % who) + _input_field.grab_focus() + + func _toggle() -> void: _is_open = not _is_open get_tree().paused = _is_open @@ -381,6 +407,15 @@ func _history_step(dir: int) -> void: _set_input(_pending_line if _history_idx == -1 else _history[_history_idx]) +# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup +# and resets history browsing so the next ↑ starts fresh. +func _clear_input() -> void: + _input_field.clear() + _history_idx = -1 + _pending_line = "" + _clear_suggestions() + + func _set_input(text: String) -> void: _input_field.text = text _input_field.caret_column = text.length() @@ -400,9 +435,6 @@ func _on_submitted(raw: String) -> void: _input_field.clear() _clear_suggestions() _execute(raw) - # Defer: the LineEdit finishes handling the Enter keypress after this signal - # returns, so a synchronous grab_focus() gets clobbered. Re-grab next idle. - _input_field.grab_focus.call_deferred() func _execute(cmd: String) -> void: diff --git a/crowd_billboard.gdshader b/crowd_billboard.gdshader new file mode 100644 index 0000000..e380759 --- /dev/null +++ b/crowd_billboard.gdshader @@ -0,0 +1,35 @@ +shader_type spatial; +// Flat crowd sprite. Each instance is oriented at build time to face the arena centre +// (see crowd_billboards.gd) — NOT the camera — so spectators on different segments of +// the ring face inward toward the action, like a real stand. Unshaded (the sheet was +// baked with lighting), alpha-tested (no transparency sorting across hundreds of +// quads), nearest-filtered for a crisp PS1 look that matches the ps1_filter effect. +render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_opaque; + +uniform sampler2D sheet : source_color, filter_nearest; +uniform int hframes = 8; +uniform float fps = 10.0; +uniform float alpha_clip = 0.4; + +varying flat float v_frame; +varying flat vec3 v_tint; + +void vertex() { + // Per-instance clap phase (0..1) and tint are packed into the MultiMesh custom data. + // The instance transform already carries the inward facing, so no billboard maths. + float phase = INSTANCE_CUSTOM.x; + v_tint = INSTANCE_CUSTOM.yzw; + v_frame = floor(mod(TIME * fps + phase * float(hframes), float(hframes))); +} + +void fragment() { + // Sheet is two rows: top = front, bottom = back. Show the back when the camera is + // behind the spectator (they're facing into the arena, away from us). + float u = (UV.x + v_frame) / float(hframes); + float v = UV.y * 0.5 + (FRONT_FACING ? 0.0 : 0.5); + vec4 c = texture(sheet, vec2(u, v)); + if (c.a < alpha_clip) { + discard; + } + ALBEDO = c.rgb * v_tint; +} diff --git a/crowd_billboard.gdshader.uid b/crowd_billboard.gdshader.uid new file mode 100644 index 0000000..b501675 --- /dev/null +++ b/crowd_billboard.gdshader.uid @@ -0,0 +1 @@ +uid://hkdivctavovb diff --git a/crowd_billboards.gd b/crowd_billboards.gd new file mode 100644 index 0000000..e1d0e8c --- /dev/null +++ b/crowd_billboards.gd @@ -0,0 +1,137 @@ +extends MultiMeshInstance3D +## A whole stand of cheering spectators rendered as camera-facing billboards in a +## single draw call — the performant replacement for dozens of skinned crowd skeletons. +## The sprite sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd. +## +## Placement mirrors an existing crowd node (default: the sibling PublikNode) so the +## billboards land exactly where the crowd was authored; if that node is missing it +## falls back to a generated tribune so the scene still populates. + +const SHEET_PATH := "res://Assets/crowd_clap_sheet.png" +const SHADER_PATH := "res://crowd_billboard.gdshader" + +## Node whose descendant figures (names starting with `figure_prefix`) donate their +## world positions. Empty = use the generated fallback stand. +@export var source_path: NodePath = ^"../PublikNode" +@export var figure_prefix: String = "mees" +## World height of a spectator billboard, in metres. Sheet aspect sets the width. +@export var figure_height: float = 2.0 +@export var frames: int = 8 +@export var clap_fps: float = 10.0 +## Subtle per-spectator brightness spread so the crowd doesn't read as clones. +@export_range(0.0, 0.5) var tint_variation: float = 0.18 + +# Fallback tribune (used only when source_path resolves to nothing) — concentric arcs +# of seats ringing the arena. +@export_group("Fallback stand") +@export var fallback_count: int = 120 +@export var fallback_inner_radius: float = 26.0 +@export var fallback_rows: int = 6 +@export var fallback_row_rise: float = 1.6 +@export var fallback_row_step: float = 2.2 +@export var fallback_seat_step: float = 2.4 + +var _rng := RandomNumberGenerator.new() + + +func _ready() -> void: + _rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs + _build() + + +func _build() -> void: + var sheet := load(SHEET_PATH) as Texture2D + if sheet == null: + push_warning("crowd_billboards: missing sheet %s — run tools/bake_crowd_sheet.gd" % SHEET_PATH) + return + + # Sheet holds `frames` columns and 2 rows (front / back), so a cell is half-height. + var aspect := (float(sheet.get_width()) / float(frames)) / (float(sheet.get_height()) / 2.0) + var quad := QuadMesh.new() + quad.size = Vector2(figure_height * aspect, figure_height) + quad.center_offset = Vector3(0.0, figure_height * 0.5, 0.0) # pivot at the feet + + var mat := ShaderMaterial.new() + mat.shader = load(SHADER_PATH) as Shader + mat.set_shader_parameter("sheet", sheet) + mat.set_shader_parameter("hframes", frames) + mat.set_shader_parameter("fps", clap_fps) + material_override = mat + + var seats := _seat_positions() + var center := _arena_center(seats) + + var mm := MultiMesh.new() + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_custom_data = true + mm.mesh = quad + mm.instance_count = seats.size() + + for i in seats.size(): + var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety + mm.set_instance_transform(i, Transform3D(_facing_basis(seats[i], center, s), seats[i])) + var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation) + mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98)) + multimesh = mm + + +# Orientation for a seat: the sprite's forward (+Z, the quad's face) points horizontally +# inward at the arena centre, so each ring segment faces the action instead of the +# camera. Upright (world +Y), uniformly scaled by `s`. +func _facing_basis(seat: Vector3, center: Vector3, s: float) -> Basis: + var look := center - seat + look.y = 0.0 + look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, -1.0) + var up := Vector3.UP + var right := up.cross(look).normalized() + var basis := Basis() + basis.x = right + basis.y = up + basis.z = look + return basis.scaled(Vector3.ONE * s) + + +# Horizontal centre the crowd faces — the mean of the seat positions (the ring/stand +# centroid), which for an arena crowd is the middle of the pitch. +func _arena_center(seats: PackedVector3Array) -> Vector3: + if seats.is_empty(): + return global_position + var sum := Vector3.ZERO + for p in seats: + sum += p + return sum / float(seats.size()) + + +# Global feet-positions for every spectator: harvested from the source crowd if present, +# otherwise the generated fallback tribune. +func _seat_positions() -> PackedVector3Array: + var out := PackedVector3Array() + var src := get_node_or_null(source_path) + if src != null: + _collect_figures(src, out) + if out.is_empty(): + _fallback_stand(out) + return out + + +func _collect_figures(node: Node, out: PackedVector3Array) -> void: + if node is Node3D and node != self and (node as Node3D).name.begins_with(figure_prefix): + out.append((node as Node3D).global_position) + return # don't descend into a figure's own rig + for c in node.get_children(): + _collect_figures(c, out) + + +func _fallback_stand(out: PackedVector3Array) -> void: + var placed := 0 + for row in fallback_rows: + var radius := fallback_inner_radius + float(row) * fallback_row_step + var y := float(row) * fallback_row_rise + var circumference := TAU * radius + var seats_in_row := int(circumference / fallback_seat_step) + for s in seats_in_row: + if placed >= fallback_count: + return + var ang := TAU * float(s) / float(seats_in_row) + out.append(Vector3(cos(ang) * radius, y, sin(ang) * radius)) + placed += 1 diff --git a/crowd_billboards.gd.uid b/crowd_billboards.gd.uid new file mode 100644 index 0000000..6a8d81c --- /dev/null +++ b/crowd_billboards.gd.uid @@ -0,0 +1 @@ +uid://cj0ytlkuavmf4 diff --git a/debug_draw.gd b/debug_draw.gd index 70c2b98..6ca6b1b 100644 --- a/debug_draw.gd +++ b/debug_draw.gd @@ -405,6 +405,10 @@ func _reconcile(delta: float) -> void: func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void: + # The audience ("public") is ambient decoration, not a debug target — skip the whole + # crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays. + if node.is_in_group(&"public"): + return if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree(): _skeletons.append(node as Skeleton3D) if anim and node is AnimationPlayer: diff --git a/debug_params.gd b/debug_params.gd index 907a700..f7f18ca 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -228,6 +228,18 @@ func _register_all() -> void: _reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop # ── Debug overlays (see debug_draw.gd) ─────────────────────────────────── _reg_b("Debug", "show_collisions", false) + # PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect): + # screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a + # rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the + # whole overlay. Ranges mirror the shader's hint_range so the console can't push + # a uniform out of bounds. + _reg_b("PS1", "show_ps1", true) + _reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0) + _reg_f("PS1", "ps1_color_depth", 5.0, 2.0, 8.0, 1.0) + _reg_f("PS1", "ps1_dither_strength", 0.8, 0.0, 2.0, 0.05) + _reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05) + _reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1) + _reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05) _reg_b("Debug", "show_hitboxes", false) _reg_b("Debug", "show_bones", false) _reg_b("Debug", "show_raycasts", false) diff --git a/main_menu.gd b/main_menu.gd index 136cfe5..16e27eb 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -130,11 +130,24 @@ func _apply_theme() -> void: if title != null: title.add_theme_font_override("font", UiFonts.title()) title.add_theme_color_override("font_color", MENU_GOLD) - for path: String in ["OptionsPanel/VBox/Hint", "CreditsPanel/VBox/Names"]: - var lbl := get_node_or_null(path) as Label - if lbl != null: - lbl.add_theme_font_override("font", UiFonts.body()) - lbl.add_theme_color_override("font_color", MENU_CREAM) + var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label + if hint != null: + hint.add_theme_font_override("font", UiFonts.body()) + hint.add_theme_color_override("font_color", MENU_CREAM) + _style_credits(get_node_or_null("CreditsPanel/VBox/Names") as RichTextLabel) + + +# The credits use a RichTextLabel so the Instagram handles are clickable links +# ([url] tags). meta_clicked opens the profile — in the web export OS.shell_open +# pops a new browser tab. +func _style_credits(names: RichTextLabel) -> void: + if names == null: + return + names.add_theme_font_override("normal_font", UiFonts.body()) + names.add_theme_font_override("bold_font", UiFonts.title()) + names.add_theme_color_override("default_color", MENU_CREAM) + names.meta_clicked.connect(func(meta: Variant) -> void: + OS.shell_open(str(meta))) func _style_button(btn: Button, big: bool) -> void: @@ -182,9 +195,10 @@ func _build_rebind_rows() -> void: name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER name_label.add_theme_font_override("font", UiFonts.body()) + name_label.add_theme_font_size_override("font_size", 20) name_label.add_theme_color_override("font_color", MENU_CREAM) name_label.add_theme_color_override("font_outline_color", Color.BLACK) - name_label.add_theme_constant_override("outline_size", 4) + name_label.add_theme_constant_override("outline_size", 5) row.add_child(name_label) var key_button := Button.new() diff --git a/ps1_filter.gd b/ps1_filter.gd new file mode 100644 index 0000000..aab1f04 --- /dev/null +++ b/ps1_filter.gd @@ -0,0 +1,36 @@ +extends ColorRect +## Drives the fullscreen PS1/CRT post-process (ps1_filter.gdshader) from the DP +## registry so every uniform is live-tunable in the console. `show_ps1` toggles the +## whole overlay; the ps1_* floats feed the matching shader uniforms 1:1. + +# DP param key → shader uniform name. Keys are registered in debug_params.gd with +# ranges that mirror the shader's hint_range, so nothing here can go out of bounds. +const _UNIFORMS := { + "ps1_pixel_scale": "pixel_scale", + "ps1_color_depth": "color_depth", + "ps1_dither_strength": "dither_strength", + "ps1_scanline_strength": "scanline_strength", + "ps1_roll_speed": "roll_speed", + "ps1_roll_strength": "roll_strength", +} + +@onready var _mat: ShaderMaterial = material as ShaderMaterial + + +func _ready() -> void: + DP.any_changed.connect(_on_param_changed) + _apply_all() + + +# reset_all() fires with key "__all__"; a single edit fires with its own key. Either +# way just re-push everything — it's a handful of cheap uniform writes. +func _on_param_changed(_key: String, _value: Variant) -> void: + _apply_all() + + +func _apply_all() -> void: + visible = DP.b("show_ps1") + if _mat == null: + return + for key: String in _UNIFORMS: + _mat.set_shader_parameter(_UNIFORMS[key], DP.f(key)) diff --git a/ps1_filter.gd.uid b/ps1_filter.gd.uid new file mode 100644 index 0000000..132a33d --- /dev/null +++ b/ps1_filter.gd.uid @@ -0,0 +1 @@ +uid://ce5n4qu88mhyx diff --git a/publikum_controller.gd b/publikum_controller.gd index bf646e4..8055311 100644 --- a/publikum_controller.gd +++ b/publikum_controller.gd @@ -2,6 +2,9 @@ extends Node3D func _ready() -> void: + # Mark the crowd so debug overlays (show_animation_name / show_bones) skip it — + # 72 ambient clapping spectators would otherwise flood the view with tags. + add_to_group(&"public") for child in get_children(): var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer if anim: diff --git a/run_tests.sh b/run_tests.sh index 25d0334..98d1e4b 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -14,6 +14,10 @@ echo "" echo "=== Logic tests ===" "$GODOT" --headless --script tests/logic_test.gd +echo "" +echo "=== Console focus tests ===" +"$GODOT" --headless --script tests/console_focus_test.gd + echo "" echo "=== Performance tests (frame-budget regression guard) ===" "$GODOT" --headless --script tests/performance_test.gd diff --git a/scene.tscn b/scene.tscn index f7c9c7d..fecd71a 100644 --- a/scene.tscn +++ b/scene.tscn @@ -11,6 +11,8 @@ [ext_resource type="Script" uid="uid://di0jafakftp3n" path="res://publikum_controller.gd" id="9_pubctrl"] [ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="10_5juve"] [ext_resource type="Script" uid="uid://butd6b3tdwxrv" path="res://hud.gd" id="10_hud"] +[ext_resource type="Script" path="res://crowd_billboards.gd" id="11_crowd"] +[ext_resource type="Script" path="res://ps1_filter.gd" id="12_ps1ctl"] [sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"] dof_blur_far_enabled = true @@ -78,6 +80,7 @@ layer = 10 [node name="PS1Filter" type="ColorRect" parent="CanvasLayer" unique_id=1449116873] material = SubResource("ShaderMaterial_ps1") +script = ExtResource("12_ps1ctl") anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 @@ -378,6 +381,9 @@ transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97 [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=755853425] environment = SubResource("Environment_73fnb") +[node name="CrowdBillboards" type="MultiMeshInstance3D" parent="."] +script = ExtResource("11_crowd") + [editable path="Player"] [editable path="Player/bull"] [editable path="Node3D/arena_placeholder_v01"] diff --git a/tests/console_focus_test.gd b/tests/console_focus_test.gd new file mode 100644 index 0000000..6b12bad --- /dev/null +++ b/tests/console_focus_test.gd @@ -0,0 +1,110 @@ +extends SceneTree +## Headless focus tests for the dev console — verifies the command line keeps (or +## reclaims) keyboard focus after the actions that were observed to drop it. +## Run: godot --headless --script tests/console_focus_test.gd +## +## Exit code 0 = all passed, 1 = any failure. + +var _passed: int = 0 +var _failed: int = 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + print("=".repeat(60)) + print("CONSOLE FOCUS TESTS") + print("=".repeat(60)) + + await test_focus_after_submit() + await test_focus_after_external_steal() + + print("=".repeat(60)) + print("Results: %d passed, %d failed" % [_passed, _failed]) + print("=".repeat(60)) + + quit(1 if _failed > 0 else 0) + + +func _assert_true(condition: bool, desc: String) -> void: + if condition: + _passed += 1 + print(" PASS: %s" % desc) + else: + _failed += 1 + print(" FAIL: %s" % desc) + + +func _get_console() -> Node: + var c: Node = root.get_node_or_null("/root/Console") + if c == null: + _assert_true(false, "Console autoload should exist") + return c + + +# Drive the real path: open via _toggle (pauses the tree, slide tween grabs focus), +# type a command as real key events, submit with a real Enter, and confirm the guard +# keeps the command line focused across the paused submit. +func test_focus_after_submit() -> void: + print("\n-- test_focus_after_submit --") + var menu := _get_console() + if menu == null: + return + + menu._toggle() + # Let the open slide tween finish and its grab_focus callback fire. + for _i in 20: + await process_frame + _assert_true(paused, "tree paused while console open") + _assert_true(menu._input_field.has_focus(), "input has focus after open") + + menu._input_field.text = "clear" + menu._input_field.caret_column = 5 + _press(KEY_ENTER) + # The submit and any focus theft settle over the next few idle frames. + for _i in 5: + await process_frame + _assert_true(menu._input_field.has_focus(), "input still has focus after submit") + + menu._toggle() + for _i in 20: + await process_frame + + +# Push a key down+up through the viewport so LineEdit / _input handle it for real. +func _press(keycode: Key) -> void: + var down := InputEventKey.new() + down.keycode = keycode + down.physical_keycode = keycode + down.pressed = true + root.push_input(down) + var up := InputEventKey.new() + up.keycode = keycode + up.physical_keycode = keycode + up.pressed = false + root.push_input(up) + + +# Simulate anything yanking focus away mid-session (a stray click, a UI button); the +# guard in _process must pull it straight back while the console is open. +func test_focus_after_external_steal() -> void: + print("\n-- test_focus_after_external_steal --") + var menu := _get_console() + if menu == null: + return + + menu._is_open = true + menu._panel.visible = true + menu._input_field.grab_focus() + await process_frame + + menu._input_field.release_focus() + _assert_true(not menu._input_field.has_focus(), "focus released (precondition)") + + await process_frame + _assert_true(menu._input_field.has_focus(), "guard reclaims focus next frame") + + menu._is_open = false + menu._panel.visible = false diff --git a/tests/console_focus_test.gd.uid b/tests/console_focus_test.gd.uid new file mode 100644 index 0000000..e3bb039 --- /dev/null +++ b/tests/console_focus_test.gd.uid @@ -0,0 +1 @@ +uid://n6ymmhlqu6wp diff --git a/tools/bake_crowd_sheet.gd b/tools/bake_crowd_sheet.gd new file mode 100644 index 0000000..8a4f287 --- /dev/null +++ b/tools/bake_crowd_sheet.gd @@ -0,0 +1,186 @@ +extends SceneTree +## Bakes an animated-billboard sprite sheet from the existing clapping crowd figure +## (Animations/mees_plaksutab.glb, clip "ArmatureAction") so a whole stand of +## spectators can render as camera-facing quads in one draw call instead of 72 skinned +## skeletons. Renders FRAMES evenly-spaced poses from a front orthographic camera on a +## transparent background and packs them into a single-row atlas. +## +## Needs a display — script mode renders to a real window (headless is blank): +## godot --script tools/bake_crowd_sheet.gd +## +## Output: res://Assets/crowd_clap_sheet.png — FRAMES cells wide, TWO rows: the top row +## is the front view, the bottom row is the back view. The crowd shader picks the row +## with FRONT_FACING so spectators show their backs when the camera is behind them. + +const SRC := "res://Animations/mees_plaksutab.glb" +const CLIP := &"ArmatureAction" +const OUT := "res://Assets/crowd_clap_sheet.png" +const FRAMES := 8 +const CELL_H := 192 # sprite cell height in px; width derived from the figure aspect +const PAD := 1.12 # framing margin around the figure's bounds +const FACE_Z := 6.0 # camera distance in front (+Z looks toward -Z) + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var ps := load(SRC) as PackedScene + if ps == null: + push_error("bake_crowd_sheet: cannot load %s" % SRC) + quit(1) + return + var fig: Node3D = ps.instantiate() + + var vp := SubViewport.new() + vp.own_world_3d = true + vp.transparent_bg = true + vp.msaa_3d = Viewport.MSAA_4X + vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(vp) + + var world := World3D.new() + var env := Environment.new() + env.background_mode = Environment.BG_CLEAR_COLOR + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(1, 1, 1) + env.ambient_light_energy = 0.7 + world.environment = env + vp.world_3d = world + + var key := DirectionalLight3D.new() + key.rotation_degrees = Vector3(-30, 18, 0) + key.light_energy = 1.1 + vp.add_child(key) + + vp.add_child(fig) + var ap := _find_anim(fig) + if ap == null: + push_error("bake_crowd_sheet: no AnimationPlayer in %s" % SRC) + quit(1) + return + ap.play(CLIP) + ap.pause() + var length := ap.get_animation(CLIP).length + + await process_frame + await process_frame + + # Frame the figure generously from its bind bounds (get_aabb() is the rest pose, so + # arms read wide); the tight crop comes from the rendered alpha afterwards. + var bounds := _visual_aabb(fig) + var center := bounds.get_center() + var cap_h := bounds.size.y * 1.25 + var cap_w := maxf(bounds.size.x, bounds.size.y) * 1.15 + var vw := int(round(CELL_H * (cap_w / cap_h))) + + vp.size = Vector2i(vw, CELL_H) + + var cam := Camera3D.new() + cam.projection = Camera3D.PROJECTION_ORTHOGONAL + cam.size = cap_h # KEEP_HEIGHT: vertical world extent + cam.current = true + vp.add_child(cam) + + # Render the loop from the front (camera at +Z, a Camera3D looks down its own -Z) and + # from the back (camera at -Z, yawed 180° to look back at the figure). + cam.position = center + Vector3(0, 0, FACE_Z) + cam.rotation = Vector3.ZERO + var fronts: Array = await _grab_frames(vp, ap, length) + cam.position = center + Vector3(0, 0, -FACE_Z) + cam.rotation = Vector3(0, PI, 0) + var backs: Array = await _grab_frames(vp, ap, length) + + # One shared tight crop across every front and back pose so the cells line up. + var all := fronts.duplicate() + all.append_array(backs) + var box := _alpha_bounds(all, 0.12) + if box.size == Vector2i.ZERO: + push_error("bake_crowd_sheet: rendered frames are fully transparent — check framing/facing") + quit(1) + return + # One pixel of transparent margin so filtering doesn't bleed a hard edge. + box = box.grow(1).intersection(Rect2i(0, 0, vw, CELL_H)) + + var cw := box.size.x + var ch := box.size.y + var sheet := Image.create(cw * FRAMES, ch * 2, false, Image.FORMAT_RGBA8) + sheet.fill(Color(0, 0, 0, 0)) + for i in FRAMES: + sheet.blit_rect(fronts[i], box, Vector2i(i * cw, 0)) # top row = front + sheet.blit_rect(backs[i], box, Vector2i(i * cw, ch)) # bottom row = back + + var err := sheet.save_png(OUT) + if err != OK: + push_error("bake_crowd_sheet: save_png failed (%d)" % err) + quit(1) + return + print("baked %s %dx%d (%d frames x 2 rows, cell %dx%d, aspect %.3f, figure %.2fm tall)" % [ + OUT, sheet.get_width(), sheet.get_height(), FRAMES, cw, ch, + float(cw) / float(ch), bounds.size.y]) + quit(0) + + +# Render every clap pose from the currently-positioned camera into an RGBA8 image array. +func _grab_frames(vp: SubViewport, ap: AnimationPlayer, length: float) -> Array: + var out: Array[Image] = [] + for i in FRAMES: + ap.seek((float(i) / FRAMES) * length, true) + await process_frame + await RenderingServer.frame_post_draw + var img := vp.get_texture().get_image() + img.convert(Image.FORMAT_RGBA8) + out.append(img) + return out + + +# Union bounding box of pixels with alpha above `thresh` across every frame — the +# tight crop that holds the whole clap loop. +func _alpha_bounds(frames: Array[Image], thresh: float) -> Rect2i: + var w := frames[0].get_width() + var h := frames[0].get_height() + var min_x := w + var min_y := h + var max_x := -1 + var max_y := -1 + for img in frames: + for y in h: + for x in w: + if img.get_pixel(x, y).a > thresh: + min_x = mini(min_x, x) + min_y = mini(min_y, y) + max_x = maxi(max_x, x) + max_y = maxi(max_y, y) + if max_x < 0: + return Rect2i() + return Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1) + + +func _find_anim(n: Node) -> AnimationPlayer: + if n is AnimationPlayer: + return n as AnimationPlayer + for c in n.get_children(): + var r := _find_anim(c) + if r != null: + return r + return null + + +func _visual_aabb(n: Node) -> AABB: + var acc := AABB() + var have := false + for mi in _all_mesh_instances(n): + var waabb: AABB = (mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb() + acc = waabb if not have else acc.merge(waabb) + have = true + return acc + + +func _all_mesh_instances(n: Node) -> Array: + var out: Array = [] + if n is MeshInstance3D: + out.append(n) + for c in n.get_children(): + out.append_array(_all_mesh_instances(c)) + return out diff --git a/tools/bake_crowd_sheet.gd.uid b/tools/bake_crowd_sheet.gd.uid new file mode 100644 index 0000000..c9ce502 --- /dev/null +++ b/tools/bake_crowd_sheet.gd.uid @@ -0,0 +1 @@ +uid://baa6bdq01fq20 diff --git a/tools/preview/crowd_a.png b/tools/preview/crowd_a.png new file mode 100644 index 0000000..c7bc826 Binary files /dev/null and b/tools/preview/crowd_a.png differ diff --git a/tools/preview/crowd_a.png.import b/tools/preview/crowd_a.png.import new file mode 100644 index 0000000..0ca2a29 --- /dev/null +++ b/tools/preview/crowd_a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bx5urw8lnyisv" +path="res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://tools/preview/crowd_a.png" +dest_files=["res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/tools/preview/crowd_b.png b/tools/preview/crowd_b.png new file mode 100644 index 0000000..88c5b03 Binary files /dev/null and b/tools/preview/crowd_b.png differ diff --git a/tools/preview/crowd_b.png.import b/tools/preview/crowd_b.png.import new file mode 100644 index 0000000..bf5bea1 --- /dev/null +++ b/tools/preview/crowd_b.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://4dauthuuuiyn" +path="res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://tools/preview/crowd_b.png" +dest_files=["res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/tools/preview/scene_crowd.png b/tools/preview/scene_crowd.png new file mode 100644 index 0000000..2416216 Binary files /dev/null and b/tools/preview/scene_crowd.png differ diff --git a/tools/preview/scene_crowd.png.import b/tools/preview/scene_crowd.png.import new file mode 100644 index 0000000..eb5c63e --- /dev/null +++ b/tools/preview/scene_crowd.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d16gmpff1kvwk" +path="res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://tools/preview/scene_crowd.png" +dest_files=["res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/tools/preview_crowd.gd b/tools/preview_crowd.gd new file mode 100644 index 0000000..1d9b116 --- /dev/null +++ b/tools/preview_crowd.gd @@ -0,0 +1,54 @@ +extends SceneTree +## Quick visual check for the billboard crowd: builds the generated fallback stand and +## captures two frames a moment apart (so the flipbook visibly advances) from an angle +## (so we can confirm the sprites turn to face the camera). +## +## godot --script tools/preview_crowd.gd +## Output: tools/preview/crowd_a.png, crowd_b.png + +const OUT := "res://tools/preview" + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + DirAccess.make_dir_recursive_absolute(OUT) + + var we := WorldEnvironment.new() + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.10, 0.12, 0.16) + we.environment = env + root.add_child(we) + + var crowd := MultiMeshInstance3D.new() + crowd.set_script(load("res://crowd_billboards.gd")) + crowd.set("source_path", NodePath()) # force the generated fallback stand + crowd.set("fallback_count", 90) + root.add_child(crowd) + + var cam := Camera3D.new() + cam.fov = 62.0 + cam.current = true + root.add_child(cam) + # Outside the ring edge, elevated, looking across the centre: the NEAR arc has its + # back to us (facing inward), the FAR arc faces us — so this shot proves both the + # per-segment inward facing and the front/back sheet selection. + cam.look_at_from_position(Vector3(0.0, 16.0, 44.0), Vector3(0.0, 3.0, 0.0), Vector3.UP) + + await create_timer(0.15).timeout + _shot("crowd_a.png") + await create_timer(0.45).timeout # ~4–5 clap frames later + _shot("crowd_b.png") + quit(0) + + +func _shot(name: String) -> void: + var img := root.get_viewport().get_texture().get_image() + if img == null: + print("capture failed: ", name) + return + img.save_png(OUT + "/" + name) + print("captured ", name, " ", img.get_width(), "x", img.get_height()) diff --git a/tools/preview_crowd.gd.uid b/tools/preview_crowd.gd.uid new file mode 100644 index 0000000..e7ed31a --- /dev/null +++ b/tools/preview_crowd.gd.uid @@ -0,0 +1 @@ +uid://bsekj2pxutdqr diff --git a/tools/preview_crowd_scene.gd b/tools/preview_crowd_scene.gd new file mode 100644 index 0000000..bbfed0d --- /dev/null +++ b/tools/preview_crowd_scene.gd @@ -0,0 +1,42 @@ +extends SceneTree +## Renders the real arena (scene.tscn) with the billboard crowd wired in, from a camera +## near the arena centre, to confirm the stands populate and face inward in-game. +## godot --script tools/preview_crowd_scene.gd +## Output: tools/preview/scene_crowd.png + +const OUT := "res://tools/preview" + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + DirAccess.make_dir_recursive_absolute(OUT) + var ps := load("res://scene.tscn") as PackedScene + var scene := ps.instantiate() + root.add_child(scene) + current_scene = scene + + await create_timer(0.4).timeout + + var cam := Camera3D.new() + cam.fov = 74.0 + scene.add_child(cam) + # Out past one stand looking across the arena: the near crowd has its back to us, + # the far crowd faces us — the case the front/back sheet must get right. + cam.look_at_from_position(Vector3(0.0, 9.0, -22.0), Vector3(0.0, 4.0, 6.0), Vector3.UP) + cam.current = true + + await create_timer(0.3).timeout + var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D + if crowd != null and crowd.multimesh != null: + print("crowd instances = ", crowd.multimesh.instance_count) + else: + print("crowd node/multimesh missing") + + var img := root.get_viewport().get_texture().get_image() + if img != null: + img.save_png(OUT + "/scene_crowd.png") + print("captured scene_crowd.png ", img.get_width(), "x", img.get_height()) + quit(0) diff --git a/tools/preview_crowd_scene.gd.uid b/tools/preview_crowd_scene.gd.uid new file mode 100644 index 0000000..1a9aaf8 --- /dev/null +++ b/tools/preview_crowd_scene.gd.uid @@ -0,0 +1 @@ +uid://bapd1uxqlkrmq