feat(editor): writer surgical del bloc data: al YAML del tilemap
This commit is contained in:
1 parent
459d18b49c
commit
6d8423764d
4 files changed
+345
No files matched your search
@@ -92,6 +92,7 @@ set(APP_SOURCES
|
||||
source/game/entities/player.cpp
|
||||
source/game/map/map.cpp
|
||||
source/game/map/tilemap.cpp
|
||||
source/game/map/tilemap_writer.cpp
|
||||
${ENTITY_SOURCES}
|
||||
# Fitxer només del binari principal: `loadFromLevel` depèn de
|
||||
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
|
||||
@@ -327,4 +328,19 @@ if(BUILD_TESTS)
|
||||
add_test(NAME scene_flow
|
||||
COMMAND test_scene_flow
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
|
||||
# Smoke del writer surgical del tilemap (escriu el bloc `data:` sense
|
||||
# tocar la resta del fitxer). Pura manipulació de text, zero
|
||||
# dependències: no cal ni SDL ni el Resource::Cache.
|
||||
add_executable(test_tilemap_writer
|
||||
tests/test_tilemap_writer.cpp
|
||||
source/game/map/tilemap_writer.cpp
|
||||
)
|
||||
target_include_directories(test_tilemap_writer PRIVATE
|
||||
"${CMAKE_SOURCE_DIR}/source"
|
||||
)
|
||||
target_compile_options(test_tilemap_writer PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_test(NAME tilemap_writer
|
||||
COMMAND test_tilemap_writer
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
endif()
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "game/map/tilemap_writer.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
namespace TilemapWriter {
|
||||
|
||||
namespace {
|
||||
|
||||
// Retorna la posició de l'inici de la línia que conté "data:" a
|
||||
// columna 0. La detecció requereix que els 5 caràcters siguin al
|
||||
// principi d'una línia i que el següent siga un terminador típic
|
||||
// (fi de línia, espai, tabulador o comentari). Així evitem trigger
|
||||
// amb claus com "data_version:" o valors escalars que comencin
|
||||
// amb "data:".
|
||||
auto findDataLine(const std::string& content) -> std::size_t {
|
||||
std::size_t pos = 0;
|
||||
while (pos < content.size()) {
|
||||
const bool AT_LINE_START = (pos == 0 || content[pos - 1] == '\n');
|
||||
if (AT_LINE_START && content.compare(pos, 5, "data:") == 0) {
|
||||
const std::size_t AFTER = pos + 5;
|
||||
if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
const auto NL = content.find('\n', pos);
|
||||
if (NL == std::string::npos) { break; }
|
||||
pos = NL + 1;
|
||||
}
|
||||
return std::string::npos;
|
||||
}
|
||||
|
||||
// Avança mentre trobe línies indentades (espai/tab). S'atura al
|
||||
// primer caràcter a columna 0 (nova clau top-level) o davant una
|
||||
// línia buida (separadors de seccions). Així els comentaris i el
|
||||
// blanc que precedeixen a la pròxima secció queden fora del bloc.
|
||||
auto advancePastIndented(const std::string& content, std::size_t pos) -> std::size_t {
|
||||
while (pos < content.size()) {
|
||||
const char C = content[pos];
|
||||
if (C != ' ' && C != '\t') { break; }
|
||||
const auto NL = content.find('\n', pos);
|
||||
pos = (NL == std::string::npos) ? content.size() : NL + 1;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
auto buildBlock(const std::vector<std::vector<int>>& data) -> std::string {
|
||||
int max_width = 1;
|
||||
for (const auto& row : data) {
|
||||
for (const int V : row) {
|
||||
const int W = static_cast<int>(std::to_string(V).size());
|
||||
max_width = std::max(max_width, W);
|
||||
}
|
||||
}
|
||||
|
||||
std::string out;
|
||||
for (const auto& row : data) {
|
||||
out += " - [";
|
||||
for (std::size_t i = 0; i < row.size(); ++i) {
|
||||
if (i > 0) { out += ","; }
|
||||
const auto S = std::to_string(row[i]);
|
||||
const int PAD = max_width - static_cast<int>(S.size());
|
||||
out.append(static_cast<std::size_t>(PAD), ' ');
|
||||
out += S;
|
||||
}
|
||||
out += "]\n";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto saveDataBlock(const std::string& path, const std::vector<std::vector<int>>& data) -> bool {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) { return false; }
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
const std::string CONTENT = ss.str();
|
||||
in.close();
|
||||
|
||||
const auto DATA_POS = findDataLine(CONTENT);
|
||||
if (DATA_POS == std::string::npos) { return false; }
|
||||
|
||||
const auto LINE_END = CONTENT.find('\n', DATA_POS);
|
||||
const auto AFTER_DATA = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1;
|
||||
const auto BLOCK_END = advancePastIndented(CONTENT, AFTER_DATA);
|
||||
|
||||
const std::string NEW_CONTENT =
|
||||
CONTENT.substr(0, AFTER_DATA) + buildBlock(data) + CONTENT.substr(BLOCK_END);
|
||||
|
||||
const auto TMP_PATH = path + ".tmp";
|
||||
{
|
||||
std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc);
|
||||
if (!out) { return false; }
|
||||
out.write(NEW_CONTENT.data(), static_cast<std::streamsize>(NEW_CONTENT.size()));
|
||||
out.close();
|
||||
if (!out) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(TMP_PATH, ec);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::rename(TMP_PATH, path, ec);
|
||||
if (ec) {
|
||||
std::filesystem::remove(TMP_PATH, ec);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace TilemapWriter
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Reescriu NOMÉS el bloc `data:` d'un YAML del tilemap, preservant byte a
|
||||
// byte la resta del fitxer: comentaris, ordre de claus, format de
|
||||
// `geometry:` i `entities:`, encoding i line endings entre línies no
|
||||
// tocades. Pensat exclusivament per a l'editor (Debug): només la graella
|
||||
// canvia durant l'edició; el preàmbul i l'epíleg mai.
|
||||
//
|
||||
// Semàntica del bloc:
|
||||
// - Línia de capçalera: primera aparició de "data:" a columna 0.
|
||||
// - Contingut: línies següents indentades (espai o tab). Una línia buida
|
||||
// o una línia a columna 0 marquen la fi del bloc.
|
||||
//
|
||||
// Format de sortida:
|
||||
// - Cada fila en flow style: ` - [v0, v1, v2, ...]`, valors separats
|
||||
// per `,` i alineats a dreta dins de `max_width` caràcters (calculat
|
||||
// a partir del valor més llarg de la graella), cadascun precedit
|
||||
// d'espais de farciment per mantenir columnes visuals.
|
||||
// - Line endings: '\n' al bloc generat (el preàmbul i l'epíleg es
|
||||
// copien byte a byte, així que no els toquem).
|
||||
//
|
||||
// Escriptura atòmica: via `<path>.tmp` + `std::filesystem::rename`, així
|
||||
// un crash a mitja escriptura mai deixa el YAML corrupte.
|
||||
//
|
||||
// Retorna false si el fitxer no es pot obrir, no conté "data:" a columna
|
||||
// 0, o l'escriptura/renombrament atòmic falla.
|
||||
namespace TilemapWriter {
|
||||
|
||||
auto saveDataBlock(const std::string& path, const std::vector<std::vector<int>>& data) -> bool;
|
||||
|
||||
} // namespace TilemapWriter
|
||||
@@ -0,0 +1,177 @@
|
||||
// Smoke test per a TilemapWriter::saveDataBlock. Stand-alone: no obre
|
||||
// finestra, no necessita SDL. Comprova que:
|
||||
//
|
||||
// T1 round-trip pur — escriure el mateix `data:` no canvia el fitxer.
|
||||
// T2 modificació — canviar un tile només toca el bloc `data:` i la
|
||||
// resta del fitxer queda byte a byte idèntica.
|
||||
// T3 format — el bloc generat respecta ` - [v0, v1, ...]` amb
|
||||
// alineació a dreta (màx. amplada) i `]\n` final.
|
||||
// T4 error path — fitxer sense `data:` a columna 0 → retorna false
|
||||
// i no escriu.
|
||||
//
|
||||
// Execució: `./build/test_tilemap_writer` o via `ctest`.
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/map/tilemap_writer.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// El fixture usa max_width=3 (valor 230) perquè coincideixi amb el format
|
||||
// que el writer emet per a la mateixa graella: així T1 pot comparar byte
|
||||
// a byte el fitxer original amb el reescrit amb la mateixa data.
|
||||
constexpr const char* FIXTURE =
|
||||
"# Capçalera amb comentaris\n"
|
||||
"tileset: data/tilesets/tomb.gif\n"
|
||||
"tile_size: 16\n"
|
||||
"origin: [-8, 0]\n"
|
||||
"\n"
|
||||
"# Bloc geometry preservat\n"
|
||||
"geometry:\n"
|
||||
" grid_cols: 4\n"
|
||||
" grid_rows: 5\n"
|
||||
"\n"
|
||||
"data:\n"
|
||||
" - [ 1, 2, 3]\n"
|
||||
" - [ 4,230, 6]\n"
|
||||
" - [ 7, 8, 9]\n"
|
||||
"\n"
|
||||
"# Epíleg amb comentari\n"
|
||||
"entities:\n"
|
||||
" - type: torch\n"
|
||||
" position: [4, 2]\n";
|
||||
|
||||
auto readFile(const std::string& path) -> std::string {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
void writeFile(const std::string& path, const std::string& content) {
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out.write(content.data(), static_cast<std::streamsize>(content.size()));
|
||||
}
|
||||
|
||||
auto tmpPath(const char* name) -> std::string {
|
||||
auto p = std::filesystem::temp_directory_path() / name;
|
||||
return p.string();
|
||||
}
|
||||
|
||||
// Extrau el substring entre "data:\n" i la pròxima línia buida o EOF.
|
||||
// Útil per aïllar el bloc `data:` i comparar preàmbul/epíleg a banda.
|
||||
auto splitAroundData(const std::string& content) -> std::tuple<std::string, std::string, std::string> {
|
||||
const auto DATA_LINE = content.find("\ndata:\n");
|
||||
assert(DATA_LINE != std::string::npos);
|
||||
const auto DATA_START = DATA_LINE + std::string("\ndata:\n").size();
|
||||
auto block_end = DATA_START;
|
||||
while (block_end < content.size()) {
|
||||
const char C = content[block_end];
|
||||
if (C != ' ' && C != '\t') { break; }
|
||||
const auto NL = content.find('\n', block_end);
|
||||
block_end = (NL == std::string::npos) ? content.size() : NL + 1;
|
||||
}
|
||||
return {
|
||||
content.substr(0, DATA_START),
|
||||
content.substr(DATA_START, block_end - DATA_START),
|
||||
content.substr(block_end),
|
||||
};
|
||||
}
|
||||
|
||||
void T1_roundTrip() {
|
||||
const auto PATH = tmpPath("aee_test_tw_t1.yaml");
|
||||
writeFile(PATH, FIXTURE);
|
||||
|
||||
const std::vector<std::vector<int>> data = {
|
||||
{1, 2, 3}, {4, 230, 6}, {7, 8, 9}
|
||||
};
|
||||
const bool OK = TilemapWriter::saveDataBlock(PATH, data);
|
||||
assert(OK);
|
||||
|
||||
const auto AFTER = readFile(PATH);
|
||||
assert(AFTER == FIXTURE);
|
||||
std::filesystem::remove(PATH);
|
||||
std::printf("T1 round-trip: OK\n");
|
||||
}
|
||||
|
||||
void T2_modificacio() {
|
||||
const auto PATH = tmpPath("aee_test_tw_t2.yaml");
|
||||
writeFile(PATH, FIXTURE);
|
||||
|
||||
// Dades distintes al fixture però mantenint max_width=3 (pel 231).
|
||||
const std::vector<std::vector<int>> data = {
|
||||
{10, 20, 30}, {40, 231, 60}, {70, 80, 90}
|
||||
};
|
||||
const bool OK = TilemapWriter::saveDataBlock(PATH, data);
|
||||
assert(OK);
|
||||
|
||||
const auto AFTER = readFile(PATH);
|
||||
const auto [PRE_BEFORE, DATA_BEFORE, POST_BEFORE] = splitAroundData(FIXTURE);
|
||||
const auto [PRE_AFTER, DATA_AFTER, POST_AFTER] = splitAroundData(AFTER);
|
||||
|
||||
// Preàmbul i epíleg byte a byte idèntics.
|
||||
assert(PRE_BEFORE == PRE_AFTER);
|
||||
assert(POST_BEFORE == POST_AFTER);
|
||||
// El bloc data ha canviat.
|
||||
assert(DATA_BEFORE != DATA_AFTER);
|
||||
// Valor nou present.
|
||||
assert(AFTER.find("231") != std::string::npos);
|
||||
std::filesystem::remove(PATH);
|
||||
std::printf("T2 modificació: OK\n");
|
||||
}
|
||||
|
||||
void T3_format() {
|
||||
const auto PATH = tmpPath("aee_test_tw_t3.yaml");
|
||||
writeFile(PATH, FIXTURE);
|
||||
|
||||
// max_width=3 → valors a 3 chars, separats per ','.
|
||||
const std::vector<std::vector<int>> data = {
|
||||
{1, 2, 3},
|
||||
{230, 1, 1},
|
||||
{1, 1, 1}
|
||||
};
|
||||
const bool OK = TilemapWriter::saveDataBlock(PATH, data);
|
||||
assert(OK);
|
||||
const auto AFTER = readFile(PATH);
|
||||
|
||||
// Primera fila: " - [ 1, 2, 3]\n"
|
||||
assert(AFTER.find(" - [ 1, 2, 3]\n") != std::string::npos);
|
||||
// Fila amb valor de 3 chars: " - [230, 1, 1]\n"
|
||||
assert(AFTER.find(" - [230, 1, 1]\n") != std::string::npos);
|
||||
std::filesystem::remove(PATH);
|
||||
std::printf("T3 format: OK\n");
|
||||
}
|
||||
|
||||
void T4_errorPath() {
|
||||
const auto PATH = tmpPath("aee_test_tw_t4.yaml");
|
||||
// Fitxer sense "data:" a columna 0.
|
||||
writeFile(PATH, "tileset: x\ntile_size: 16\n# sense data\n");
|
||||
const auto BEFORE = readFile(PATH);
|
||||
|
||||
const std::vector<std::vector<int>> data = {{1, 2, 3}};
|
||||
const bool OK = TilemapWriter::saveDataBlock(PATH, data);
|
||||
assert(!OK);
|
||||
|
||||
const auto AFTER = readFile(PATH);
|
||||
assert(AFTER == BEFORE); // fitxer intacte
|
||||
std::filesystem::remove(PATH);
|
||||
std::printf("T4 error path: OK\n");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto main() -> int {
|
||||
T1_roundTrip();
|
||||
T2_modificacio();
|
||||
T3_format();
|
||||
T4_errorPath();
|
||||
std::printf("tilemap_writer: tots els tests OK\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user