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).
This commit is contained in:
2026-10-07 20:32:42 +08:00
commit 9dffacd938
558 changed files with 12258 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
extends Area2D
## 走到就捡走,给玩家增加一次"回血道具"。
## 碰撞掩码已经限制成只检测玩家那一层,所以进到这里的一定是玩家。
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
if body.has_method("add_heal_item"):
body.add_heal_item()
queue_free()
+1
View File
@@ -0,0 +1 @@
uid://dcemiea0m0t48
+64
View File
@@ -0,0 +1,64 @@
extends CanvasLayer
## 生命值归零后的死亡界面:画面渐黑 + 重新开始按钮。
## 只负责显示和重启,死亡状态由玩家(is_dead)决定。
signal restart_requested
@export var fade_time: float = 1.0
## 渐黑完成后再过多久显示标题和按钮
@export var button_delay: float = 0.4
var _player: Node = null
var _fade: ColorRect = null
var _center: CenterContainer = null
var _button: Button = null
var _elapsed: float = 0.0
var _dying: bool = false
func _ready() -> void:
visible = false
_fade = get_node_or_null("Fade")
_center = get_node_or_null("Center")
_button = get_node_or_null("Center/VBox/RestartButton")
if _button != null:
_button.pressed.connect(restart)
if _fade != null:
_fade.color = Color(0.0, 0.0, 0.0, 0.0)
if _center != null:
_center.visible = false
func _process(delta: float) -> void:
if not _dying:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
if _player == null:
return
if bool(_player.is_dead):
_dying = true
visible = true
else:
return
_elapsed += delta
if _fade != null:
_fade.color = Color(0.0, 0.0, 0.0, clampf(_elapsed / maxf(fade_time, 0.01), 0.0, 1.0))
if _center != null and _elapsed >= fade_time + button_delay:
_center.visible = true
func _unhandled_input(event: InputEvent) -> void:
if not _dying:
return
var key := event as InputEventKey
if key != null and key.pressed and not key.echo:
if key.physical_keycode == KEY_R or key.physical_keycode == KEY_ENTER:
restart()
func restart() -> void:
restart_requested.emit()
get_tree().paused = false
get_tree().reload_current_scene()
+1
View File
@@ -0,0 +1 @@
uid://b31yxkyym11xb
+14
View File
@@ -0,0 +1,14 @@
extends Area2D
## 尖刺:碰到就把角色打回出生点。
## 碰撞掩码已经限制成只检测玩家那一层,所以进到这里的一定是玩家。
@export var damage: int = 1
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
if body.has_method("hurt"):
body.hurt(damage)
+1
View File
@@ -0,0 +1 @@
uid://bkhda0ohbgla2
+83
View File
@@ -0,0 +1,83 @@
extends Node2D
const ATLAS := preload("res://assets/game/tilesets/kenney_tiles_atlas.png")
const TILE := 18
## 隐藏通道:下面这些格子在关卡开始时会被"挖空",
## 原本是实心石壁的地方就变成能穿过去的暗道。
## 坐标是格坐标(格边长 18px),改这里就能挪位置。
@export var hidden_cells: Array = [
# ① 主线走廊上的石柱(第 13 列第 6~7 行)
Vector2i(13, 6), Vector2i(13, 7),
# ② 低层暗道(第 11 行第 12~15 列)
Vector2i(12, 11), Vector2i(13, 11), Vector2i(14, 11), Vector2i(15, 11),
]
## 伪装贴图所在层:要低于角色(z=10)、高于地形(z=0~2)
@export var cover_z_index: int = 3
## 是否给洞口盖上"假墙"(关掉的话洞口会明显地露出来)
@export var hide_with_cover: bool = true
func _ready() -> void:
var layers := _tile_layers()
if layers.is_empty():
return
var base: TileMapLayer = layers[0]
# 先把盖子做好(此时参考的那块墙还在),再挖空
var covers: Array = []
for cell in hidden_cells:
covers.append([cell, _make_cover(cell) if hide_with_cover else null])
for cell in hidden_cells:
for layer in layers:
layer.erase_cell(cell)
for pair in covers:
var sprite: Sprite2D = pair[1]
if sprite != null:
add_child(sprite)
sprite.global_position = base.to_global(base.map_to_local(pair[0]))
func _tile_layers() -> Array:
var out: Array = []
for child in get_children():
if child is TileMapLayer:
out.append(child)
return out
## 借用正下方最近一块实心墙的贴图,做成一个完全没有碰撞的 Sprite2D
func _make_cover(cell: Vector2i) -> Sprite2D:
var found := Vector2i(-1, -1)
for dy in range(1, 6):
for layer in _tile_layers():
var probe: Vector2i = cell + Vector2i(0, dy)
var source_id: int = layer.get_cell_source_id(probe)
if source_id == -1:
continue
var coords: Vector2i = layer.get_cell_atlas_coords(probe)
var source := layer.tile_set.get_source(source_id) as TileSetAtlasSource
if source == null:
continue
var data: TileData = source.get_tile_data(coords, 0)
if data != null and data.get_collision_polygons_count(0) > 0:
found = coords
break
if found.x >= 0:
break
if found.x < 0:
return null
var atlas_texture := AtlasTexture.new()
atlas_texture.atlas = ATLAS
atlas_texture.region = Rect2(found.x * TILE, found.y * TILE, TILE, TILE)
var sprite := Sprite2D.new()
sprite.texture = atlas_texture
sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
sprite.z_index = cover_z_index
return sprite
+1
View File
@@ -0,0 +1 @@
uid://dxwa7fxghp3a2
+45
View File
@@ -0,0 +1,45 @@
extends CanvasLayer
## 左上角的状态显示:3 颗心 + 回血道具数量 + 护盾剩余时间。
## 每帧从场景里找带 "player" 组的玩家读取状态。
@export var full_heart_texture: Texture2D
@export var empty_heart_texture: Texture2D
var _player: Node = null
var _hearts: Array = []
var _count_label: Label = null
var _shield_label: Label = null
func _ready() -> void:
for i in range(1, 4):
var heart := get_node_or_null("Heart%d" % i)
if heart != null:
_hearts.append(heart)
_count_label = get_node_or_null("CountLabel")
_shield_label = get_node_or_null("ShieldLabel")
func _process(_delta: float) -> void:
if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player")
if _player == null:
return
var health: int = int(_player.health)
for i in _hearts.size():
var heart: TextureRect = _hearts[i]
heart.texture = full_heart_texture if i < health else empty_heart_texture
var items: int = int(_player.heal_items)
if _count_label != null:
_count_label.text = "x%d" % items
_count_label.modulate = Color.WHITE if items > 0 else Color(1.0, 1.0, 1.0, 0.4)
if _shield_label != null:
var shield: float = float(_player.shield_left)
if shield > 0.0:
_shield_label.text = "SHIELD %.1fs" % shield
else:
_shield_label.text = ""
+1
View File
@@ -0,0 +1 @@
uid://8o2g2u6clfh6
+125
View File
@@ -0,0 +1,125 @@
extends Area2D
signal collected
## 钥匙中心到"脚底"的距离:射线从脚底出发,落地时钥匙正好站在表面上
const FOOT_OFFSET := 7.0
const STEP := 1.0 / 60.0
const MAX_STEPS := 240
enum State { IDLE, THROWN, LANDED }
## 关卡里那把钥匙原本是用 TerrainBase 的一块地砖画的。
## 这个开关让钥匙对象在开场时把脚下那块占位地砖擦掉,避免两个钥匙重叠。
@export var erase_placeholder_on_ready: bool = true
## 落地后多久才允许再次被捡起
@export var settle_time: float = 0.4
## 飞行时用哪几层做落地判定。玩家投掷时会把它设成玩家自己的 collision_mask,
## 所以"钥匙能落在哪"严格等于"玩家能站在哪"。
@export var flight_mask: int = 1
var state: State = State.IDLE
var velocity := Vector2.ZERO
var _settle := 0.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
if erase_placeholder_on_ready:
_erase_placeholder_tile()
_start_idle()
func _physics_process(_delta: float) -> void:
if state == State.LANDED:
_settle += STEP
if _settle >= settle_time:
_start_idle()
return
if state != State.THROWN:
return
var g := gravity()
var motion := velocity * STEP
var from := global_position + Vector2(0.0, FOOT_OFFSET)
var query := PhysicsRayQueryParameters2D.create(from, from + motion, flight_mask)
var hit := get_world_2d().direct_space_state.intersect_ray(query)
if hit.is_empty():
global_position += motion
velocity.y += g * STEP
else:
global_position = hit["position"] - Vector2(0.0, FOOT_OFFSET)
velocity = Vector2.ZERO
state = State.LANDED
_settle = 0.0
# ---------------------------------------------------------------- 对外接口
func launch(v: Vector2) -> void:
velocity = v
state = State.THROWN
monitoring = false
_settle = 0.0
func _start_idle() -> void:
state = State.IDLE
velocity = Vector2.ZERO
monitoring = true
func _on_body_entered(body: Node2D) -> void:
if state != State.IDLE:
return
if body.has_method("collect_key"):
body.collect_key()
collected.emit()
queue_free()
# ---------------------------------------------------------------- 轨迹模拟
static func gravity() -> float:
return float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0))
## 和 _physics_process 里的推进方式完全一致,玩家用它来画预览弧线,
## 所以预览落点和真正落点会重合。mask 就是玩家的碰撞掩码。
static func simulate_trajectory(
space: PhysicsDirectSpaceState2D,
start: Vector2,
vel: Vector2,
mask: int,
max_steps: int = MAX_STEPS) -> Dictionary:
var points := PackedVector2Array()
var p := start
var v := vel
var g := gravity()
points.append(p)
for i in max_steps:
var next := p + v * STEP
var query := PhysicsRayQueryParameters2D.create(
p + Vector2(0.0, FOOT_OFFSET), next + Vector2(0.0, FOOT_OFFSET), mask)
var hit := space.intersect_ray(query)
if not hit.is_empty():
var landing: Vector2 = hit["position"] - Vector2(0.0, FOOT_OFFSET)
points.append(landing)
return {"points": points, "landed": true, "landing": landing}
p = next
v.y += g * STEP
points.append(p)
return {"points": points, "landed": false, "landing": p}
# ---------------------------------------------------------------- 清理占位地砖
func _erase_placeholder_tile() -> void:
var root: Node = owner if owner != null else get_parent()
if root == null:
return
for layer in root.find_children("*", "TileMapLayer", true, false):
var cell: Vector2i = layer.local_to_map(layer.to_local(global_position))
if layer.get_cell_source_id(cell) != -1:
layer.erase_cell(cell)
+1
View File
@@ -0,0 +1 @@
uid://bx5nwb3eghex7
+405
View File
@@ -0,0 +1,405 @@
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
+1
View File
@@ -0,0 +1 @@
uid://b0gltif7vthau
+23
View File
@@ -0,0 +1,23 @@
extends Area2D
## 弹起角色时给它的向上速度(负值 = 向上)。玩家正常跳跃是 -190,这里给约 1.6 倍。
@export var bounce_velocity: float = -300.0
## 角色的竖直速度要 >= 这个值才弹(正在往上冲的时候不弹,免得把人往下拍)
@export var min_downward_speed: float = -1.0
## 角色的中心必须比弹簧中心高出这么多像素,才算"从上面踩下来";从侧面撞过去不弹
@export var top_zone_offset: float = 4.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
var character := body as CharacterBody2D
if character == null:
return
if character.velocity.y < min_downward_speed:
return
if character.global_position.y > global_position.y - top_zone_offset:
return
character.velocity.y = bounce_velocity
+1
View File
@@ -0,0 +1 @@
uid://bc10ax0n3w2sp