修复 LightGame 多项 gameplay bug 并完善光敏渲染体系

- 出生点校验:load_level() 加载后 assert 验证 spawn point 是否站在固体地面且不穿墙
- Checkpoint 系统:LevelLoader 为 Checkpoint Trigger 注册 checkpoint 位置,check_triggers() 正确激活 checkpoint,respawn() 使用最后激活的 checkpoint
- Death 触发器:PlayerController 新增 kill(),TriggerAction::Death 正确触发死亡
- Tilemap 光敏 tile:新增 TileLightRule 与 mutable tile buffer,LightEffectSystem::update_tilemap() 按光照条件动态显示/隐藏 tilemap 中的 tile
- GameObject 渲染:LevelLoader 为所有对象分配 tile atlas sprite(金币、门、陷阱、平台、checkpoint 等不再隐形)
- 光敏对象双状态:LightPlatform / ShadowPlatform / Door 均支持 on/off(或 open/closed)两种 sprite 切换,should_render() 始终返回 true,视觉状态由 sprite 承担
- 渲染层级:LevelRenderer 最后绘制玩家,确保玩家始终在最上层
This commit is contained in:
SepComet 2026-06-11 11:33:36 +08:00
parent acf162d1b9
commit 46c76ec7fc
13 changed files with 215 additions and 13 deletions

View File

@ -135,4 +135,20 @@ namespace LightGame
} }
return spawn_point_; return spawn_point_;
} }
void Level::init_tile_buffer(const uint16_t* source, int32_t count)
{
if (count <= 0 || source == nullptr)
{
return;
}
original_tiles_.resize(count);
tile_buffer_.resize(count);
for (int32_t i = 0; i < count; ++i)
{
original_tiles_[i] = source[i];
tile_buffer_[i] = source[i];
}
tilemap_.tiles = tile_buffer_.data();
}
} }

View File

