修复 LightGame 玩家地面抖动、动画切换异常并完善 spike/终点视觉

## 物理与动画(核心修复)

- PlayerController::update:地面状态下跳过 apply_gravity。之前每帧重力累入 velocity.y,sub_pixel_y_ 会缓慢累到 ≥1000 像素阈值,导致玩家下沉 1px 后被 collide_tilemap 推回,每隔几帧抖动一次。这是"动画机切不回 idle"的真正根因 —— 单帧 grounded=false 让状态切到 Falling,下一帧又回 Idle,视觉表现为 idle/fall 帧间歇错位。

- Physics2D::collide_tilemap:发生轴向推回时清掉对应 sub_pixel_x_/y_,防止小数残留在下一帧继续推进位置。
- Physics2D::is_grounded:feet 探测窗口(max.y..max.y+2)跨越的所有 tile 行都要扫描,而非只查 feet.min.y/ts 那一行。原写法在边界帧(feet.max.y 临时为 31n+0)会错把上一行空 tile 当判定行返回 false,触发上述的单帧 Falling 抖动。
- PlayerController::update:grounded && velocity.y >= 0 时无条件清零 velocity.y(不再要求 !was_grounded_),避免重力残留导致连续下沉。

## Spike 行为

- Physics2D::is_tile_solid:spike(tile id 4) 改为非实体,玩家可踩入触发死亡。原先 spike 与普通地块都被当 solid,玩家停在 spike 顶部不会死。
- PlayerController::check_death:spike 击杀区域收紧到 tile 底部 14px(视觉高度),不再用整 32px tile 误判。

## 关卡视觉(出生点 / 检查点 / 终点)

- LevelLoader:玩家出生点附近生成 flag 装饰对象;Checkpoint 触发器初始显示 checkpoint 精灵,被激活后由 LightGameApp 在 check_triggers 中替换为 flag 精灵;LevelComplete 触发器使用 checkpoint 精灵作为终点。
- LevelLoader 暴露 flag_sprite() 静态访问器,供 LightGameApp 触发激活时复用同一份 sprite 引用。

## 资源与配套

- 重新打包 tile_atlas(重新生成 generated/tile_atlas.h),更新 assets/tile/checkpoint.png、flag.png 的视觉素材。
- Level1Data、LevelEditor、LevelRenderer、GameObject、tests 跟随接口调整同步更新;game_engine_tests 重新编译通过。
This commit is contained in:
SepComet
2026-06-13 14:54:29 +08:00
parent 05d7d9783e
commit 254a8ce5c8
21 changed files with 849 additions and 238 deletions
+22 -21
View File
@@ -16,7 +16,7 @@ namespace
{
GameObject obj;
assert(obj.id == 0);
assert(obj.type == GameObjectType::StaticPlatform);
assert(obj.type == GameObjectType::Player);
assert(obj.position.x == 0);
assert(obj.position.y == 0);
assert(obj.velocity.x == 0);
@@ -48,7 +48,7 @@ namespace
assert(level.object_count() == 0);
GameObject platform;
platform.type = GameObjectType::StaticPlatform;
platform.type = GameObjectType::Collectible;
platform.position = Math::Vector2Int(0, 400);
uint32_t id1 = level.add_object(platform);
assert(id1 == 1);
@@ -63,7 +63,7 @@ namespace
const GameObject* found = level.get_object(id1);
assert(found != nullptr);
assert(found->type == GameObjectType::StaticPlatform);
assert(found->type == GameObjectType::Collectible);
assert(found->id == 1);
const GameObject* not_found = level.get_object(999);
@@ -88,7 +88,7 @@ namespace
Level level;
GameObject platform;
platform.type = GameObjectType::StaticPlatform;
platform.type = GameObjectType::Collectible;
level.add_object(platform);
GameObject light_plat;
@@ -99,15 +99,15 @@ namespace
player.type = GameObjectType::Player;
level.add_object(player);
auto platforms = level.get_objects_by_type(GameObjectType::StaticPlatform);
auto platforms = level.get_objects_by_type(GameObjectType::Collectible);
assert(platforms.size() == 1);
assert(platforms[0]->type == GameObjectType::StaticPlatform);
assert(platforms[0]->type == GameObjectType::Collectible);
auto players = level.get_objects_by_type(GameObjectType::Player);
assert(players.size() == 1);
auto hazards = level.get_objects_by_type(GameObjectType::Hazard);
assert(hazards.size() == 0);
auto doors = level.get_objects_by_type(GameObjectType::Door);
assert(doors.size() == 0);
}
void TestLevelQueryRegion()
@@ -115,14 +115,14 @@ namespace
Level level;
GameObject obj1;
obj1.type = GameObjectType::StaticPlatform;
obj1.type = GameObjectType::Collectible;
obj1.position = Math::Vector2Int(100, 100);
obj1.collider = RenderData::BoundingBox2D(
Math::Vector2Int(0, 0), Math::Vector2Int(32, 32));
level.add_object(obj1);
GameObject obj2;
obj2.type = GameObjectType::StaticPlatform;
obj2.type = GameObjectType::Collectible;
obj2.position = Math::Vector2Int(500, 500);
obj2.collider = RenderData::BoundingBox2D(
Math::Vector2Int(0, 0), Math::Vector2Int(32, 32));
@@ -226,7 +226,7 @@ namespace
level.set_bounds(LevelBounds(0, 0, 800, 600));
GameObject ground;
ground.type = GameObjectType::StaticPlatform;
ground.type = GameObjectType::Collectible;
ground.position = Math::Vector2Int(0, 400);
ground.collider = RenderData::BoundingBox2D(
Math::Vector2Int(0, 0), Math::Vector2Int(800, 32));
@@ -249,7 +249,8 @@ namespace
void TestOneWayPlatform()
{
Level level;
level.set_bounds(LevelBounds(0, 0, 800, 600));
LevelBounds bounds(0, 0, 800, 600);
level.set_bounds(bounds);
GameObject platform;
platform.type = GameObjectType::LightPlatform;
@@ -349,17 +350,17 @@ namespace
{
LightEffectSystem system;
GameObject platform;
platform.type = GameObjectType::StaticPlatform;
platform.solid = true;
GameObject collectible;
collectible.type = GameObjectType::Collectible;
collectible.solid = true;
GameObject hazard;
hazard.type = GameObjectType::Hazard;
hazard.solid = true;
GameObject trigger;
trigger.type = GameObjectType::Trigger;
trigger.solid = true;
std::vector<GameObject> objects;
objects.push_back(platform);
objects.push_back(hazard);
objects.push_back(collectible);
objects.push_back(trigger);
system.update(objects, 0);
assert(objects[0].solid == true);
@@ -380,7 +381,7 @@ namespace
const ObjectSpawn spawns[] = {
{ GameObjectType::Player, 1, 1, 0, 0, TriggerAction::None, 0 },
{ GameObjectType::StaticPlatform, 0, 2, 0, 0, TriggerAction::None, 0 },
{ GameObjectType::Collectible, 0, 2, 0, 0, TriggerAction::None, 0 },
};
const LevelData data = {