Add stage map tile rendering path

This commit is contained in:
2026-06-09 23:32:47 +08:00
parent 0257c8058e
commit 51a3776494
10 changed files with 200 additions and 19 deletions
+11 -8
View File
@@ -12,6 +12,9 @@ collision 1760 500 300 28
collision 2180 455 260 28
collision 2650 515 360 28
tileset graybox_ground assets/stage/stage_01/tiles/graybox_ground.png 10 10 0.62 0.68 0.73 1.0
tileset graybox_platform assets/stage/stage_01/tiles/graybox_platform.png 10 14 0.62 0.68 0.73 1.0
layer background_far BackgroundFar 0.25
rect 0 0 3200 720 0.08 0.10 0.14 1.0
rect 360 120 420 220 0.13 0.16 0.21 1.0
@@ -27,14 +30,14 @@ rect 2860 280 260 320 0.18 0.22 0.27 1.0
endlayer
layer level_visual LevelVisual 1.0
rect 0 600 3200 80 0.62 0.68 0.73 1.0
rect 230 500 220 28 0.62 0.68 0.73 1.0
rect 560 430 260 28 0.62 0.68 0.73 1.0
rect 930 510 260 28 0.62 0.68 0.73 1.0
rect 1250 420 230 28 0.62 0.68 0.73 1.0
rect 1760 500 300 28 0.62 0.68 0.73 1.0
rect 2180 455 260 28 0.62 0.68 0.73 1.0
rect 2650 515 360 28 0.62 0.68 0.73 1.0
tile_rect graybox_ground 0 600 320 8 0 0.62 0.68 0.73
tile_rect graybox_platform 230 500 22 2 0 0.62 0.68 0.73
tile_rect graybox_platform 560 430 26 2 0 0.62 0.68 0.73
tile_rect graybox_platform 930 510 26 2 0 0.62 0.68 0.73
tile_rect graybox_platform 1250 420 23 2 0 0.62 0.68 0.73
tile_rect graybox_platform 1760 500 30 2 0 0.62 0.68 0.73
tile_rect graybox_platform 2180 455 26 2 0 0.62 0.68 0.73
tile_rect graybox_platform 2650 515 36 2 0 0.62 0.68 0.73
endlayer
layer props_back PropsBack 1.0
+2
View File
@@ -9,6 +9,8 @@ This directory is reserved for the first playable side-scrolling ACT stage.
| Far background | `background/background_far.png` | Large or horizontally tileable image. | TBD |
| Mid background | `background/background_mid.png` | Parallax midground image or prop sheet. | TBD |
| Tileset | `tiles/stage_01_tileset.png` | Ground, platform, wall, edge tiles. | TBD |
| Graybox ground tiles | `tiles/graybox_ground.png` | Optional temporary tileset referenced by `stage_01.map`; fallback color is used if missing. | TBD |
| Graybox platform tiles | `tiles/graybox_platform.png` | Optional temporary platform tileset referenced by `stage_01.map`; fallback color is used if missing. | TBD |
| Props sheet | `props/stage_01_props.png` | Crates, signs, lamps, blockers, exits. | TBD |
| Hit spark | `effects/hit_spark.png` | Small combat impact effect. | TBD |
| Dust | `effects/dust.png` | Run, jump, and landing dust. | TBD |
+17 -2
View File
@@ -37,6 +37,7 @@ stage_01.map
- 玩家出生点。
- 摄像机边界。
- 平台碰撞世界。
- tileset 定义和 tile entry。
- 场景视觉层。
- battle zone。
- enemy spawn point。
@@ -49,18 +50,32 @@ world <width> <height>
camera <x> <y> <width> <height>
player_spawn <x> <y>
collision <x> <y> <width> <height>
tileset <id> <texture> <tile_w> <tile_h> <r> <g> <b> <a>
layer <id> <BackgroundFar|BackgroundMid|LevelVisual|PropsBack|PropsFront> <parallax>
rect <x> <y> <width> <height> <r> <g> <b> <a>
rect_sprite <x> <y> <width> <height> <r> <g> <b> <a> <texture>
rect_sprite_src <x> <y> <width> <height> <r> <g> <b> <a> <texture> <sx> <sy> <sw> <sh>
tile_rect <tileset> <x> <y> <columns> <rows> <tile_index> <r> <g> <b>
endlayer
battle_zone <id> <trigger_x> <trigger_y> <trigger_w> <trigger_h> <camera_x> <camera_y> <camera_w> <camera_h>
spawn <id> <x> <y>
endbattle_zone
```
## Tile 约定
`tileset` 定义 tile 图片来源、单格尺寸和 fallback 色。图片不存在时,`StageRenderer` 会按 tile fallback 色绘制,不阻断场景启动。
`tile_rect` 只能写在 `layer` 内,用于批量铺同一种 tile
- `columns` / `rows` 是重复格数。
- `tile_index` 当前表示 tileset 第一行的横向 tile 序号。
- `r g b` 是该批 tile 的灰盒 fallback 色,alpha 固定为 `1.0`
当前 `stage_01.map` 已把 `level_visual` 地面和平台从长色块替换为 `tile_rect`,碰撞仍由 `collision` 独立定义。
## 下一步
- `.map` 增加 tileset/tile entry
- 增加重复 block、空 layer、battle zone 无 spawn、tile 引用不存在等错误日志。
- `tile_rect` 扩展成可表达 tile row/column 或 tile id
- 增加空 layer、battle zone 无 spawn、tile texture 不存在等更严格错误日志。
- 保留 `whitebox_level.cpp` 作为 fallback,不再把主地图写死在 C++。
+4 -2
View File
@@ -31,6 +31,8 @@ UI
- 支持色块占位。
- 支持普通 PNG texture path。
- 支持 source rect。
- 支持 `.map` tile entry 绘制。
- 支持 tileset 图片缺失时按 tile fallback 色绘制。
- 资源缺失时回退色块,不阻断场景运行。
## 约束
@@ -41,6 +43,6 @@ UI
## 下一步
- 增加 tile entry 绘制路径
- `level_visual` 长色块逐步替换为 tile
- tile entry 增加 tile id 或二维 source index
-正式 tileset 素材接入 `stage_01.map`
- 如果引擎提供统一 Texture cache,再迁移当前局部贴图缓存。
+15 -5
View File
@@ -286,11 +286,21 @@ game/assets/stage/stage_01/
继续沿着“优先使用引擎能力”的方向,把地图从色块灰盒推进到可放素材的完整骨架。当前不进入敌人和战斗玩法,下一轮只处理地图视觉表达:
1.`.map` 增加 tileset/tile entry 约定,先不做编辑器,保证工具或手写数据能表达重复地砖。
2.`StageRenderer` 中增加 tile 绘制路径,复用当前 `Texture` 加载和 source rect 支持。
3.`level_visual` 的长色块逐步替换成 tile entries,碰撞仍独立保留在 `collision`
4. 给 map loader 增加更严格的错误日志,例如重复 block、空 layer、battle zone 无 spawn、tile 引用不存在的提示
5.一个轻量 map 验收流程:启动时日志确认加载 `.map`Windows/Switch 产物确认存在 `assets/map/stage_01.map` 和全局素材槽位。
1. 已完成:`.map` 增加 `tileset` / `tile_rect` 约定,先不做编辑器,保证手写数据能表达重复地砖。
2. 已完成:`StageRenderer` 中增加 tile 绘制路径,复用当前 `Texture` 加载和 source rect 支持。
3. 已完成:`level_visual` 的长色块替换成 tile entries,碰撞仍独立保留在 `collision`
4. 已完成:给 map loader 增加重复 tileset、未知 tileset、非法 tile rect 等错误日志
5. 已完成:补轻量 map 验收流程:启动时日志确认加载 `.map`Windows/Switch 产物确认存在 `assets/map/stage_01.map` 和全局素材槽位。
### 下一轮建议任务
继续围绕地图视觉骨架,不进入战斗玩法:
1.`tile_rect` 增加二维 source index 或 tile id,避免只支持 tileset 第一行。
2. 按正式素材规格整理 `stage_01_tileset.png` 的格子约定。
3. 用一个临时 tileset PNG 替换当前 fallback 色块,验证 source rect 对齐。
4. 增加 tile texture 缺失的日志去重,避免大量 tile 重复报同一个资源。
5. 手动跑图确认所有平台视觉和碰撞误差不超过 4 像素。
### Debug 快捷键
+2
View File
@@ -14,6 +14,7 @@
- Battle zones`zone_01_intro``zone_02_platform``zone_03_exit`
- 资源槽位:`game/assets/stage/stage_01/`
- 地图资源:`game/assets/map/stage_01.map`
- Tile 数据:`stage_01.map` 中的 `tileset` / `tile_rect`
- 全局资源槽位:`game/assets/ASSETS.md`
- UI overlay`GameUiOverlay`,有字体时使用 `TextSprite`,无字体时使用 fallback 色条。
- 音频 cue`stage_01``player_jump``player_attack``battle_zone_enter`
@@ -91,6 +92,7 @@ Test-Path build\switch\aarch64\release\switch_game\romfs\assets\fonts
- Switch `Frostbite2DGameSwitch` 构建:通过。
- Switch RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map`
- 地图加载:`stage_01.map` 已作为第一关主数据源;C++ 白盒数据仅作为 fallback。
- 地图 tile 化:`level_visual` 已由 `tileset` / `tile_rect` 表达;tileset 图片缺失时使用 fallback 色绘制。
- 引擎能力接入:`Animation`/NPK/PNG 角色资源优先级、`UIScene`/`TextSprite` UI、`Sound`/`Music` cue、`SaveSystem` debug 存档已接入。
- 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。
+17
View File
@@ -32,6 +32,21 @@ struct StageRect {
StageLayerSource source;
};
struct TileSetDefinition {
std::string id;
std::string texturePath;
float tileWidth = 0.0f;
float tileHeight = 0.0f;
frostbite2D::Color fallbackColor;
};
struct StageTile {
std::string tileSetId;
frostbite2D::Rect bounds;
frostbite2D::Rect sourceRect;
frostbite2D::Color fallbackColor;
};
struct SpawnPoint {
std::string id;
frostbite2D::Vec2 position;
@@ -60,6 +75,7 @@ struct StageLayer {
StageLayerRole role = StageLayerRole::LevelVisual;
float parallax = 1.0f;
std::vector<StageRect> rects;
std::vector<StageTile> tiles;
};
struct LevelDefinition {
@@ -68,6 +84,7 @@ struct LevelDefinition {
frostbite2D::Vec2 playerSpawn;
frostbite2D::Rect cameraBounds;
PlatformWorld collision;
std::vector<TileSetDefinition> tileSets;
std::vector<StageLayer> layers;
std::vector<BattleZoneDefinition> battleZones;
};
+93 -2
View File
@@ -5,6 +5,7 @@
#include <SDL2/SDL.h>
#include <charconv>
#include <cmath>
#include <sstream>
namespace ns_game {
@@ -66,6 +67,16 @@ parseColor(const std::vector<std::string>& tokens, size_t start) {
return frostbite2D::Color(*r, *g, *b, *a);
}
std::optional<TileSetDefinition*>
findTileSet(ParseContext& context, const std::string& id) {
for (TileSetDefinition& tileSet : context.level.tileSets) {
if (tileSet.id == id) {
return &tileSet;
}
}
return std::nullopt;
}
std::optional<StageLayerRole> parseLayerRole(const std::string& value) {
if (value == "BackgroundFar") {
return StageLayerRole::BackgroundFar;
@@ -188,6 +199,28 @@ bool parseLine(ParseContext& context, const std::string& path,
return true;
}
if (command == "tileset") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 9) {
return warnLine(path, lineNumber,
"expected: tileset <id> <texture> <tile_w> <tile_h> <r> <g> <b> <a>");
}
if (findTileSet(context, tokens[1])) {
return warnLine(path, lineNumber, "duplicate tileset id: " + tokens[1]);
}
const auto tileWidth = parseFloat(tokens[3]);
const auto tileHeight = parseFloat(tokens[4]);
const auto fallbackColor = parseColor(tokens, 5);
if (!tileWidth || !tileHeight || *tileWidth <= 0.0f ||
*tileHeight <= 0.0f || !fallbackColor) {
return warnLine(path, lineNumber, "invalid tileset definition");
}
context.level.tileSets.push_back(
{tokens[1], tokens[2], *tileWidth, *tileHeight, *fallbackColor});
return true;
}
if (command == "layer") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 4) {
return warnLine(path, lineNumber,
@@ -226,6 +259,59 @@ bool parseLine(ParseContext& context, const std::string& path,
return true;
}
if (command == "tile_rect") {
if (!context.currentLayer || tokens.size() != 10) {
return warnLine(path, lineNumber,
"expected in layer: tile_rect <tileset> <x> <y> <columns> <rows> <tile_index> <r> <g> <b>");
}
const auto tileSet = findTileSet(context, tokens[1]);
if (!tileSet) {
return warnLine(path, lineNumber, "unknown tileset id: " + tokens[1]);
}
const auto x = parseFloat(tokens[2]);
const auto y = parseFloat(tokens[3]);
const auto columnsFloat = parseFloat(tokens[4]);
const auto rowsFloat = parseFloat(tokens[5]);
const auto tileIndexFloat = parseFloat(tokens[6]);
const auto r = parseFloat(tokens[7]);
const auto g = parseFloat(tokens[8]);
const auto b = parseFloat(tokens[9]);
if (!x || !y || !columnsFloat || !rowsFloat || !tileIndexFloat || !r ||
!g || !b) {
return warnLine(path, lineNumber, "invalid tile_rect values");
}
const int columns = static_cast<int>(*columnsFloat);
const int rows = static_cast<int>(*rowsFloat);
const int tileIndex = static_cast<int>(*tileIndexFloat);
if (columns <= 0 || rows <= 0 || tileIndex < 0 ||
std::floor(*columnsFloat) != *columnsFloat ||
std::floor(*rowsFloat) != *rowsFloat ||
std::floor(*tileIndexFloat) != *tileIndexFloat) {
return warnLine(path, lineNumber,
"tile_rect columns, rows and tile_index must be non-negative integers");
}
const TileSetDefinition& definition = **tileSet;
const frostbite2D::Color fallbackColor(*r, *g, *b, 1.0f);
for (int row = 0; row < rows; ++row) {
for (int column = 0; column < columns; ++column) {
const frostbite2D::Rect bounds(
*x + static_cast<float>(column) * definition.tileWidth,
*y + static_cast<float>(row) * definition.tileHeight,
definition.tileWidth, definition.tileHeight);
const frostbite2D::Rect sourceRect(
static_cast<float>(tileIndex) * definition.tileWidth, 0.0f,
definition.tileWidth, definition.tileHeight);
context.currentLayer->tiles.push_back(
{definition.id, bounds, sourceRect, fallbackColor});
}
}
return true;
}
if (command == "rect_sprite" || command == "rect_sprite_src") {
if (!context.currentLayer) {
return warnLine(path, lineNumber,
@@ -386,9 +472,14 @@ std::optional<LevelDefinition> LoadStageMap(const std::string& path) {
return std::nullopt;
}
SDL_Log("StageMapLoader: loaded %s from %s (%zu platforms, %zu layers, %zu zones)",
size_t tileCount = 0;
for (const StageLayer& layer : level->layers) {
tileCount += layer.tiles.size();
}
SDL_Log("StageMapLoader: loaded %s from %s (%zu platforms, %zu layers, %zu tiles, %zu zones)",
resource->path.c_str(), sourceName(resource->source),
level->collision.platforms.size(), level->layers.size(),
level->collision.platforms.size(), level->layers.size(), tileCount,
level->battleZones.size());
return level;
}
+37
View File
@@ -38,6 +38,16 @@ float worldWidthOf(const LevelDefinition& level) {
return level.collision.levelRight - level.collision.levelLeft;
}
const TileSetDefinition* findTileSet(const LevelDefinition& level,
const std::string& id) {
for (const TileSetDefinition& tileSet : level.tileSets) {
if (tileSet.id == id) {
return &tileSet;
}
}
return nullptr;
}
} // namespace
void StageRenderer::Render(const LevelDefinition& level,
@@ -102,6 +112,13 @@ void StageRenderer::drawLayer(const LevelDefinition& level,
frostbite2D::Color(0.91f, 0.95f, 0.98f, 1.0f));
}
}
for (const StageTile& tile : layer.tiles) {
frostbite2D::Rect bounds(tile.bounds.left() + parallaxOffset.x,
tile.bounds.top() + parallaxOffset.y,
tile.bounds.width(), tile.bounds.height());
drawStageTile(level, tile, bounds);
}
}
void StageRenderer::drawStageRect(const StageRect& rect,
@@ -130,6 +147,26 @@ void StageRenderer::drawStageRect(const StageRect& rect,
texture, rect.source.tint);
}
void StageRenderer::drawStageTile(const LevelDefinition& level,
const StageTile& tile,
const frostbite2D::Rect& bounds) const {
const TileSetDefinition* tileSet = findTileSet(level, tile.tileSetId);
if (!tileSet || tileSet->texturePath.empty()) {
drawRect(bounds, tile.fallbackColor);
return;
}
frostbite2D::Ptr<frostbite2D::Texture> texture =
getTexture(tileSet->texturePath);
if (!texture) {
drawRect(bounds, tile.fallbackColor);
return;
}
drawTexture(bounds, tile.sourceRect, texture,
frostbite2D::Color(1.0f, 1.0f, 1.0f, 1.0f));
}
frostbite2D::Ptr<frostbite2D::Texture>
StageRenderer::getTexture(const std::string& texturePath) const {
const auto existing = textureCache_.find(texturePath);
+2
View File
@@ -20,6 +20,8 @@ private:
void drawLayer(const LevelDefinition& level, const StageLayer& layer) const;
void drawStageRect(const StageRect& rect,
const frostbite2D::Rect& bounds) const;
void drawStageTile(const LevelDefinition& level, const StageTile& tile,
const frostbite2D::Rect& bounds) const;
frostbite2D::Ptr<frostbite2D::Texture>
getTexture(const std::string& texturePath) const;