From 0d2bccc3a9a3c7edcc159ab04f35ddc8111e19a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sijcdu=E7=9A=84=E6=B8=B8=E6=88=8F?= Date: Wed, 7 Oct 2026 22:12:14 +0800 Subject: [PATCH] Monsters are now unkillable and fire a fixed bullet pattern; add periodic lasers Removed the stomp-kill from walker.gd: landing on an enemy now just costs a heart like any other contact, so the only way past is to weave through the pattern or use the key throw + W warp. Enemies fire a fan of bullets every fire_interval whose directions are computed only from the facing and the exported spread, never from the player, so every volley traces the identical path and can be memorised. Bullets are code-generated circles (no art needed), die on hitting terrain via a probe raycast, and are freed after their lifetime. New laser prefab cycles wait -> blinking warmup -> firing, and only damages during the firing phase. --- scenes/level2.tscn | 25 +++++--- scenes/prefabs/enemies/bullet.tscn | 21 +++++++ scenes/prefabs/enemies/laser.tscn | 19 ++++++ scripts/bullet.gd | 79 +++++++++++++++++++++++++ scripts/laser.gd | 74 +++++++++++++++++++++++ scripts/walker.gd | 95 +++++++++++++++++++----------- scripts/walker.gd.uid | 1 + 7 files changed, 271 insertions(+), 43 deletions(-) create mode 100644 scenes/prefabs/enemies/bullet.tscn create mode 100644 scenes/prefabs/enemies/laser.tscn create mode 100644 scripts/bullet.gd create mode 100644 scripts/laser.gd create mode 100644 scripts/walker.gd.uid diff --git a/scenes/level2.tscn b/scenes/level2.tscn index 56e409d..93d5107 100644 --- a/scenes/level2.tscn +++ b/scenes/level2.tscn @@ -18,6 +18,7 @@ [ext_resource type="PackedScene" path="res://scenes/prefabs/collectibles/key_gold.tscn" id="16_3xffc"] [ext_resource type="PackedScene" path="res://scenes/prefabs/collectibles/diamond.tscn" id="17_icuov"] [ext_resource type="PackedScene" path="res://scenes/prefabs/enemies/walker.tscn" id="18_walker"] +[ext_resource type="PackedScene" path="res://scenes/prefabs/enemies/laser.tscn" id="19_laser"] [node name="Main" type="Node2D" unique_id=2061129075] @@ -167,7 +168,7 @@ tile_set = ExtResource("9_nflru") [node name="TerrainDetailsA" type="TileMapLayer" parent="Level/TerrainTileMaps" unique_id=1513560262] z_index = 1 position = Vector2(9, 9) -tile_map_data = PackedByteArray("AAADAA0AAAADAAYAAAABAAwAAAABAAYAAAABAA0AAAABAAYAAAACAAoAAAAIAAYAAAACAAsAAAACAAEAAAADAAsAAAADAAEAAAACAAwAAAACAAYAAAACAA4AAAACAAYAAAAXAAsAAAADAAUAAAAXAAwAAAADAAYAAAAXAA0AAAADAAYAAAAXAA4AAAADAAYAAAAWAAoAAAAEAAcAAAAWAAsAAAACAAUAAAAWAAwAAAAEAAUAAAAWAA0AAAACAAYAAAAWAA4AAAAEAAUAAAAVAAsAAAABAAUAAAAVAAwAAAABAAYAAAAVAA0AAAABAAYAAAAVAA4AAAABAAYAAAAQAA4AAAADAAYAAAAOAA4AAAAEAAUAAAAPAA4AAAACAAYAAAAQAA0AAAADAAMAAAANAA4AAAABAAYAAAAOAAwAAAAEAAYAAAAPAA0AAAACAAMAAAAOAA0AAAACAAMAAAANAA0AAAABAAMAAAAQAAQAAAATAAIAAAAQAAMAAAATAAEAAAAPAAQAAAASAAIAAAAKAAMAAAARAAAAAAAKAAQAAAARAAIAAAALAAMAAAASAAAAAAAMAAMAAAATAAAAAAAOAAIAAAARAAAAAAAPAAIAAAASAAAAAAAOAAMAAAARAAEAAAAMAAQAAAATAAIAAAALAAQAAAASAAIAAAADAAwAAAADAAYAAAAOAAQAAAARAAIAAAAPAAMAAAASAAEAAAAQAAIAAAATAAAAAAACAA0AAAAEAAUAAAABAA4AAAABAAYAAAADAA4AAAADAAYAAAABAAsAAAABAAEAAAAOAAsAAAANAAUAAAAOAAoAAAAPAAUAAAAPAAsAAAANAAYAAAAPAAkAAAANAAYAAAAOAAkAAAANAAQAAAAQAAkAAAAOAAQAAAAQAAoAAAAPAAUAAAAQAAsAAAAOAAUAAAAPAAoAAAASAAcAAAAPAAgAAAAIAAMAAAAUAPz/AAASAAcAAAA=") +tile_map_data = PackedByteArray("AAADAA0AAAADAAYAAAABAAwAAAABAAYAAAABAA0AAAABAAYAAAACAAsAAAACAAEAAAADAAsAAAADAAEAAAACAAwAAAACAAYAAAACAA4AAAACAAYAAAAXAAsAAAADAAUAAAAXAAwAAAADAAYAAAAXAA0AAAADAAYAAAAXAA4AAAADAAYAAAAWAAoAAAAEAAcAAAAWAAsAAAACAAUAAAAWAAwAAAAEAAUAAAAWAA0AAAACAAYAAAAWAA4AAAAEAAUAAAAVAAsAAAABAAUAAAAVAAwAAAABAAYAAAAVAA0AAAABAAYAAAAVAA4AAAABAAYAAAAQAA4AAAADAAYAAAAOAA4AAAAEAAUAAAAPAA4AAAACAAYAAAAQAA0AAAADAAMAAAANAA4AAAABAAYAAAAOAAwAAAAEAAYAAAAPAA0AAAACAAMAAAAOAA0AAAACAAMAAAANAA0AAAABAAMAAAAQAAQAAAATAAIAAAAQAAMAAAATAAEAAAAPAAQAAAASAAIAAAAKAAMAAAARAAAAAAAKAAQAAAARAAIAAAALAAMAAAASAAAAAAAMAAMAAAATAAAAAAAOAAIAAAARAAAAAAAPAAIAAAASAAAAAAAOAAMAAAARAAEAAAAMAAQAAAATAAIAAAALAAQAAAASAAIAAAADAAwAAAADAAYAAAAOAAQAAAARAAIAAAAPAAMAAAASAAEAAAAQAAIAAAATAAAAAAACAA0AAAAEAAUAAAABAA4AAAABAAYAAAADAA4AAAADAAYAAAABAAsAAAABAAEAAAAUAPz/AAASAAcAAAA=") tile_set = ExtResource("9_nflru") [node name="TerrainDetailsB" type="TileMapLayer" parent="Level/TerrainTileMaps" unique_id=478648603] @@ -206,19 +207,29 @@ position = Vector2(109, 199) z_index = 5 position = Vector2(331, 216) -[node name="Enemies" type="Node2D" parent="Level/PrefabInstances"] +[node name="Enemies" type="Node2D" parent="Level/PrefabInstances" unique_id=93211253] -[node name="Walker_01" parent="Level/PrefabInstances/Enemies" instance=ExtResource("18_walker")] +[node name="Walker_01" parent="Level/PrefabInstances/Enemies" unique_id=2100888628 instance=ExtResource("18_walker")] position = Vector2(216, 216) -[node name="Walker_02" parent="Level/PrefabInstances/Enemies" instance=ExtResource("18_walker")] +[node name="Walker_02" parent="Level/PrefabInstances/Enemies" unique_id=1806208529 instance=ExtResource("18_walker")] position = Vector2(306, 216) start_direction = 1 -[node name="Walker_03" parent="Level/PrefabInstances/Enemies" instance=ExtResource("18_walker")] +[node name="Walker_03" parent="Level/PrefabInstances/Enemies" unique_id=1711052130 instance=ExtResource("18_walker")] position = Vector2(90, 198) start_direction = 1 +[node name="Laser_01" parent="Level/PrefabInstances/Enemies" instance=ExtResource("19_laser")] +position = Vector2(189, 216) +rotation = -1.5707964 + +[node name="Laser_02" parent="Level/PrefabInstances/Enemies" instance=ExtResource("19_laser")] +position = Vector2(297, 216) +rotation = -1.5707964 +start_delay = 2.4 +length = 108.0 + [node name="Interactables" type="Node2D" parent="Level/PrefabInstances" unique_id=769532488] [node name="SpringRed_01" parent="Level/PrefabInstances/Interactables" unique_id=34192139 instance=ExtResource("13_ac7c3")] @@ -246,7 +257,7 @@ position = Vector2(219, 54) [node name="KeyGold_01" parent="Level/PrefabInstances/Collectibles" unique_id=2060959680 instance=ExtResource("16_3xffc")] z_index = 5 -position = Vector2(126, 144) +position = Vector2(71, 182) [node name="Diamond_01" parent="Level/PrefabInstances/Collectibles" unique_id=909237898 instance=ExtResource("17_icuov")] z_index = 5 @@ -258,6 +269,6 @@ position = Vector2(204, 217) [node name="Diamond_03" parent="Level/PrefabInstances/Collectibles" unique_id=1261326529 instance=ExtResource("17_icuov")] z_index = 5 -position = Vector2(270, 144) +position = Vector2(288, 125) [node name="Signs" type="Node2D" parent="Level/PrefabInstances" unique_id=113197076] diff --git a/scenes/prefabs/enemies/bullet.tscn b/scenes/prefabs/enemies/bullet.tscn new file mode 100644 index 0000000..b4b55d2 --- /dev/null +++ b/scenes/prefabs/enemies/bullet.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/bullet.gd" id="1_bullet"] + +[sub_resource type="CircleShape2D" id="Shape_bullet"] +radius = 3.0 + +[node name="Bullet" type="Area2D" groups=["bullet"]] +collision_layer = 4 +collision_mask = 2 +script = ExtResource("1_bullet") + +[node name="Sprite" type="Sprite2D" parent="."] +texture_filter = 1 + +[node name="HitBox" type="CollisionShape2D" parent="."] +shape = SubResource("Shape_bullet") + +[node name="Probe" type="RayCast2D" parent="."] +collision_mask = 1 +target_position = Vector2(-12, 0) diff --git a/scenes/prefabs/enemies/laser.tscn b/scenes/prefabs/enemies/laser.tscn new file mode 100644 index 0000000..b93acd2 --- /dev/null +++ b/scenes/prefabs/enemies/laser.tscn @@ -0,0 +1,19 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scripts/laser.gd" id="1_laser"] + +[sub_resource type="RectangleShape2D" id="Shape_laser"] +size = Vector2(90, 6) + +[node name="Laser" type="Area2D" groups=["hazard"]] +collision_layer = 4 +collision_mask = 2 +script = ExtResource("1_laser") + +[node name="Beam" type="Polygon2D" parent="."] +color = Color(1, 0.25, 0.25, 1) +polygon = PackedVector2Array(0, -3, 90, -3, 90, 3, 0, 3) + +[node name="HitBox" type="CollisionShape2D" parent="."] +position = Vector2(45, 0) +shape = SubResource("Shape_laser") diff --git a/scripts/bullet.gd b/scripts/bullet.gd new file mode 100644 index 0000000..8a604b5 --- /dev/null +++ b/scripts/bullet.gd @@ -0,0 +1,79 @@ +extends Area2D + +## 弹幕子弹:直线飞行,碰到玩家扣血,撞到地形或超时消失。 +## 贴图是代码生成的实心圆,不需要额外美术素材。 + +@export var speed: float = 70.0 +@export var damage: int = 1 +@export var lifetime: float = 8.0 +@export var radius: int = 3 +@export var bullet_color: Color = Color(1.0, 0.35, 0.7) + +var direction: Vector2 = Vector2.LEFT + +var _age: float = 0.0 +var _probe: RayCast2D = null + +static var _cache: Dictionary = {} + + +## 生成(并缓存)一个实心圆贴图 +static func circle_texture(r: int, color: Color) -> ImageTexture: + var key := "%d|%s" % [r, color.to_html()] + if _cache.has(key): + return _cache[key] + var size: int = r * 2 + 1 + var img := Image.create_empty(size, size, false, Image.FORMAT_RGBA8) + img.fill(Color(0.0, 0.0, 0.0, 0.0)) + var limit := float(r * r) + float(r) * 0.6 + for y in size: + for x in size: + var dx := float(x - r) + var dy := float(y - r) + if dx * dx + dy * dy <= limit: + img.set_pixel(x, y, color) + var tex := ImageTexture.create_from_image(img) + _cache[key] = tex + return tex + + +func _ready() -> void: + add_to_group("bullet") + var sprite := get_node_or_null("Sprite") as Sprite2D + if sprite != null: + sprite.texture = circle_texture(radius, bullet_color) + var hit := get_node_or_null("HitBox") as CollisionShape2D + if hit != null and hit.shape is CircleShape2D: + var circle := (hit.shape as CircleShape2D).duplicate() as CircleShape2D + circle.radius = float(radius) + hit.shape = circle + _probe = get_node_or_null("Probe") as RayCast2D + body_entered.connect(_on_body_entered) + + +func launch(dir: Vector2, spd: float = -1.0) -> void: + direction = dir.normalized() + if spd > 0.0: + speed = spd + + +func _physics_process(delta: float) -> void: + _age += delta + if _age >= lifetime: + queue_free() + return + + var step := direction * speed * delta + if _probe != null: + _probe.target_position = step * 1.5 + _probe.force_raycast_update() + if _probe.is_colliding(): + queue_free() + return + position += step + + +func _on_body_entered(body: Node2D) -> void: + if body.has_method("hurt"): + body.hurt(damage) + queue_free() diff --git a/scripts/laser.gd b/scripts/laser.gd new file mode 100644 index 0000000..6067504 --- /dev/null +++ b/scripts/laser.gd @@ -0,0 +1,74 @@ +extends Area2D + +## 固定激光:每隔一段时间循环「蓄力闪烁 -> 发射 -> 熄灭」。 +## 蓄力阶段只闪不伤人(给玩家反应时间),发射阶段碰到就扣血。 +## 光束从自己的原点沿 +X 方向伸出,想改朝向就转节点本身。 + +enum State { WAIT, WARMUP, FIRING } + +@export var length: float = 90.0 +@export var thickness: float = 6.0 +@export var damage: int = 1 +## 蓄力(闪烁警告)时长 +@export var warmup: float = 0.8 +## 发射持续时长 +@export var fire_time: float = 0.35 +## 两次发射之间的间隔 +@export var cooldown: float = 1.8 +## 开场后第一次发射前的延迟 +@export var start_delay: float = 1.0 +@export var laser_color: Color = Color(1.0, 0.25, 0.25) +@export var warn_color: Color = Color(1.0, 0.8, 0.4) + +var _state: int = State.WAIT +var _timer: float = 0.0 +var _first: bool = true +var _beam: Polygon2D = null + + +func _ready() -> void: + add_to_group("hazard") + _beam = get_node_or_null("Beam") as Polygon2D + var half := thickness * 0.5 + if _beam != null: + _beam.polygon = PackedVector2Array([ + Vector2(0.0, -half), Vector2(length, -half), + Vector2(length, half), Vector2(0.0, half)]) + _beam.color = laser_color + _beam.visible = false + var hit := get_node_or_null("HitBox") as CollisionShape2D + if hit != null: + var rect := RectangleShape2D.new() + rect.size = Vector2(length, thickness) + hit.shape = rect + hit.position = Vector2(length * 0.5, 0.0) + + +func _physics_process(delta: float) -> void: + _timer += delta + match _state: + State.WAIT: + var wait: float = start_delay if _first else cooldown + if _timer >= wait: + _first = false + _state = State.WARMUP + _timer = 0.0 + State.WARMUP: + if _beam != null: + _beam.color = warn_color + _beam.visible = fmod(_timer, 0.16) < 0.08 + if _timer >= warmup: + _state = State.FIRING + _timer = 0.0 + if _beam != null: + _beam.color = laser_color + _beam.visible = true + State.FIRING: + for body in get_overlapping_bodies(): + if body.has_method("hurt"): + body.hurt(damage) + if _timer >= fire_time: + _state = State.WAIT + _timer = 0.0 + if _beam != null: + _beam.visible = false diff --git a/scripts/walker.gd b/scripts/walker.gd index 3eb44fa..65d3503 100644 --- a/scripts/walker.gd +++ b/scripts/walker.gd @@ -1,22 +1,43 @@ extends CharacterBody2D -## 小怪:在平台上左右巡逻,碰到玩家扣一颗心; -## 被玩家从上方踩到就死,并且把玩家弹起来。 -## 走到平台边缘会自动掉头,所以不会掉下去。 +## 小怪:在平台上左右巡逻,并且按固定节奏发射弹幕。 +## **打不死** —— 从上方踩上去也一样会被扣血。 +## 弹道方向是写死的(不追着玩家走),所以每一波的弹道完全一样, +## 玩家可以背下空隙走位躲过去,或者用「扔钥匙 + W 传送」直接穿过去。 +const BULLET := preload("res://scenes/prefabs/enemies/bullet.tscn") + +@export_category("巡逻") @export var speed: float = 28.0 -@export var damage: int = 1 -## 被踩死时把玩家往上弹的速度 -@export var stomp_bounce: float = -150.0 ## 巡逻方向:-1 向左,1 向右 @export var start_direction: int = -1 ## 掉头后的冷却时间:防止贴着墙时每帧都掉头、结果原地抖动 @export var turn_cooldown: float = 0.25 +## 接触玩家扣几颗心 +@export var damage: int = 1 +## 速度设成 0 就变成固定炮台(弹幕就完全静止可背板了) + +@export_category("弹幕") +@export var fires_bullets: bool = true +## 发射间隔(秒) +@export var fire_interval: float = 2.0 +## 开场后第一次发射前的延迟 +@export var fire_delay: float = 1.0 +## 每次发射几颗 +@export var bullet_count: int = 3 +## 扇面张开角度 +@export var spread_deg: float = 60.0 +## 扇面中心朝向(度):0=右 90=下 180=左 270=上 +@export var base_angle_deg: float = 0.0 +## 开火方向跟随巡逻朝向(打开时 base_angle_deg 只当上下偏移用) +@export var aim_along_facing: bool = true +@export var bullet_speed: float = 70.0 +@export var bullet_color: Color = Color(1.0, 0.35, 0.7) var direction: int = -1 -var dead: bool = false var _turn_timer: float = 0.0 +var _fire_timer: float = 1.0 @onready var sprite: Sprite2D = $Sprite @onready var floor_probe: RayCast2D = $FloorProbe @@ -27,13 +48,11 @@ func _ready() -> void: add_to_group("enemy") direction = -1 if start_direction < 0 else 1 _apply_direction() + _fire_timer = fire_delay touch_area.body_entered.connect(_on_touch) func _physics_process(delta: float) -> void: - if dead: - return - if _turn_timer > 0.0: _turn_timer -= delta @@ -50,6 +69,33 @@ func _physics_process(delta: float) -> void: move_and_slide() + if fires_bullets: + _fire_timer -= delta + if _fire_timer <= 0.0: + _fire_timer = maxf(fire_interval, 0.05) + fire() + + +## 按固定扇面发射一批子弹。方向是算死的,不参考玩家位置, +## 所以每一波的弹道都一样,玩家能背下来找空隙。 +func fire() -> void: + var n: int = maxi(bullet_count, 1) + var base_deg: float = base_angle_deg + if aim_along_facing: + base_deg = 0.0 if direction > 0 else 180.0 + var base := deg_to_rad(base_deg) + var spread := deg_to_rad(spread_deg) + for i in n: + var t: float = 0.0 if n == 1 else (float(i) / float(n - 1) - 0.5) + var ang := base + spread * t + var bullet := BULLET.instantiate() + # 这两个属性必须在 add_child 之前设好,子弹的 _ready 会拿它们生成贴图 + bullet.bullet_color = bullet_color + bullet.speed = bullet_speed + get_parent().add_child(bullet) + bullet.global_position = global_position + Vector2(0.0, -2.0) + bullet.launch(Vector2(cos(ang), sin(ang)), bullet_speed) + func _apply_direction() -> void: if sprite != null: @@ -59,29 +105,6 @@ func _apply_direction() -> void: func _on_touch(body: Node2D) -> void: - if dead or not body.has_method("hurt"): - return - - # 玩家从上往下踩到 -> 小怪死,玩家弹一下 - var p := body as CharacterBody2D - if p != null and p.velocity.y > 0.0 and p.global_position.y < global_position.y - 3.0: - p.velocity.y = stomp_bounce - _die() - return - - body.hurt(damage) - - -func _die() -> void: - dead = true - velocity = Vector2.ZERO - touch_area.set_deferred("monitoring", false) - set_deferred("collision_layer", 0) - set_deferred("collision_mask", 0) - - # 被踩扁 + 淡出 - var tw := create_tween() - tw.set_parallel(true) - tw.tween_property(sprite, "scale", Vector2(1.35, 0.2), 0.12) - tw.tween_property(sprite, "modulate:a", 0.0, 0.3) - tw.chain().tween_callback(queue_free) + # 打不死:踩上去也一样只是扣血 + if body.has_method("hurt"): + body.hurt(damage) diff --git a/scripts/walker.gd.uid b/scripts/walker.gd.uid new file mode 100644 index 0000000..701a473 --- /dev/null +++ b/scripts/walker.gd.uid @@ -0,0 +1 @@ +uid://b0lnhlnkjhxug