I am in so far above my head...

Godot Version

4.4.1

Question

I am making my first game. I think I have a cool idea and I have a good working prototype, but polishing the game is a monstrous task.

I tried to import a model of a laser gun that would sit at the front of my game in lower front area of the screen. It should track the mouse and emit a beam when clicking on a target. No matter what I try, the model is either pointing 90 degrees left when the mouse is centered on the screen, or it’s blocking the entire view.

Has anyone had success with creating a decent cannon effect that follows the mouse and shoots a beam when triggered?

Share some screenshots of the issue, and past your code as preformatted text with ```

1 Like

The game is simple…you must spin the sphere (arrow keys) to find the target color indicated in the lower left. (I will likely change it so the sphere is spinning on load, but the user can control the spin) As the levels are completed, the sphere gets larger and larger, adding more bubbles to pop, but the camera also recedes so the sphere appears the same size.

Each successful level adds remaining time to the next level’s timer. I’m still tweaking this and haven’t settled on a final time limit.

I will add some powerups to the sphere to add time or maybe lives if I implement limited lives.

I’ve downloaded maybe 5 model ray guns and all of them have different issues but they all have one thing in common…the gun is pointed directly to the left of the screen when the mouse is centered.

This is the latest attempt and is actually closer than I have gotten previously. I will go back and tweak to see if I can get it work better. It’s rolling and spinning instead of locked into a forward position following the mouse.

When you place the mouse cursor at the left edge of the screen, the gun is in a somewhat ok position, but if you move the mouse to the right edge of the screen, the gun is facing the viewer.

First part of the code (too long for one post)

extends Node3D  # Extends the Node3D class for 3D scene management.

@export var sphere_radius: float = 4.0  # Exportable variable for initial sphere radius.
@export var base_num_facets: int = 100  # Exportable base number of facets.
@export var num_facets: int = 100  # Current number of facets, updated with level.
@export var facet_radius: float = 0.45  # Radius of each facet.
@export var facet_height: float = 0.15  # Height of each facet extrusion.
@export var base_color: Color = Color(0.2, 0.2, 0.2, 1.0)  # Base color for facets and sphere.
@export var level: int = 1  # Current game level.
@export var wave_strength: float = 0.2  # Strength of wave effect (currently unused).
@export var wave_frequency: float = 2.0  # Frequency of wave effect (currently unused).
@export var wave_speed: float = 1.0  # Speed of wave animation (currently unused).
@export var radial_segments: int = 32  # Number of radial segments for facet meshes.
@export var bevel_height: float = 0.1  # Bevel height for facet edges.
@export var bevel_inset: float = 0.05  # Bevel inset for facet edges.
@export var shake_amplitude: float = 0.2  # Base camera shake distance (scales with radius for progressive effect).
@export var shake_frequency: float = 20.0  # Shake speed (higher = faster jitter).

var red_index: int = -1  # Index of the target (colored) facet.
var target_color: Color  # Color of the target facet.
var possible_targets: Array[Color] = [Color.RED, Color.BLUE, Color.GREEN, Color.YELLOW, Color.PURPLE]  # Possible colors for target.
var alternate_colors: Array[Color] = []  # Distractor colors based on level.
var game_over: bool = false  # Flag for game over state.
var rotation_speed: float = 2.5  # Speed of sphere rotation.
var dragging: bool = false  # Flag for mouse dragging state.
var start_mouse_pos: Vector2 = Vector2.ZERO  # Starting position of drag.
var dragged_distance: float = 0.0  # Distance dragged to distinguish click from drag.
const DRAG_THRESHOLD: float = 10.0  # Threshold in pixels for click detection.
var leftover_time: float = 0.0  # Time left from previous level for bonus.
var angular_velocity: Vector3 = Vector3.ZERO  # Current angular velocity for rotation momentum.
const MAX_ANGULAR_SPEED: float = 2.0  # Maximum angular velocity cap.
const FRICTION: float = 0.95  # Friction factor for rotation decay.
const COLOR_SIMILARITY_THRESHOLD: float = 0.3  # Threshold for color similarity check.
var facets: Array[Area3D] = []  # Array to track facet nodes.
var noise: FastNoiseLite = FastNoiseLite.new()  # Noise generator for wave effect (unused).
var original_camera_pos: Vector3 = Vector3.ZERO  # Store original camera position for shake reset.
const INITIAL_RADIUS: float = 4.0  # Reference for scaling shake with sphere growth.

var game_timer: Timer = null
var intro_timer: Timer = null
var camera: Camera3D = null
var timer_label: Label = null
var status_label: Label = null
var target_label: Label = null
var target_swatch: ColorRect = null
var next_button: TextureButton = null
var play_again_button: TextureButton = null
var level_label: Label = null
var swatch_frame: TextureRect = null
var win_image: TextureRect = null
var countdown_image: TextureRect = null
var countdown_images: Array[Texture2D] = [
	preload("res://assets/ui/5sec.webp"),
	preload("res://assets/ui/4sec.webp"),
	preload("res://assets/ui/3sec.webp"),
	preload("res://assets/ui/2sec.webp"),
	preload("res://assets/ui/1sec.webp")
]
var countdown_count: int = 0  # Counter for countdown (starts at 5)
var cannon_base: Node3D = null  # Parent node to anchor the cannon base
var yaw_node: Node3D = null  # Node for yaw rotation (left-right)
var pitch_node: Node3D = null  # Node for pitch rotation (up-down)
var cannon: Node3D = null  # Loaded 3D model for the laser gun
var barrel_tip_offset: Vector3 = Vector3(0, 0, -0.5)  # Offset to the barrel tip in local space (adjust based on laser.glb model dimensions)
var is_mouse_inside: bool = false  # Flag to track if mouse is inside the window
var laser_audio: AudioStreamPlayer3D = null  # Persistent audio player for laser sound
var is_level_active: bool = false  # Flag to enable actions after countdown

var pop_sound: AudioStream = preload("res://sounds/pop.wav")  # Preload the pop sound effect.
var laser_sound: AudioStream = preload("res://sounds/laser.wav")  # Preload the laser sound effect.

func _enter_tree():
	if Engine.is_editor_hint():  # Check if in editor.
		regenerate()  # Regenerate sphere in editor.

func _ready():
	if not Engine.is_editor_hint():  # Check if not in editor.
		noise.seed = randi()  # Set random seed for noise.
		noise.noise_type = FastNoiseLite.TYPE_PERLIN  # Set noise type to Perlin.

		# Initialize node references
		var parent = get_parent()
		if parent:
			game_timer = parent.get_node_or_null("Timer")
			intro_timer = parent.get_node_or_null("IntroTimer")
			camera = parent.get_node_or_null("Camera3D")
			var ui = parent.get_node_or_null("UI")
			if ui:
				timer_label = ui.get_node_or_null("TimerLabel")
				status_label = ui.get_node_or_null("StatusLabel")
				target_label = ui.get_node_or_null("TargetLabel")
				target_swatch = ui.get_node_or_null("TargetSwatch")
				next_button = ui.get_node_or_null("NextLevelButton")
				play_again_button = ui.get_node_or_null("PlayAgainButton")
				level_label = ui.get_node_or_null("LevelLabel")

		# Setup UI elements
		if status_label:
			status_label.text = ""  # Clear status text.
			status_label.visible = true  # Make status visible.
			status_label.mouse_filter = Control.MOUSE_FILTER_IGNORE  # Ignore mouse on status.
		if target_label:
			target_label.visible = true  # Make target label visible.
			target_label.mouse_filter = Control.MOUSE_FILTER_IGNORE  # Ignore mouse on target label.
		if target_swatch:
			target_swatch.visible = true  # Make swatch visible.
			target_swatch.mouse_filter = Control.MOUSE_FILTER_IGNORE  # Ignore mouse on swatch.
			target_swatch.size = Vector2(80, 80)  # Set smaller swatch size.

			# Apply shader to ColorRect for inverse masking with rounded corners
			var shader_material = ShaderMaterial.new()
			shader_material.shader = preload("res://shaders/swatch_mask.gdshader")
			shader_material.set_shader_parameter("mask_texture", preload("res://assets/ui/swatch-frame.webp"))
			shader_material.set_shader_parameter("base_color", target_swatch.color)
			shader_material.set_shader_parameter("rect_size", Vector2(80.0, 80.0))
			shader_material.set_shader_parameter("corner_radius", 30.0)
			target_swatch.material = shader_material

			# Create TextureRect for the frame
			swatch_frame = TextureRect.new()
			swatch_frame.name = "SwatchFrame"
			swatch_frame.texture = preload("res://assets/ui/swatch-frame.webp")
			swatch_frame.expand_mode = TextureRect.EXPAND_FIT_WIDTH
			swatch_frame.stretch_mode = TextureRect.STRETCH_SCALE
			swatch_frame.size = Vector2(80, 80)
			swatch_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
			swatch_frame.z_index = 1
			target_swatch.get_parent().add_child(swatch_frame)

		# Create TextureRect for win image
		win_image = TextureRect.new()
		win_image.texture = preload("res://assets/ui/great-job.webp")
		win_image.expand_mode = TextureRect.EXPAND_FIT_WIDTH
		win_image.stretch_mode = TextureRect.STRETCH_SCALE
		win_image.visible = false
		win_image.mouse_filter = Control.MOUSE_FILTER_IGNORE
		win_image.anchor_left = 0.5
		win_image.anchor_top = 0.5
		win_image.anchor_right = 0.5
		win_image.anchor_bottom = 0.5
		var tex_size = win_image.texture.get_size() if win_image.texture else Vector2(228, 54)
		win_image.offset_left = -tex_size.x / 2
		win_image.offset_top = -tex_size.y / 2
		win_image.offset_right = tex_size.x / 2
		win_image.offset_bottom = tex_size.y / 2
		if parent and parent.has_node("UI"):
			parent.get_node("UI").add_child(win_image)

		# Create TextureRect for countdown image
		countdown_image = TextureRect.new()
		countdown_image.expand_mode = TextureRect.EXPAND_FIT_WIDTH
		countdown_image.stretch_mode = TextureRect.STRETCH_SCALE
		countdown_image.visible = false
		countdown_image.mouse_filter = Control.MOUSE_FILTER_IGNORE
		countdown_image.anchor_left = 0.5
		countdown_image.anchor_top = 0.5
		countdown_image.anchor_right = 0.5
		countdown_image.anchor_bottom = 0.5
		var countdown_tex_size = countdown_images[0].get_size() if !countdown_images.is_empty() else Vector2(228, 54)
		countdown_image.offset_left = -countdown_tex_size.x / 2
		countdown_image.offset_top = -countdown_tex_size.y / 2
		countdown_image.offset_right = countdown_tex_size.x / 2
		countdown_image.offset_bottom = countdown_tex_size.y / 2
		if parent and parent.has_node("UI"):
			parent.get_node("UI").add_child(countdown_image)

		# Create and add cannon base and loaded laser model at bottom center with gimbal setup
		cannon_base = Node3D.new()  # Parent node to anchor the base
		if camera:
			camera.add_child(cannon_base)
			# Project bottom center of screen to 3D space
			var viewport = get_viewport()
			var screen_center = Vector2(viewport.size.x / 2, viewport.size.y)
			var ray_origin = camera.project_ray_origin(screen_center)
			var ray_direction = camera.project_ray_normal(screen_center)
			var distance = sphere_radius * 1.5  # Match aim plane distance
			var base_position = ray_origin + ray_direction * distance
			cannon_base.global_transform.origin = base_position
			yaw_node = Node3D.new()
			cannon_base.add_child(yaw_node)
			pitch_node = Node3D.new()
			yaw_node.add_child(pitch_node)
			var laser_scene = preload("res://assets/models/laser.glb")
			cannon = laser_scene.instantiate()
			pitch_node.add_child(cannon)
			# Adjust position and rotation if the model's orientation doesn't match (e.g., barrel should extend along -Z)
			# cannon.position = Vector3(0, 0, -0.5)  # Example: Uncomment and adjust if needed
			# cannon.rotation_degrees = Vector3(90, 0, 0)  # Example: Uncomment and adjust if needed

		# Initialize persistent laser audio player
		laser_audio = AudioStreamPlayer3D.new()
		laser_audio.stream = laser_sound
		laser_audio.volume_db = 0.0
		laser_audio.max_distance = 10.0
		add_child(laser_audio)  # Add to the root node to persist across levels

		if next_button:
			next_button.visible = false
			next_button.texture_normal = preload("res://assets/ui/next-level.webp")
			next_button.pressed.connect(_on_next_level_pressed)
		if play_again_button:
			play_again_button.visible = false
			play_again_button.pressed.connect(_on_play_again_pressed)
		if level_label:
			level_label.visible = true
			level_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
			level_label.anchor_left = 1.0
			level_label.anchor_top = 0.0
			level_label.anchor_right = 1.0
			level_label.anchor_bottom = 0.0
			level_label.offset_left = -200.0
			level_label.offset_top = 10.0
			level_label.offset_right = -10.0
			level_label.offset_bottom = 50.0
		if intro_timer:
			intro_timer.timeout.connect(_on_intro_timeout)
		if game_timer:
			game_timer.timeout.connect(_on_timer_timeout)

		# Add skip level menu at bottom for testing
		if parent and parent.has_node("UI"):
			var ui = parent.get_node("UI")
			var skip_menu = HBoxContainer.new()
			skip_menu.anchor_bottom = 1.0
			skip_menu.anchor_left = 0.5
			skip_menu.anchor_right = 0.5
			skip_menu.anchor_top = 1.0
			skip_menu.offset_bottom = -10
			skip_menu.offset_top = -50
			ui.add_child(skip_menu)
			var btn10 = Button.new()
			btn10.text = "Jump to 10"
			btn10.pressed.connect(func(): set_level(10))
			skip_menu.add_child(btn10)
			var btn20 = Button.new()
			btn20.text = "Jump to 20"
			btn20.pressed.connect(func(): set_level(20))
			skip_menu.add_child(btn20)
			var btn30 = Button.new()
			btn30.text = "Jump to 30"
			btn30.pressed.connect(func(): set_level(30))
			skip_menu.add_child(btn30)

		set_level(level)  # Initialize camera and sphere on load

func _notification(what):
	if what == NOTIFICATION_WM_MOUSE_ENTER:
		is_mouse_inside = true
	elif what == NOTIFICATION_WM_MOUSE_EXIT:
		is_mouse_inside = false

func start_level():
	is_level_active = false  # Disable actions at start of level
	visible = false  # Hide the sphere node.
	if game_timer:
		var base_time = 10.0  # Base time for level.
		var bonus_time = leftover_time  # Add leftover time as bonus.
		if level >= 10:  # If level 10 or higher.
			bonus_time += 15.0  # Add extra bonus time.
		var new_wait_time = base_time + bonus_time  # Calculate total time.
		game_timer.wait_time = new_wait_time  # Set timer wait time.
	regenerate()  # Regenerate the sphere and facets.
	if level_label:  # If level label exists.
		level_label.text = "LEVEL: " + str(level)  # Update level text.
	if target_label:  # If target label exists.
		target_label.text = "Target Color:"  # Set target label text.
		target_label.visible = true  # Make visible.
	if target_swatch:  # If target swatch exists.
		target_swatch.color = target_color  # Set swatch color to target.
		target_swatch.material.set_shader_parameter("base_color", target_color) # Update shader color
		target_swatch.material.set_shader_parameter("rect_size", Vector2(80.0, 80.0)) # Update shader rect_size
		target_swatch.anchor_left = 1.0  # Anchor to bottom-right.
		target_swatch.anchor_top = 1.0  # Anchor top to bottom.
		target_swatch.anchor_bottom = 1.0  # Anchor bottom to bottom.
		target_swatch.anchor_right = 1.0  # Anchor right to right.
		target_swatch.offset_left = -90.0  # Adjusted for 80x80 with 10-pixel margin
		target_swatch.offset_top = -90.0  # Adjusted for 80x80 with 10-pixel margin
		target_swatch.offset_bottom = -10.0  # Maintain 10-pixel margin
		target_swatch.offset_right = -10.0  # Maintain 10-pixel margin
		target_swatch.size = Vector2(80, 80)  # Set size
		target_swatch.visible = true  # Make visible.
		if swatch_frame:  # If frame exists.
			swatch_frame.anchor_left = 1.0  # Match ColorRect positioning
			swatch_frame.anchor_top = 1.0
			swatch_frame.anchor_bottom = 1.0
			swatch_frame.anchor_right = 1.0
			swatch_frame.offset_left = -90.0  # Adjusted for 80x80 with 10-pixel margin
			swatch_frame.offset_top = -90.0  # Adjusted for 80x80 with 10-pixel margin
			swatch_frame.offset_bottom = -10.0  # Maintain 10-pixel margin
			swatch_frame.offset_right = -10.0  # Maintain 10-pixel margin
			swatch_frame.size = Vector2(80, 80)  # Match new ColorRect size
			swatch_frame.visible = true
	_start_countdown()  # Start the countdown sequence instead of intro_timer

func _start_countdown():
	if countdown_image:  # If countdown image exists.
		countdown_count = 5  # Start at 5
		countdown_image.texture = countdown_images[0]  # Show 5sec image
		countdown_image.visible = true  # Make visible

		# Create a temporary timer for countdown
		var countdown_timer = Timer.new()
		add_child(countdown_timer)
		countdown_timer.wait_time = 1.0
		countdown_timer.one_shot = false
		countdown_timer.timeout.connect(_on_countdown_timeout.bind(countdown_timer))  # Pass timer to disconnect later
		countdown_timer.start()

func _on_countdown_timeout(countdown_timer: Timer):
	countdown_count -= 1  # Decrement count
	if countdown_count > 0:
		var image_index = 5 - countdown_count  # 4 -> index 1 (4sec), etc.
		countdown_image.texture = countdown_images[image_index]
	else:
		countdown_image.visible = false  # Hide on completion
		visible = true  # Show the sphere
		is_level_active = true  # Enable actions after countdown
		if game_timer:
			game_timer.start()  # Start game timer
		countdown_timer.stop()  # Stop and free the timer
		countdown_timer.timeout.disconnect(_on_countdown_timeout)
		countdown_timer.queue_free()

func _on_intro_timeout():
	visible = true  # Show the sphere after intro.
	if game_timer:  # If game timer exists.
		game_timer.start()  # Start game timer.

func _input(event):
	if not Engine.is_editor_hint():  # Not in editor.
		if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:  # Left mouse button event.
			if event.pressed:  # Button pressed.
				dragging = true  # Start dragging.
				start_mouse_pos = event.position  # Record start position.
			else:  # Button released.
				dragging = false  # Stop dragging.
				if dragged_distance < DRAG_THRESHOLD:  # If short drag, treat as click.
					handle_click(event.position)  # Handle click.
				dragged_distance = 0.0  # Reset drag distance.
		elif event is InputEventMouseMotion and dragging:  # Mouse motion during drag.
			var delta = event.relative  # Get motion delta.
			angular_velocity.y -= delta.x * 0.003  # Adjust y velocity.
			angular_velocity.x -= delta.y * 0.003  # Adjust x velocity.
			angular_velocity = angular_velocity.limit_length(MAX_ANGULAR_SPEED)  # Limit speed.
			dragged_distance += delta.length()  # Add to drag distance.

func _process(delta):
	if not Engine.is_editor_hint():  # Not in editor.
		var input_strength = 0.0  # Initialize input strength.
		if Input.is_action_pressed("ui_left"):  # Left arrow pressed.
			angular_velocity.y += rotation_speed * delta * 0.6  # Increase y velocity.
			input_strength = rotation_speed * delta * 0.6  # Update strength.
		if Input.is_action_pressed("ui_right"):  # Right arrow pressed.
			angular_velocity.y -= rotation_speed * delta * 0.6  # Decrease y velocity.
			input_strength = rotation_speed * delta * 0.6  # Update strength.
		if Input.is_action_pressed("ui_up"):  # Up arrow pressed.
			angular_velocity.x += rotation_speed * delta * 0.6  # Increase x velocity.
			input_strength = rotation_speed * delta * 0.6  # Update strength.
		if Input.is_action_pressed("ui_down"):  # Down arrow pressed.
			angular_velocity.x -= rotation_speed * delta * 0.6  # Decrease x velocity.
			input_strength = rotation_speed * delta * 0.6  # Update strength.
		if Input.is_action_just_pressed("ui_cancel"):  # Cancel action pressed.
			dragging = false  # Stop dragging.
			angular_velocity = Vector3.ZERO  # Reset velocity.
		if input_strength == 0.0:  # No input.
			angular_velocity *= FRICTION  # Apply friction decay.
		rotate_object_local(Vector3(1, 0, 0), angular_velocity.x * delta)  # Rotate on x axis.
		rotate_object_local(Vector3(0, 1, 0), angular_velocity.y * delta)  # Rotate on y axis.
		if timer_label:  # If timer label exists.
			if game_timer and game_timer.is_stopped():  # Timer stopped.
				timer_label.text = "Time: " + str(snapped(leftover_time, 0.1))  # Show leftover time.
			elif game_timer:  # Timer running.
				timer_label.text = "Time: " + str(snapped(game_timer.time_left, 0.1))  # Show remaining time.
		# Camera shake logic: Start at level 15+ when time <= 10s, scale with radius for progressive intensity.
		if level >= 15 and game_timer and not game_timer.is_stopped() and game_timer.time_left <= 10.0 and not game_over:
			var time_left = game_timer.time_left  # Get remaining time.
			var time_intensity = clamp((10.0 - time_left) / 10.0 * 3.0, 0.5, 3.0)  # Mild at 10s (0.5), intense at 0s (3.0).
			var scale_factor = sphere_radius / INITIAL_RADIUS  # Progressive scale with sphere growth/camera distance.
			var intensity = time_intensity * scale_factor  # Combine time and scale for worse shake at higher levels.
			var shake_x = sin(Time.get_ticks_msec() / 1000.0 * shake_frequency) * shake_amplitude * intensity  # X shake.
			var shake_y = sin(Time.get_ticks_msec() / 1000.0 * shake_frequency * 1.5) * shake_amplitude * intensity  # Y shake (varied freq).
			var shake_z = sin(Time.get_ticks_msec() / 1000.0 * shake_frequency * 0.5) * (shake_amplitude / 2) * intensity  # Z shake (milder).
			camera.position = original_camera_pos + Vector3(shake_x, shake_y, shake_z)  # Apply shake offset.
		else:
			camera.position = original_camera_pos  # Reset camera if not shaking.

		# Cannon tracks mouse with gimbal: yaw (left-right) and pitch (up-down), no roll
		if cannon_base and camera and is_mouse_inside:
			var mouse_pos = get_viewport().get_mouse_position()
			var from = camera.project_ray_origin(mouse_pos)
			var dir = camera.project_ray_normal(mouse_pos)
			var aim_distance = sphere_radius * 1.5
			var aim_point = from + dir * aim_distance
			var target_dir = (aim_point - cannon_base.global_position).normalized()
			var yaw = atan2(target_dir.x, target_dir.z)
			var horizontal = sqrt(target_dir.x * target_dir.x + target_dir.z * target_dir.z)
			var pitch = atan2(target_dir.y, horizontal)
			yaw_node.rotation.y = yaw
			pitch_node.rotation.x = -pitch  # Negative pitch to point up when target is above (adjust sign based on model orientation)```
	facets = []
	clear_children()
	add_base_sphere()
	target_color = possible_targets.pick_random()
	var rng = RandomNumberGenerator.new()
	red_index = rng.randi_range(0, num_facets - 1)
	alternate_colors.clear()
	if level >= 2:  # For level 2+.
		alternate_colors.append_array([Color(0.0, 0.5, 1.0), Color(0.0, 1.0, 0.5)])  # Add colors.
	if level >= 3:  # For level 3+.
		alternate_colors.append(Color(1.0, 0.5, 0.0))  # Add color.
	if level >= 4:  # For level 4+.
		alternate_colors.append(Color(0.5, 0.0, 1.0))  # Add color.
	if level >= 5:  # For level 5+.
		alternate_colors.append(Color(1.0, 1.0, 0.0))  # Add color.
	generate_facets()
	if status_label:  # If status label.
		status_label.text = ""  # Clear text.
		status_label.modulate = Color.WHITE  # Set color to white.
	if win_image:  # Hide win image
		win_image.visible = false
	if countdown_image:  # Hide countdown image
		countdown_image.visible = false
	if next_button:  # If next button.
		next_button.visible = false  # Hide.
	if play_again_button:  # If play again button.
		play_again_button.visible = false  # Hide.

