ユーザ用ツール

文書の過去の版を表示しています。


ハイハイスクールアドベンチャー Qt版

あらすじ

2019年神奈山県立ハイ高等学校は 地盤が弱く校舎の老朽化も進んだため、 とうとう廃校にする以外方法がなく なってしまった。

ところで大変な情報を手に入れた。 それは、

「ハイ高校にATOMIC BOMBが仕掛けられている。」

と、いうものだ。 どうやらハイ高が廃校になった時、 気が狂った理科の先生がATOMIC BOMBを 学校のどこかに仕掛けてしまったらしい。

お願いだ。我が母校のコナゴナになった 姿を見たくはない。 早くATOMIC BOMBを取り除いてくれ……!!

行動は英語で、“<動詞>” 或いは、“<動詞>”+“<目的語>“のように入れていただきたい。 例えば、”look room”と入れれば部屋の様子を見ることが出来るという訳だ。

それでは Good Luck!!!…………

概要

M5版を作った勢いで、SDL2版を作ろうと思ったのだが、Textureの扱いがイマイチ判然とせず、動き始めたものの表示が不規則に切り替わり、それを直すくらいならと、Qtにスイッチしたのがこのバージョンである。

入出力部以外はM5版と同じである。

ビルド

QtCreatorを使用して作成したので、QtCreatorを使ってビルドするのが簡単だが、cmakeで直接ビルドすることも可能である。 ソースはM5版同様GitHubから取得可能である。 データファイルは ~/.HHSAdv の下に展開する。

GitHubからは git clone で取得する。

$ git clone https://github.com/wildtree/qhhsadv.git

QtCreatorを使用する場合には、CMakeLists.txt がプロジェクトファイルになる。 cmakeでビルドするなら、

$ mkdir build
$ cd build
$ cmake ..
$ make

と、通常の cmakeの作法に従えばいい。

実行は qhhsadv を実行すればいい。

ハイハイスクールアドベンチャー

あれこれ

ダイアログについて

M5版とは異なり、自由度の高いダイアログなどを用意できる関係で、「ストーリー」や「このプログラムについて」などのダイアログを実装したが、スタッフクレジットが意外と長くて、環境によっては画面に収まらないことが判明。

だったら、スクロールするようにすればいいじゃんと思ったのだが、これが以外にもめんどくさかった。

スクロールバーつきのダイアログ

Qtではこの種のダイアログは QMessageBox::about() を使って表示するのだが、残念なことにスクロールバーがつくことはない。 なので、自力でそのようなダイアログをつくらなければならない。

普通に考えれば、QDialogのインスタンスを作って、そこに必要な部品をはめていけばいいのだが、うっかり、QMessageBoxクラスを継承して、うまいことごにょごにょすればいいんじゃないかと思ってしまった。

それが、間違いだったが、始めてしまったからには仕方がない。

方針としては、QMessageBoxで表示してる QLabelをスクロールバーつきにしてやればいいのだ。 そうだ、そんなに難しいことではない……と、思ってた。

ウィジェットをいくつも詰め込む場合にはレイアウトも必要になるが、今回は QLabelひとつを QScrollAreaに突っ込むだけなので大したことはない。

QScrollArea *scrollArea = new QScrollArea();
QLabel *label = new QLabel(text);
scrollArea->setWidget(label);

これを、元々表示している QLabelと入れ替えてやればいい。 QMessageBoxには QGridLayout がはりついていて、そこにQLabel がはまっているので、そのGridLayoutに対して差し替えをさせればいい。

QGridLayout *l = qobject_cast<QGridLayout*>(this->layout());
l->addWidget(scrollArea, 0, 2, 1, -1); // アプリケーションアイコンがある場合。ない場合は 2->1に置き換える 

ところが、これやったら、ダイアログの左上に出てくるだけで、狙ったところにスクロールバーはつかない。 1)