@ -4,6 +4,7 @@
#include <vector> #include <vector>
#include "GameObject.h" #include "GameObject.h"
#include "Tilemap.h" #include "Tilemap.h"
#include "LevelData.h"
namespace LightGame namespace LightGame
{ {
@ -33,6 +34,9 @@ namespace LightGame
private: private:
std::vector<GameObject> objects_; std::vector<GameObject> objects_;
std::vector<Checkpoint> checkpoints_; std::vector<Checkpoint> checkpoints_;
std::vector<uint16_t> original_tiles_;
std::vector<uint16_t> tile_buffer_;
std::vector<TileLightRule> tile_light_rules_;
RenderData::Tilemap tilemap_; RenderData::Tilemap tilemap_;
LevelBounds bounds_; LevelBounds bounds_;
Math::Vector2Int spawn_point_; Math::Vector2Int spawn_point_;
@ -55,6 +59,13 @@ namespace LightGame
void set_tilemap(const RenderData::Tilemap& tilemap) { tilemap_ = tilemap; } void set_tilemap(const RenderData::Tilemap& tilemap) { tilemap_ = tilemap; }
const RenderData::Tilemap& get_tilemap() const { return tilemap_; } const RenderData::Tilemap& get_tilemap() const { return tilemap_; }
void init_tile_buffer(const uint16_t* source, int32_t count);
uint16_t* get_mutable_tiles() { return tile_buffer_.empty() ? nullptr : tile_buffer_.data(); }
const uint16_t* get_original_tiles() const { return original_tiles_.empty() ? nullptr : original_tiles_.data(); }
void add_tile_light_rule(const TileLightRule& rule) { tile_light_rules_.push_back(rule); }
const std::vector<TileLightRule>& get_tile_light_rules() const { return tile_light_rules_; }
void set_bounds(const LevelBounds& bounds) { bounds_ = bounds; } void set_bounds(const LevelBounds& bounds) { bounds_ = bounds; }
const LevelBounds& get_bounds() const { return bounds_; } const LevelBounds& get_bounds() const { return bounds_; }
@ -62,6 +73,7 @@ namespace LightGame
const Math::Vector2Int& get_spawn_point() const { return spawn_point_; } const Math::Vector2Int& get_spawn_point() const { return spawn_point_; }
void add_checkpoint(const Checkpoint& cp); void add_checkpoint(const Checkpoint& cp);
std::vector<Checkpoint>& get_checkpoints() { return checkpoints_; }
const std::vector<Checkpoint>& get_checkpoints() const { return checkpoints_; } const std::vector<Checkpoint>& get_checkpoints() const { return checkpoints_; }
Math::Vector2Int get_active_checkpoint() const; Math::Vector2Int get_active_checkpoint() const;

View File

@ -20,6 +20,14 @@ namespace LightGame
uint32_t trigger_target; uint32_t trigger_target;
}; };
struct TileLightRule
{
int32_t tile_x;
int32_t tile_y;
uint16_t light_min;
uint16_t light_max;
};
struct LevelData struct LevelData
{ {
const uint16_t* tiles; const uint16_t* tiles;
@ -32,6 +40,9 @@ namespace LightGame
const ObjectSpawn* spawns; const ObjectSpawn* spawns;
int32_t spawn_count; int32_t spawn_count;
const TileLightRule* tile_light_rules;
int32_t tile_light_rule_count;
Math::Vector2Int spawn_point; Math::Vector2Int spawn_point;
int32_t bounds_min_x; int32_t bounds_min_x;
int32_t bounds_min_y; int32_t bounds_min_y;

View File

@ -1,5 +1,6 @@
#include "LevelLoader.h" #include "LevelLoader.h"
#include "Sprite.h" #include "Sprite.h"
#include "tile_atlas.h"
namespace LightGame namespace LightGame
{ {
@ -12,6 +13,14 @@ namespace LightGame
const int32_t row = tile_id / atlas_columns; const int32_t row = tile_id / atlas_columns;
return RenderData::Sprite(atlas, col * tile_size, row * tile_size, tile_size, tile_size); return RenderData::Sprite(atlas, col * tile_size, row * tile_size, tile_size, tile_size);
} }
static const RenderData::Sprite spr_platform = MakeTileSprite(&tile_atlas_image, 3, 32, 4);
static const RenderData::Sprite spr_spike = MakeTileSprite(&tile_atlas_image, 4, 32, 4);
static const RenderData::Sprite spr_light_platform_on = MakeTileSprite(&tile_atlas_image, 6, 32, 4);
static const RenderData::Sprite spr_shadow_platform_on = MakeTileSprite(&tile_atlas_image, 8, 32, 4);
static const RenderData::Sprite spr_door_closed = MakeTileSprite(&tile_atlas_image, 9, 32, 4);
static const RenderData::Sprite spr_coin = MakeTileSprite(&tile_atlas_image, 11, 32, 4);
static const RenderData::Sprite spr_checkpoint = MakeTileSprite(&tile_atlas_image, 13, 32, 4);
} }
void LevelLoader::load(Level& level, const LevelData& data) void LevelLoader::load(Level& level, const LevelData& data)
@ -25,6 +34,7 @@ namespace LightGame
tilemap.atlas = data.atlas; tilemap.atlas = data.atlas;
tilemap.atlas_columns = data.atlas_columns; tilemap.atlas_columns = data.atlas_columns;
level.set_tilemap(tilemap); level.set_tilemap(tilemap);
level.init_tile_buffer(data.tiles, data.map_width * data.map_height);
level.set_bounds(LevelBounds( level.set_bounds(LevelBounds(
data.bounds_min_x, data.bounds_min_x,
@ -53,6 +63,7 @@ namespace LightGame
obj.collider = RenderData::BoundingBox2D( obj.collider = RenderData::BoundingBox2D(
Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts)); Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts));
obj.solid = true; obj.solid = true;
obj.sprite = &spr_platform;
break; break;
case GameObjectType::LightPlatform: case GameObjectType::LightPlatform:
@ -60,6 +71,7 @@ namespace LightGame
Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts)); Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts));
obj.solid = false; obj.solid = false;
obj.light_threshold = LightThreshold(spawn.light_min, spawn.light_max); obj.light_threshold = LightThreshold(spawn.light_min, spawn.light_max);
obj.sprite = &spr_light_platform_on;
break; break;
case GameObjectType::ShadowPlatform: case GameObjectType::ShadowPlatform:
@ -67,6 +79,7 @@ namespace LightGame
Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts)); Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts));
obj.solid = true; obj.solid = true;
obj.light_threshold = LightThreshold(spawn.light_min, spawn.light_max); obj.light_threshold = LightThreshold(spawn.light_min, spawn.light_max);
obj.sprite = &spr_shadow_platform_on;
break; break;
case GameObjectType::Door: case GameObjectType::Door:
@ -74,6 +87,7 @@ namespace LightGame
Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts * 2)); Math::Vector2Int(0, 0), Math::Vector2Int(ts, ts * 2));
obj.solid = true; obj.solid = true;
obj.light_threshold = LightThreshold(spawn.light_min, spawn.light_max); obj.light_threshold = LightThreshold(spawn.light_min, spawn.light_max);
obj.sprite = &spr_door_closed;
break; break;
case GameObjectType::Hazard: case GameObjectType::Hazard:
@ -81,6 +95,7 @@ namespace LightGame
Math::Vector2Int(0, ts / 2), Math::Vector2Int(0, ts / 2),
Math::Vector2Int(ts, ts)); Math::Vector2Int(ts, ts));
obj.solid = false; obj.solid = false;
obj.sprite = &spr_spike;
break; break;
case GameObjectType::Collectible: case GameObjectType::Collectible:
@ -88,6 +103,7 @@ namespace LightGame
Math::Vector2Int(ts / 4, ts / 4), Math::Vector2Int(ts / 4, ts / 4),
Math::Vector2Int(ts * 3 / 4, ts * 3 / 4)); Math::Vector2Int(ts * 3 / 4, ts * 3 / 4));
obj.solid = false; obj.solid = false;
obj.sprite = &spr_coin;
break; break;
case GameObjectType::Trigger: case GameObjectType::Trigger:
@ -96,6 +112,11 @@ namespace LightGame
obj.solid = false; obj.solid = false;
obj.trigger_action = spawn.trigger_action; obj.trigger_action = spawn.trigger_action;
obj.trigger_target_id = spawn.trigger_target; obj.trigger_target_id = spawn.trigger_target;
if (spawn.trigger_action == TriggerAction::Checkpoint)
{
level.add_checkpoint(Checkpoint(obj.position.x, obj.position.y));
obj.sprite = &spr_checkpoint;
}
break; break;
case GameObjectType::Player: case GameObjectType::Player:
@ -110,5 +131,10 @@ namespace LightGame
level.add_object(obj); level.add_object(obj);
} }
for (int32_t i = 0; i < data.tile_light_rule_count; ++i)
{
level.add_tile_light_rule(data.tile_light_rules[i]);
}
} }
} }