func clear_children():
	var children = get_children()  # Get all children.
	for child in children:  # Loop through children.
		if child is MeshInstance3D or child is Area3D:  # If mesh or area.
			remove_child(child)  # Remove from parent.
			child.free()  # Free the node.

func add_base_sphere():
	var base_mesh = MeshInstance3D.new()  # Create new mesh instance.
	var sphere_mesh = SphereMesh.new()  # Create sphere mesh.
	sphere_mesh.radius = sphere_radius  # Set radius.
	sphere_mesh.height = sphere_radius * 2  # Set height.
	sphere_mesh.radial_segments = 128  # Set radial segments.
	sphere_mesh.rings = 64  # Set rings.
	base_mesh.mesh = sphere_mesh  # Assign mesh.
	add_child(base_mesh)  # Add to scene.
	var material = StandardMaterial3D.new()  # Create material.
	material.albedo_color = base_color  # Set albedo color.
	material.emission_enabled = true  # Enable emission.
	material.emission = base_color  # Set emission color to base.
	material.emission_energy_multiplier = 0.2 * (sphere_radius / 4.0)  # Scale emission with radius to prevent washout.
	base_mesh.material_override = material  # Override material.

func color_distance(c1: Color, c2: Color) -> float:
	return pow(c1.r - c2.r, 2) + pow(c1.g - c2.g, 2) + pow(c1.b - c2.b, 2)  # Calculate squared color distance.

