/* * Copyright 2016 - 2019 gtalent2@gmail.com * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "consts.hpp" #include "tilesheeteditor.hpp" namespace nostalgia::core { QColor toQColor(Color16 nc) { const auto r = red32(nc); const auto g = green32(nc); const auto b = blue32(nc); const auto a = 255; return QColor(r, g, b, a); } struct LabeledSpinner: public QWidget { QSpinBox *const spinBox = new QSpinBox(this); LabeledSpinner(QString name, int minimum, int value) { auto lyt = new QHBoxLayout; setLayout(lyt); auto lbl = new QLabel(name, this); lbl->setBuddy(spinBox); lyt->addWidget(lbl); lyt->addWidget(spinBox); spinBox->setMinimum(minimum); spinBox->setValue(value); } virtual ~LabeledSpinner() = default; }; class UpdateDimensionsCommand: public QUndoCommand { public: enum class Dimension { Rows, Columns, }; private: Dimension m_dimension = Dimension::Rows; int m_oldVal = 0; int m_newVal = 0; SheetData *m_sheetData = nullptr; public: UpdateDimensionsCommand(SheetData *sheetData, Dimension dim, int oldVal, int newVal) { m_sheetData = sheetData; m_dimension = dim; m_newVal = newVal; m_oldVal = oldVal; setObsolete(newVal == oldVal); } virtual ~UpdateDimensionsCommand() = default; int id() const override { return static_cast(CommandId::UpdateDimension); } void redo() override { switch (m_dimension) { case Dimension::Rows: m_sheetData->setRows(m_newVal); break; case Dimension::Columns: m_sheetData->setColumns(m_newVal); break; } } void undo() override { switch (m_dimension) { case Dimension::Rows: m_sheetData->setRows(m_oldVal); break; case Dimension::Columns: m_sheetData->setColumns(m_oldVal); break; } } }; class UpdatePixelsCommand: public QUndoCommand { private: struct PixelUpdate { QPointer item; int oldColorId = 0; bool operator==(const PixelUpdate &o) const { return item == o.item; } bool operator<(const PixelUpdate &o) const { return item < o.item; } operator quint64() const { return reinterpret_cast(item.data()); } }; uint64_t m_cmdIdx = 0; int m_newColorId = 0; const QStringList &m_palette; QVector &m_pixels; QSet m_pixelUpdates; public: UpdatePixelsCommand(QVector &pixels, const QStringList &palette, QQuickItem *pixelItem, int newColorId, uint64_t cmdIdx): m_palette(palette), m_pixels(pixels) { m_newColorId = newColorId; m_cmdIdx = cmdIdx; PixelUpdate pu; pu.item = pixelItem; pu.oldColorId = m_palette.indexOf(pixelItem->property("color").value().name(QColor::HexArgb)); m_pixelUpdates.insert(pu); setObsolete(pu.oldColorId == newColorId); } virtual ~UpdatePixelsCommand() = default; int id() const override { return static_cast(CommandId::UpdatePixel); } bool mergeWith(const QUndoCommand *cmd) override { auto other = dynamic_cast(cmd); if (other && m_cmdIdx == other->m_cmdIdx) { m_pixelUpdates.unite(other->m_pixelUpdates); return true; } return false; } void redo() override { for (const auto &pu : m_pixelUpdates) { pu.item->setProperty("color", m_palette[m_newColorId]); const auto index = pu.item->property("pixelNumber").toInt(); // resize to appropriate number of tiles if pixel beyond current // range if (m_pixels.size() < index) { auto sz = (index / 64 + 1) * 64; m_pixels.resize(sz); } m_pixels[index] = m_newColorId; } } void undo() override { for (const auto &pu : m_pixelUpdates) { pu.item->setProperty("color", m_palette[pu.oldColorId]); m_pixels[pu.item->property("pixelNumber").toInt()] = pu.oldColorId; } } }; class InsertTileCommand: public QUndoCommand { private: SheetData *m_sheetData = nullptr; int m_idx = 0; bool m_delete = false; QVector m_tileRestore; public: InsertTileCommand(SheetData *sheetData, int idx, bool del = false) { m_sheetData = sheetData; m_idx = idx; m_delete = del; } virtual ~InsertTileCommand() = default; int id() const override { return static_cast(CommandId::InsertTile); } void redo() override { if (m_delete) { m_tileRestore = m_sheetData->deleteTile(m_idx); } else { m_sheetData->insertTile(m_idx, m_tileRestore); } } void undo() override { if (m_delete) { m_sheetData->insertTile(m_idx, m_tileRestore); } else { m_tileRestore = m_sheetData->deleteTile(m_idx); } } }; SheetData::SheetData(QUndoStack *undoStack): m_cmdStack(undoStack) { } void SheetData::updatePixel(QVariant pixelItem) { auto p = qobject_cast(pixelItem.value()); if (p && p != m_prevPixelUpdated) { m_cmdStack->push(new UpdatePixelsCommand(m_pixels, m_palette, p, m_selectedColor, m_cmdIdx)); m_prevPixelUpdated = p; emit changeOccurred(); } } void SheetData::beginCmd() { ++m_cmdIdx; } void SheetData::endCmd() { m_prevPixelUpdated = nullptr; } void SheetData::insertTileCmd(int tileIdx) { m_cmdStack->push(new InsertTileCommand(this, tileIdx)); } void SheetData::deleteTileCmd(int tileIdx) { m_cmdStack->push(new InsertTileCommand(this, tileIdx, true)); } int SheetData::columns() const { return m_columns; } int SheetData::rows() const { return m_rows; } const QVector &SheetData::pixels() const { return m_pixels; } QStringList SheetData::palette() const { return m_palette; } QString SheetData::palettePath() const { return m_currentPalettePath; } void SheetData::load(const studio::Context *ctx, QString ngPath, QString palPath) { auto ng = ctx->project->loadObj(ngPath); if (palPath == "" && ng->defaultPalette.type() == ox::FileAddressType::Path) { palPath = ng->defaultPalette.getPath().value; } m_tilesheetPath = ngPath; m_currentPalettePath = palPath; setRows(ng->rows); setColumns(ng->columns); if (palPath != "") { setPalette(ctx, palPath); } else { setPalette(&ng->pal); } updatePixels(ng.get()); } void SheetData::save(const studio::Context *ctx, QString ngPath) const { auto ng = toNostalgiaGraphic(); ctx->project->writeObj(ngPath, ng.get()); } void SheetData::setPalette(const NostalgiaPalette *npal) { // load palette m_palette.clear(); for (std::size_t i = 0; i < npal->colors.size(); i++) { const auto c = toQColor(npal->colors[i]); const auto color = c.name(QColor::HexArgb); m_palette.append(color); } emit paletteChanged(); } void SheetData::setPalette(const studio::Context *ctx, QString palPath) { std::unique_ptr npal; try { npal = ctx->project->loadObj(palPath); } catch (ox::Error) { qWarning() << "Could not open palette" << palPath; } if (npal) { setPalette(npal.get()); } m_currentPalettePath = palPath; emit changeOccurred(); } void SheetData::insertTile(int tileIdx, QVector tileData) { auto pxIdx = tileIdx * PixelsPerTile; m_pixels.insert(pxIdx, PixelsPerTile, 0); std::copy(tileData.begin(), tileData.end(), &m_pixels[pxIdx]); emit pixelsChanged(); emit changeOccurred(); } QVector SheetData::deleteTile(int tileIdx) { QVector out; auto pxIdx = tileIdx * PixelsPerTile; std::copy(m_pixels.begin() + pxIdx, m_pixels.begin() + (pxIdx + PixelsPerTile), std::back_inserter(out)); m_pixels.remove(pxIdx, PixelsPerTile); emit pixelsChanged(); emit changeOccurred(); return out; } void SheetData::setSelectedColor(int index) { m_selectedColor = index; } std::unique_ptr SheetData::toNostalgiaGraphic() const { auto ng = std::make_unique(); const auto highestColorIdx = static_cast(*std::max_element(m_pixels.begin(), m_pixels.end())); ng->defaultPalette = m_currentPalettePath.toUtf8().data(); ng->bpp = highestColorIdx > 15 ? 8 : 4; ng->columns = m_columns; ng->rows = m_rows; auto pixelCount = ng->rows * ng->columns * PixelsPerTile; if (ng->bpp == 4) { ng->tiles.resize(pixelCount / 2); for (int i = 0; i < m_pixels.size(); ++i) { if (i & 1) { ng->tiles[i / 2] |= static_cast(m_pixels[i]) << 4; } else { ng->tiles[i / 2] = static_cast(m_pixels[i]); } } } else { ng->tiles.resize(pixelCount); for (std::size_t i = 0; i < ng->tiles.size(); ++i) { ng->tiles[i] = static_cast(m_pixels[i]); } } return ng; } void SheetData::setColumns(int columns) { m_columns = columns; emit columnsChanged(columns); emit changeOccurred(); } void SheetData::setRows(int rows) { m_rows = rows; emit rowsChanged(rows); emit changeOccurred(); } void SheetData::updateColumns(int columns) { m_cmdStack->push(new UpdateDimensionsCommand(this, UpdateDimensionsCommand::Dimension::Columns, m_columns, columns)); } void SheetData::updateRows(int rows) { m_cmdStack->push(new UpdateDimensionsCommand(this, UpdateDimensionsCommand::Dimension::Rows, m_rows, rows)); } void SheetData::updatePixels(const NostalgiaGraphic *ng) { if (ng->bpp == 8) { for (std::size_t i = 0; i < ng->tiles.size(); i++) { m_pixels.push_back(ng->tiles[i]); } } else { for (std::size_t i = 0; i < ng->tiles.size() * 2; i++) { uint8_t p; if (i & 1) { p = ng->tiles[i / 2] >> 4; } else { p = ng->tiles[i / 2] & 0xF; } m_pixels.push_back(p); } } emit pixelsChanged(); } TileSheetEditor::TileSheetEditor(QString path, const studio::Context *ctx, QWidget *parent): studio::Editor(parent), m_sheetData(undoStack()) { m_ctx = ctx; m_itemPath = path; m_itemName = path.mid(path.lastIndexOf('/')); auto lyt = new QVBoxLayout(this); m_splitter = new QSplitter(this); auto canvasParent = new QWidget(m_splitter); auto canvasLyt = new QVBoxLayout(canvasParent); m_canvas = new QQuickWidget(canvasParent); canvasLyt->addWidget(m_canvas); auto tb = new QToolBar(tr("Tile Sheet Options")); m_tilesX = new LabeledSpinner(tr("Tiles &X:"), 1, m_sheetData.columns()); m_tilesY = new LabeledSpinner(tr("Tiles &Y:"), 1, m_sheetData.rows()); tb->addWidget(m_tilesX); tb->addWidget(m_tilesY); canvasLyt->setMenuBar(tb); lyt->addWidget(m_splitter); m_splitter->addWidget(canvasParent); m_splitter->addWidget(setupColorPicker(m_splitter)); m_splitter->setStretchFactor(0, 1); connect(&m_sheetData, &SheetData::columnsChanged, [this](int val) { disconnect(m_tilesX->spinBox, QOverload::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateColumns); m_tilesX->spinBox->setValue(val); connect(m_tilesX->spinBox, QOverload::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateColumns); }); connect(&m_sheetData, &SheetData::rowsChanged, [this](int val) { disconnect(m_tilesY->spinBox, QOverload::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateRows); m_tilesY->spinBox->setValue(val); connect(m_tilesY->spinBox, QOverload::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateRows); }); m_sheetData.load(m_ctx, m_itemPath); connect(m_tilesX->spinBox, QOverload::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateColumns); connect(m_tilesY->spinBox, QOverload::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateRows); m_canvas->rootContext()->setContextProperty("sheetData", &m_sheetData); m_canvas->setSource(QUrl::fromLocalFile(":/qml/TileSheetEditor.qml")); m_canvas->setResizeMode(QQuickWidget::SizeRootObjectToView); setPalette(); setColorTable(); restoreState(); connect(&m_sheetData, &SheetData::changeOccurred, [this] { setUnsavedChanges(true); }); connect(&m_sheetData, &SheetData::paletteChanged, this, &TileSheetEditor::setColorTable); setExportable(true); } TileSheetEditor::~TileSheetEditor() { saveState(); } QString TileSheetEditor::itemName() { return m_itemName; } void TileSheetEditor::exportFile() { auto path = QFileDialog::getSaveFileName(this, tr("Export to Image"), "", tr("PNG (*.png)")); auto ng = m_sheetData.toNostalgiaGraphic(); QString palPath; if (palPath == "" && ng->defaultPalette.type() == ox::FileAddressType::Path) { palPath = ng->defaultPalette.getPath().value; } std::unique_ptr npal; try { npal = m_ctx->project->loadObj(palPath); } catch (ox::Error) { qWarning() << "Could not open palette" << palPath; // TODO: message box to notify of failure } toQImage(ng.get(), npal.get()).save(path, "PNG"); } void TileSheetEditor::saveItem() { m_sheetData.save(m_ctx, m_itemPath); } QWidget *TileSheetEditor::setupColorPicker(QWidget *parent) { auto colorPicker = new QWidget(parent); auto lyt = new QVBoxLayout(colorPicker); m_colorPicker.palette = new QComboBox(colorPicker); m_colorPicker.colorTable = new QTableWidget(colorPicker); m_colorPicker.colorTable->setColumnCount(2); m_colorPicker.colorTable->setSelectionBehavior(QAbstractItemView::SelectRows); m_colorPicker.colorTable->setSelectionMode(QAbstractItemView::SingleSelection); m_colorPicker.colorTable->setHorizontalHeaderLabels(QStringList() << tr("Hex Code") << tr("Color")); m_colorPicker.colorTable->horizontalHeader()->setStretchLastSection(true); m_colorPicker.colorTable->verticalHeader()->hide(); connect(m_colorPicker.colorTable, &QTableWidget::itemSelectionChanged, this, &TileSheetEditor::colorSelected); connect(m_colorPicker.palette, &QComboBox::currentTextChanged, this, [this](QString name) { m_sheetData.setPalette(m_ctx, palettePath(name)); }); lyt->addWidget(m_colorPicker.palette); lyt->addWidget(m_colorPicker.colorTable); m_ctx->project->subscribe(studio::ProjectEvent::FileRecognized, m_colorPicker.palette, [this](QString path) { if (path.startsWith(PaletteDir) && path.endsWith(FileExt_npal)) { auto name = paletteName(path); m_colorPicker.palette->addItem(name); } }); m_ctx->project->subscribe(studio::ProjectEvent::FileDeleted, m_colorPicker.palette, [this](QString path) { if (path.startsWith(PaletteDir) && path.endsWith(FileExt_npal)) { auto name = paletteName(path); auto idx = m_colorPicker.palette->findText(name); m_colorPicker.palette->removeItem(idx); } }); return colorPicker; } void TileSheetEditor::setPalette() { auto name = paletteName(m_sheetData.palettePath()); m_colorPicker.palette->setCurrentText(name); } void TileSheetEditor::saveState() { QSettings settings(m_ctx->orgName, PluginName); settings.beginGroup("TileSheetEditor/" + m_itemName); settings.setValue("m_splitter/state", m_splitter->saveState()); settings.setValue("m_sheetData/tileRows", m_sheetData.rows()); settings.setValue("m_sheetData/tileColumns", m_sheetData.columns()); settings.setValue("m_colorPicker.colorTable/geometry", m_colorPicker.colorTable->horizontalHeader()->saveState()); settings.endGroup(); } void TileSheetEditor::restoreState() { QSettings settings(m_ctx->orgName, PluginName); settings.beginGroup("TileSheetEditor/" + m_itemName); m_splitter->restoreState(settings.value("m_splitter/state", m_splitter->saveState()).toByteArray()); m_colorPicker.colorTable->horizontalHeader()->restoreState(settings.value("m_colorPicker.colorTable/geometry", m_colorPicker.colorTable->horizontalHeader()->saveState()).toByteArray()); settings.endGroup(); } QString TileSheetEditor::paletteName(QString palettePath) const { const auto begin = ox_strlen(PaletteDir); const auto end = palettePath.size() - (ox_strlen(FileExt_npal) + ox_strlen(PaletteDir)); return palettePath.mid(begin, end); } QString TileSheetEditor::palettePath(QString paletteName) const { return PaletteDir + paletteName + FileExt_npal; } constexpr common::Point idxToPt(int i, int c) noexcept { common::Point p; const auto t = i / PixelsPerTile; // tile number const auto iti = i % PixelsPerTile; // in tile index const auto tc = t % c; // tile column const auto tr = t / c; // tile row const auto itx = iti % TileWidth; // in tile x const auto ity = iti / TileHeight; // in tile y return { itx + tc * TileWidth, ity + tr * TileHeight, }; } static_assert(idxToPt(4, 1) == common::Point{4, 0}); static_assert(idxToPt(8, 1) == common::Point{0, 1}); static_assert(idxToPt(8, 2) == common::Point{0, 1}); static_assert(idxToPt(64, 2) == common::Point{8, 0}); static_assert(idxToPt(128, 2) == common::Point{0, 8}); static_assert(idxToPt(129, 2) == common::Point{1, 8}); static_assert(idxToPt(192, 2) == common::Point{8, 8}); static_assert(idxToPt(384, 8) == common::Point{48, 0}); QImage TileSheetEditor::toQImage(NostalgiaGraphic *ng, NostalgiaPalette *npal) const { const auto w = ng->columns * TileWidth; const auto h = ng->rows * TileHeight; QImage dst(w, h, QImage::Format_RGB32); auto setPixel = [&dst, npal, ng](std::size_t i, uint8_t p) { const auto color = toColor32(npal->colors[p]) >> 8; const auto pt = idxToPt(i, ng->columns); dst.setPixel(pt.x, pt.y, color); }; if (ng->bpp == 4) { for (std::size_t i = 0; i < ng->tiles.size(); ++i) { auto p1 = ng->tiles[i] & 0xF; auto p2 = ng->tiles[i] >> 4; setPixel(i * 2 + 0, p1); setPixel(i * 2 + 1, p2); } } else { for (std::size_t i = 0; i < ng->tiles.size(); i++) { const auto p = ng->tiles[i]; setPixel(i, p); } } return dst; } void TileSheetEditor::colorSelected() { m_sheetData.setSelectedColor(m_colorPicker.colorTable->currentRow()); } void TileSheetEditor::setColorTable() { auto hexColors = m_sheetData.palette(); auto tbl = m_colorPicker.colorTable; tbl->setRowCount(hexColors.size()); for (int i = 0; i < hexColors.size(); i++) { auto hexCode = new QTableWidgetItem; hexCode->setText(hexColors[i]); hexCode->setFont(QFont("monospace")); tbl->setItem(i, 0, hexCode); auto color = new QTableWidgetItem; color->setBackground(QColor(hexColors[i])); tbl->setItem(i, 1, color); } } }