View File

@ -17,18 +17,18 @@ namespace LightGame
} }
const auto& objects = level.get_all_objects(); const auto& objects = level.get_all_objects();
for (size_t i = 0; i < objects.size(); ++i)
auto try_draw = [&](const GameObject& obj)
{ {
const GameObject& obj = objects[i];
if (!obj.active) if (!obj.active)
{ {
continue; return;
} }
const RenderData::Sprite* sprite = obj.sprite; const RenderData::Sprite* sprite = obj.sprite;
if (!sprite) if (!sprite)
{ {
continue; return;
} }
const int32_t screen_x = obj.position.x - camera.get_x(); const int32_t screen_x = obj.position.x - camera.get_x();
@ -37,19 +37,35 @@ namespace LightGame
if (screen_x + sprite->width < 0 || screen_x > camera.get_screen_width() || if (screen_x + sprite->width < 0 || screen_x > camera.get_screen_width() ||
screen_y + sprite->height < 0 || screen_y > camera.get_screen_height()) screen_y + sprite->height < 0 || screen_y > camera.get_screen_height())
{ {
continue; return;
} }
const bool is_light_obj = (obj.type == GameObjectType::LightPlatform || const bool is_light_obj = (obj.type == GameObjectType::LightPlatform ||
obj.type == GameObjectType::ShadowPlatform || obj.type == GameObjectType::ShadowPlatform ||
obj.type == GameObjectType::Door); obj.type == GameObjectType::Door);
if (is_light_obj && !light_system.is_platform_active(obj, light_level)) if (is_light_obj && !light_system.should_render(obj, light_level))
{ {
continue; return;
} }
ctx.draw_sprite_ex(screen_x, screen_y, *sprite, 1, obj.flip_h, false); ctx.draw_sprite_ex(screen_x, screen_y, *sprite, 1, obj.flip_h, false);
};
for (size_t i = 0; i < objects.size(); ++i)
{
if (objects[i].type != GameObjectType::Player)
{
try_draw(objects[i]);
}
}
for (size_t i = 0; i < objects.size(); ++i)
{
if (objects[i].type == GameObjectType::Player)
{
try_draw(objects[i]);
}
} }
} }