もうなんか、アイコンのあるなしで場所代わるし、レイアウト触るんじゃなくて、表示してる QLabel の中身をスクロールバーつきの QLabelに変えちゃえばいいんじゃね? ということに気づいた。 勿論、正しいやり方かどうかは知らない。 そこまでQtに詳しいわけじゃない。

    QLabel *label = this->findChild<QLabel*>("qt_msgbox_label");
    QLabel *content = new QLabel(label->text());
    content->setWordWrap(true);
    content->setOpenExternalLinks(true);
    label->clear();
    _scrollArea = new QScrollArea(label);
    _scrollArea->setWidget(content);
    _scrollArea->setWidgetResizable(true);
    _scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
    _scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
    _scrollArea->setMinimumSize(label->width(),label->height());
    label->setMinimumSize(label->width(),label->height());

最後、最小サイズを指定してやらないとダイアログが極小になってしまうのでこれがある意味キモ。

なお、QMessageBoxを継承した場合は void about()も定義しなおしておかないといけない。 最後の setMinimunSize()に関しては、StyleSheetでやっている例もあったが、QLabel { min-width: 300 px; min-height: 300 px; }みたいにしてしまうと、textだけでなく、アイコンの方もそのサイズにされてしまうので、アイコン付きの about()では都合が悪いのだ。

void
ScrollMessageBox::about(QWidget *parent, const QString &title, const QString &text)
{
    ScrollMessageBox *scrBox = new ScrollMessageBox(QMessageBox::Icon::Information, title, text, QMessageBox::StandardButton::Ok, parent);
    scrBox->setAttribute(Qt::WA_DeleteOnClose);
    QIcon icon = scrBox->windowIcon();
    QSize size = icon.actualSize(QSize(64, 64));
    scrBox->setIconPixmap(icon.pixmap(size));

    scrBox->exec();
}

Qt::WA_DeleteOnClose をセットしておくことで、ダイアログを閉じたら勝手に削除してくれるので staticな関数なのに、オブジェクトを返さずにすむ。 お手軽だが、こんなの、Qtのソースみないとわからない。

Qt5/Qt6の互換性

主にuConsoleでコード書いていた関係で、Qt5を使って開発したため、Qt6で動くかどうかとか全然ちゃんと調べてなかった。 DevTermの方は逆にQt6をインストールしてあったので、そっちでビルドできるかトライしたらあっさりこけた。

ただし、こけたのは QMediaPlayer 周り。

音鳴らす機能はおまけなのでなくてもいい。

とりあえず、動作チェックのために、Qt6 なら飛ばすようにした。

#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
        _mp.setMedia(QUrl::fromLocalFile(files()->mp3_file(sound_names[n]).c_str()));
        _mp.setVolume(100);
        _mp.play();
#endif

ざっくり、Qt6.0.0より前のバージョンの時だけコンパイルされるようにした。

でも待って。Qt6のリファレンスにサンプルコードあるじゃん。 ってことで、速攻で Qt6用のコードも追加した。 音もちゃんと鳴った。

#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
        _mp.setMedia(QUrl::fromLocalFile(files()->mp3_file(sound_names[n]).c_str()));
        _mp.setVolume(100);
        _mp.play();
#else
        _mp.setAudioOutput(&_audio);
        _mp.setSource(QUrl::fromLocalFile(files()->mp3_file(sound_names[n]).c_str()));
        _audio.setVolume(100);
        _mp.play();
#endif

ボリュームの制御が QAudioOutput に移されてて、あとは setMedia()が setSource()になっただけ。 とはいえ、違いは違い。

テーマ対応

ハイハイスクールアドベンチャー Android版ハイハイスクールアドベンチャー Windows版と、テーマ対応を成し遂げてきたのであれば、Qt版でも対応するのが筋というものでしょう。

Qtのテーマ対応は、ちょっと見ただけだとそれほど難しくも手間でもなさそうです。 但し、完璧を期さないのであれば、ですが。

テーマ対応の概要

いくつかやり方はあるようですが、テーマファイル2)で作って、それを適用するのが柔軟性がありそうでよさそうです。

しかも、Windows版と違って、書くこと少なくていいです。

light.qss
/* light qss */
QWidget {
        background-color: #ffffff;
        color: #000000;
}
 
