Files
sijcdusmode/scripts/player_controller.gd
T
sijcdu 9dffacd938 Initial commit: Godot 4.7 pixel platformer prototype
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).
2026-10-07 20:32:42 +08:00

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