- Stage 1: Eliminate fish-eye distortion with precomputed tangential ray offset table and fractional DDA depth - Stage 2: Full asset management in EblanStudio (stb_image/stb_image_write, 128x128 BGRA texture packaging, WAV sound manager) - Stage 3: Multi-level room streaming, LevelTransition/CELL_PORTAL trigger system, 16-frame screen fade to black - Stage 4: 60Hz cutscene engine in pure x86 ASM (v4 binary track, waypoint camera motion, shortest-arc pan, CP1251 Russian subtitles, letterbox overlay, audio) - Stage 5: EblanStudio cutscene timeline (F6) and windowless Live ASM Viewport over shared-memory IPC directly rendered into DX11 - Tooling & Tests: comprehensive regression test suite (94 editor + 107 engine tests), IPC tests, and cutscene demo
116 lines
6.7 KiB
C++
116 lines
6.7 KiB
C++
#pragma once
|
||
#include "level_format.h"
|
||
#include "cutscene_format.h"
|
||
#include <array>
|
||
#include <cstring>
|
||
#include <fstream>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
// Parse into a separate value: a failed load must never partially replace a room.
|
||
struct BinaryLevel {
|
||
MapHeader header{};
|
||
std::array<uint8_t, MAP_TOTAL_CELLS> cells{};
|
||
std::vector<SpriteEntry> sprites;
|
||
std::vector<LevelTransition> transitions;
|
||
CutsceneTrack cutscene;
|
||
};
|
||
|
||
inline bool IsMapFilename(const char* name) {
|
||
size_t length = 0;
|
||
for (; length < 32 && name[length]; ++length) {
|
||
unsigned char ch = static_cast<unsigned char>(name[length]);
|
||
if (ch < 32 || ch >= 127 || std::strchr("\\/:*?\"<>|", ch)) return false;
|
||
}
|
||
if (length < 5 || length >= 32) return false;
|
||
const char* ext = name + length - 4;
|
||
return ext[0] == '.' && (ext[1] | 32) == 'd' &&
|
||
(ext[2] | 32) == 'a' && (ext[3] | 32) == 't';
|
||
}
|
||
|
||
inline bool IsWalkableCell(uint8_t cell) {
|
||
return cell == CELL_EMPTY || cell == CELL_DOOR_OPEN || cell == CELL_PORTAL;
|
||
}
|
||
|
||
inline bool ValidateLevel(const BinaryLevel& level, std::string& error) {
|
||
const auto& h = level.header;
|
||
auto fail = [&](const char* reason) { error = reason; return false; };
|
||
if (h.magic != MAP_MAGIC || h.version < 1 || h.version > MAP_FORMAT_VERSION ||
|
||
h.mapWidth != MAP_GRID_W || h.mapHeight != MAP_GRID_H)
|
||
return fail("Неверный формат или версия карты");
|
||
if (level.sprites.size() > MAX_LEVEL_SPRITES || level.transitions.size() > MAX_LEVEL_TRANSITIONS)
|
||
return fail("Превышен лимит спрайтов или переходов");
|
||
if (h.playerStartX >= MAP_GRID_W || h.playerStartY >= MAP_GRID_H || h.playerStartAngle > 511)
|
||
return fail("Неверные координаты или угол спавна");
|
||
for (uint8_t cell : level.cells) if (cell > 15) return fail("Неверный тип клетки");
|
||
if (!IsWalkableCell(level.cells[h.playerStartY * MAP_GRID_W + h.playerStartX]))
|
||
return fail("Спавн игрока находится внутри стены");
|
||
for (const auto& sprite : level.sprites) {
|
||
if (sprite.x < 0 || sprite.x >= MAP_GRID_W * 64 || sprite.y < 0 || sprite.y >= MAP_GRID_H * 64 ||
|
||
sprite.textureId >= 16 || sprite.scale < 0 || sprite.scale > 16 * 65536)
|
||
return fail("Неверные координаты, текстура или масштаб спрайта");
|
||
}
|
||
std::array<bool, MAP_TOTAL_CELLS> triggers{};
|
||
for (const auto& transition : level.transitions) {
|
||
if (transition.triggerCellX >= MAP_GRID_W || transition.triggerCellY >= MAP_GRID_H ||
|
||
transition.targetSpawnX >= MAP_GRID_W || transition.targetSpawnY >= MAP_GRID_H ||
|
||
transition.targetAngle > 511 || transition.fadeEffect > 1 || !IsMapFilename(transition.targetMap))
|
||
return fail("Неверные свойства перехода (имя файла: ASCII, до 31 символа, .dat)");
|
||
size_t index = transition.triggerCellY * MAP_GRID_W + transition.triggerCellX;
|
||
if (triggers[index] || !IsWalkableCell(level.cells[index]))
|
||
return fail("Повторный переход или триггер внутри стены");
|
||
triggers[index] = true;
|
||
}
|
||
for (size_t i = 0; i < level.cells.size(); ++i)
|
||
if (level.cells[i] == CELL_PORTAL && !triggers[i]) return fail("Портал не имеет настроенного перехода");
|
||
return ValidateCutscene(level.cutscene, level.cells.data(), level.transitions.size(), error);
|
||
}
|
||
|
||
inline bool ReadLevelFile(const std::string& path, BinaryLevel& result, std::string& error) {
|
||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
||
if (!in) { error = "Не удалось открыть карту: " + path; return false; }
|
||
const auto fileSize = in.tellg();
|
||
in.seekg(0);
|
||
BinaryLevel candidate;
|
||
if (!in.read(reinterpret_cast<char*>(&candidate.header), sizeof(MapHeader))) {
|
||
error = "Обрезанный заголовок карты"; return false;
|
||
}
|
||
const auto& h = candidate.header;
|
||
const size_t transitionCount = h.version == 1 ? 0 : h.transitionCount;
|
||
if (h.spriteCount > MAX_LEVEL_SPRITES || transitionCount > MAX_LEVEL_TRANSITIONS) {
|
||
error = "Превышен лимит объектов карты"; return false;
|
||
}
|
||
const size_t eventCount = h.version >= 4 ? h.cutsceneCount : 0;
|
||
const size_t stringCount = h.version >= 4 ? h.cutsceneStringCount : 0;
|
||
if (eventCount > MAX_CUTSCENE_EVENTS || stringCount > MAX_CUTSCENE_STRINGS) {
|
||
error = "Превышен лимит событий или строк кат-сцены"; return false;
|
||
}
|
||
const size_t expected = sizeof(MapHeader) + MAP_TOTAL_CELLS +
|
||
h.spriteCount * sizeof(SpriteEntry) + transitionCount * sizeof(LevelTransition) +
|
||
eventCount * sizeof(CutsceneEvent) + stringCount * CUTSCENE_STRING_BYTES;
|
||
if (fileSize != static_cast<std::streamoff>(expected)) {
|
||
error = "Размер файла не соответствует заголовку карты"; return false;
|
||
}
|
||
candidate.sprites.resize(h.spriteCount);
|
||
candidate.transitions.resize(transitionCount);
|
||
in.read(reinterpret_cast<char*>(candidate.cells.data()), MAP_TOTAL_CELLS);
|
||
if (!candidate.sprites.empty())
|
||
in.read(reinterpret_cast<char*>(candidate.sprites.data()), candidate.sprites.size() * sizeof(SpriteEntry));
|
||
if (!candidate.transitions.empty())
|
||
in.read(reinterpret_cast<char*>(candidate.transitions.data()), candidate.transitions.size() * sizeof(LevelTransition));
|
||
candidate.cutscene.events.resize(eventCount);
|
||
if (eventCount) in.read(reinterpret_cast<char*>(candidate.cutscene.events.data()), eventCount * sizeof(CutsceneEvent));
|
||
candidate.cutscene.flags = h.version >= 4 ? h.cutsceneFlags : 0;
|
||
for (size_t i = 0; i < stringCount; ++i) {
|
||
char bytes[CUTSCENE_STRING_BYTES]{};
|
||
if (!in.read(bytes, sizeof(bytes))) { error = "Обрезанная строка кат-сцены"; return false; }
|
||
const char* end = static_cast<const char*>(std::memchr(bytes, 0, sizeof(bytes)));
|
||
if (!end) { error = "Строка кат-сцены не имеет завершающего нуля"; return false; }
|
||
candidate.cutscene.strings.push_back(CutsceneDecode(bytes, static_cast<int>(end - bytes)));
|
||
}
|
||
if (!in) { error = "Не удалось прочитать карту целиком"; return false; }
|
||
if (h.version == 1) for (auto& sprite : candidate.sprites) { sprite.x >>= 16; sprite.y >>= 16; }
|
||
if (!ValidateLevel(candidate, error)) return false;
|
||
result = std::move(candidate);
|
||
return true;
|
||
}
|