func generate_facets():
	var golden_ratio = (1 + sqrt(5)) / 2  # Golden ratio for distribution.
	var rng = RandomNumberGenerator.new()  # New RNG.
	for i in range(num_facets):  # Loop for each facet.
		var y = 1 - (i / float(num_facets - 1)) * 2  # Calculate y position.
		var radius_at_y = sqrt(1 - y * y)  # Radius at y.
		var theta = 2 * PI * i / golden_ratio  # Theta for spiral.
		var x = cos(theta) * radius_at_y  # X position.
		var z = sin(theta) * radius_at_y  # Z position.
		var facet_position = Vector3(x, y, z).normalized() * sphere_radius  # Normalized position.
		var normal = facet_position.normalized()  # Normal vector.
		var facet = Area3D.new()  # New area node for facet.
		facet.translate(facet_position)  # Translate to position.
		add_child(facet)  # Add to scene.
		facet.set_meta("original_pos", facet_position)  # Set meta for original position.
		facet.set_meta("original_normal", normal)  # Set meta for normal.
		facets.append(facet)  # Add to facets array.
		var up = normal  # Up vector as normal.
		var arbitrary = Vector3.UP if abs(up.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD  # Arbitrary vector for cross.
		var right = up.cross(arbitrary).normalized()  # Right vector.
		var forward = right.cross(up).normalized()  # Forward vector.
		facet.transform.basis = Basis(right, up, forward)  # Set basis for orientation.
		facet.collision_layer = 1  # Set collision layer.
		facet.collision_mask = 1  # Set collision mask.
		var mesh_instance = MeshInstance3D.new()  # New mesh instance.
		var cylinder_mesh = create_beveled_cylinder_mesh(facet_height, facet_radius, radial_segments, bevel_height, bevel_inset)  # Create beveled mesh.
		mesh_instance.mesh = cylinder_mesh  # Assign mesh.
		mesh_instance.translate(Vector3(0, facet_height / 2, 0))  # Translate outward.
		facet.add_child(mesh_instance)  # Add mesh to facet.
		var collision_shape = CollisionShape3D.new()  # New collision shape.
		var cylinder_shape = CylinderShape3D.new()  # New cylinder shape.
		cylinder_shape.radius = facet_radius  # Set radius.
		cylinder_shape.height = facet_height  # Set height.
		collision_shape.shape = cylinder_shape  # Assign shape.
		collision_shape.translate(Vector3(0, facet_height / 2, 0))  # Translate to match mesh.
		collision_shape.disabled = false  # Enable shape.
		facet.add_child(collision_shape)  # Add to facet.
		var material = StandardMaterial3D.new()  # New material.
		var facet_color: Color  # Facet color variable.
		var emission_mult: float  # Emission multiplier.
		if i == red_index:  # If target facet.
			facet_color = target_color  # Set to target color.
			emission_mult = 0.5 * (sphere_radius / 4.0)  # Scale emission with radius.
		elif alternate_colors.size() > 0:  # If distractors available.
			var distractor_color = alternate_colors[rng.randi() % alternate_colors.size()]  # Pick distractor.
			if color_distance(distractor_color, target_color) < COLOR_SIMILARITY_THRESHOLD: # If too similar.
				facet_color = base_color  # Use base.
				emission_mult = 0.2 * (sphere_radius / 4.0)  # Scale emission.
			else:  # Not similar.
				facet_color = distractor_color  # Use distractor.
				emission_mult = 0.2 * (sphere_radius / 4.0)  # Scale emission.
		else:  # No distractors.
			facet_color = base_color  # Use base.
			emission_mult = 0.2 * (sphere_radius / 4.0)  # Scale emission.
		material.albedo_color = facet_color  # Set albedo.
		material.emission_enabled = true  # Enable emission.
		material.emission = facet_color  # Set emission to color.
		material.emission_energy_multiplier = emission_mult  # Set scaled intensity.
		material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA  # Enable alpha transparency for fade.
		mesh_instance.material_override = material  # Override material.
		facet.set_meta("facet_index", i)  # Set facet index meta.
		var audio_player = AudioStreamPlayer3D.new()  # New 3D audio player for positional sound.
		audio_player.stream = pop_sound  # Set the preloaded pop sound.
		audio_player.volume_db = 0.0 # Set volume (adjust as needed, e.g., -10 for quieter).
		audio_player.pitch_scale = 1.0 + randf_range(-0.2, 0.2)  # Slight pitch variation for natural pops.
		facet.add_child(audio_player)  # Add audio to facet.

func create_beveled_cylinder_mesh(height: float, radius: float, segments: int, bh: float, bi: float) -> Mesh:
	if bh > height * 0.4:  # Cap bevel height.
		bh = height * 0.4  # Adjust if too large.
	if bi > radius * 0.5:  # Cap bevel inset.
		bi = radius * 0.5  # Adjust if too large.
	var ring_y: Array[float] = [-height / 2, -height / 2 + bh, height / 2 - bh, height / 2]  # Ring y positions.
	var ring_rad: Array[float] = [radius - bi, radius, radius, radius - bi]  # Ring radii.
	var num_rings: int = 4  # Number of rings.
	var st = SurfaceTool.new()  # New surface tool.
	st.begin(Mesh.PRIMITIVE_TRIANGLES)  # Start triangle mesh.
	for ring in num_rings:  # Loop rings.
		for s in segments:  # Loop segments.
			var theta = 2 * PI * s / segments  # Calculate theta.
			var x = cos(theta) * ring_rad[ring]  # X coord.
			var z = sin(theta) * ring_rad[ring]  # Z coord.
			st.add_vertex(Vector3(x, ring_y[ring], z))  # Add vertex.
	for ring in range(num_rings - 1):  # Loop for sides.
		for s in segments:  # Loop segments.
			var s1 = (s + 1) % segments  # Next segment.
			var base = ring * segments  # Base index.
			var base_next = (ring + 1) * segments  # Next base.
			st.add_index(base + s)  # Add index 1.
			st.add_index(base_next + s)  # Add index 2.
			st.add_index(base + s1)  # Add index 3.
			st.add_index(base + s1)  # Add index 4.
			st.add_index(base_next + s)  # Add index 5.
			st.add_index(base_next + s1)  # Add index 6.
	var vertex_count = num_rings * segments  # Total vertices.
	var bottom_center = vertex_count  # Bottom center index.
	st.add_vertex(Vector3(0, ring_y[0], 0))  # Add bottom center.
	var bottom_ring_base = 0  # Bottom ring base.
	for s in segments:  # Loop for bottom cap.
		var s1 = (s + 1) % segments  # Next.
		st.add_index(bottom_center)  # Center.
		st.add_index(bottom_ring_base + s)  # Current.
		st.add_index(bottom_ring_base + s1)  # Next.
	var top_center = vertex_count + 1  # Top center index.
	st.add_vertex(Vector3(0, ring_y[3], 0))  # Add top center.
	var top_ring_base = (num_rings - 1) * segments  # Top ring base.
	for s in segments:  # Loop for top cap.
		var s1 = (s + 1) % segments  # Next.
		st.add_index(top_center)  # Center.
		st.add_index(top_ring_base + s1)  # Next (reversed for winding).
		st.add_index(top_ring_base + s)  # Current.
	st.generate_normals()  # Generate normals.
	var mesh = ArrayMesh.new()  # New array mesh.
	st.commit(mesh)  # Commit to mesh.
	return mesh  # Return the mesh.

func handle_click(click_pos: Vector2):
	if not camera or not is_level_active:  # Ignore clicks during countdown
		return
	var from = camera.project_ray_origin(click_pos)  # Ray origin from camera.
	var to = from + camera.project_ray_normal(click_pos) * 1000  # Ray end.
	var space_state = get_world_3d().direct_space_state  # Get space state.
	var query = PhysicsRayQueryParameters3D.create(from, to)  # Create query.
	query.collide_with_areas = true  # Collide with areas.
	query.collision_mask = 1  # Collision mask.
	var result = space_state.intersect_ray(query)  # Intersect ray.
	if result:  # If hit.
		if result.collider and result.collider.has_meta("facet_index"):  # If has facet index.
			var clicked_index = result.collider.get_meta("facet_index")  # Get index.
			var clicked_mesh = result.collider.get_child(0)  # Get mesh child.
			var clicked_color = clicked_mesh.material_override.albedo_color  # Get color.
			var clicked_material = clicked_mesh.material_override  # Get material for fade.
			var audio_player = result.collider.get_child(2)  # Get audio player (now index 2 after mesh and collision).
			var pop_tween = get_tree().create_tween().set_parallel(true)  # Create parallel tween for pop.
			pop_tween.tween_property(clicked_mesh, "scale", Vector3(1.2, 1.5, 1.2), 0.1)  # Inflate with more y for rounder top in 0.1s.
			pop_tween.chain().tween_property(clicked_mesh, "scale", Vector3.ZERO, 0.15)  # Then burst to zero in 0.15s for faster pop.
			pop_tween.tween_property(clicked_material, "albedo_color:a", 0.0, 0.25)  # Fade alpha to 0 over 0.25s.
			pop_tween.tween_callback(audio_player.play).set_delay(0.1)  # Play sound at pop moment (after inflate).
			pop_tween.tween_callback(result.collider.queue_free).set_delay(0.25)  # Free facet after animation.
			# Fire laser beam on every click
			if cannon:
				create_laser_beam(cannon.global_transform * barrel_tip_offset, result.position)  # Use barrel tip
			if clicked_color.is_equal_approx(target_color):  # If matches target.
				if game_timer:  # If timer.
					leftover_time = game_timer.time_left  # Save leftover time.
					game_timer.stop()  # Stop timer.
				if timer_label:  # If label.
					timer_label.text = "Time: " + str(snapped(leftover_time, 0.1))  # Update label.
				if status_label:  # If status.
					status_label.visible = false  # Hide status label to replace with image.
				if win_image:  # Show win image instead of text.
					win_image.visible = true
				if next_button:  # If next button.
					next_button.visible = true  # Show next.
			else:  # Wrong click.
				pop_tween.tween_property(clicked_material, "albedo_color", Color(0.5, 0.5, 0.5), 0.25)  # Gray out for wrong pop (fizzle effect).
	else:  # No hit.
		pass  # Do nothing.

func create_laser_beam(start: Vector3, end: Vector3):
	var direction = (end - start).normalized()  # Direction vector
	var distance = start.distance_to(end)  # Full distance
	var beam_start = start  # Start beam from barrel tip
	var adjusted_end = end - direction * 0.1  # Shorten slightly to end on edge (adjust 0.1 as needed)
	var adjusted_distance = beam_start.distance_to(adjusted_end)  # Adjusted length

	var laser = MeshInstance3D.new()  # New mesh for laser
	var cylinder = CylinderMesh.new()  # Thin cylinder for beam
	cylinder.top_radius = 0.05  # Thin radius; adjust
	cylinder.bottom_radius = 0.05
	cylinder.height = adjusted_distance  # Adjusted length
	laser.mesh = cylinder
	add_child(laser)  # Add to scene root (global space)

	# Position at beam_start and orient
	laser.global_position = beam_start
	laser.look_at(adjusted_end, Vector3.UP)  # Orient towards adjusted end
	laser.rotate_object_local(Vector3(1, 0, 0), PI / 2)  # Rotate to align cylinder axis with direction

	# Material: Glowing red laser
	var mat = StandardMaterial3D.new()
	mat.albedo_color = Color.RED
	mat.emission_enabled = true
	mat.emission = Color.RED
	mat.emission_energy_multiplier = 2.0
	mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	laser.material_override = mat

	# Play laser sound using persistent audio player
	if laser_audio:
		laser_audio.global_transform.origin = beam_start  # Update position
		laser_audio.play()
		print("Laser sound triggered")  # Debug print

	# Fade out and free after 0.5s
	var tween = get_tree().create_tween()
	tween.tween_property(mat, "albedo_color:a", 0.0, 0.5)
	tween.tween_callback(laser.queue_free)

func _on_timer_timeout():
	if timer_label:  # If timer label.
		timer_label.text = "Lose!"  # Set lose text.
	if status_label:  # If status.
		status_label.text = "Game Over!"  # Set game over.
		status_label.modulate = Color.RED  # Red color.
		status_label.visible = true  # Show.
	game_over = true  # Set game over flag.
	if next_button:  # If next button.
		next_button.visible = false  # Hide.
	if play_again_button:  # If play again.
		play_again_button.visible = true  # Show.
	if game_over and red_index >= 0 and red_index < (get_child_count() - 1):  # If valid target.
		var target_facet = get_child(red_index + 1)  # Get target facet (skip base).
		if target_facet is Area3D:  # If area.
			var target_pos = target_facet.global_transform.origin.normalized()  # Normalized position.
			var target_rotation = Basis.looking_at(-target_pos, Vector3.UP).get_euler()  # Rotation to look at target.
			var rotation_tween = get_tree().create_tween()  # Create tween for rotation.
			if rotation_tween:  # If tween.
				var rot_prop = rotation_tween.tween_property(self, "rotation", target_rotation, 1.0)  # Tween rotation.
				rot_prop.set_trans(Tween.TRANS_QUAD)  # Quad transition.
				rot_prop.set_ease(Tween.EASE_OUT)  # Ease out.
			var target_mesh = target_facet.get_child(0)  # Get mesh.
			if target_mesh and target_mesh.material_override:  # If material.
				var material = target_mesh.material_override.duplicate()  # Duplicate material.
				material.emission_energy_multiplier = 0.5  # Set initial emission.
				target_mesh.material_override = material  # Assign.
				var pulse_tween = get_tree().create_tween()  # Create pulse tween.
				if pulse_tween:  # If tween.
					var emission_up = pulse_tween.tween_property(material, "emission_energy_multiplier", 0.8, 1.0)  # Tween emission up.
					emission_up.set_trans(Tween.TRANS_SINE)  # Sine transition.
					emission_up.set_ease(Tween.EASE_IN_OUT)  # Ease in out.
					var scale_up = pulse_tween.tween_property(target_mesh, "scale", Vector3(1.2, 1.2, 1.2), 1.0)  # Scale up.
					scale_up.set_trans(Tween.TRANS_SINE)  # Sine.
					scale_up.set_ease(Tween.EASE_IN_OUT)  # Ease.
					var emission_down = pulse_tween.tween_property(material, "emission_energy_multiplier", 0.5, 1.0)  # Emission down.
					emission_down.set_trans(Tween.TRANS_SINE)  # Sine.
					emission_down.set_ease(Tween.EASE_IN_OUT)  # Ease.
					var scale_down = pulse_tween.tween_property(target_mesh, "scale", Vector3(1.0, 1.0, 1.0), 1.0)  # Scale down.
					scale_down.set_trans(Tween.TRANS_SINE)  # Sine.
					scale_down.set_ease(Tween.EASE_IN_OUT)  # Ease.

func _on_next_level_pressed():
	if not game_over:  # If not over.
		set_level(level + 1)  # Advance level.
		if status_label:  # If status.
			status_label.text = ""  # Clear.
		if win_image:  # Hide win image
			win_image.visible = false
		if next_button:  # If next.
			next_button.visible = false  # Hide.
		if play_again_button:  # If play again.
			play_again_button.visible = false  # Hide.

func _on_play_again_pressed():
	game_over = false  # Reset game over.
	angular_velocity = Vector3.ZERO  # Reset velocity.
	if status_label:  # If status.
		status_label.text = ""  # Clear.
		status_label.visible = false  # Hide.
	if win_image:  # Hide win image
		win_image.visible = false
	if play_again_button:  # If play again.
		play_again_button.visible = false  # Hide.
	if next_button:  # If next.
		next_button.visible = false  # Hide.
	if game_timer:  # If timer.
		leftover_time = 0.0  # Reset leftover.
		game_timer.stop()  # Stop timer.
	set_level(1)  # Reset to level 1.

func set_level(new_level: int):
	level = new_level  # Set new level.
	var base_time = 10.0  # Base time.
	var bonus_time = leftover_time  # Bonus from leftover.
	if level >= 10:  # For high levels.
		bonus_time += 5.0  # Extra bonus.
	var new_wait_time = base_time + bonus_time  # Total time.
	if game_timer:  # If timer.
		game_timer.wait_time = new_wait_time  # Set time.
	sphere_radius = 4.0 + (level - 1) * 0.3  # Update radius with smaller increment.
	num_facets = base_num_facets + (level - 1) * 50  # Update facets.
	if camera:  # If camera.
		camera.position = Vector3(0, 0, sphere_radius * 2.0)  # Pull camera further back
		camera.look_at(Vector3.ZERO)  # Look at center.
		camera.far = sphere_radius * 3.0 + 100.0  # Dynamically increase far clip to avoid clipping/washout (buffer for safety).
		original_camera_pos = camera.position  # Store original pos for shake.
	start_level()  # Start the level.```