3c6f31b503
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
308 lines
9.4 KiB
GDScript
308 lines
9.4 KiB
GDScript
class_name Player
|
||
extends Area2D
|
||
## 玩家战机:移动钳制、无敌闪烁与受击。
|
||
## Variant.FIGHTER:机枪弹幕(PlayerBullet)。
|
||
## Variant.FUNNEL:机体+浮游炮持续激光;按 focus(I) 浮游炮聚拢合体粗激光。
|
||
|
||
signal hit
|
||
|
||
enum Variant { FIGHTER, FUNNEL }
|
||
|
||
const SPEED := 340.0
|
||
const FIRE_INTERVAL := 0.13
|
||
const INVULN_ON_RESPAWN := 2.5
|
||
const WEAPON_LEVEL_MAX := 4
|
||
const BOMBS_MAX := 5
|
||
const BOMB_INVULN := 1.0
|
||
const TEX := preload("res://assets/art/P-38.png")
|
||
const TEX_FUNNEL := preload("res://assets/svg/plane_funnel.svg")
|
||
const FUNNEL_TEX := preload("res://assets/svg/funnel.svg")
|
||
|
||
const PLANE_LASER_DPS := 18.0
|
||
const FUNNEL_LASER_DPS := 7.0
|
||
const FOCUS_MULT := 1.5
|
||
const FOCUS_SPEED_MULT := 0.5
|
||
const PLANE_MUZZLE := Vector2(0.0, -30.0)
|
||
## 侧身帧切换:三帧贴图(平飞/左倾/右倾),机头始终垂直
|
||
const BANK_LERP := 12.0
|
||
const BANK_ENTER := 0.45
|
||
const BANK_EXIT := 0.25
|
||
## 展开阵列(左侧三槽,右侧镜像):错开的三角编队
|
||
const FUNNEL_SPREAD_LEFT: Array[Vector2] = [
|
||
Vector2(-36.0, -4.0),
|
||
Vector2(-62.0, -22.0),
|
||
Vector2(-40.0, -46.0),
|
||
]
|
||
const FUNNEL_FOCUS_X := 12.0
|
||
|
||
var variant: Variant = Variant.FIGHTER
|
||
var weapon_level := 1
|
||
var bombs := 2
|
||
var fire_timer := 0.0
|
||
var invuln := 0.0
|
||
var active := true
|
||
var sprite: Sprite2D
|
||
|
||
var _bank := 0.0
|
||
var _bank_state := 0
|
||
var _tex_neutral: Texture2D
|
||
var _tex_bank_left: Texture2D
|
||
var _tex_bank_right: Texture2D
|
||
|
||
var _funnels: Array[Node2D] = []
|
||
var _funnel_beams: Array[LaserBeam] = []
|
||
var _plane_beam: LaserBeam
|
||
var _focus_beam: LaserBeam
|
||
|
||
|
||
func _ready() -> void:
|
||
collision_layer = 1
|
||
collision_mask = 4 | 8 | 16
|
||
sprite = Sprite2D.new()
|
||
if variant == Variant.FUNNEL:
|
||
_tex_neutral = _load_optional_texture("res://assets/art/plane_funnel.png")
|
||
if _tex_neutral == null:
|
||
_tex_neutral = TEX_FUNNEL
|
||
_tex_bank_left = _load_optional_texture("res://assets/art/plane_funnel-bank-left.png")
|
||
_tex_bank_right = _load_optional_texture("res://assets/art/plane_funnel-bank-right.png")
|
||
else:
|
||
_tex_neutral = TEX
|
||
_tex_bank_left = _load_optional_texture("res://assets/art/P-38-bank-left.png")
|
||
_tex_bank_right = _load_optional_texture("res://assets/art/P-38-bank-right.png")
|
||
sprite.texture = _tex_neutral
|
||
add_child(sprite)
|
||
var shape := CollisionShape2D.new()
|
||
var capsule := CapsuleShape2D.new()
|
||
capsule.radius = 13.0
|
||
capsule.height = 44.0
|
||
shape.shape = capsule
|
||
add_child(shape)
|
||
area_entered.connect(_on_area_entered)
|
||
if variant == Variant.FUNNEL:
|
||
_plane_beam = _make_beam(PLANE_LASER_DPS, 5.0, Color(0.6, 0.85, 1.0), PLANE_MUZZLE)
|
||
_focus_beam = _make_beam(0.0, 16.0, Color(0.92, 0.98, 1.0), PLANE_MUZZLE)
|
||
_sync_funnels()
|
||
|
||
|
||
func _make_beam(p_dps: float, p_width: float, p_color: Color, offset: Vector2) -> LaserBeam:
|
||
var beam := LaserBeam.new()
|
||
beam.dps = p_dps
|
||
beam.width = p_width
|
||
beam.color = p_color
|
||
beam.position = offset
|
||
add_child(beam)
|
||
return beam
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if not active:
|
||
return
|
||
var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
||
# 聚焦合体期间:威力换机动,移动速度减半(仍可移动)
|
||
var speed := SPEED
|
||
if variant == Variant.FUNNEL and Input.is_action_pressed("fire") and Input.is_action_pressed("focus"):
|
||
speed *= FOCUS_SPEED_MULT
|
||
position += direction * speed * delta
|
||
|
||
# 侧身帧:带迟滞的三态切换(左倾/平飞/右倾),纯贴图切换、机头不歪
|
||
var dir_x := clampf(direction.x, -1.0, 1.0)
|
||
_bank = lerpf(_bank, dir_x, minf(BANK_LERP * delta, 1.0))
|
||
if _bank_state == 0:
|
||
if _bank > BANK_ENTER:
|
||
_bank_state = 1
|
||
elif _bank < -BANK_ENTER:
|
||
_bank_state = -1
|
||
elif absf(_bank) < BANK_EXIT:
|
||
_bank_state = 0
|
||
match _bank_state:
|
||
-1:
|
||
if _tex_bank_left != null:
|
||
sprite.texture = _tex_bank_left
|
||
1:
|
||
if _tex_bank_right != null:
|
||
sprite.texture = _tex_bank_right
|
||
_:
|
||
sprite.texture = _tex_neutral
|
||
position.x = clampf(position.x, 22.0, 518.0)
|
||
position.y = clampf(position.y, 90.0, 930.0)
|
||
|
||
if variant == Variant.FIGHTER:
|
||
fire_timer -= delta
|
||
if Input.is_action_pressed("fire") and fire_timer <= 0.0:
|
||
fire_timer = FIRE_INTERVAL
|
||
_fire()
|
||
else:
|
||
_update_funnel_weapons(delta)
|
||
|
||
if Input.is_action_just_pressed("bomb") and active and bombs > 0:
|
||
use_bomb()
|
||
|
||
if invuln > 0.0:
|
||
invuln -= delta
|
||
sprite.modulate.a = 1.0 if fmod(invuln * 8.0, 2.0) < 1.0 else 0.3
|
||
else:
|
||
sprite.modulate.a = 1.0
|
||
|
||
|
||
## 浮游炮机体武器:常态机体激光 + 各浮游炮激光;按 I 聚焦合体粗激光。
|
||
## 合体总威力 = 散开总威力 × FOCUS_MULT。
|
||
func _update_funnel_weapons(delta: float) -> void:
|
||
var firing := Input.is_action_pressed("fire")
|
||
var focus := firing and Input.is_action_pressed("focus")
|
||
_plane_beam.active = firing and not focus
|
||
_focus_beam.active = focus
|
||
if focus:
|
||
_focus_beam.dps = (PLANE_LASER_DPS + float(_funnels.size()) * FUNNEL_LASER_DPS) * FOCUS_MULT
|
||
for beam in _funnel_beams:
|
||
beam.active = firing and not focus
|
||
for i in _funnels.size():
|
||
var left := i % 2 == 0
|
||
var rank := i / 2
|
||
var target: Vector2
|
||
if focus:
|
||
target = Vector2(-FUNNEL_FOCUS_X if left else FUNNEL_FOCUS_X, -28.0 - 20.0 * float(rank))
|
||
else:
|
||
var slot: Vector2 = FUNNEL_SPREAD_LEFT[rank]
|
||
target = slot if left else Vector2(-slot.x, slot.y)
|
||
var funnel: Node2D = _funnels[i]
|
||
funnel.position = funnel.position.lerp(target, minf(10.0 * delta, 1.0))
|
||
|
||
|
||
## 按当前火力等级同步浮游炮数量(每侧 level-1 台)
|
||
func _sync_funnels() -> void:
|
||
if variant != Variant.FUNNEL:
|
||
return
|
||
var wanted := (weapon_level - 1) * 2
|
||
while _funnels.size() > wanted:
|
||
var removed: Node2D = _funnels.pop_back()
|
||
_funnel_beams.pop_back()
|
||
removed.queue_free()
|
||
while _funnels.size() < wanted:
|
||
var funnel := Node2D.new()
|
||
var icon := Sprite2D.new()
|
||
icon.texture = FUNNEL_TEX
|
||
funnel.add_child(icon)
|
||
var beam := LaserBeam.new()
|
||
beam.dps = FUNNEL_LASER_DPS
|
||
beam.width = 4.0
|
||
beam.color = Color(0.45, 0.9, 1.0)
|
||
beam.position = Vector2(0.0, -8.0)
|
||
funnel.add_child(beam)
|
||
add_child(funnel)
|
||
funnel.position = Vector2(0.0, 20.0)
|
||
_funnels.append(funnel)
|
||
_funnel_beams.append(beam)
|
||
|
||
|
||
func _fire() -> void:
|
||
var container := get_tree().get_first_node_in_group("player_bullets")
|
||
if container == null:
|
||
return
|
||
match weapon_level:
|
||
1:
|
||
_spawn_volley(container, [-11.0, 11.0], Vector2.UP)
|
||
2:
|
||
_spawn_volley(container, [-17.0, -6.0, 6.0, 17.0], Vector2.UP)
|
||
3:
|
||
_spawn_volley(container, [-17.0, -6.0, 6.0, 17.0], Vector2.UP)
|
||
_spawn_bullet(container, Vector2(-22.0, -4.0), Vector2.UP.rotated(deg_to_rad(-12.0)))
|
||
_spawn_bullet(container, Vector2(22.0, -4.0), Vector2.UP.rotated(deg_to_rad(12.0)))
|
||
_:
|
||
# L4:6 直射 + 双道 22° 斜射 + 30° 斜射
|
||
_spawn_volley(container, [-25.0, -15.0, -5.0, 5.0, 15.0, 25.0], Vector2.UP)
|
||
_spawn_bullet(container, Vector2(-32.0, -4.0), Vector2.UP.rotated(deg_to_rad(-22.0)))
|
||
_spawn_bullet(container, Vector2(-32.0, 6.0), Vector2.UP.rotated(deg_to_rad(-22.0)))
|
||
_spawn_bullet(container, Vector2(32.0, -4.0), Vector2.UP.rotated(deg_to_rad(22.0)))
|
||
_spawn_bullet(container, Vector2(32.0, 6.0), Vector2.UP.rotated(deg_to_rad(22.0)))
|
||
_spawn_bullet(container, Vector2(-20.0, 10.0), Vector2.UP.rotated(deg_to_rad(-30.0)))
|
||
_spawn_bullet(container, Vector2(20.0, 10.0), Vector2.UP.rotated(deg_to_rad(30.0)))
|
||
|
||
|
||
func _spawn_volley(container: Node, x_offsets: Array, dir: Vector2) -> void:
|
||
for x in x_offsets:
|
||
_spawn_bullet(container, Vector2(x, -8.0), dir)
|
||
|
||
|
||
func _spawn_bullet(container: Node, offset: Vector2, dir: Vector2) -> void:
|
||
var bullet := PlayerBullet.new()
|
||
bullet.direction = dir
|
||
container.add_child(bullet)
|
||
bullet.global_position = global_position + offset
|
||
|
||
|
||
func _collect_powerup(pickup: Powerup) -> void:
|
||
pickup.queue_free()
|
||
match pickup.kind:
|
||
Powerup.Kind.BOMB:
|
||
var game := get_tree().get_first_node_in_group("game") as Game
|
||
bombs = mini(bombs + 1, BOMBS_MAX)
|
||
if game != null:
|
||
game.notify_bomb_pickup()
|
||
_:
|
||
if weapon_level < WEAPON_LEVEL_MAX:
|
||
weapon_level += 1
|
||
_sync_funnels()
|
||
else:
|
||
var game := get_tree().get_first_node_in_group("game") as Game
|
||
if game != null:
|
||
game.add_score(500)
|
||
|
||
|
||
## 投放炸弹:清屏伤害 + 清除敌弹 + 白闪光效 + 短暂无敌
|
||
func use_bomb() -> void:
|
||
if not active or bombs <= 0:
|
||
return
|
||
bombs -= 1
|
||
invuln = maxf(invuln, BOMB_INVULN)
|
||
var game := get_tree().get_first_node_in_group("game") as Game
|
||
if game != null:
|
||
game.detonate_bomb()
|
||
|
||
|
||
func _on_area_entered(area: Area2D) -> void:
|
||
if area is Powerup:
|
||
_collect_powerup(area)
|
||
return
|
||
if not active or invuln > 0.0:
|
||
return
|
||
if area is EnemyBullet:
|
||
area.queue_free()
|
||
emit_signal("hit")
|
||
elif area is EnemyBase:
|
||
emit_signal("hit")
|
||
|
||
|
||
## 被击中后的消隐(禁用碰撞需延迟,避免在物理回调中直接修改);火力降一级
|
||
func die() -> void:
|
||
active = false
|
||
weapon_level = maxi(1, weapon_level - 1)
|
||
_sync_funnels()
|
||
if _plane_beam != null:
|
||
_plane_beam.active = false
|
||
if _focus_beam != null:
|
||
_focus_beam.active = false
|
||
for beam in _funnel_beams:
|
||
beam.active = false
|
||
visible = false
|
||
set_deferred("monitoring", false)
|
||
|
||
|
||
## 复活并给予无敌时间
|
||
func respawn(pos: Vector2) -> void:
|
||
position = pos
|
||
active = true
|
||
visible = true
|
||
invuln = INVULN_ON_RESPAWN
|
||
fire_timer = 0.0
|
||
_bank = 0.0
|
||
_bank_state = 0
|
||
sprite.texture = _tex_neutral
|
||
set_deferred("monitoring", true)
|
||
|
||
|
||
## 可选素材:文件存在才加载(侧身帧未生成时回退平飞帧)
|
||
func _load_optional_texture(path: String) -> Texture2D:
|
||
if not ResourceLoader.exists(path):
|
||
return null
|
||
return load(path) as Texture2D
|