QPushButton {
        background-color: #e0e0e0;
        border: 1px solid #888;
        padding: 4px 8px;
}
dark.qss
/* dark qss */
QWidget {
        background-color: #2b2b2b;
        color: #dddddd;
}
 
QPushButton {
        background-color: #444444;
        border: 1px solid #666;
        padding: 4px 8px;
}
void
applyTheme(bool dark)
{
    QString fileName = QString(theme_file(dark).c_str());
    QFile f(fileName);
    if (f.open(QFile::ReadOnly))
    {
        QString style = QLatin1String(f.readAll());
        qApp->setStyleSheet(style);
    }
}

やっぱりタイトルバー

Qtもテーマ対応をしたところで、タイトルバーは「対象外」なんだとのことです。 もちろん、Windows版同様、カスタムタイトルバーを使うことで対応できるようです。 そして、Windows版同様、ボタンの実装や処理もやらないといけないようです。

ここで問題となるのが、マルチプラットフォーム対応の場合。 要するに、どこまでやれるのかがまだよくわかってない3)ので、一応この部分はまだ保留にしてあります。

システムテーマの検出

Qt6.5.0以降は機種非依存でシステムテーマを取り出すことができる。 なので、この機能を使えば、機種ごとに頑張ってテーマの検出コードを書く必要がない。

頑張って機種ごとのテーマ検出コード書くくらいなら、世の中のQtが全部6.5以降になるのを期待して、Qt6.5以降のみ、「システム追随」が機能するってことで良しとしています。

        bool dark = false;
        if (theme == ThemeType::System)
        {
#if QT_VERSION >= QT_VERSION_CHECK(6,5,0)
            dark = qApp->styleHints()->colorScheme() == Qt::ColorScheme::Dark;
#endif
        }
        else
        {
            dark = (theme == ThemeType::Dark);
        }
        this->applyTheme(dark);

QSSの限界

WindowsのXAMLでもHTMLにおけるCSSでも、コーディングなしでスライドスイッチ風ウィジェットを実現できます。 QtでもQSSでできるのかなあと思ってAIに聞いたら「できます」というので、やってみたら、全然できませんでした。

最終的に、AIも「それはQSSの限界ですね」といって、あっさり、カスタムウィジェット+カスタムスタイルで逃げちゃいました。

QSSはCSSのサブセットみたいなものですが、本当に装飾的な部分しか扱えないようです。

チェックボックスをスライドスイッチに

チェックボックスでも機能的には十分なのですが、今どき、スライドスイッチ風があるべき姿でしょう。 QSSでできると期待したのにできなかった奴です。

なお、現状のバージョンはスライドスイッチをアニメーションで切り替えたりはできません。 ザクっとON/OFFが切り替わるやつです。

スイッチ部品をテーマに対応させるために、プロパティだけを提供するためのラッパークラスを用意します。

switchbox.h
#ifndef SWITCHBOX_H
#define SWITCHBOX_H
 
#include <QObject>
#include <QCheckBox>
 
class SwitchBox : public QCheckBox {
    Q_OBJECT
    Q_PROPERTY(QColor switchColorOn READ switchColorOn WRITE setSwitchColorOn)
    Q_PROPERTY(QColor switchColorOff READ switchColorOff WRITE setSwitchColorOff)
    Q_PROPERTY(QColor switchButtonColor READ switchButtonColor WRITE setSwitchButtonColor)
 
public:
    explicit SwitchBox(QWidget *parent = nullptr)
        : QCheckBox(parent),
        m_on(Qt::blue),
        m_off(Qt::gray),
        m_button(Qt::white) {}
 
    QColor switchColorOn() const { return m_on; }
    void setSwitchColorOn(const QColor &c) { m_on = c; }
 
    QColor switchColorOff() const { return m_off; }
    void setSwitchColorOff(const QColor &c) { m_off = c; }
 
    QColor switchButtonColor() const { return m_button; }
    void setSwitchButtonColor(const QColor &c) { m_button = c; }
 
private:
    QColor m_on;
    QColor m_off;
    QColor m_button;
};
 
#endif // SWITCHBOX_H

これだけでは足りないので、スイッチボックス風のスタイルを提供するクラスを作ります。