View File

@ -10,6 +10,8 @@
#include "Level2Data.h" #include "Level2Data.h"
#include "Level3Data.h" #include "Level3Data.h"
#include "sprite_atlas.h" #include "sprite_atlas.h"
#include "Tilemap.h"
#include <cassert>
namespace LightGame namespace LightGame
{ {
@ -156,6 +158,40 @@ namespace LightGame
level_ = Level(); level_ = Level();
LevelLoader::load(level_, *kLevels[level_index]); LevelLoader::load(level_, *kLevels[level_index]);
// Validate spawn point: must be on solid ground and not inside solid tiles.
{
GameObject temp_player;
temp_player.position = level_.get_spawn_point();
temp_player.collider = RenderData::BoundingBox2D(
Math::Vector2Int(8, 0), Math::Vector2Int(24, 32));
assert(physics_.is_grounded(temp_player, level_) &&
"Player spawn point is not on solid ground");
const RenderData::Tilemap& tilemap = level_.get_tilemap();
if (tilemap.tile_w > 0)
{
const RenderData::BoundingBox2D world_box = temp_player.get_world_collider();
const int32_t ts = tilemap.tile_w;
const int32_t tile_left = world_box.min.x / ts;
const int32_t tile_right = (world_box.max.x - 1) / ts;
const int32_t tile_top = world_box.min.y / ts;
const int32_t tile_bottom = (world_box.max.y - 1) / ts;
for (int32_t ty = tile_top; ty <= tile_bottom; ++ty)
{
for (int32_t tx = tile_left; tx <= tile_right; ++tx)
{
if (tx < 0 || tx >= tilemap.width || ty < 0 || ty >= tilemap.height)
continue;
const uint16_t tile_id = tilemap.get_tile(tx, ty);
assert((tile_id == 0 || tile_id == RenderData::Tilemap::EmptyTile) &&
"Player spawn point overlaps solid tiles");
}
}
}
}
const auto players = level_.get_objects_by_type(GameObjectType::Player); const auto players = level_.get_objects_by_type(GameObjectType::Player);
if (!players.empty()) if (!players.empty())
{ {
@ -187,6 +223,7 @@ namespace LightGame
{ {
const uint16_t light = manual_light_level_; const uint16_t light = manual_light_level_;
light_system_.update(level_.get_all_objects(), light); light_system_.update(level_.get_all_objects(), light);
light_system_.update_tilemap(level_, light);
state_manager_.get_hud().light_level = light; state_manager_.get_hud().light_level = light;
GameObject* player = level_.get_object(player_id_); GameObject* player = level_.get_object(player_id_);
@ -268,11 +305,25 @@ namespace LightGame
case TriggerAction::Checkpoint: case TriggerAction::Checkpoint:
{ {
const_cast<Level&>(level_).get_checkpoints(); const Math::Vector2Int trigger_pos = obj.position;
auto& checkpoints = level_.get_checkpoints();
for (size_t j = 0; j < checkpoints.size(); ++j)
{
if (checkpoints[j].position.x == trigger_pos.x &&
checkpoints[j].position.y == trigger_pos.y)
{
checkpoints[j].activated = true;
break;
}
}
break; break;
} }
case TriggerAction::Death: case TriggerAction::Death:
if (!player_controller_.is_dead())
{
player_controller_.kill();
}
break; break;
default: default:

View File

@ -257,6 +257,15 @@ namespace LightGame
} }
} }
void PlayerController::kill()
{
if (state_ != PlayerState::Dead)
{
state_ = PlayerState::Dead;
respawn_timer_ms_ = 0;
}
}
void PlayerController::respawn(GameObject& obj, Level& level) void PlayerController::respawn(GameObject& obj, Level& level)
{ {
const Math::Vector2Int spawn = level.get_active_checkpoint(); const Math::Vector2Int spawn = level.get_active_checkpoint();

View File

@ -78,6 +78,7 @@ namespace LightGame
PlayerState get_state() const { return state_; } PlayerState get_state() const { return state_; }
bool is_dead() const { return state_ == PlayerState::Dead; } bool is_dead() const { return state_ == PlayerState::Dead; }
void kill();
private: private:
void update_input(const Platform::IKeyboardState* keyboard, const Platform::IPointerInput* pointer, int32_t screen_width, int32_t screen_height); void update_input(const Platform::IKeyboardState* keyboard, const Platform::IPointerInput* pointer, int32_t screen_width, int32_t screen_height);

View File

@ -45,6 +45,8 @@ namespace LightGame
tile_atlas_columns, tile_atlas_columns,
spawns, spawns,
8, 8,
nullptr,
0,
Math::Vector2Int(2 * kTileSize, 5 * kTileSize), Math::Vector2Int(2 * kTileSize, 5 * kTileSize),
0, 0,
0, 0,

View File

@ -55,6 +55,8 @@ namespace LightGame
tile_atlas_columns, tile_atlas_columns,
spawns, spawns,
15, 15,
nullptr,
0,
Math::Vector2Int(2 * kTileSize, 5 * kTileSize), Math::Vector2Int(2 * kTileSize, 5 * kTileSize),
0, 0,
0, 0,

View File

@ -58,6 +58,8 @@ namespace LightGame
tile_atlas_columns, tile_atlas_columns,
spawns, spawns,
15, 15,
nullptr,
0,
Math::Vector2Int(2 * kTileSize, 5 * kTileSize), Math::Vector2Int(2 * kTileSize, 5 * kTileSize),
0, 0,
0, 0,

View File

@ -1,7 +1,26 @@
#include "LightEffectSystem.h" #include "LightEffectSystem.h"
#include "tile_atlas.h"
namespace LightGame namespace LightGame
{ {
namespace
{
static RenderData::Sprite MakeTileSprite(const RenderData::Image* atlas, int32_t tile_id,
int32_t tile_size, int32_t atlas_columns)
{
const int32_t col = tile_id % atlas_columns;
const int32_t row = tile_id / atlas_columns;
return RenderData::Sprite(atlas, col * tile_size, row * tile_size, tile_size, tile_size);
}
static const RenderData::Sprite spr_light_platform_on = MakeTileSprite(&tile_atlas_image, 6, 32, 4);
static const RenderData::Sprite spr_light_platform_off = MakeTileSprite(&tile_atlas_image, 5, 32, 4);
static const RenderData::Sprite spr_shadow_platform_on = MakeTileSprite(&tile_atlas_image, 8, 32, 4);
static const RenderData::Sprite spr_shadow_platform_off = MakeTileSprite(&tile_atlas_image, 7, 32, 4);
static const RenderData::Sprite spr_door_closed = MakeTileSprite(&tile_atlas_image, 9, 32, 4);
static const RenderData::Sprite spr_door_open = MakeTileSprite(&tile_atlas_image, 10, 32, 4);
}
void LightEffectSystem::update(std::vector<GameObject>& objects, uint16_t light_level) void LightEffectSystem::update(std::vector<GameObject>& objects, uint16_t light_level)
{ {
for (size_t i = 0; i < objects.size(); ++i) for (size_t i = 0; i < objects.size(); ++i)
@ -12,14 +31,17 @@ namespace LightGame
{ {
case GameObjectType::LightPlatform: case GameObjectType::LightPlatform:
obj.solid = (light_level >= obj.light_threshold.min_level); obj.solid = (light_level >= obj.light_threshold.min_level);
obj.sprite = obj.solid ? &spr_light_platform_on : &spr_light_platform_off;
break; break;
case GameObjectType::ShadowPlatform: case GameObjectType::ShadowPlatform:
obj.solid = (light_level <= obj.light_threshold.max_level); obj.solid = (light_level <= obj.light_threshold.max_level);
obj.sprite = obj.solid ? &spr_shadow_platform_on : &spr_shadow_platform_off;
break; break;
case GameObjectType::Door: case GameObjectType::Door:
obj.solid = !is_door_open(obj, light_level); obj.solid = !is_door_open(obj, light_level);
obj.sprite = obj.solid ? &spr_door_closed : &spr_door_open;
break; break;
default: default:
@ -28,15 +50,45 @@ namespace LightGame
} }
} }
bool LightEffectSystem::is_platform_active(const GameObject& obj, uint16_t light_level) void LightEffectSystem::update_tilemap(Level& level, uint16_t light_level)
{ {
const auto& rules = level.get_tile_light_rules();
if (rules.empty())
{
return;
}
uint16_t* tiles = level.get_mutable_tiles();
const uint16_t* original = level.get_original_tiles();
if (!tiles || !original)
{
return;
}
const RenderData::Tilemap& tilemap = level.get_tilemap();
for (size_t i = 0; i < rules.size(); ++i)
{
const TileLightRule& rule = rules[i];
if (rule.tile_x < 0 || rule.tile_x >= tilemap.width ||
rule.tile_y < 0 || rule.tile_y >= tilemap.height)
{
continue;
}
const int32_t index = rule.tile_y * tilemap.width + rule.tile_x;
const bool active = (light_level >= rule.light_min && light_level <= rule.light_max);
tiles[index] = active ? original[index] : RenderData::Tilemap::EmptyTile;
}
}
bool LightEffectSystem::should_render(const GameObject& obj, uint16_t light_level)
{
(void)light_level;
switch (obj.type) switch (obj.type)
{ {
case GameObjectType::LightPlatform: case GameObjectType::LightPlatform:
return light_level >= obj.light_threshold.min_level;
case GameObjectType::ShadowPlatform: case GameObjectType::ShadowPlatform:
return light_level <= obj.light_threshold.max_level; case GameObjectType::Door:
return true;
default: default:
return obj.solid; return obj.solid;

View File

@ -3,6 +3,7 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "GameObject.h" #include "GameObject.h"
#include "Level.h"
namespace LightGame namespace LightGame
{ {
@ -10,8 +11,9 @@ namespace LightGame
{ {
public: public:
void update(std::vector<GameObject>& objects, uint16_t light_level); void update(std::vector<GameObject>& objects, uint16_t light_level);
void update_tilemap(Level& level, uint16_t light_level);
static bool is_platform_active(const GameObject& obj, uint16_t light_level); static bool should_render(const GameObject& obj, uint16_t light_level);
static bool is_door_open(const GameObject& obj, uint16_t light_level); static bool is_door_open(const GameObject& obj, uint16_t light_level);
}; };
} }