diff --git a/assets/sounds/effects/explosion.mp3 b/assets/sounds/effects/explosion.mp3 new file mode 100644 index 0000000..df79e3e Binary files /dev/null and b/assets/sounds/effects/explosion.mp3 differ diff --git a/assets/sounds/effects/explosion.mp3.import b/assets/sounds/effects/explosion.mp3.import new file mode 100644 index 0000000..d005f62 --- /dev/null +++ b/assets/sounds/effects/explosion.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://bt61nivobxmjc" +path="res://.godot/imported/explosion.mp3-9b15137be8aff53d245e350137bf182a.mp3str" + +[deps] + +source_file="res://assets/sounds/effects/explosion.mp3" +dest_files=["res://.godot/imported/explosion.mp3-9b15137be8aff53d245e350137bf182a.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/resources/effects/ship_fireball.gdshader b/resources/effects/ship_fireball.gdshader new file mode 100644 index 0000000..2a95017 --- /dev/null +++ b/resources/effects/ship_fireball.gdshader @@ -0,0 +1,50 @@ +shader_type canvas_item; +render_mode unshaded, blend_add; + +uniform float phase = 0.0; +uniform float variation = 0.0; +uniform float strength = 1.0; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float value = 0.0; + float weight = 0.5; + for (int i = 0; i < 5; i++) { + value += noise(p) * weight; + p = mat2(vec2(1.6, 1.2), vec2(-1.2, 1.6)) * p + vec2(17.0, 9.0); + weight *= 0.5; + } + return value; +} + +void fragment() { + vec2 p = (UV - vec2(0.5)) * 2.0; + float radius = length(p); + float age = max(phase, 0.0); + float expansion = 0.16 + 0.44 * (1.0 - exp(-age * 7.0)); + vec2 flow = p * 5.0 / max(expansion, 0.12); + flow += vec2(variation, -age * 3.2); + vec2 warp = vec2(fbm(flow + vec2(age * 1.3, 0.0)), fbm(flow + vec2(8.0, -age))); + float turbulence = fbm(flow + warp * 3.0); + float edge = expansion * (0.65 + turbulence * 0.65); + float body = 1.0 - smoothstep(edge * 0.58, edge, radius); + float fade = 1.0 - smoothstep(0.35, 1.3, age); + float heat = clamp(body * (0.35 + turbulence * 0.9) * exp(-age * 1.0), 0.0, 1.0); + vec3 fire = mix(vec3(0.85, 0.12, 0.015), vec3(1.0, 0.52, 0.035), smoothstep(0.08, 0.35, heat)); + fire = mix(fire, vec3(1.0, 0.88, 0.46), smoothstep(0.3, 0.6, heat)); + fire = mix(fire, vec3(1.0, 0.98, 0.88), smoothstep(0.6, 0.9, heat)); + float glow = exp(-radius * radius / max(0.02, expansion * expansion * 0.85)) * 0.11 * exp(-age * 3.5); + float flash = exp(-radius * radius * 35.0) * exp(-age * 26.0); + vec3 light = fire * body * fade * (0.5 + turbulence) + vec3(1.0, 0.25, 0.035) * glow + vec3(1.0, 0.97, 0.85) * flash; + COLOR = vec4(light * strength, clamp((body * fade + glow + flash) * strength, 0.0, 1.0)); +} diff --git a/resources/effects/ship_fireball.gdshader.uid b/resources/effects/ship_fireball.gdshader.uid new file mode 100644 index 0000000..3f39c4b --- /dev/null +++ b/resources/effects/ship_fireball.gdshader.uid @@ -0,0 +1 @@ +uid://iwpo5xctaw06 diff --git a/resources/items/beam_laser.tres b/resources/items/beam_laser.tres index d30aefc..124be9a 100644 --- a/resources/items/beam_laser.tres +++ b/resources/items/beam_laser.tres @@ -10,13 +10,13 @@ icon = ExtResource("icon") item_id = "beam_laser" display_name = "Laser wiązkowy" category = 0 -description = "Oszczędny laser pulsacyjny: małe obrażenia i bardzo duży zasięg. Koszt impulsu rośnie z obrażeniami i addonami egzemplarza." +description = "Oszczędny laser pulsacyjny: małe obrażenia i zasięg około jednego ekranu. Koszt impulsu rośnie z obrażeniami i addonami egzemplarza." primary_stat = "damage" primary_value = 3.0 stat_suffix = " DMG" weapon_kind = 2 fire_interval = 0.2 -weapon_range = 800.0 +weapon_range = 600.0 energy_cost = 2.5 damage_lower_roll_min = 1 damage_lower_roll_max = 3 diff --git a/scripts/game/asteroid_destruction_effect.gd b/scripts/game/asteroid_destruction_effect.gd new file mode 100644 index 0000000..c739195 --- /dev/null +++ b/scripts/game/asteroid_destruction_effect.gd @@ -0,0 +1,74 @@ +extends Node2D + +const DURATION := 1.6 +var _texture: Texture2D +var _fragments: Array[Dictionary] = [] +var _dust: Array[Dictionary] = [] +var _elapsed := 0.0 +var _rng := RandomNumberGenerator.new() +var _dust_color := Color("b4a99a") + +func configure(sprite: Sprite2D) -> void: + _texture = sprite.texture + transform = sprite.global_transform + var sprite_scale := global_scale + scale = Vector2.ONE + _rng.randomize() + var dimensions := _texture.get_size() + var grid: Array[Vector2] = [] + for y in 5: + for x in 5: + var vertex := Vector2(x, y) * dimensions / 4.0 + if x > 0 and x < 4: + vertex.x += _rng.randf_range(-0.3, 0.3) * dimensions.x / 4.0 + if y > 0 and y < 4: + vertex.y += _rng.randf_range(-0.3, 0.3) * dimensions.y / 4.0 + grid.append(vertex) + for y in 4: + for x in 4: + var a := grid[y * 5 + x] + var b := grid[y * 5 + x + 1] + var c := grid[(y + 1) * 5 + x + 1] + var d := grid[(y + 1) * 5 + x] + for triangle in [[a, b, c], [a, c, d]]: + var center: Vector2 = (triangle[0] + triangle[1] + triangle[2]) / 3.0 + var origin := (center - dimensions * 0.5) * sprite_scale + var points := PackedVector2Array() + var uv := PackedVector2Array() + for vertex: Vector2 in triangle: + points.append((vertex - center) * sprite_scale) + uv.append(vertex / dimensions) + var direction := origin.normalized() if origin.length_squared() > 0.1 else Vector2.RIGHT + _fragments.append({"points": points, "uv": uv, "position": origin, + "velocity": direction.rotated(_rng.randf_range(-0.35, 0.35)) * _rng.randf_range(70.0, 170.0), + "angle": 0.0, "spin": _rng.randf_range(-4.0, 4.0)}) + _dust_color = Color("a1c7df") if "ice" in _texture.resource_path else Color("b4a99a") + for index in 28: + _dust.append({"direction": Vector2.RIGHT.rotated(_rng.randf_range(0.0, TAU)), + "speed": _rng.randf_range(45.0, 210.0), "size": _rng.randf_range(1.0, 3.0)}) + z_index = 35 + light_mask = 4 + +func _process(delta: float) -> void: + _elapsed += delta + if _elapsed >= DURATION: + queue_free() + return + for fragment in _fragments: + fragment.position += fragment.velocity * delta + fragment.velocity *= exp(-1.2 * delta) + fragment.angle += fragment.spin * delta + queue_redraw() + +func _draw() -> void: + var fade := 1.0 - smoothstep(0.65, DURATION, _elapsed) + for fragment in _fragments: + draw_set_transform(fragment.position, fragment.angle) + draw_polygon(fragment.points, PackedColorArray([Color(1.0, 1.0, 1.0, fade)]), fragment.uv, _texture) + draw_set_transform(Vector2.ZERO) + var dust_fade := maxf(0.0, 1.0 - _elapsed / 0.8) + for mote in _dust: + var travel: Vector2 = mote.direction * mote.speed * (1.0 - exp(-2.0 * _elapsed)) / 2.0 + draw_circle(travel, mote.size * (1.0 + _elapsed), Color(_dust_color, dust_fade * 0.65)) + if _elapsed < 0.18: + draw_circle(Vector2.ZERO, 16.0 * (1.0 - _elapsed / 0.18), Color(0.85, 0.9, 1.0, (1.0 - _elapsed / 0.18) * 0.55)) diff --git a/scripts/game/asteroid_destruction_effect.gd.uid b/scripts/game/asteroid_destruction_effect.gd.uid new file mode 100644 index 0000000..a437a64 --- /dev/null +++ b/scripts/game/asteroid_destruction_effect.gd.uid @@ -0,0 +1 @@ +uid://dnqrd4p1yobkx diff --git a/scripts/game/enemy_ship.gd b/scripts/game/enemy_ship.gd index 74bdd7b..fcb509c 100644 --- a/scripts/game/enemy_ship.gd +++ b/scripts/game/enemy_ship.gd @@ -89,7 +89,7 @@ func get_visual_size() -> float: return 112.0 if loot_tier == 2 else (68.0 if loot_tier == 1 else 56.0) func _physics_process(delta: float) -> void: - if not is_instance_valid(_player): + if not is_instance_valid(_player) or bool(_player.get("_is_destroyed")): velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta) move_and_slide() return diff --git a/scripts/game/player_destruction_effect.gd b/scripts/game/player_destruction_effect.gd new file mode 100644 index 0000000..4bb30db --- /dev/null +++ b/scripts/game/player_destruction_effect.gd @@ -0,0 +1,306 @@ +extends Node2D + +signal finished + +const CHARGE_TIME := 2.3 +const DURATION := CHARGE_TIME + 3.0 +const EXPLOSION_AUDIO_PATH := "res://assets/sounds/effects/explosion.mp3" +const FIRE_SHADER := preload("res://resources/effects/ship_fireball.gdshader") +static var _smoke_texture: Texture2D +var _elapsed := 0.0 +var _texture: Texture2D +var _ship_scale := Vector2.ONE +var _drift := Vector2.ZERO +var _fragments: Array[Dictionary] = [] +var _sparks: Array[Dictionary] = [] +var _smoke: Array[Dictionary] = [] +var _camera: Camera2D +var _camera_offset := Vector2.ZERO +var _detonated := false +var _smoke_timer := 0.0 +var _done := false +var _rng := RandomNumberGenerator.new() +var _fireball: MeshInstance2D +var _fire_material: ShaderMaterial +var _charge_audio: AudioStreamPlayer2D +var _burns: Array[Dictionary] = [] +var _ignition_timer := 0.0 + +func configure(sprite: Sprite2D, drift: Vector2, camera: Camera2D) -> void: + _texture = sprite.texture + global_position = sprite.global_position + global_rotation = sprite.global_rotation + _ship_scale = sprite.global_scale + _drift = drift.rotated(-global_rotation) * 0.12 + _camera = camera + if is_instance_valid(_camera): + _camera_offset = _camera.offset + +func _ready() -> void: + z_index = 45 + light_mask = 4 + _rng.randomize() + _build_fragments() + _prepare_smoke_texture() + _ignite_burn() + _ignite_burn() + _fireball = MeshInstance2D.new() + var quad := QuadMesh.new() + quad.size = Vector2(240, 240) + _fireball.mesh = quad + _fire_material = ShaderMaterial.new() + _fire_material.shader = FIRE_SHADER + _fire_material.set_shader_parameter("variation", _rng.randf_range(0, 100)) + _fireball.material = _fire_material + _fireball.light_mask = 4 + _fireball.z_index = 1 + _fireball.hide() + add_child(_fireball) + _play_charge_sound() + +func _build_fragments() -> void: + if _texture == null: + return + var dimensions := _texture.get_size() + var image := _texture.get_image() + for y in 8: + for x in 8: + var a := Vector2(x, y) * dimensions / 8.0 + var b := Vector2(x + 1, y) * dimensions / 8.0 + var c := Vector2(x + 1, y + 1) * dimensions / 8.0 + var d := Vector2(x, y + 1) * dimensions / 8.0 + for triangle in [[a, b, c], [a, c, d]]: + var center: Vector2 = (triangle[0] + triangle[1] + triangle[2]) / 3.0 + if image != null and image.get_pixel(clampi(int(center.x / dimensions.x * image.get_width()), 0, image.get_width() - 1), clampi(int(center.y / dimensions.y * image.get_height()), 0, image.get_height() - 1)).a < 0.1: + continue + var origin := (center - dimensions * 0.5) * _ship_scale + var points := PackedVector2Array() + var uv := PackedVector2Array() + for vertex in triangle: + points.append((vertex - center) * _ship_scale) + uv.append(vertex / dimensions) + var wreck := origin.length() < 12.0 + var radial := origin.normalized() if origin.length_squared() > 0.1 else Vector2.RIGHT.rotated(_rng.randf_range(0, TAU)) + _fragments.append({"points": points, "uv": uv, "position": origin, "velocity": radial.rotated(_rng.randf_range(-0.45, 0.45)) * _rng.randf_range(65, 155) + _drift if not wreck else radial * 5.0, "angle": 0.0, "spin": _rng.randf_range(-5, 5) if not wreck else _rng.randf_range(-0.3, 0.3), "wreck": wreck}) + +func _process(delta: float) -> void: + if _done and _elapsed >= DURATION + 1.8: + _smoke.clear() + _sparks.clear() + set_process(false) + queue_redraw() + return + _elapsed += delta + if not _detonated and _elapsed < CHARGE_TIME: + _ignition_timer -= delta + if _ignition_timer <= 0.0: + _ignite_burn() + var progress := _elapsed / CHARGE_TIME + _ignition_timer = _rng.randf_range(0.1, 0.22) * lerpf(1.0, 0.55, progress) + for index in range(_burns.size() - 1, -1, -1): + var burn := _burns[index] + burn["age"] += delta + var age := clampf(float(burn["age"]) / float(burn["life"]), 0, 1) + var intensity := sin(age * PI) + var wobble := sin(_elapsed * 23 + float(burn["phase"])) + var flame: MeshInstance2D = burn["visual"] + flame.position = burn["position"] + Vector2.UP.rotated(float(burn["angle"])) * 4 * intensity + flame.rotation = float(burn["angle"]) + wobble * 0.3 + flame.scale = Vector2(0.8 + intensity * 0.3, 0.7 + intensity * 0.4 + wobble * 0.1) + var material: ShaderMaterial = burn["material"] + material.set_shader_parameter("phase", 0.12 + age * 0.65) + material.set_shader_parameter("strength", intensity * (1.4 + wobble * 0.3)) + if age >= 1.0: + flame.queue_free() + _burns.remove_at(index) + if _elapsed >= CHARGE_TIME and not _detonated: + _detonated = true + for burn in _burns: + burn["visual"].queue_free() + _burns.clear() + _play_explosion_sound() + for index in 16: + var outward := Vector2.RIGHT.rotated(_rng.randf_range(0, TAU)) + _smoke.append({"position": outward * _rng.randf_range(4, 18), "velocity": outward * _rng.randf_range(25, 65), "age": -0.08, "life": _rng.randf_range(1.8, 2.8), "radius": _rng.randf_range(15, 28)}) + for index in 75: + var direction := Vector2.RIGHT.rotated(_rng.randf_range(0, TAU)) + _sparks.append({"position": direction * _rng.randf_range(3, 24), "velocity": direction * _rng.randf_range(80, 330), "life": _rng.randf_range(0.45, 1.8), "age": 0.0}) + if _detonated: + for fragment in _fragments: + fragment["position"] += fragment["velocity"] * delta + fragment["velocity"] *= exp(-0.7 * delta) + fragment["angle"] += float(fragment["spin"]) * delta + _smoke_timer += delta + var smoke_interval := 0.065 if _detonated else 0.12 + while _smoke_timer >= smoke_interval and _elapsed < CHARGE_TIME + 2.5: + _smoke_timer -= smoke_interval + _emit_smoke() + for index in range(_sparks.size() - 1, -1, -1): + var spark := _sparks[index] + spark["position"] += spark["velocity"] * delta + spark["velocity"] *= exp(-1.3 * delta) + spark["age"] += delta + if float(spark["age"]) >= float(spark["life"]): + _sparks.remove_at(index) + for index in range(_smoke.size() - 1, -1, -1): + var puff := _smoke[index] + puff["position"] += puff["velocity"] * delta + puff["age"] += delta + if float(puff["age"]) >= float(puff["life"]): + _smoke.remove_at(index) + if not _done: + _update_camera() + var blast_age := maxf(0, _elapsed - CHARGE_TIME) + _fireball.visible = _detonated and blast_age < 1.3 + _fire_material.set_shader_parameter("phase", blast_age) + queue_redraw() + if _elapsed >= DURATION and not _done: + _done = true + _restore_camera() + finished.emit() + +func _hull_burn_position() -> Vector2: + if not _fragments.is_empty(): + return _fragments[_rng.randi_range(0, _fragments.size() - 1)]["position"] + return Vector2.ZERO + +func _ignite_burn() -> void: + var origin := _hull_burn_position() + var angle := origin.angle() + PI * 0.5 + _rng.randf_range(-0.65, 0.65) + var flame := MeshInstance2D.new() + var quad := QuadMesh.new() + quad.size = Vector2(_rng.randf_range(24, 34), _rng.randf_range(36, 52)) + flame.mesh = quad + var material := ShaderMaterial.new() + material.shader = FIRE_SHADER + material.set_shader_parameter("variation", _rng.randf_range(0, 100)) + material.set_shader_parameter("strength", 0.0) + flame.material = material + flame.position = origin + flame.rotation = angle + flame.z_index = 1 + add_child(flame) + _burns.append({"position": origin, "angle": angle, "age": 0.0, "life": _rng.randf_range(0.3, 0.7), "phase": _rng.randf_range(0, TAU), "visual": flame, "material": material}) + var direction := Vector2.UP.rotated(angle) + for index in _rng.randi_range(3, 6): + _sparks.append({"position": origin, "velocity": direction.rotated(_rng.randf_range(-0.9, 0.9)) * _rng.randf_range(25, 100), "life": _rng.randf_range(0.18, 0.5), "age": 0.0}) + +func _emit_smoke() -> void: + var position := Vector2.ZERO + if not _detonated: + position = _burns[_rng.randi_range(0, _burns.size() - 1)]["position"] if not _burns.is_empty() else _hull_burn_position() + elif not _fragments.is_empty() and _rng.randf() < 0.7: + position = _fragments[_rng.randi_range(0, _fragments.size() - 1)]["position"] + _smoke.append({"position": position, "velocity": Vector2(_rng.randf_range(-12, 12), _rng.randf_range(-22, 8)), "age": 0.0, "life": _rng.randf_range(0.9, 1.7), "radius": _rng.randf_range(5, 12)}) + +func _update_camera() -> void: + if not is_instance_valid(_camera): + return + var time := _elapsed - CHARGE_TIME + var strength := 0.8 if time < 0.0 else 7.0 * exp(-5.0 * time) + _camera.offset = _camera_offset + Vector2(sin(_elapsed * 83), cos(_elapsed * 67)) * strength + +func _restore_camera() -> void: + if is_instance_valid(_camera): + _camera.offset = _camera_offset + +func _exit_tree() -> void: + _restore_camera() + +func _draw() -> void: + if _texture == null: + return + var time := maxf(0.0, _elapsed - CHARGE_TIME) + for puff in _smoke: + var progress: float = float(puff["age"]) / float(puff["life"]) + var opacity := (1.0 - progress) * minf(1, float(puff["age"]) * 8.0) + var radius := float(puff["radius"]) * (1 + maxf(0, progress) * 1.5) + var soot := Color("6c3e25").lerp(Color("3c4149"), clampf(progress * 3, 0, 1)) + soot.a = maxf(0, opacity) * 0.7 + draw_texture_rect(_smoke_texture, Rect2(Vector2(puff["position"]) - Vector2.ONE * radius, Vector2.ONE * radius * 2), false, soot) + if not _detonated: + var progress := clampf(_elapsed / CHARGE_TIME, 0, 1) + var pulse := (sin(_elapsed * 19) + 1.0) * 0.5 + var dimensions := _texture.get_size() * _ship_scale + draw_texture_rect(_texture, Rect2(-dimensions * 0.5, dimensions), false, Color(1, 0.95 - progress * 0.1 + pulse * 0.05, 0.88 - progress * 0.08 + pulse * 0.1)) + _draw_sparks() + return + for fragment in _fragments: + var fade := 1.0 if bool(fragment["wreck"]) else clampf((3.4 - time) / 1.1, 0, 1) + var shade := lerpf(1.0, 0.22 if bool(fragment["wreck"]) else 0.55, minf(1, time)) + draw_set_transform(fragment["position"], fragment["angle"]) + draw_polygon(fragment["points"], PackedColorArray([Color(shade, shade * 0.85, shade * 0.7, fade)]), fragment["uv"], _texture) + if time < 1.7 and not bool(fragment["wreck"]): + draw_line(Vector2.ZERO, -Vector2(fragment["velocity"]).normalized().rotated(-float(fragment["angle"])) * 9, Color(1, 0.35, 0.06, (1.0 - time / 1.7) * 0.7), 2, true) + draw_set_transform(Vector2.ZERO) + if time < 0.55: + var fade := 1.0 - time / 0.55 + for layer in 3: + draw_arc(Vector2.ZERO, 18 + time * 230 + layer * 2, 0, TAU, 96, Color(0.85, 0.75, 0.6, fade * 0.08), 2, true) + _draw_sparks() + if time > 0.8: + for index in 4: + var flicker := maxf(0, sin(_elapsed * 17 + index * 8)) * clampf((3.3 - time) / 2, 0, 1) + draw_circle(Vector2.RIGHT.rotated(index * 2.3) * (4 + index * 2), 2 + flicker * 2, Color(1, 0.28, 0.04, flicker * 0.75)) + +func _draw_sparks() -> void: + for spark in _sparks: + var fade := 1 - float(spark["age"]) / float(spark["life"]) + var length := clampf(Vector2(spark["velocity"]).length() * 0.03, 2, 8) + draw_line(spark["position"], spark["position"] - Vector2(spark["velocity"]).normalized() * length, Color(1, 0.65 + fade * 0.2, 0.2, fade), 1.2, true) + +func _prepare_smoke_texture() -> void: + if _smoke_texture != null: + return + var image := Image.create(96, 96, false, Image.FORMAT_RGBA8) + var noise := FastNoiseLite.new() + noise.seed = 71539 + noise.frequency = 0.06 + noise.fractal_octaves = 4 + for y in 96: + for x in 96: + var p := (Vector2(x, y) - Vector2(47.5, 47.5)) / 47.5 + var radial := 1.0 - smoothstep(0.2, 1.0, p.length()) + var density := clampf(noise.get_noise_2d(x, y) * 0.8 + 0.55, 0, 1) + image.set_pixel(x, y, Color(1, 1, 1, radial * density)) + _smoke_texture = ImageTexture.create_from_image(image) + +func _play_explosion_sound() -> void: + if is_instance_valid(_charge_audio): + _charge_audio.stop() + _charge_audio.queue_free() + var audio := AudioStreamPlayer2D.new() + audio.name = "ExplosionAudio" + audio.stream = load(EXPLOSION_AUDIO_PATH) as AudioStream + if audio.stream == null: + audio.free() + return + audio.volume_db = -6 + audio.max_distance = 1800 + add_child(audio) + audio.finished.connect(audio.queue_free) + audio.play() + +func _play_charge_sound() -> void: + var rate := 22050 + var frames := int(rate * CHARGE_TIME) + var data := PackedByteArray() + data.resize(frames * 2) + var filtered := 0.0 + for index in frames: + var t := float(index) / rate + filtered = lerpf(filtered, _rng.randf_range(-1, 1), 0.16) + var value := sin(TAU * (80 + t * 70 / CHARGE_TIME) * t) * 0.06 * (t / CHARGE_TIME) + filtered * 0.025 + data.encode_s16(index * 2, int(clampf(value, -1, 1) * 25000)) + var sound := AudioStreamWAV.new() + sound.format = AudioStreamWAV.FORMAT_16_BITS + sound.mix_rate = rate + sound.data = data + var audio := AudioStreamPlayer2D.new() + _charge_audio = audio + audio.stream = sound + audio.volume_db = -8 + audio.max_distance = 1800 + add_child(audio) + audio.finished.connect(audio.queue_free) + audio.play() diff --git a/scripts/game/player_destruction_effect.gd.uid b/scripts/game/player_destruction_effect.gd.uid new file mode 100644 index 0000000..6d4fc9e --- /dev/null +++ b/scripts/game/player_destruction_effect.gd.uid @@ -0,0 +1 @@ +uid://b3vtjeqhycmqk diff --git a/scripts/game/ship_controller.gd b/scripts/game/ship_controller.gd index 7f92b3e..29abb33 100644 --- a/scripts/game/ship_controller.gd +++ b/scripts/game/ship_controller.gd @@ -8,6 +8,7 @@ const CAPTAIN_BALANCE := preload("res://scripts/captain/captain_balance.gd") const SHIP_CATALOG := preload("res://scripts/game/ship_catalog.gd") const COMBAT_FLASH := preload("res://scripts/game/combat_flash.gd") const MINI_BOOST_ENERGY_PER_SECOND := 20.0 +const DESTRUCTION_EFFECT := preload("res://scripts/game/player_destruction_effect.gd") @export var max_speed := 76.0 @export var reverse_max_speed := 210.0 @@ -212,19 +213,45 @@ func _update_shield_visual() -> void: _shield_visual.call("set_shield_values", shields, max_shields) func _destroy_ship() -> void: + if _is_destroyed: + return _is_destroyed = true + var death_velocity := velocity velocity = Vector2.ZERO collision_layer = 0 collision_mask = 0 - var tween := create_tween() - tween.tween_property(self, "modulate:a", 0.0, 1.1) - _restart_level_after_destruction() + _mini_boost_active = false + var explosion := Node2D.new() + explosion.name = "PlayerDestructionEffect" + explosion.set_script(DESTRUCTION_EFFECT) + explosion.call("configure", _sprite, death_velocity, get_node_or_null("Camera2D")) + get_parent().add_child(explosion) + explosion.connect("finished", _show_defeat_screen) + _sprite.hide() + for node_name in ["ShieldVisual", "EngineVisual", "EnergyIndicator", "SolarEnergyEffect"]: + var visual := get_node_or_null(node_name) as CanvasItem + if visual != null: + visual.hide() + $EngineAudio.stop() + $EngineAudio.set_process(false) + $EngineVisual.set_process(false) + $ShieldVisual.set_process(false) + var radiation := get_node_or_null("RadiationStatus") + if radiation != null: + radiation.set_physics_process(false) + +func _show_defeat_screen() -> void: + if not is_inside_tree() or get_parent().has_node("DefeatOverlay"): + return + var overlay := CanvasLayer.new() + overlay.set_script(preload("res://scripts/ui/defeat_overlay.gd")) + overlay.set("ship", self) + get_parent().add_child(overlay) func _restart_level_after_destruction() -> void: - await get_tree().create_timer(1.1).timeout if is_inside_tree(): $ShipInventory.preserve_for_restart() - get_tree().reload_current_scene() + get_tree().reload_current_scene.call_deferred() func _physics_process(delta: float) -> void: if _is_destroyed or is_docked: diff --git a/scripts/levels/exploration_poi.gd b/scripts/levels/exploration_poi.gd index d8cb732..dc30adf 100644 --- a/scripts/levels/exploration_poi.gd +++ b/scripts/levels/exploration_poi.gd @@ -4,6 +4,7 @@ const GENERATOR := preload("res://scripts/items/item_generator.gd") const ASTEROID_TEXTURES := [preload("res://assets/asteroids/asteroid_rock.png"), preload("res://assets/asteroids/asteroid_metal.png"), preload("res://assets/asteroids/asteroid_ice.png")] const WRECK_TEXTURES := [preload("res://assets/wrecks/wreck_frigate.png"), preload("res://assets/wrecks/wreck_freighter.png"), preload("res://assets/wrecks/wreck_interceptor.png")] const LOOT := preload("res://scripts/items/loot_pickup.gd") +const ASTEROID_DESTRUCTION := preload("res://scripts/game/asteroid_destruction_effect.gd") var kind := "wreck" var poi_id := "" var reward_seed := 1 @@ -147,6 +148,13 @@ func claim() -> bool: popup.position = position + Vector2(-80, -65) get_parent().add_child.call_deferred(popup) set_physics_process(false) + if kind == "asteroid" and is_instance_valid(_asteroid_sprite): + var debris := Node2D.new() + debris.set_script(ASTEROID_DESTRUCTION) + debris.name = "AsteroidDebris" + debris.call("configure", _asteroid_sprite) + # The POI may be destroyed during a physics query; add debris outside it. + get_parent().add_child.call_deferred(debris) queue_free() return true diff --git a/scripts/ui/defeat_overlay.gd b/scripts/ui/defeat_overlay.gd new file mode 100644 index 0000000..994d31c --- /dev/null +++ b/scripts/ui/defeat_overlay.gd @@ -0,0 +1,62 @@ +extends CanvasLayer + +var ship: CharacterBody2D +var _retrying := false + +func _ready() -> void: + layer = 40 + name = "DefeatOverlay" + var root := Control.new() + add_child(root) + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var shade := ColorRect.new() + shade.color = Color(0.015, 0.025, 0.045, 0.55) + root.add_child(shade) + shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var center := CenterContainer.new() + root.add_child(center) + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var panel := PanelContainer.new() + panel.custom_minimum_size.x = 390 + var style := StyleBoxFlat.new() + style.bg_color = Color("0b1520f5") + style.border_color = Color("bf604d") + style.set_border_width_all(1) + style.border_width_top = 3 + style.set_content_margin_all(24) + style.set_corner_radius_all(5) + panel.add_theme_stylebox_override("panel", style) + center.add_child(panel) + var contents := VBoxContainer.new() + contents.add_theme_constant_override("separation", 14) + panel.add_child(contents) + var title := Label.new() + title.text = "STATEK ZNISZCZONY" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 20) + title.add_theme_color_override("font_color", Color("ffae8c")) + contents.add_child(title) + var note := Label.new() + note.text = "Układ %d\nWyposażenie i zapasy zostają zachowane." % GameState.system_level + note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + note.add_theme_font_size_override("font_size", 11) + note.add_theme_color_override("font_color", Color("a6bdce")) + contents.add_child(note) + var retry := Button.new() + retry.name = "RetryButton" + retry.text = "SPRÓBUJ PONOWNIE [R]" + preload("res://scripts/ui/hud_button_style.gd").apply(retry) + var key := InputEventKey.new() + key.physical_keycode = KEY_R + retry.shortcut = Shortcut.new() + retry.shortcut.events = [key] + retry.pressed.connect(_retry) + contents.add_child(retry) + root.modulate.a = 0 + create_tween().tween_property(root, "modulate:a", 1.0, 0.3) + +func _retry() -> void: + if _retrying or not is_instance_valid(ship): + return + _retrying = true + ship.call("_restart_level_after_destruction") diff --git a/scripts/ui/defeat_overlay.gd.uid b/scripts/ui/defeat_overlay.gd.uid new file mode 100644 index 0000000..9149a23 --- /dev/null +++ b/scripts/ui/defeat_overlay.gd.uid @@ -0,0 +1 @@ +uid://coeyame45wvng diff --git a/tests/items/player_destruction_test.gd b/tests/items/player_destruction_test.gd new file mode 100644 index 0000000..45e78be --- /dev/null +++ b/tests/items/player_destruction_test.gd @@ -0,0 +1,63 @@ +extends Node2D + +var _finished_count := 0 + +func _ready() -> void: + GameState.start_new_run() + var game := preload("res://scenes/game/game.tscn").instantiate() + add_child(game) + game.set_physics_process(false) + var ship: CharacterBody2D = game.get_node("PlayerShip") + ship.set_physics_process(false) + ship.get_node("WeaponController").set_physics_process(false) + var camera: Camera2D = ship.get_node("Camera2D") + camera.offset = Vector2(3, 5) + ship.velocity = Vector2(100, -40) + ship.call("_destroy_ship") + assert(ship.get("_is_destroyed") and ship.collision_layer == 0 and ship.velocity == Vector2.ZERO) + assert(not ship.get_node("Sprite2D").visible and not ship.get_node("ShieldVisual").visible) + var effect: Node = game.get_node("PlayerDestructionEffect") + effect.set_process(false) + assert(effect.get("_texture") == ship.get_node("Sprite2D").texture) + assert(effect.get("_fragments").size() >= 4) + assert(not game.has_node("DefeatOverlay")) + assert(not effect.has_node("ExplosionAudio")) + effect.finished.connect(func() -> void: _finished_count += 1) + effect._process(0.2) + assert(not effect.get("_detonated") and camera.offset != Vector2(3, 5)) + for step in 18: + effect._process(0.1) + assert(not effect.get("_detonated") and not game.has_node("DefeatOverlay")) + assert(effect.get("_smoke").size() > 0) + effect._process(0.35) + assert(effect.get("_detonated") and effect.get("_sparks").size() > 30) + assert(effect.has_node("ExplosionAudio")) + assert(effect.get_node("ExplosionAudio").stream.resource_path == "res://assets/sounds/effects/explosion.mp3") + assert(not game.has_node("DefeatOverlay")) + effect._process(2.7) + assert(not game.has_node("DefeatOverlay")) + effect._process(0.3) + assert(game.has_node("DefeatOverlay") and _finished_count == 1) + assert(camera.offset == Vector2(3, 5)) + effect._process(0.1) + ship.call("_destroy_ship") + assert(_finished_count == 1) + var deaths := 0 + for child in game.get_children(): + if child.name == "PlayerDestructionEffect": + deaths += 1 + assert(deaths == 1) + for chassis in preload("res://scripts/game/ship_catalog.gd").SHIPS: + var source := Sprite2D.new() + source.texture = load(preload("res://scripts/game/ship_catalog.gd").texture_path(chassis)) + source.scale = Vector2.ONE * 64.0 / source.texture.get_width() + var variant := Node2D.new() + variant.set_script(preload("res://scripts/game/player_destruction_effect.gd")) + variant.configure(source, Vector2.ZERO, null) + add_child(variant) + variant.set_process(false) + assert(variant.get("_fragments").size() >= 4) + source.free() + variant.queue_free() + print("PLAYER_DESTRUCTION_TEST: PASS (ship texture fragments, collision shutdown, overload, detonation, delayed defeat, camera restoration, single death)") + get_tree().quit() diff --git a/tests/items/player_destruction_test.gd.uid b/tests/items/player_destruction_test.gd.uid new file mode 100644 index 0000000..08267db --- /dev/null +++ b/tests/items/player_destruction_test.gd.uid @@ -0,0 +1 @@ +uid://xkpr5tm2f1ed diff --git a/tests/items/player_destruction_test.tscn b/tests/items/player_destruction_test.tscn new file mode 100644 index 0000000..baf655c --- /dev/null +++ b/tests/items/player_destruction_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/items/player_destruction_test.gd" id="1"] +[node name="PlayerDestructionTest" type="Node2D"] +script = ExtResource("1") diff --git a/tests/levels/system_lifecycle_test.gd b/tests/levels/system_lifecycle_test.gd index de767fb..9c6bc48 100644 --- a/tests/levels/system_lifecycle_test.gd +++ b/tests/levels/system_lifecycle_test.gd @@ -80,7 +80,11 @@ func _ready() -> void: assert(menu.visible) player.position = initial_data["player_start"] player.call("take_damage", 100000.0) - await get_tree().create_timer(1.3).timeout + await get_tree().create_timer(preload("res://scripts/game/player_destruction_effect.gd").DURATION + 0.2).timeout + assert(game.has_node("DefeatOverlay")) + game.get_node("DefeatOverlay").call("_retry") + await get_tree().process_frame + await get_tree().process_frame game = get_tree().current_scene as Node2D _freeze(game) player = game.get_node("PlayerShip") as CharacterBody2D