switchstyle.h
#ifndef SWITCHSTYLE_H
#define SWITCHSTYLE_H
 
#include <QProxyStyle>
#include <QStyleOption>
#include <QStyleOptionButton>
#include <QPainter>
#include <QRect>
#include <QColor>
 
class SwitchStyle : public QProxyStyle {
public:
    using QProxyStyle::QProxyStyle;
 
    // QSS から色を取得(プロパティベース)
    QColor getSwitchColor(const QWidget *widget, bool checked) const {
        const char *propName = checked ? "switchColorOn" : "switchColorOff";
        QVariant colorProp = widget ? widget->property(propName) : QVariant();
        if (colorProp.canConvert<QColor>())
            return colorProp.value<QColor>();
        return checked ? QColor("#1E90FF") : QColor("#AAAAAA"); // デフォルト
    }
 
    QColor getButtonColor(const QWidget *widget) const {
        QVariant colorProp = widget ? widget->property("switchButtonColor") : QVariant();
        if (colorProp.canConvert<QColor>())
            return colorProp.value<QColor>();
        return Qt::white; // デフォルト
    }
 
    QRect subElementRect(SubElement element,
                         const QStyleOption *option,
                         const QWidget *widget) const override {
        if (element == SE_CheckBoxIndicator) {
            // チェックボックス全体の矩形
            QRect fullRect = option->rect;
 
            // インジケータのサイズ(例:40x20)
            int w = 40;
            int h = 20;
 
            // fullRect の右端にインジケータを配置
            int x = fullRect.right() - w;
            int y = fullRect.top() + (fullRect.height() - h) / 2;
 
            return QRect(x, y, w, h);
        }
        return QProxyStyle::subElementRect(element, option, widget);
    }
 
    QSize sizeFromContents(ContentsType type,
                           const QStyleOption *option,
                           const QSize &contentsSize,
                           const QWidget *widget) const override {
        QSize sz = QProxyStyle::sizeFromContents(type, option, contentsSize, widget);
        if (type == CT_CheckBox)
        {
            sz.setHeight(std::max(sz.height(), 24));
            sz.setWidth(std::max(sz.width(), 80)); // ← 幅を確保
        }
        return sz;
    }
 
    int pixelMetric(PixelMetric metric,
                    const QStyleOption *option,
                    const QWidget *widget) const override {
        if (metric == PM_IndicatorWidth) return 40;
        if (metric == PM_IndicatorHeight) return 20;
        return QProxyStyle::pixelMetric(metric, option, widget);
    }
 
    void drawPrimitive(PrimitiveElement element,
                       const QStyleOption *option,
                       QPainter *painter,
                       const QWidget *widget = nullptr) const override {
        if (element == PE_IndicatorCheckBox) {
            if (!option) return;
            QRect r = option->rect;
            painter->setRenderHint(QPainter::Antialiasing);
 
            bool checked = option->state & State_On;
            QColor trackColor = getSwitchColor(widget, checked);
 
            // 背景(トラック)
            painter->setBrush(trackColor);
            painter->setPen(Qt::NoPen);
            painter->drawRoundedRect(r, r.height()/2, r.height()/2);
 
            // つまみ(白い円)
            int knobDiameter = r.height() - 4;
            int x = checked ? r.right() - knobDiameter - 2 : r.left() + 2;
            QRect knobRect(x, r.top() + 2, knobDiameter, knobDiameter);
 
            painter->setBrush(getButtonColor(widget));
            painter->drawEllipse(knobRect);
            return;
        }
        QProxyStyle::drawPrimitive(element, option, painter, widget);
    }
};
 
#endif // SWITCHSTYLE_H

最初は、描画するdrawPrimitive()だけだったのですが、QSSから色情報をとりたいとか、スイッチをダイアログの右端に配置しようとしたら、右にはみ出したり、ラベルの高さより高くなってしたが削られたりしたのでsizeFromContents()を投入したり、右にはみ出さなくなったけれど、今度は、右端の部分しか表示されないのでsubElementRect()で十分な幅が得られるようにしたりと、この分量になりました。

