9dffacd938
Player: move/jump (Space), test flight (F, debug builds only). Key: pickup, hold S to aim with live trajectory preview, release to throw, W to warp to the thrown key. Spring bounce. Spikes deal damage. 3-heart health, Z consumes a heal item for +1 heart and a 5s shield. HUD with hearts and item count. Death screen with fade-to-black and restart. Hidden passages. Assets: Kenney Pixel Platformer (CC0).
406 lines
11 KiB
GDScript
406 lines
11 KiB
GDScript
extends CharacterBody2D
|
|
|
|
signal died
|
|
|
|
const KEY_SCENE := preload("res://scenes/prefabs/collectibles/key_gold.tscn")
|
|
const KEY_SCRIPT := preload("res://scripts/key.gd")
|
|
const KEY_ATLAS := preload("res://assets/game/tilesets/kenney_tiles_atlas.png")
|
|
const KEY_REGION := Rect2(126, 18, 18, 18)
|
|
|
|
@export_category("移动参数")
|
|
@export var move_speed: float = 75.0
|
|
@export var acceleration: float = 600.0
|
|
@export var deceleration: float = 800.0
|
|
@export var jump_velocity: float = -190.0
|
|
|
|
@export_category("生命值")
|
|
## 最大生命值(几颗心)
|
|
@export var max_health: int = 3
|
|
## 按 Z 用掉一个回血道具后,获得的无敌盾持续时间(秒)
|
|
@export var shield_time: float = 5.0
|
|
## 受伤后的无敌时间,避免站在尖刺上反复触发
|
|
@export var invulnerable_time: float = 1.0
|
|
## 受伤瞬间的染色
|
|
@export var hurt_tint: Color = Color(1.0, 0.35, 0.35)
|
|
@export var hurt_flash_time: float = 0.25
|
|
## 有护盾时的染色
|
|
@export var shield_tint: Color = Color(0.6, 0.95, 1.0, 1.0)
|
|
## 死亡后的染色
|
|
@export var dead_tint: Color = Color(0.85, 0.3, 0.3, 1.0)
|
|
|
|
@export_category("钥匙投掷")
|
|
## 投掷初速度大小
|
|
@export var throw_speed: float = 300.0
|
|
## 投掷仰角(度)
|
|
@export_range(0.0, 89.0, 1.0) var throw_angle_deg: float = 60.0
|
|
## 预测弧线颜色
|
|
@export var arc_color: Color = Color(1.0, 0.94, 0.45, 0.85)
|
|
@export var arc_width: float = 1.5
|
|
## 拿着钥匙时头顶显示一个小图标(不想显示就关掉)
|
|
@export var show_carried_key: bool = true
|
|
|
|
@export_category("测试用飞行")
|
|
## 开发期测试用:按 F 切换飞行,方便飞到各处查看碰撞效果。
|
|
## 只在编辑器 / 调试版里生效;导出的正式版里按键无效,不会影响平衡。
|
|
@export var debug_fly: bool = true
|
|
@export var fly_speed: float = 90.0
|
|
## 飞行时的角色染色,用来一眼看出飞行开着(不想染色就改成白色)
|
|
@export var fly_tint: Color = Color(0.65, 0.9, 1.0)
|
|
|
|
|
|
@onready var sprite: Sprite2D = $Sprite2D
|
|
@onready var collider: CollisionShape2D = $CollisionShape2D
|
|
|
|
var flying: bool = false
|
|
var facing: int = 1
|
|
var held_key: bool = false
|
|
var is_aiming: bool = false
|
|
var hurt_count: int = 0
|
|
## 当前生命值(几颗心)
|
|
var health: int = 3
|
|
## 身上的回血道具数量(捡到的小球)
|
|
var heal_items: int = 0
|
|
## 无敌盾剩余时间,给 HUD 显示用
|
|
var shield_left: float = 0.0
|
|
## 生命值归零后进入死亡状态:不能操作,交给死亡界面
|
|
var is_dead: bool = false
|
|
|
|
var _arc: Line2D = null
|
|
var _landing_marker: Polygon2D = null
|
|
var _carried_icon: Sprite2D = null
|
|
## 最近一次扔出去、还没被回收的钥匙
|
|
var _thrown_key: Node = null
|
|
## 角色原点到底部的距离,传送时用来对齐脚底
|
|
var _feet_offset: float = 10.5
|
|
## 出生点,受伤后回到这里
|
|
var _spawn_point := Vector2.ZERO
|
|
var _invulnerable: float = 0.0
|
|
var _hurt_flash: float = 0.0
|
|
|
|
|
|
func _ready() -> void:
|
|
add_to_group("player")
|
|
_build_throw_visuals()
|
|
_measure_feet_offset()
|
|
_spawn_point = global_position
|
|
health = max_health
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if _invulnerable > 0.0:
|
|
_invulnerable -= delta
|
|
if _hurt_flash > 0.0:
|
|
_hurt_flash -= delta
|
|
if shield_left > 0.0:
|
|
shield_left -= delta
|
|
|
|
if is_dead:
|
|
# 死亡后不接受任何操作,只让重力把人放下来
|
|
apply_gravity(delta)
|
|
velocity.x = move_toward(velocity.x, 0.0, deceleration * delta)
|
|
_update_tint()
|
|
move_and_slide()
|
|
return
|
|
|
|
# 正式版里不允许飞行;即使 toggle 开着也强制落回地面
|
|
if flying and not fly_available():
|
|
set_flying(false)
|
|
|
|
if flying:
|
|
handle_fly_movement()
|
|
else:
|
|
apply_gravity(delta)
|
|
handle_jump()
|
|
handle_horizontal_movement(delta)
|
|
|
|
update_sprite_direction()
|
|
_update_tint()
|
|
if is_aiming:
|
|
update_arc()
|
|
move_and_slide()
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if is_dead:
|
|
return
|
|
|
|
if fly_available() and event.is_action_pressed("fly") and not event.is_echo():
|
|
set_flying(not flying)
|
|
return
|
|
|
|
if event.is_action_pressed("use_heal") and not event.is_echo():
|
|
use_heal_item()
|
|
return
|
|
|
|
if event.is_action_pressed("warp_to_key") and not event.is_echo():
|
|
warp_to_thrown_key()
|
|
return
|
|
|
|
if event.is_action_pressed("squat") and not event.is_echo():
|
|
if can_aim():
|
|
is_aiming = true
|
|
update_arc()
|
|
elif event.is_action_released("squat"):
|
|
if is_aiming:
|
|
release_throw()
|
|
|
|
|
|
# ---------------------------------------------------------------- 生命值
|
|
|
|
## 由尖刺之类的危险物调用
|
|
func hurt(_amount: int = 1) -> void:
|
|
if is_dead or _invulnerable > 0.0:
|
|
return
|
|
hurt_count += 1
|
|
health -= 1
|
|
_invulnerable = invulnerable_time
|
|
_hurt_flash = hurt_flash_time
|
|
if health <= 0:
|
|
health = 0
|
|
_die()
|
|
return
|
|
# 没死:打回出生点
|
|
global_position = _spawn_point
|
|
velocity = Vector2.ZERO
|
|
if flying:
|
|
set_flying(false)
|
|
if is_aiming:
|
|
_cancel_aim()
|
|
|
|
|
|
func _die() -> void:
|
|
is_dead = true
|
|
velocity = Vector2.ZERO
|
|
if flying:
|
|
set_flying(false)
|
|
if is_aiming:
|
|
_cancel_aim()
|
|
died.emit()
|
|
|
|
|
|
## 由可收集物调用
|
|
func add_heal_item() -> void:
|
|
heal_items += 1
|
|
|
|
|
|
## 按 Z:消耗一个回血道具,回一颗心并获得无敌盾
|
|
func use_heal_item() -> bool:
|
|
if is_dead or heal_items <= 0:
|
|
return false
|
|
heal_items -= 1
|
|
health = mini(health + 1, max_health)
|
|
_invulnerable = maxf(_invulnerable, shield_time)
|
|
shield_left = shield_time
|
|
return true
|
|
|
|
|
|
func _update_tint() -> void:
|
|
if is_dead:
|
|
sprite.modulate = dead_tint
|
|
elif _hurt_flash > 0.0:
|
|
sprite.modulate = hurt_tint
|
|
elif shield_left > 0.0:
|
|
sprite.modulate = shield_tint
|
|
elif flying:
|
|
sprite.modulate = fly_tint
|
|
else:
|
|
sprite.modulate = Color.WHITE
|
|
|
|
|
|
# ---------------------------------------------------------------- 钥匙投掷
|
|
|
|
func can_aim() -> bool:
|
|
return held_key and not flying and not is_aiming
|
|
|
|
|
|
## 由钥匙对象在碰到玩家时调用
|
|
func collect_key() -> void:
|
|
held_key = true
|
|
_update_carried_icon()
|
|
|
|
|
|
func throw_velocity() -> Vector2:
|
|
var rad := deg_to_rad(throw_angle_deg)
|
|
return Vector2(facing * throw_speed * cos(rad), -throw_speed * sin(rad))
|
|
|
|
|
|
func update_arc() -> void:
|
|
if not is_aiming or _arc == null:
|
|
return
|
|
# 用玩家自己的 collision_mask 做预测,所以"弧线能落到的地方"就是"玩家能站的地方"
|
|
var result := KEY_SCRIPT.simulate_trajectory(
|
|
get_world_2d().direct_space_state,
|
|
global_position,
|
|
throw_velocity(),
|
|
collision_mask)
|
|
_arc.points = result["points"]
|
|
_arc.visible = _arc.points.size() > 1
|
|
var landed: bool = result["landed"]
|
|
if _landing_marker != null:
|
|
_landing_marker.visible = landed
|
|
if landed:
|
|
_landing_marker.global_position = result["landing"]
|
|
|
|
|
|
func release_throw() -> void:
|
|
is_aiming = false
|
|
_hide_throw_visuals()
|
|
if not held_key:
|
|
return
|
|
|
|
held_key = false
|
|
_update_carried_icon()
|
|
|
|
var key: Node = KEY_SCENE.instantiate()
|
|
# 这是玩家扔出去的钥匙,不要去擦关卡里的占位地砖
|
|
key.erase_placeholder_on_ready = false
|
|
# 让钥匙的落地判定和玩家完全一致
|
|
key.flight_mask = collision_mask
|
|
get_parent().add_child(key)
|
|
key.global_position = global_position
|
|
key.launch(throw_velocity())
|
|
_thrown_key = key
|
|
|
|
|
|
## 按 W:传送到扔出去的那把钥匙所在的位置,并顺手把它捡回来
|
|
func warp_to_thrown_key() -> void:
|
|
if _thrown_key == null or not is_instance_valid(_thrown_key):
|
|
return
|
|
|
|
var key_pos: Vector2 = _thrown_key.global_position
|
|
# 让玩家的脚底落在钥匙脚底所在的那个面上,避免直接嵌进地形
|
|
global_position = Vector2(key_pos.x, key_pos.y + KEY_SCRIPT.FOOT_OFFSET - _feet_offset)
|
|
velocity = Vector2.ZERO
|
|
|
|
if is_aiming:
|
|
_cancel_aim()
|
|
|
|
_thrown_key.queue_free()
|
|
_thrown_key = null
|
|
held_key = true
|
|
_update_carried_icon()
|
|
|
|
|
|
func _cancel_aim() -> void:
|
|
is_aiming = false
|
|
_hide_throw_visuals()
|
|
|
|
|
|
func _hide_throw_visuals() -> void:
|
|
if _arc != null:
|
|
_arc.visible = false
|
|
if _landing_marker != null:
|
|
_landing_marker.visible = false
|
|
|
|
|
|
func _update_carried_icon() -> void:
|
|
if _carried_icon != null:
|
|
_carried_icon.visible = held_key
|
|
|
|
|
|
func _measure_feet_offset() -> void:
|
|
if collider == null or collider.shape == null:
|
|
return
|
|
var shape := collider.shape
|
|
if shape is CapsuleShape2D:
|
|
_feet_offset = collider.position.y + shape.height * 0.5
|
|
elif shape is RectangleShape2D:
|
|
_feet_offset = collider.position.y + shape.size.y * 0.5
|
|
elif shape is CircleShape2D:
|
|
_feet_offset = collider.position.y + shape.radius
|
|
|
|
|
|
func _build_throw_visuals() -> void:
|
|
# top_level = true 让它们忽略角色自身那个 rotation/scale 翻转,直接用世界坐标
|
|
_arc = Line2D.new()
|
|
_arc.width = arc_width
|
|
_arc.default_color = arc_color
|
|
_arc.visible = false
|
|
add_child(_arc)
|
|
_arc.top_level = true
|
|
|
|
_landing_marker = Polygon2D.new()
|
|
var s := 4.0
|
|
_landing_marker.polygon = PackedVector2Array([
|
|
Vector2(0.0, -s), Vector2(s, 0.0), Vector2(0.0, s), Vector2(-s, 0.0)])
|
|
_landing_marker.color = arc_color
|
|
_landing_marker.visible = false
|
|
add_child(_landing_marker)
|
|
_landing_marker.top_level = true
|
|
|
|
if show_carried_key:
|
|
var tex := AtlasTexture.new()
|
|
tex.atlas = KEY_ATLAS
|
|
tex.region = KEY_REGION
|
|
_carried_icon = Sprite2D.new()
|
|
_carried_icon.texture = tex
|
|
_carried_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
|
_carried_icon.position = Vector2(0.0, -15.0)
|
|
_carried_icon.visible = false
|
|
add_child(_carried_icon)
|
|
|
|
|
|
# ---------------------------------------------------------------- 测试用飞行
|
|
|
|
func fly_available() -> bool:
|
|
return debug_fly and OS.is_debug_build()
|
|
|
|
|
|
func set_flying(on: bool) -> void:
|
|
flying = on
|
|
velocity = Vector2.ZERO
|
|
if on:
|
|
_cancel_aim()
|
|
|
|
|
|
func handle_fly_movement() -> void:
|
|
# get_axis(负方向, 正方向),屏幕坐标里 y 向下为正,所以空格(跳跃)要放在负方向
|
|
var direction := Vector2(
|
|
Input.get_axis("move_left", "move_right"),
|
|
Input.get_axis("jump", "squat")
|
|
)
|
|
if direction == Vector2.ZERO:
|
|
velocity = Vector2.ZERO
|
|
else:
|
|
velocity = direction.normalized() * fly_speed
|
|
|
|
|
|
# ---------------------------------------------------------------- 正常移动
|
|
|
|
func apply_gravity(delta: float) -> void:
|
|
if not is_on_floor():
|
|
velocity += get_gravity() * delta
|
|
|
|
|
|
func handle_horizontal_movement(delta: float) -> void:
|
|
var direction := Input.get_axis("move_left", "move_right")
|
|
var target_speed := direction * move_speed
|
|
|
|
if direction != 0.0:
|
|
velocity.x = move_toward(
|
|
velocity.x,
|
|
target_speed,
|
|
acceleration * delta
|
|
)
|
|
else:
|
|
velocity.x = move_toward(
|
|
velocity.x,
|
|
0.0,
|
|
deceleration * delta
|
|
)
|
|
|
|
|
|
func handle_jump() -> void:
|
|
# 只有踩在地面上才能起跳 —— 空中不再给第二次机会,不做多段跳
|
|
if Input.is_action_just_pressed("jump") and is_on_floor():
|
|
velocity.y = jump_velocity
|
|
|
|
|
|
func update_sprite_direction() -> void:
|
|
if velocity.x > 0.0:
|
|
facing = 1
|
|
sprite.flip_h = false
|
|
elif velocity.x < 0.0:
|
|
facing = -1
|
|
sprite.flip_h = true
|