From 98eaa77175e057b2704c0ed5cdf12631b71f19c8 Mon Sep 17 00:00:00 2001 From: alpacamannn Date: Tue, 21 Apr 2026 15:32:59 +0200 Subject: [PATCH] add ci build --- .claude/CLAUDE.md | 146 +++++++++++++++++++++++++++++++++++++ .gitea/workflows/build.yml | 34 +++++++++ export_presets.cfg | 50 +++++++++++++ scripts/cosmic_objects.gd | 4 +- scripts/game_world.gd | 82 ++++++++++++++++++--- scripts/music_player.gd | 142 +++++++++++++++++++++++++++++++++++- 6 files changed, 446 insertions(+), 12 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .gitea/workflows/build.yml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..fbba045 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,146 @@ +# Godot MCP Pro - AI Assistant Instructions + +You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully. + +## Critical: Editor vs Runtime Tools + +Tools are split into two categories. **Using a runtime tool without starting the game will always fail.** + +### Editor Tools (always available) +These work on the currently open scene in the Godot editor: +- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports` +- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group` +- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts` +- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action` +- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project` +- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview` +- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property` +- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap` +- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation` +- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter` +- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info` +- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info` +- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info` +- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info` +- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param` +- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox` +- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells` +- **Export**: `list_export_presets`, `get_export_info`, `export_project` +- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references` +- **Profiling**: `get_editor_performance` + +### Runtime Tools (require `play_scene` first) +You MUST call `play_scene` before using any of these. They interact with the running game: +- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script` +- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence` +- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties` +- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to` +- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report` +- **Screenshots**: `get_game_screenshot`, `compare_screenshots` +- **Control**: `play_scene`, `stop_scene` + +## Workflow Patterns + +### Building a scene from scratch +1. `create_scene` or `open_scene` +2. Use `add_node` or `batch_add_nodes` to add nodes +3. `create_script` + `attach_script` for behavior +4. `save_scene` + +### Testing gameplay +1. Build scene with editor tools (above) +2. `play_scene` to start the game +3. Use `simulate_key`/`simulate_mouse_click` for input +4. `get_game_screenshot` or `capture_frames` to observe results +5. `stop_scene` when done + +### Inspecting a project +1. `get_project_info` for overview +2. `get_scene_tree` for current scene structure +3. `read_script` to read code +4. `get_node_properties` for specific node details + +### Migrating code properties to inspector +When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector: +1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`) +2. `get_node_properties` to see current inspector values +3. `update_property` to set the same values as node properties in the inspector +4. `edit_script` to remove the hardcoded lines from the script +5. `save_scene` to persist the inspector changes +6. `validate_script` to verify the script still works + +## Formatting Rules + +### execute_editor_script +The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output. + +``` +# Correct +_mcp_print("hello") + +# Correct - multi-line +var nodes = [] +for child in EditorInterface.get_edited_scene_root().get_children(): + nodes.append(child.name) +_mcp_print(str(nodes)) +``` + +### execute_game_script +Same as above but runs inside the running game. Additional rules: +- No nested functions (`func` inside `func` is invalid GDScript) +- Use `.get("property")` instead of `.property` for safe access +- Runs in a temporary node — use `get_tree()` to access the scene tree + +### batch_add_nodes +Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones: +```json +{ + "nodes": [ + {"type": "Node2D", "name": "Container", "parent_path": "."}, + {"type": "Sprite2D", "name": "Icon", "parent_path": "Container"}, + {"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}} + ] +} +``` + +## Best Practices + +1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +## Common Pitfalls + +1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file. +2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`. +3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect. +4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`. +5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting. +6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data. + +## CLI Mode (Alternative to MCP Tools) + +If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI. +The CLI requires the server to be built first (`node build/setup.js install` in the server directory). + +```bash +# Discover available command groups +node /path/to/server/build/cli.js --help + +# Discover commands in a group +node /path/to/server/build/cli.js scene --help + +# Discover options for a specific command +node /path/to/server/build/cli.js node add --help + +# Execute commands +node /path/to/server/build/cli.js project info +node /path/to/server/build/cli.js scene tree +node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main +node /path/to/server/build/cli.js script read --path res://player.gd +node /path/to/server/build/cli.js scene play +node /path/to/server/build/cli.js input key --key W --duration 0.5 +node /path/to/server/build/cli.js runtime tree +``` + +**Command groups**: project, scene, node, script, editor, input, runtime + +Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..6d6b877 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,34 @@ +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + container: + image: barichello/godot-ci:4.6.2 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get build number + run: echo "BUILD_NUM=$(git rev-list --count HEAD)" >> $GITHUB_ENV + + - name: Prepare build dir + run: mkdir -p build + + - name: Build Windows + run: godot --headless --export-release "Windows Desktop" build/spacel.exe + + - name: Build Linux + run: godot --headless --export-release "Linux" build/spacel.x86_64 + + - name: Build Android + run: godot --headless --export-release "Android" build/spacel.apk + + - name: Build Web + run: godot --headless --export-release "Web" build/spacel.html + + - uses: actions/upload-artifact@v4 + with: + name: build-${{ env.BUILD_NUM }} + path: build/ \ No newline at end of file diff --git a/export_presets.cfg b/export_presets.cfg index ed2c889..d601907 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -342,3 +342,53 @@ permissions/write_sms=false permissions/write_social_stream=false permissions/write_sync_settings=false permissions/write_user_dictionary=false + +[preset.3] + +name="Web" +platform="Web" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/build9/spacel.html" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.3.options] + +custom_template/debug="" +custom_template/release="" +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=false +progressive_web_app/enabled=false +progressive_web_app/ensure_cross_origin_isolation_headers=true +progressive_web_app/offline_page="" +progressive_web_app/display=1 +progressive_web_app/orientation=0 +progressive_web_app/icon_144x144="" +progressive_web_app/icon_180x180="" +progressive_web_app/icon_512x512="" +progressive_web_app/background_color=Color(0, 0, 0, 1) +threads/emscripten_pool_size=8 +threads/godot_pool_size=4 diff --git a/scripts/cosmic_objects.gd b/scripts/cosmic_objects.gd index 5215962..3231e4c 100644 --- a/scripts/cosmic_objects.gd +++ b/scripts/cosmic_objects.gd @@ -576,8 +576,8 @@ class AntimatterStar extends RefCounted: var alpha: float = clamp(life / MAX_LIFE, 0.0, 1.0) var cv := Vector2(x, y) # Outer magenta glow - canvas.draw_circle(cv, radius + 8.0, Color(0.75, 0.0, 0.9, p * 0.10 * alpha)) - canvas.draw_circle(cv, radius + 3.0, Color(0.6, 0.0, 0.85, p * 0.18 * alpha)) + canvas.draw_circle(cv, radius + 8.0, Color(0.75, 0.0, 0.9, p * 0.05 * alpha)) + canvas.draw_circle(cv, radius + 3.0, Color(0.6, 0.0, 0.85, p * 0.09 * alpha)) # Dark antimatter core canvas.draw_circle(cv, radius, Color(0.15, 0.0, 0.22, alpha)) # Rotating ring diff --git a/scripts/game_world.gd b/scripts/game_world.gd index 5f2bb47..33b4778 100644 --- a/scripts/game_world.gd +++ b/scripts/game_world.gd @@ -14,8 +14,8 @@ const BH_MAX_COUNT := 8 const WHITE_HOLE_MAX := 2 # cap white holes so they don't pile up const COMET_SPAWN_MIN := 3.0 const COMET_SPAWN_MAX := 10.0 -const ANTIMATTER_SPAWN_MIN := 180.0 -const ANTIMATTER_SPAWN_MAX := 360.0 +const ANTIMATTER_SPAWN_MIN := 300.0 +const ANTIMATTER_SPAWN_MAX := 600.0 const DIFFICULTY_RAMP_SECS := 300.0 # full difficulty at 5 minutes const OBJECT_WIPE_THRESHOLD := 500 const PHYS_DT := 1.0 / 60.0 @@ -48,6 +48,10 @@ var comet_timer: float = 0.0 var comet_next: float = 5.0 var antimatter_timer: float = 0.0 var antimatter_next: float = 50.0 +var antimatter_warn_pending: bool = false +var antimatter_warn_pos: Vector2 = Vector2.ZERO +var antimatter_warn_timer: float = 0.0 +const ANTIMATTER_WARN_DURATION := 1.5 var credit_tick_timer: float = 0.0 var big_wipe: BigWipe = null @@ -312,12 +316,21 @@ func _tick(dt: float) -> void: c.init(W, H) comets.append(c) antimatter_timer += dt - var am_max_interval: float = lerp(float(ANTIMATTER_SPAWN_MAX), float(ANTIMATTER_SPAWN_MIN), difficulty) - if antimatter_timer >= antimatter_next: - antimatter_timer = 0.0 - antimatter_next = randf_range(ANTIMATTER_SPAWN_MIN, am_max_interval) - _spawn_antimatter_swarm(W * 0.5 + randf_range(-100, 100), H * 0.5 + randf_range(-100, 100)) - SoundManager.play_sfx("antimatter_swarm") + if not antimatter_warn_pending: + var am_max_interval: float = lerp(float(ANTIMATTER_SPAWN_MAX), 120.0, difficulty) + if antimatter_timer >= antimatter_next: + antimatter_timer = 0.0 + antimatter_next = randf_range(ANTIMATTER_SPAWN_MIN, am_max_interval) + antimatter_warn_pos = Vector2(W * 0.5 + randf_range(-100, 100), H * 0.5 + randf_range(-100, 100)) + antimatter_warn_pending = true + antimatter_warn_timer = 0.0 + SoundManager.play_sfx("antimatter_warn") + if antimatter_warn_pending: + antimatter_warn_timer += dt + if antimatter_warn_timer >= ANTIMATTER_WARN_DURATION: + antimatter_warn_pending = false + _spawn_antimatter_swarm(antimatter_warn_pos.x, antimatter_warn_pos.y) + SoundManager.play_sfx("antimatter_swarm") var all_dead: bool = true for player in players: var sp: Spaceship = player @@ -414,6 +427,11 @@ func _update_objects(delta: float) -> void: for comet in comets: var c: CosmicObjects.Comet = comet c.update(W, H) + for comet in comets: + var c: CosmicObjects.Comet = comet + if c.dead: + SoundManager.play_sfx("comet_whistle") + _fire_music_event("comet_pass") comets = comets.filter(func(c): return not c.dead) for gal in galaxies: var g: CosmicObjects.Galaxy = gal @@ -426,9 +444,11 @@ func _update_objects(delta: float) -> void: g.consuming_bh = null g.respawning = true g.respawn_timer = randf_range(5.0, 10.0) + _fire_music_event("galaxy_consumed") if cbh.consumed >= 12 and not cbh.is_smbh: cbh.become_smbh() SoundManager.play_sfx("smbh_spawn") + _fire_music_event("bh_pulse") for qsr in quasars: var q: CosmicObjects.Quasar = qsr q.update(delta) @@ -459,6 +479,7 @@ func _update_objects(delta: float) -> void: if q.boost_sound_cd <= 0.0: q.boost_sound_cd = 1.2 SoundManager.play_sfx("quasar_boost") + _fire_music_event("quasar_jet") quasars = quasars.filter(func(q): return not q.dead) _merge_quasars() var new_stars_from_wh: Array = [] @@ -468,6 +489,8 @@ func _update_objects(delta: float) -> void: var result: Dictionary = wh.update(delta) new_stars_from_wh.append_array(result["stars"]) new_planets_from_wh.append_array(result["planets"]) + if result["stars"].size() > 0 or result["planets"].size() > 0: + _fire_music_event("white_hole_eject") for player in players: var p: Spaceship = player if not p.dead: @@ -500,8 +523,11 @@ func _update_objects(delta: float) -> void: for player in players: var p: Spaceship = player if not p.dead: + var prev_vx := p.vx; var prev_vy := p.vy var nv: Vector2 = ns.push_if_in_beam(p.x, p.y, p.vx, p.vy) p.vx = nv.x; p.vy = nv.y + if nv.x != prev_vx or nv.y != prev_vy: + _fire_music_event("neutron_beam") neutron_stars = neutron_stars.filter(func(ns): return not ns.dead) for am_obj in antimatter: var am: CosmicObjects.Antimatter = am_obj @@ -537,6 +563,7 @@ func _update_objects(delta: float) -> void: if b != null: bullets.append(b) SoundManager.play_sfx("enemy_shoot") + _update_bh_proximity_audio() func _apply_gravity_all(_delta: float) -> void: for bhole in black_holes: @@ -578,6 +605,8 @@ func _apply_gravity_all(_delta: float) -> void: _spawn_spiral_particles(p.x, p.y, bh.x, bh.y, int(p.radius * 3.0), p.color, 5.0) bh.trigger_flash(p.color, 0.8) p.dead = true + SoundManager.play_sfx("planet_impact") + _fire_music_event("planet_captured") var trigger: bool = bh.on_swallow() if trigger: _trigger_supernova(bh) @@ -1077,7 +1106,7 @@ func _spawn_universe_partial() -> void: planets.append(p) func _spawn_antimatter_swarm(cx: float, cy: float) -> void: - var count: int = randi_range(6, 12) + var count: int = randi_range(3, 5) for _i in count: var a: float = randf() * TAU var spd: float = randf_range(1.0, 3.0) @@ -1086,6 +1115,34 @@ func _spawn_antimatter_swarm(cx: float, cy: float) -> void: antimatter.append(am) +func _fire_music_event(event_name: String) -> void: + if main_node and "music_player" in main_node and main_node.music_player: + main_node.music_player.play_music_event(event_name) + +func _update_bh_proximity_audio() -> void: + var mp: Node = main_node.music_player if main_node and "music_player" in main_node else null + if mp == null or not is_playing or players.size() == 0: + if mp: mp.set_bh_proximity(0.0, 1.0, false) + return + var player: Spaceship = players[0] + if player.dead: + mp.set_bh_proximity(0.0, 1.0, false) + return + var max_prox := 0.0 + var max_size := 1.0 + var any_smbh := false + for bhole in black_holes: + var bh: BlackHole = bhole + if bh.dead or bh.pull_radius <= 0.0: continue + var dx := player.x - bh.x + var dy := player.y - bh.y + var prox: float = clamp(1.0 - sqrt(dx*dx + dy*dy) / bh.pull_radius, 0.0, 1.0) + if prox > max_prox: + max_prox = prox + max_size = bh.radius / 14.0 + any_smbh = bh.is_smbh + mp.set_bh_proximity(max_prox, max_size, any_smbh) + func _spawn_explosion_particles(cx: float, cy: float, count: int, col: Color) -> void: for _i in count: var a: float = randf() * TAU @@ -1115,6 +1172,13 @@ func _spawn_spiral_particles(cx: float, cy: float, bh_x: float, bh_y: float, func _draw() -> void: draw_rect(get_viewport_rect(), Color("#0a0a14")) + if antimatter_warn_pending: + var warn_t: float = antimatter_warn_timer / ANTIMATTER_WARN_DURATION + var pulse_a: float = abs(sin(antimatter_warn_timer * TAU * 3.5)) + var ring_r: float = 25.0 + 8.0 * warn_t + var warn_alpha: float = 0.55 * pulse_a * (1.0 - warn_t * 0.25) + draw_arc(antimatter_warn_pos, ring_r, 0.0, TAU, 20, Color(1.0, 0.2, 0.8, warn_alpha), 2.0) + draw_arc(antimatter_warn_pos, ring_r - 5.0, 0.0, TAU, 14, Color(0.8, 0.0, 1.0, warn_alpha * 0.5), 1.0) if Settings.nebula_enabled: for neb in nebulae: var n: CosmicObjects.Nebula = neb diff --git a/scripts/music_player.gd b/scripts/music_player.gd index 5a39adb..ff6f27f 100644 --- a/scripts/music_player.gd +++ b/scripts/music_player.gd @@ -11,7 +11,7 @@ extends Node # Voice 2 · Bass · Sawtooth (boss2: +detuneter 2. Layer für Schwebung) # Voice 3 · Schlagzeug · Hi-Hat+Kick+Snare (normal/boss1) · # Kick+Floor-Tom+Mid-Tom+Reverse-Whoosh (boss2) -# Voice 4 · Phase-2-Layer · Triangle 1 Oktave über Melodie (nur boss2 Phase 2) +# Voice 4 · BH-Bass · Sine Sub-Bass, proximity-driven, beat-pulsed # ═══════════════════════════════════════════════════════════════════════════ @export var volume_db: float = -8.0 @@ -249,6 +249,16 @@ var _bass_phase := 0.0 # Bass-Zweit-Oszillator (nur boss_mode=2, detunet für Schwebung) var _bass2_phase := 0.0 +# Voice 4: BH Proximity Sub-Bass +var _v4_freq: float = 0.0 +var _v4_freq_cur: float = 0.0 +var _v4_gain: float = 0.0 +var _v4_phase: float = 0.0 +var _v4_is_smbh: bool = false + +# Music event stingers (short pitched overlays) +var _stingers: Array = [] + # Globaler Zustand var _section := 0 var _chord := 0 @@ -284,6 +294,7 @@ func play() -> void: _arp_step = 0; _arp_pos = 0; _arp_phase = 0.0 _bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0 _sample_g = 0; _section = 0; _chord = 0 + _v4_gain = 0.0; _v4_freq = 0.0; _v4_freq_cur = 0.0; _v4_phase = 0.0; _v4_is_smbh = false; _stingers.clear() _playing = true _player.play() _playback = _player.get_stream_playback() @@ -297,6 +308,7 @@ func play_boss() -> void: _arp_step = 0; _arp_pos = 0; _arp_phase = 0.0 _bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0 _sample_g = 0; _section = 3; _chord = 0 + _v4_gain = 0.0; _v4_freq = 0.0; _v4_freq_cur = 0.0; _v4_phase = 0.0; _v4_is_smbh = false; _stingers.clear() if not _playing: _playing = true _player.play() @@ -313,6 +325,7 @@ func play_boss_leviathan(phase: int = 1) -> void: _arp_step = 0; _arp_pos = 0; _arp_phase = 0.0 _bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0 _sample_g = 0; _section = 3; _chord = 0 + _v4_gain = 0.0; _v4_freq = 0.0; _v4_freq_cur = 0.0; _v4_phase = 0.0; _v4_is_smbh = false; _stingers.clear() if not _playing: _playing = true _player.play() @@ -336,6 +349,7 @@ func play_normal() -> void: _arp_step = 0; _arp_pos = 0; _arp_phase = 0.0 _bass_chord = -1; _bass_phase = 0.0; _bass2_phase = 0.0 _sample_g = 0 + _v4_gain = 0.0; _v4_freq = 0.0; _v4_freq_cur = 0.0; _v4_phase = 0.0; _v4_is_smbh = false; _stingers.clear() _load_mel() func stop_music() -> void: @@ -353,6 +367,83 @@ func set_muted(muted: bool) -> void: if idx != -1: AudioServer.set_bus_mute(idx, muted) +func set_bh_proximity(proximity: float, size_factor: float, is_smbh: bool) -> void: + _v4_is_smbh = is_smbh + var sf := minf(size_factor * 0.12, 0.55) + _v4_gain = clamp(proximity * sf, 0.0, 0.50) + _v4_freq = _get_v4_freq() + +func _get_v4_freq() -> float: + var root_freq: float + match _boss_mode: + 2: root_freq = FREQ.get(BOSS2_BASS.get(_chord, "E2"), FREQ["E2"]) + 1: root_freq = FREQ.get(BOSS_BASS.get(_chord, "A2"), FREQ["A2"]) + _: root_freq = FREQ.get(BASS.get(_chord, "A3"), FREQ["A3"]) + var base := maxf(30.0, root_freq * 0.25) + if _v4_is_smbh: + return maxf(22.0, base * 0.5) + return base + +func play_music_event(event_name: String) -> void: + var root_hz: float = _bass_freq if _bass_freq > 0.0 else FREQ["A3"] + match event_name: + "bh_pulse": + _stingers.append({"type": "sine", "phase": 0.0, + "freq": root_hz * 0.25, "freq_end": root_hz * 0.125, + "gain": 0.20, "pos": 0, "total": int(1.2 * SAMPLE_RATE), + "attack": int(0.05 * SAMPLE_RATE)}) + "neutron_beam": + _stingers.append({"type": "noise", "phase": 0.0, + "freq": 0.0, "freq_end": 0.0, + "gain": 0.14, "pos": 0, "total": int(0.07 * SAMPLE_RATE)}) + _stingers.append({"type": "sine", "phase": 0.0, + "freq": root_hz * 8.0, "freq_end": root_hz * 4.0, + "gain": 0.10, "pos": 0, "total": int(0.10 * SAMPLE_RATE)}) + "white_hole_eject": + var arp_table: Dictionary + match _boss_mode: + 2: arp_table = BOSS2_ARP + 1: arp_table = BOSS_ARP + _: arp_table = ARP + var chord_notes: Array = arp_table.get(_chord, ["A4", "C5", "E5"]) + for i in 3: + var note_str: String = chord_notes[i] if i < chord_notes.size() else "A4" + var note_hz: float = FREQ.get(note_str, root_hz * 2.0) + _stingers.append({"type": "triangle", "phase": 0.0, + "freq": note_hz, "freq_end": note_hz, + "gain": 0.10 - i * 0.025, + "pos": -int(i * 0.09 * SAMPLE_RATE), + "total": int(0.28 * SAMPLE_RATE)}) + "quasar_jet": + var base_hz := root_hz * 2.0 + var scale_mult := [1.0, 1.122, 1.260, 1.498] + for i in 4: + var note_hz: float = base_hz * float(scale_mult[i]) + _stingers.append({"type": "sine", "phase": 0.0, + "freq": note_hz, "freq_end": note_hz * 1.04, + "gain": 0.08 + i * 0.01, + "pos": -int(i * 0.11 * SAMPLE_RATE), + "total": int(0.18 * SAMPLE_RATE)}) + "galaxy_consumed": + var swell_freqs: Array[float] = [root_hz * 0.5, root_hz * 0.75, root_hz] + for i in 3: + _stingers.append({"type": "sine", "phase": 0.0, + "freq": swell_freqs[i], "freq_end": swell_freqs[i], + "gain": 0.18 - i * 0.04, "pos": 0, + "total": int(1.6 * SAMPLE_RATE), + "attack": int(0.5 * SAMPLE_RATE)}) + "planet_captured": + _stingers.append({"type": "noise", "phase": 0.0, + "freq": 0.0, "freq_end": 0.0, + "gain": 0.20, "pos": 0, "total": int(0.10 * SAMPLE_RATE)}) + _stingers.append({"type": "sine", "phase": 0.0, + "freq": root_hz * 2.0, "freq_end": root_hz * 0.5, + "gain": 0.18, "pos": 0, "total": int(0.45 * SAMPLE_RATE)}) + "comet_pass": + _stingers.append({"type": "sine", "phase": 0.0, + "freq": root_hz * 14.0, "freq_end": root_hz * 7.0, + "gain": 0.06, "pos": 0, "total": int(0.16 * SAMPLE_RATE)}) + # ── Hauptschleife ───────────────────────────────────────────────────────── func _process(_delta: float) -> void: if _playing: @@ -562,6 +653,54 @@ func _fill_buffer() -> void: var snare_env := 1.0 - float(snare_pos) / 340.0 s += randf_range(-1.0, 1.0) * snare_env * 0.13 + # Voice 4: BH Proximity Sub-Bass — sine, beat-pulsed, frequency glide + var v4_glide := 0.9997 + _v4_freq_cur = _v4_freq_cur * v4_glide + _v4_freq * (1.0 - v4_glide) + if _v4_gain > 0.004 and _v4_freq_cur > 0.0: + var beat_samples_v4 := int(_cur_beat * SAMPLE_RATE) + if beat_samples_v4 > 0: + var beat_pos_v4 := _sample_g % beat_samples_v4 + var beat_t_v4 := float(beat_pos_v4) / float(beat_samples_v4) + var beat_pulse_v4: float + if beat_t_v4 < 0.06: + beat_pulse_v4 = beat_t_v4 / 0.06 + else: + beat_pulse_v4 = 1.0 - (beat_t_v4 - 0.06) / 0.94 * 0.55 + _v4_phase += _v4_freq_cur / SAMPLE_RATE + s += sin(_v4_phase * TAU) * _v4_gain * (0.45 + 0.55 * beat_pulse_v4) + + # Stingers: short pitched musical overlays for celestial events + for st in _stingers: + st["pos"] = int(st["pos"]) + 1 + var sp: int = int(st["pos"]) + if sp <= 0: + continue + var stotal: int = int(st["total"]) + if sp > stotal: + continue + var t_st := float(sp) / float(stotal) + var atk: int = int(st.get("attack", int(0.008 * SAMPLE_RATE))) + var env_st: float + if sp < atk: + env_st = float(sp) / float(atk) + else: + var remaining := stotal - atk + if remaining > 0: + env_st = maxf(0.0, 1.0 - float(sp - atk) / float(remaining)) + else: + env_st = 0.0 + var sf_st: float = lerp(float(st["freq"]), float(st["freq_end"]), t_st) + match st["type"]: + "sine": + st["phase"] = fmod(float(st["phase"]) + sf_st / SAMPLE_RATE, 1.0) + s += sin(float(st["phase"]) * TAU) * env_st * float(st["gain"]) + "triangle": + st["phase"] = fmod(float(st["phase"]) + sf_st / SAMPLE_RATE, 1.0) + s += (1.0 - absf(4.0 * float(st["phase"]) - 2.0)) * env_st * float(st["gain"]) + "noise": + s += randf_range(-1.0, 1.0) * env_st * float(st["gain"]) + _stingers = _stingers.filter(func(st): return int(st["pos"]) <= int(st["total"])) + _playback.push_frame(Vector2(s, s)) _mel_pos += 1 _arp_pos += 1 @@ -595,6 +734,7 @@ func _load_mel() -> void: _bass_freq = FREQ.get(bass_dict[_bass_chord], 0.0) _bass_phase = 0.0 _bass2_phase = 0.0 + _v4_freq = _get_v4_freq() func _load_arp() -> void: var arp_dict: Dictionary