このスイッチは、色について三つのプロパティを持っています。

  • qproperty-switchColorOn Onの時の背景色
  • qproperty-switchColorOff Offの時の背景色
  • qproperty-switchButtonColor ボタン色

QSSにはこんな感じで設定します。

SwitchBox {
    qproperty-switchColorOn: #4CC2FF;
    qproperty-switchColorOff: #CCCCCC;
    qproperty-switchButtonColor: #FFFFFF;
}

使い方はこんな感じで。

    SwitchBox *chkSound = new SwitchBox();
    chkSound->setStyle(new SwitchStyle(chkSound->style()));
    chkSound->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    chkSound->setChecked(_sound);
    QObject::connect(chkSound, SIGNAL(stateChanged(int)), this, SLOT(setSound(int)));
    QGridLayout *grid0 = new QGridLayout;
    grid0->addWidget(new QLabel("音を鳴らす"), 0, 0, Qt::AlignLeft);
    grid0->addWidget(chkSound, 0, 1, Qt::AlignRight);

SwitchBox のインスタンスを作ったら、setStyleで SwitchStyleのインスタンスを割り当てます。 ここまでしてやるとスライドスイッチ風のウィジェットが出来上がります。 中身は見ての通り CheckBoxそのものなんですけれどね。

環境非依存化

Qtはマルチプラットフォームのツールキットなので、適切にコーディングすれば、Linuxだけでなく、WindowsやMacOSでも動作させることができます。

当初、Qt版はLinux上で開発していたため、GUIまわり以外の部分は、UNIX由来のサービスを使いまくっていたので、Windows版Qtでビルドしようとしたら、山ほどエラーが出まして、これを機に環境依存コードを排除して、全面的に書き直しました。

ファイル入出力も、Qtのコールに書き換えました。 これは、WindowsとUNIX(とMacOS)で改行コードの扱いが違うことで、ファイル操作の際に余計な処理が入る関係です。

とりあえず、WindowsとLinux 4)でのビルドが可能なことは確認しました。

Windows版

Windows版を実行したら、コンソールウィンドウが開く、コンソールアプリでした。 調べたところ、エントリーポイントが main()だとコンソールアプリ、WinMain()だと非コンソールアプリになるとか。

え、てことは #if とかでエントリポイントを変える感じ?

と、思ったら他にもやり方がありました。 CMakeLists.txt でコントロールできるそうで。 qt_add_executable()に WIN32 ってつければいいんだとか。 結局 if(WIN32)とかで切り替えるわけですが5)、プログラム側でやるよりはすっきりして異様に思います。

if(WIN32)
    set(app_icon_resource_windows "${CMAKE_CURRENT_SOURCE_DIR}/qhhsadv.rc")
    list(APPEND PROJECT_SOURCES "${app_icon_resources_windows}")
endif()
 
if(${QT_VERSION_MAJOR} GREATER_EQUAL 6)
    if(WIN32)
        qt_add_executable(qhhsadv WIN32
            ${PROJECT_SOURCES}
            qhhsadv.rc
        )
    else()
        qt_add_executable(qhhsadv
            ${PROJECT_SOURCES}
        )
    endif()
 
    qt_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES})
else()
    if(ANDROID)
        add_library(qhhsadv SHARED
            ${PROJECT_SOURCES}
        )
    else()
        add_executable(qhhsadv
            ${PROJECT_SOURCES}
        )
    endif()
 
    qt5_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES})
endif()
1)
後で考えたらもしかすると QGridLayout l = qobject_cast<QGridLayout>(this→widget()→layout() ); とするべきだったのかもしれない。
2)
QSS: Qt Style Sheet
3)
調査中
4)
Ubuntu 24.04LTS, Ubuntu 25.04、Debian 12(ARM64)で確認
5)
WIN32は関係ない環境では無視されるらしいので、別にifいらんのですが、アイコンの処理とかの関連もあるので。

This website uses cookies. By using the website, you agree with storing cookies on your computer. Also, you acknowledge that you have read and understand our Privacy Policy. If you do not agree, please leave the website.

More information