Compare commits
1 Commits
3defc9da95
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 271de1b045 |
@@ -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")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -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
|
||||
+20
-8
@@ -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)
|
||||
|
||||
+35
-3
@@ -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 "<nothing>"
|
||||
_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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://hkdivctavovb
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj0ytlkuavmf4
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+20
-6
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce5n4qu88mhyx
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://n6ymmhlqu6wp
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://baa6bdq01fq20
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsekj2pxutdqr
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bapd1uxqlkrmq
|
||||
Reference in New Issue
Block a user