Merge branch 'Cockatrice:master' into toggle-normal-untapping-once

This commit is contained in:
Skagra42 2026-08-13 13:09:08 -06:00 committed by GitHub
commit fdd7d4eac0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
543 changed files with 82935 additions and 48240 deletions

View file

@ -159,6 +159,7 @@ if [[ $PACKAGE_TYPE ]]; then
fi
if [[ $USE_VCPKG ]]; then
flags+=("-DUSE_VCPKG=1")
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
fi
# Add cmake --build flags

View file

@ -1,5 +0,0 @@
---
exclude_paths:
- '**/translations/*.ts'
# codacy config documentation: https://support.codacy.com/hc/en-us/articles/115002130625-Codacy-Configuration-File

View file

@ -334,7 +334,7 @@ the tr() call, also you can add an extra string as a hint for translators:
QString message = tr("Everyone draws %n cards", "english hint for translators", amount);
```
See [Qt's wiki on translations](
https://doc.qt.io/qt-5/i18n-source-translation.html#handling-plurals)
https://doc.qt.io/qt-6/i18n-source-translation.html#handling-plurals)
If you're about to propose a change that adds or modifies any translatable
string in the code, you don't need to take care of adding the new strings to

View file

@ -3,7 +3,7 @@ name: Build Desktop
permissions:
actions: write # needed to delete entries in GHA cache (update ccache)
attestations: write # needed to persist the attestation.
contents: write
contents: write # needed for e.g. vcpkg dependency graph updates
id-token: write # needed for signing certificate in attestation
on:
@ -47,26 +47,21 @@ jobs:
tag: ${{ steps.configure.outputs.tag }}
sha: ${{ steps.configure.outputs.sha }}
steps:
steps:
- name: "Configure"
env:
RESOLVED_SHA: ${{ case(github.event_name == 'pull_request', github.event.pull_request.head.sha, github.sha) }}
id: configure
shell: bash
run: |
tag_regex='^refs/tags/'
if [[ $GITHUB_EVENT_NAME == pull-request ]]; then # pull request
sha="${{github.event.pull_request.head.sha}}"
elif [[ $GITHUB_REF =~ $tag_regex ]]; then # release
sha="$GITHUB_SHA"
tag="${GITHUB_REF/refs\/tags\//}"
echo "tag=$tag" >>"$GITHUB_OUTPUT"
else # push to branch
sha="$GITHUB_SHA"
if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
fi
echo "sha=$sha" >>"$GITHUB_OUTPUT"
echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
- name: "Checkout"
if: steps.configure.outputs.tag != null
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # fetch all history for all branches and tags
@ -92,7 +87,7 @@ jobs:
run: |
args=()
[[ $prerelease == yes ]] && args+=(--prerelease)
gh release create "$tag_name" --verify-tag --draft "${args[@]}" \
--target "$target" \
--title "$release_name" \
@ -105,48 +100,48 @@ jobs:
# The files in ".ci/$distro$version" correspond to the values given here
include:
- distro: Arch
allow-failure: yes
package: skip # We are packaged in Arch already
- distro: Servatrice_Debian
version: 12
package: DEB
server_only: yes
test: skip
- distro: Debian
version: 12
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Debian
version: 13
package: DEB
- distro: Fedora
version: 43
package: RPM
test: skip # Running tests on all distros is superfluous
- distro: Fedora
version: 44
package: RPM
- distro: Ubuntu
version: 24.04
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Ubuntu
version: 26.04
package: DEB
name: ${{ matrix.distro }} ${{ matrix.version }}
@ -163,11 +158,11 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Restore compiler cache (ccache)"
id: ccache_restore
uses: actions/cache/restore@v5
uses: actions/cache/restore@v6
env:
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
with:
@ -203,7 +198,7 @@ jobs:
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
args+=(--suffix "$SUFFIX")
RUN --server --release --package "$package" "${args[@]}"
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
@ -211,15 +206,16 @@ jobs:
if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
run: |
if gh cache delete --repo ${{ github.repository }} ${{ steps.ccache_restore.outputs.cache-primary-key }}; then
if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
echo "Cache deleted successfully"
fi
- name: "Save updated compiler cache (ccache)"
if: github.ref == 'refs/heads/master'
uses: actions/cache/save@v5
uses: actions/cache/save@v6
with:
key: ${{ steps.ccache_restore.outputs.cache-primary-key }}
path: ${{ env.CACHE }}
@ -256,8 +252,9 @@ jobs:
if: steps.attestation.outcome == 'success'
shell: bash
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "${{ steps.build.outputs.path }}" --repo Cockatrice/Cockatrice
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
build-vcpkg:
strategy:
@ -267,14 +264,13 @@ jobs:
- os: macOS
target: 13
runner: macos-15-intel
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 13
package_suffix: "-macOS13_Intel"
qt_version: 6.11.0
qt_arch: clang_64
qt_modules: qtimageformats qtmultimedia qtwebsockets
soc: Intel
type: Release
@ -284,13 +280,12 @@ jobs:
- os: macOS
target: 14
runner: macos-14
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
package_suffix: "-macOS14"
qt_version: 6.11.0
qt_arch: clang_64
qt_modules: qtimageformats qtmultimedia qtwebsockets
soc: Apple
type: Release
@ -300,13 +295,12 @@ jobs:
- os: macOS
target: 15
runner: macos-15
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
package_suffix: "-macOS15"
qt_version: 6.11.0
qt_arch: clang_64
qt_modules: qtimageformats qtmultimedia qtwebsockets
soc: Apple
type: Release
@ -316,11 +310,10 @@ jobs:
- os: macOS
target: 15
runner: macos-15
ccache_eviction_age: 7d
cmake_generator: Ninja
qt_version: 6.11.0
qt_arch: clang_64
qt_modules: qtimageformats qtmultimedia qtwebsockets
soc: Apple
type: Debug
@ -330,13 +323,12 @@ jobs:
- os: Windows
target: 10
runner: windows-2025
cmake_generator: "Visual Studio 17 2022"
cmake_generator: "Visual Studio 18 2026"
cmake_generator_platform: x64
make_package: 1
package_suffix: "-Win10"
qt_version: 6.11.0
qt_arch: win64_msvc2022_64
qt_modules: qtimageformats qtmultimedia qtwebsockets
type: Release
@ -350,7 +342,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
@ -368,7 +360,7 @@ jobs:
- name: "[macOS] Restore compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1
id: ccache_restore
uses: actions/cache/restore@v5
uses: actions/cache/restore@v6
env:
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
with:
@ -381,14 +373,16 @@ jobs:
# Resolve given wildcard versions (e.g. Qt 6.6.*) to latest version via aqtinstall to avoid stale caches on new releases
- name: "Resolve latest Qt patch version"
env:
QT_VERSION: ${{ matrix.qt_version }}
id: resolve_qt_version
shell: bash
run: .ci/resolve_latest_aqt_qt_version.sh "${{ matrix.qt_version }}"
run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION"
- name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries"
if: matrix.os == 'macOS'
id: restore_qt
uses: actions/cache/restore@v5
uses: actions/cache/restore@v6
with:
key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }}
path: ${{ github.workspace }}/Qt
@ -399,7 +393,6 @@ jobs:
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
uses: jurplel/install-qt-action@v4
with:
arch: ${{ matrix.qt_arch }}
cache: false
dir: ${{ github.workspace }}
modules: ${{ matrix.qt_modules }}
@ -411,7 +404,7 @@ jobs:
- name: "[macOS] Cache thin Qt libraries"
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
uses: actions/cache/save@v6
with:
key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }}
path: ${{ github.workspace }}/Qt
@ -422,7 +415,6 @@ jobs:
with:
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
aqtsource: git+https://github.com/miurahr/aqtinstall.git
arch: ${{ matrix.qt_arch }}
cache: true
modules: ${{ matrix.qt_modules }}
version: ${{ steps.resolve_qt_version.outputs.version }}
@ -448,6 +440,7 @@ jobs:
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }}
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }}
@ -458,6 +451,7 @@ jobs:
USE_CCACHE: ${{ matrix.use_ccache }}
VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite'
VCPKG_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph
run: .ci/compile.sh --server --test --vcpkg
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
@ -465,15 +459,16 @@ jobs:
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
run: |
if gh cache delete --repo ${{ github.repository }} ${{ steps.ccache_restore.outputs.cache-primary-key }}; then
if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
echo "Cache deleted successfully"
fi
- name: "[macOS] Save updated compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master'
uses: actions/cache/save@v5
uses: actions/cache/save@v6
with:
key: ${{ steps.ccache_restore.outputs.cache-primary-key }}
path: ${{ env.CCACHE_DIR }}
@ -482,18 +477,20 @@ jobs:
if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null
id: sign_macos
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
run: |
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]
then
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
/usr/bin/codesign --sign="$MACOS_CERTIFICATE_NAME" --entitlements=".ci/macos.entitlements" --options=runtime --force --deep --timestamp --verbose "${{ steps.build.outputs.path }}"
/usr/bin/codesign --sign="$MACOS_CERTIFICATE_NAME" --entitlements=".ci/macos.entitlements" --options=runtime --force --deep --timestamp --verbose "$BUILD_PATH"
fi
- name: "[macOS] Notarize app bundle"
if: matrix.os == 'macOS' && steps.sign_macos.outcome == 'success'
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}
@ -508,7 +505,7 @@ jobs:
# Therefore, we create a zip file containing our app bundle, so that we can send it to the
# notarization service
echo "Creating temp notarization archive"
ditto -c -k --keepParent "${{ steps.build.outputs.path }}" "notarization.zip"
ditto -c -k --keepParent "$BUILD_PATH" "notarization.zip"
# Here we send the notarization request to the Apple's Notarization service, waiting for the result.
# This typically takes a few seconds inside a CI environment, but it might take more depending on the App
@ -520,7 +517,7 @@ jobs:
# Finally, we need to "attach the staple" to our executable, which will allow our app to be
# validated by macOS even when an internet connection is not available.
echo "Attach staple"
xcrun stapler staple "${{ steps.build.outputs.path }}"
xcrun stapler staple "$BUILD_PATH"
fi
- name: "Upload artifact"
@ -566,5 +563,6 @@ jobs:
if: steps.attestation.outcome == 'success'
shell: bash
env:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "${{ steps.build.outputs.path }}" --repo Cockatrice/Cockatrice
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice

View file

@ -22,7 +22,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 20 # should be enough to find merge base

View file

@ -31,7 +31,7 @@ jobs:
steps:
- name: "Checkout"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Docker metadata"
id: metadata
@ -56,8 +56,9 @@ jobs:
- name: "Set up Docker buildx"
uses: docker/setup-buildx-action@v4
- name: "Login to GitHub Container Registry"
if: contains(github.event.release.tag_name, 'Release') && github.event.release.target_commitish == 'master'
- name: "Login to GitHub Container Registry (GHCR)"
if: github.event_name == 'release' && github.event.release.prerelease == false
id: login
uses: docker/login-action@v4
with:
password: ${{ github.token }}
@ -73,5 +74,5 @@ jobs:
context: .
labels: ${{ steps.metadata.outputs.labels }}
platforms: linux/amd64,linux/arm64
push: ${{ github.ref_type == 'tag' }}
push: ${{ steps.login.outcome == 'success' }}
tags: ${{ steps.metadata.outputs.tags }}

View file

@ -21,7 +21,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive

View file

@ -20,9 +20,11 @@ jobs:
steps:
- name: "Checkout repo"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Pull translated strings from Transifex"
# Do not run this step for PR's from forks, they don't have access to the secret
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: transifex/cli-action@v2
with:
# Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config
@ -41,11 +43,11 @@ jobs:
author: github-actions <github-actions@github.com> # owner of the commit
body: |
Pulled all translated strings from [Transifex][1].
---
*This PR is automatically generated and updated by the workflow at `.github/workflows/translations-pull.yml`. Review [action runs][2].*<br>
*After merging, all new languages and translations are available in the next build.*
[1]: https://explore.transifex.com/cockatrice/cockatrice/
[2]: https://github.com/Cockatrice/Cockatrice/actions/workflows/translations-pull.yml?query=branch%3Amaster
branch: ci-update_translations
@ -61,11 +63,9 @@ jobs:
if: github.event_name != 'pull_request'
shell: bash
env:
STATUS: ${{ steps.create_pr.outputs.pull-request-operation }}
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
PR_URL: ${{ steps.create_pr.outputs.pull-request-url }}
STATUS: ${{ case(steps.create_pr.outputs.pull-request-operation == 'none', 'unchanged', steps.create_pr.outputs.pull-request-operation) }}
run: |
if [[ "$STATUS" == "none" ]]; then
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} unchanged!" >> $GITHUB_STEP_SUMMARY
else
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} $STATUS!" >> $GITHUB_STEP_SUMMARY
fi
echo "URL: ${{ steps.create_pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
echo "PR #$PR_NUMBER $STATUS!" >> "$GITHUB_STEP_SUMMARY"
echo "URL: $PR_URL" >> "$GITHUB_STEP_SUMMARY"

View file

@ -20,7 +20,7 @@ jobs:
steps:
- name: "Checkout repo"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Install lupdate"
shell: bash
@ -29,12 +29,13 @@ jobs:
sudo apt-get install -y --no-install-recommends qttools5-dev-tools
- name: "Update Cockatrice translation source"
env:
FILE: cockatrice/cockatrice_en@source.ts
id: cockatrice
shell: bash
run: |
FILE="cockatrice/cockatrice_en@source.ts"
export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
FILE="$FILE" DIRS="$DIRS" .ci/update_translation_source_strings.sh
run: >
DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
.ci/update_translation_source_strings.sh
- name: "Update Oracle translation source"
id: oracle
@ -77,11 +78,9 @@ jobs:
if: github.event_name != 'pull_request'
shell: bash
env:
STATUS: ${{ steps.create_pr.outputs.pull-request-operation }}
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
PR_URL: ${{ steps.create_pr.outputs.pull-request-url }}
STATUS: ${{ case(steps.create_pr.outputs.pull-request-operation == 'none', 'unchanged', steps.create_pr.outputs.pull-request-operation) }}
run: |
if [[ "$STATUS" == "none" ]]; then
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} unchanged!" >> $GITHUB_STEP_SUMMARY
else
echo "PR #${{ steps.create_pr.outputs.pull-request-number }} $STATUS!" >> $GITHUB_STEP_SUMMARY
fi
echo "URL: ${{ steps.create_pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
echo "PR #$PR_NUMBER $STATUS!" >> "$GITHUB_STEP_SUMMARY"
echo "URL: $PR_URL" >> "$GITHUB_STEP_SUMMARY"

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ mysql.cnf
.DS_Store
.idea/
*.aps
*.cache
cmake-build*
preferences
compile_commands.json

View file

@ -64,10 +64,11 @@ if(WIN32 OR USE_VCPKG)
else()
set(QTDIR
""
CACHE PATH "Path to Qt (e.g. C:/Qt/5.7/msvc2015_64)"
CACHE PATH "Path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)"
)
message(
WARNING "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/5.7/msvc2015_64)"
WARNING
"QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)"
)
endif()
endif()
@ -174,7 +175,7 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
-Wno-error=delete-non-virtual-dtor
-Wno-error=sign-compare
-Wno-error=missing-declarations
-Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this
-Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this
)
foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS})
@ -280,11 +281,7 @@ if(UNIX)
if(CPACK_GENERATOR STREQUAL "RPM")
set(CPACK_RPM_PACKAGE_LICENSE "GPLv2")
set(CPACK_RPM_MAIN_COMPONENT "cockatrice")
if(Qt6_FOUND)
set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
elseif(Qt5_FOUND)
set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt5-qttools, qt5-qtsvg, qt5-qtmultimedia")
endif()
set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice")
# stop directories from making package conflicts
@ -302,12 +299,8 @@ if(UNIX)
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice")
if(Qt6_FOUND)
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
elseif(Qt5_FOUND)
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt5multimedia5-plugins, libqt5svg5")
endif()
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
endif()
endif()
elseif(WIN32)

View file

@ -349,7 +349,7 @@ OPTIMIZE_OUTPUT_SLICE = NO
#
# Note see also the list of default file extension mappings.
EXTENSION_MAPPING =
EXTENSION_MAPPING = proto=C++
# If the MARKDOWN_SUPPORT tag is enabled then Doxygen pre-processes all comments
# according to the Markdown format, which allows for more readable
@ -1086,7 +1086,8 @@ FILE_PATTERNS = *.cc \
*.h++ \
*.markdown \
*.md \
*.dox
*.dox \
*.proto
# The RECURSIVE tag can be used to specify whether or not subdirectories should
# be searched for input files as well.
@ -1103,6 +1104,7 @@ RECURSIVE = YES
EXCLUDE = build/ \
cmake/ \
cmake-build-debug/ \
doc/doxygen/theme/docs/ \
doc/doxygen/theme/include/ \
vcpkg/
@ -1195,7 +1197,7 @@ INPUT_FILTER =
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
# properly processed by Doxygen.
FILTER_PATTERNS =
FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py"
# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using
# INPUT_FILTER) will also be used to filter the input files that are used for

View file

@ -158,7 +158,6 @@ The following flags (with their non-default values) can be passed to `cmake`:
| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode |
| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code<br> **Note:** `make clean` will remove the .ts files |
| `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
| `-DFORCE_USE_QT5=1` | Skip looking for Qt6 before trying to find Qt5 |
# Run

View file

@ -1,8 +1,7 @@
# Find a compatible Qt version
# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE, FORCE_USE_QT5
# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE
# Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system
# Optional Input: QT5_DIR -- Hint as to where Qt5 lives on the system
# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt5, Qt6
# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6
# Output: SERVATRICE_QT_MODULES
# Output: COCKATRICE_QT_MODULES
# Output: ORACLE_QT_MODULES
@ -39,69 +38,37 @@ set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_CO
)
list(REMOVE_DUPLICATES REQUIRED_QT_COMPONENTS)
if(NOT FORCE_USE_QT5)
# Linguist is now a component in Qt6 instead of an external package
find_package(
Qt6 6.4.2
COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist
QUIET HINTS ${Qt6_DIR}
)
# Linguist is now a component in Qt6 instead of an external package
find_package(
Qt6 6.4.2
COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist
QUIET HINTS ${Qt6_DIR}
)
if(NOT Qt6_FOUND)
message(FATAL_ERROR "No suitable version of Qt was found")
endif()
if(Qt6_FOUND)
set(COCKATRICE_QT_VERSION_NAME Qt6)
set(COCKATRICE_QT_VERSION_NAME Qt6)
list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX)
if(QT6_LRELEASE_INDEX EQUAL -1)
message(WARNING "Qt6 lrelease not found.")
endif()
list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX)
if(QT6_LUPDATE_INDEX EQUAL -1)
message(WARNING "Qt6 lupdate not found.")
endif()
else()
find_package(
Qt5 5.15.2
COMPONENTS ${REQUIRED_QT_COMPONENTS}
QUIET HINTS ${Qt5_DIR}
)
if(Qt5_FOUND)
set(COCKATRICE_QT_VERSION_NAME Qt5)
else()
message(FATAL_ERROR "No suitable version of Qt was found")
endif()
# Qt5 Linguist is in a separate package
find_package(Qt5LinguistTools QUIET)
if(Qt5LinguistTools_FOUND)
if(NOT Qt5_LRELEASE_EXECUTABLE)
message(WARNING "Qt5 lrelease not found.")
endif()
if(NOT Qt5_LUPDATE_EXECUTABLE)
message(WARNING "Qt5 lupdate not found.")
endif()
else()
message(WARNING "Linguist Tools not found, cannot handle translations")
endif()
list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX)
if(QT6_LRELEASE_INDEX EQUAL -1)
message(WARNING "Qt6 lrelease not found.")
endif()
if(Qt5_POSITION_INDEPENDENT_CODE OR Qt6_FOUND)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX)
if(QT6_LUPDATE_INDEX EQUAL -1)
message(WARNING "Qt6 lupdate not found.")
endif()
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Establish Qt Plugins directory & Library directories
get_target_property(QT_LIBRARY_DIR ${COCKATRICE_QT_VERSION_NAME}::Core LOCATION)
get_filename_component(QT_LIBRARY_DIR ${QT_LIBRARY_DIR} DIRECTORY)
if(Qt6_FOUND)
get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE)
get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE)
if(UNIX AND APPLE)
# Mac needs a bit more help finding all necessary components
list(APPEND QT_LIBRARY_DIR "/usr/local/lib")
endif()
elseif(Qt5_FOUND)
get_filename_component(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins" ABSOLUTE)
get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/.." ABSOLUTE)
get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE)
get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE)
if(UNIX AND APPLE)
# Mac needs a bit more help finding all necessary components
list(APPEND QT_LIBRARY_DIR "/usr/local/lib")
endif()
message(DEBUG "QT_PLUGINS_DIR = ${QT_PLUGINS_DIR}")
message(DEBUG "QT_LIBRARY_DIR = ${QT_LIBRARY_DIR}")

View file

@ -1,38 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- ========================= -->
<!-- CORE BUNDLE METADATA -->
<!-- ========================= -->
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
<key>CFBundleGetInfoString</key>
<string>${MACOSX_BUNDLE_INFO_STRING}</string>
<key>CFBundleIconFile</key>
<string>${MACOSX_BUNDLE_ICON_FILE}</string>
<key>CFBundleIdentifier</key>
<string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLongVersionString</key>
<string>${MACOSX_BUNDLE_LONG_VERSION_STRING}</string>
<key>CFBundleName</key>
<string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
<key>CSResourcesFileMapped</key>
<true/>
<key>LSRequiresCarbon</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>${MACOSX_BUNDLE_COPYRIGHT}</string>
<key>NSHighResolutionCapable</key>
<true/>
<!-- ========================= -->
<!-- FILE TYPE (.cod) SUPPORT -->
<!-- ========================= -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.cockatrice.deck</string>
<key>UTTypeDescription</key>
<string>Cockatrice Deck</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>cod</string>
</array>
</dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Cockatrice Deck</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>org.cockatrice.deck</string>
</array>
</dict>
</array>
<!-- ========================= -->
<!-- URL SCHEME (cockatrice://) -->
<!-- ========================= -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Cockatrice URL Scheme</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cockatrice</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -117,21 +117,22 @@ ${If} $InstDir == ""
; we need to set a default based on the install mode
StrCpy $InstDir $0
${EndIf}
Call SetModeDestinationFromInstdir
; --- Detect portable install when using /R ---
; --- Detect portable install when using /R (must come BEFORE SetModeDestinationFromInstdir) ---
${If} $ReinstallMode = 1
IfFileExists "$InstDir\portable.dat" 0 not_portable
StrCpy $PortableMode 1
Goto portable_done
not_portable:
StrCpy $PortableMode 0
portable_done:
${EndIf}
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
Call SetModeDestinationFromInstdir
${If} $ReinstallMode = 1
${AndIf} $PortableMode = 0
Call AutoUninstallIfNeeded
${EndIf}

View file

@ -97,7 +97,11 @@ set(cockatrice_SOURCES
src/game_graphics/player/menu/rfg_menu.cpp
src/game_graphics/player/menu/say_menu.cpp
src/game_graphics/player/menu/sideboard_menu.cpp
src/game_graphics/player/menu/tally_menu.cpp
src/game_graphics/player/menu/utility_menu.cpp
src/game_graphics/tally/stats_tally.cpp
src/game_graphics/tally/subtype_tally.cpp
src/game_graphics/tally/tally.cpp
src/game/player/player_actions.cpp
src/game_graphics/player/player_area.cpp
src/game_graphics/player/player_dialogs.cpp
@ -131,9 +135,19 @@ set(cockatrice_SOURCES
src/interface/card_picture_loader/card_picture_loader_worker.cpp
src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
src/interface/card_picture_loader/card_picture_to_load.cpp
src/interface/intents/intent.cpp
src/interface/intents/intent.h
src/interface/intents/intent_open_local_deck.cpp
src/interface/intents/intent_open_local_deck.h
src/interface/intents/intent_wait_for_database_load.cpp
src/interface/intents/intent_wait_for_database_load.h
src/interface/layouts/flow_layout.cpp
src/interface/layouts/overlap_layout.cpp
src/interface/widgets/utility/card_completer_delegate.cpp
src/interface/widgets/utility/card_completer_styler.cpp
src/interface/widgets/utility/completer_utils.cpp
src/interface/widgets/utility/line_edit_completer.cpp
src/interface/widgets/utility/reversed_completer_model.cpp
src/interface/pixel_map_generator.cpp
src/interface/theme_config.cpp
src/interface/theme_manager.cpp
@ -227,7 +241,9 @@ set(cockatrice_SOURCES
src/interface/widgets/quick_settings/settings_button_widget.cpp
src/interface/widgets/quick_settings/settings_popup_widget.cpp
src/interface/widgets/replay/replay_manager.cpp
src/interface/widgets/replay/replay_quick_settings_widget.cpp
src/interface/widgets/replay/replay_timeline_widget.cpp
src/interface/widgets/replay/replay_widget.cpp
src/interface/widgets/server/chat_view/chat_view.cpp
src/interface/widgets/server/game_filter_configs.cpp
src/interface/widgets/server/game_selector.cpp
@ -236,15 +252,22 @@ set(cockatrice_SOURCES
src/interface/widgets/server/handle_public_servers.cpp
src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp
src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp
src/interface/widgets/server/user/user_avatar_provider.cpp
src/interface/widgets/server/user/user_card_art_provider.cpp
src/interface/widgets/server/user/user_card_settings_dialog.cpp
src/interface/widgets/server/user/user_context_menu.cpp
src/interface/widgets/server/user/user_info_box.cpp
src/interface/widgets/server/user/user_info_connection.cpp
src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_widget.cpp
src/interface/widgets/settings_page/abstract_settings_page.cpp
src/interface/widgets/settings_page/appearance_settings_page.cpp
src/interface/widgets/settings_page/deck_editor_settings_page.cpp
src/interface/widgets/settings_page/general_settings_page.cpp
src/interface/widgets/settings_page/messages_settings_page.cpp
src/interface/widgets/settings_page/settings_search_delegate.cpp
src/interface/widgets/settings_page/settings_search_model.cpp
src/interface/widgets/settings_page/shortcut_settings_page.cpp
src/interface/widgets/settings_page/sound_settings_page.cpp
src/interface/widgets/settings_page/storage_settings_page.cpp
@ -283,6 +306,7 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
src/interface/window_main.cpp
src/main.cpp
src/single_instance_manager.cpp
src/interface/widgets/tabs/abstract_tab_deck_editor.cpp
src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp
@ -298,6 +322,13 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp
src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp
src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp
src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp
src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp
src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp
@ -327,6 +358,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab.cpp
src/interface/widgets/tabs/tab_account.cpp
src/interface/widgets/tabs/tab_admin.cpp
src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp
src/interface/widgets/tabs/tab_game.cpp
@ -343,12 +375,31 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp
src/interface/key_signals.cpp
src/interface/logger.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
src/interface/widgets/utility/compact_push_button.cpp
src/interface/widgets/utility/compact_push_button.h
src/single_instance_manager.h
src/client/url_scheme_event_filter.h
src/interface/intents/intent_connect_to_server.cpp
src/interface/intents/intent_connect_to_server.h
src/interface/intents/intent_disconnect_from_server.cpp
src/interface/intents/intent_disconnect_from_server.h
src/interface/intents/intent_join_server_game.cpp
src/interface/intents/intent_join_server_game.h
src/interface/intents/intent_join_server_room.cpp
src/interface/intents/intent_join_server_room.h
src/interface/intents/intent_login.cpp
src/interface/intents/intent_login.h
src/interface/intents/url_parser.cpp
src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp
src/interface/widgets/server/user/user_info_popup.h
)
add_subdirectory(sounds)
@ -390,11 +441,7 @@ if(APPLE)
set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns)
endif(APPLE)
if(Qt6_FOUND)
qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
elseif(Qt5_FOUND)
qt5_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
endif()
qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
# Declare path variables
set(ICONDIR
@ -406,71 +453,37 @@ set(DESKTOPDIR
CACHE STRING "desktop file destination"
)
set(MIMEDIR
share/mime/packages
CACHE STRING "mime file destination"
)
set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translations")
set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations")
set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations")
if(Qt6_FOUND)
qt6_add_executable(
cockatrice
WIN32
MACOSX_BUNDLE
${cockatrice_SOURCES}
${cockatrice_RESOURCES_RCC}
${cockatrice_MOC_SRCS}
MANUAL_FINALIZATION
)
elseif(Qt5_FOUND)
# Qt5 Translations need to be linked at executable creation time
if(Qt5LinguistTools_FOUND)
if(UPDATE_TRANSLATIONS)
qt5_create_translation(cockatrice_QM ${translate_SRCS} ${cockatrice_TS})
else()
qt5_add_translation(cockatrice_QM ${cockatrice_TS})
endif()
endif()
add_executable(
cockatrice WIN32 MACOSX_BUNDLE ${cockatrice_MOC_SRCS} ${cockatrice_QM} ${cockatrice_RESOURCES_RCC}
${cockatrice_SOURCES}
)
if(UNIX)
if(APPLE)
install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_MAC_QM_INSTALL_DIR})
else()
install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_UNIX_QM_INSTALL_DIR})
endif()
elseif(WIN32)
install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_WIN32_QM_INSTALL_DIR})
endif()
endif()
qt6_add_executable(
cockatrice
WIN32
MACOSX_BUNDLE
${cockatrice_SOURCES}
${cockatrice_RESOURCES_RCC}
${cockatrice_MOC_SRCS}
MANUAL_FINALIZATION
)
if(Qt5_FOUND)
target_link_libraries(
cockatrice
libcockatrice_card
libcockatrice_deck_list
libcockatrice_filters
libcockatrice_utility
libcockatrice_network
libcockatrice_models
libcockatrice_rng
libcockatrice_settings
${COCKATRICE_QT_MODULES}
)
else()
target_link_libraries(
cockatrice
PUBLIC libcockatrice_card
libcockatrice_deck_list
libcockatrice_filters
libcockatrice_utility
libcockatrice_network
libcockatrice_models
libcockatrice_rng
libcockatrice_settings
${COCKATRICE_QT_MODULES}
)
endif()
target_link_libraries(
cockatrice
PUBLIC libcockatrice_card
libcockatrice_deck_list
libcockatrice_filters
libcockatrice_utility
libcockatrice_network
libcockatrice_models
libcockatrice_rng
libcockatrice_settings
${COCKATRICE_QT_MODULES}
)
if(UNIX)
if(APPLE)
@ -490,6 +503,23 @@ if(UNIX)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.png DESTINATION ${ICONDIR}/hicolor/48x48/apps)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.svg DESTINATION ${ICONDIR}/hicolor/scalable/apps)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice.desktop DESTINATION ${DESKTOPDIR})
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice-cod.xml DESTINATION ${MIMEDIR})
# Refresh the freedesktop databases so the file associations and scheme
# handler register without requiring the user to run them manually. The
# tools may be missing on minimal systems; that is fine, packaging systems
# usually refresh these databases through their own triggers.
find_program(UPDATE_MIME_DATABASE update-mime-database)
if(UPDATE_MIME_DATABASE)
install(CODE "execute_process(COMMAND \"${UPDATE_MIME_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/mime\")")
endif()
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
if(UPDATE_DESKTOP_DATABASE)
install(
CODE "execute_process(COMMAND \"${UPDATE_DESKTOP_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/applications\")"
)
endif()
endif()
elseif(WIN32)
install(TARGETS cockatrice RUNTIME DESTINATION ./)
@ -500,7 +530,7 @@ if(APPLE)
set(plugin_dest_dir cockatrice.app/Contents/Plugins)
set(qtconf_dest_dir cockatrice.app/Contents/Resources)
# Qt plugins: audio (Qt5), iconengines, imageformats, multimedia (Qt6), platforms, printsupport (Qt5), styles, tls (Qt6)
# Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls
install(
DIRECTORY "${QT_PLUGINS_DIR}/"
DESTINATION ${plugin_dest_dir}
@ -567,7 +597,7 @@ if(WIN32)
PATTERN "*.ini"
)
# Qt plugins: audio (Qt5), iconengines, imageformats, multimedia (Qt6) platforms, printsupport (Qt5), styles, tls (Qt6)
# Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls
install(
DIRECTORY "${QT_PLUGINS_DIR}/"
DESTINATION ${plugin_dest_dir}
@ -623,7 +653,7 @@ Data = Resources\")
endif()
endif()
if(Qt6_FOUND AND Qt6LinguistTools_FOUND)
if(Qt6LinguistTools_FOUND)
#Qt6 Translations happen after the executable is built up
if(UPDATE_TRANSLATIONS)
qt6_add_translations(
@ -650,6 +680,4 @@ if(Qt6_FOUND AND Qt6LinguistTools_FOUND)
endif()
endif()
if(Qt6_FOUND)
qt6_finalize_target(cockatrice)
endif()
qt6_finalize_target(cockatrice)

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-cockatrice">
<comment>Cockatrice Deck File</comment>
<glob pattern="*.cod"/>
</mime-type>
</mime-info>

View file

@ -3,6 +3,8 @@
Version=1.0
Type=Application
Name=Cockatrice
Exec=cockatrice
Exec=cockatrice %U
Icon=cockatrice
Categories=Game;CardGame;
MimeType=application/x-cockatrice;
X-Scheme-Handler/cockatrice=true

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@
#include <QThread>
#include <libcockatrice/network/client/remote/remote_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/settings/servers_settings.h>
ConnectionController::ConnectionController(QWidget *dialogParent, QObject *parent)
: QObject(parent), dialogParent(dialogParent)

View file

@ -14,6 +14,8 @@
#include <QtConcurrent>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <version_string.h>
#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"
@ -21,7 +23,7 @@
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr)
{
isSpoilerDownloadEnabled = SettingsCache::instance().getDownloadSpoilersStatus();
isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus();
if (isSpoilerDownloadEnabled) {
// Start the process of checking if we're in spoiler season
// File exists means we're in spoiler season
@ -75,7 +77,7 @@ void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile()
bool SpoilerBackgroundUpdater::deleteSpoilerFile()
{
QString fileName = SettingsCache::instance().getSpoilerCardDatabasePath();
QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
QFileInfo fi(fileName);
QDir fileDir(fi.path());
QFile file(fileName);
@ -126,7 +128,7 @@ void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
{
QString fileName = SettingsCache::instance().getSpoilerCardDatabasePath();
QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
QFileInfo fi(fileName);
QDir fileDir(fi.path());

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
#include <QtMath>
CardCounterSettings::CardCounterSettings(const QString &settingsPath, QObject *parent)
: SettingsManager(settingsPath + "global.ini", "cards", "counters", parent)
: SettingsManager(settingsPath + "card_counters.ini", "cards", "counters", parent)
{
}

View file

@ -223,6 +223,10 @@ private:
{"TabDeckEditor/aLoadDeck", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck..."),
parseSequenceString("Ctrl+O"),
ShortcutGroup::Deck_Editor)},
{"TabDeckEditor/aLoadDeckFromWebsite",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load deck from online service..."),
parseSequenceString("Ctrl+Shift+O"),
ShortcutGroup::Deck_Editor)},
{"TabDeckEditor/aLoadDeckFromClipboard",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
parseSequenceString("Ctrl+Shift+V"),
@ -283,6 +287,10 @@ private:
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load Deck from Clipboard..."),
parseSequenceString("Ctrl+Shift+V"),
ShortcutGroup::Game_Lobby)},
{"DeckViewContainer/loadFromWebsiteButton",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Load from website..."),
parseSequenceString("Ctrl+Shift+O"),
ShortcutGroup::Game_Lobby)},
{"DeckViewContainer/unloadDeckButton", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Unload Deck"),
parseSequenceString("Ctrl+Alt+U"),
ShortcutGroup::Game_Lobby)},

View file

@ -2,12 +2,11 @@
#include "settings/cache_settings.h"
#include <QApplication>
#include <QAudioOutput>
#include <QDir>
#include <QMediaPlayer>
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
#include <QAudioOutput>
#endif
#include <libcockatrice/settings/sound_settings.h>
#define DEFAULT_THEME_NAME "Default"
#define TEST_SOUND_FILENAME "player_join"
@ -15,8 +14,10 @@
SoundEngine::SoundEngine(QObject *parent) : QObject(parent), audioOutput(nullptr), player(nullptr)
{
ensureThemeDirectoryExists();
connect(&SettingsCache::instance(), &SettingsCache::soundThemeChanged, this, &SoundEngine::themeChangedSlot);
connect(&SettingsCache::instance(), &SettingsCache::soundEnabledChanged, this, &SoundEngine::soundEnabledChanged);
connect(&SettingsCache::instance().sound(), &SoundSettings::soundThemeChanged, this,
&SoundEngine::themeChangedSlot);
connect(&SettingsCache::instance().sound(), &SoundSettings::soundEnabledChanged, this,
&SoundEngine::soundEnabledChanged);
soundEnabledChanged();
themeChangedSlot();
@ -36,14 +37,12 @@ SoundEngine::~SoundEngine()
void SoundEngine::soundEnabledChanged()
{
if (SettingsCache::instance().getSoundEnabled()) {
if (SettingsCache::instance().sound().getSoundEnabled()) {
qCInfo(SoundEngineLog) << "SoundEngine: enabling sound with" << audioData.size() << "sounds";
if (!player) {
player = new QMediaPlayer;
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
audioOutput = new QAudioOutput(player);
player->setAudioOutput(audioOutput);
#endif
}
} else {
qCInfo(SoundEngineLog) << "SoundEngine: disabling sound";
@ -70,14 +69,9 @@ void SoundEngine::playSound(const QString &fileName)
}
player->stop();
int volumeSliderValue = SettingsCache::instance().getMasterVolume();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
int volumeSliderValue = SettingsCache::instance().sound().getMasterVolume();
player->audioOutput()->setVolume(qreal(volumeSliderValue) / 100);
player->setSource(QUrl::fromLocalFile(audioData[fileName]));
#else
player->setVolume(volumeSliderValue);
player->setMedia(QUrl::fromLocalFile(audioData[fileName]));
#endif
player->play();
}
@ -88,10 +82,10 @@ void SoundEngine::testSound()
void SoundEngine::ensureThemeDirectoryExists()
{
if (SettingsCache::instance().getSoundThemeName().isEmpty() ||
!getAvailableThemes().contains(SettingsCache::instance().getSoundThemeName())) {
if (SettingsCache::instance().sound().getSoundThemeName().isEmpty() ||
!getAvailableThemes().contains(SettingsCache::instance().sound().getSoundThemeName())) {
qCInfo(SoundEngineLog) << "Sounds theme name not set, setting default value";
SettingsCache::instance().setSoundThemeName(DEFAULT_THEME_NAME);
SettingsCache::instance().sound().setSoundThemeName(DEFAULT_THEME_NAME);
}
}
@ -132,7 +126,7 @@ QStringMap &SoundEngine::getAvailableThemes()
void SoundEngine::themeChangedSlot()
{
QString themeName = SettingsCache::instance().getSoundThemeName();
QString themeName = SettingsCache::instance().sound().getSoundThemeName();
qCInfo(SoundEngineLog) << "Sound theme changed:" << themeName;
QDir dir = getAvailableThemes().value(themeName);

View file

@ -0,0 +1,69 @@
#ifndef COCKATRICE_URL_SCHEME_EVENT_FILTER_H
#define COCKATRICE_URL_SCHEME_EVENT_FILTER_H
#include <QEvent>
#include <QFileOpenEvent>
#include <QObject>
#include <QString>
#include <QUrl>
/**
* @brief Event filter that catches QFileOpenEvent URLs matching a scheme and
* re-emits them as urlReceived().
*
* On macOS, when the application is registered as a URL scheme handler, the
* OS delivers incoming URLs via QFileOpenEvent on the QApplication object.
* Install this filter on QApplication to intercept them:
*
* @code
* UrlSchemeEventFilter filter(QStringList{QStringLiteral("cockatrice")});
* QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived,
* &mainWindow, &MainWindow::handleUrl);
* app.installEventFilter(&filter);
* @endcode
*
* Note: the strings are compared against QUrl::scheme(), so they must be
* written without the "://" suffix (e.g. "cockatrice", not "cockatrice://").
*/
class UrlSchemeEventFilter : public QObject
{
Q_OBJECT
public:
explicit UrlSchemeEventFilter(const QStringList &schemes, QObject *parent = nullptr)
: QObject(parent), prefixes(schemes)
{
}
signals:
void urlReceived(const QString &url);
public:
bool eventFilter(QObject *watched, QEvent *event) override
{
if (event->type() == QEvent::FileOpen) {
auto *fileEvent = static_cast<QFileOpenEvent *>(event);
const QUrl url = fileEvent->url();
for (const auto &prefix : prefixes) {
if (url.scheme() == prefix) {
emit urlReceived(url.toString());
return true;
}
}
if (url.isLocalFile()) {
emit urlReceived(url.toLocalFile());
return true;
}
}
return QObject::eventFilter(watched, event);
}
private:
QStringList prefixes;
};
#endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H

View file

@ -0,0 +1,41 @@
#ifndef SETTINGS_CARD_DATABASE_PATH_PROVIDER_H
#define SETTINGS_CARD_DATABASE_PATH_PROVIDER_H
#include "../../client/settings/cache_settings.h"
#include <libcockatrice/interfaces/interface_card_database_path_provider.h>
#include <libcockatrice/settings/paths_settings.h>
class SettingsCardDatabasePathProvider : public ICardDatabasePathProvider
{
Q_OBJECT
public:
explicit SettingsCardDatabasePathProvider(QObject *parent = nullptr) : ICardDatabasePathProvider(parent)
{
connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this,
&ICardDatabasePathProvider::cardDatabasePathChanged);
}
[[nodiscard]] QString getCardDatabasePath() const override
{
return SettingsCache::instance().paths().getCardDatabasePath();
}
[[nodiscard]] QString getCustomCardDatabasePath() const override
{
return SettingsCache::instance().paths().getCustomCardDatabasePath();
}
[[nodiscard]] QString getTokenDatabasePath() const override
{
return SettingsCache::instance().paths().getTokenDatabasePath();
}
[[nodiscard]] virtual QString getSpoilerCardDatabasePath() const override
{
return SettingsCache::instance().paths().getSpoilerCardDatabasePath();
}
};
#endif // SETTINGS_CARD_DATABASE_PATH_PROVIDER_H

View file

@ -3,6 +3,8 @@
#include "../../client/settings/cache_settings.h"
#include <libcockatrice/interfaces/interface_card_preference_provider.h>
#include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
class SettingsCardPreferenceProvider : public ICardPreferenceProvider
{
@ -14,7 +16,7 @@ public:
[[nodiscard]] bool getIncludeRebalancedCards() const override
{
return SettingsCache::instance().getIncludeRebalancedCards();
return SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards();
}
};

View file

@ -52,18 +52,14 @@ static void setupParserRules()
search["Start"] = passthru;
search["QueryPartList"] = [](const peg::SemanticValues &sv) -> DeckFilter {
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) {
auto matchesFilter = [&deck, &info](const std::any &query) {
return std::any_cast<DeckFilter>(query)(deck, info);
};
return [=](const DeckSearchData &data) {
auto matchesFilter = [&data](const std::any &query) { return std::any_cast<DeckFilter>(query)(data); };
return std::all_of(sv.begin(), sv.end(), matchesFilter);
};
};
search["ComplexQueryPart"] = [](const peg::SemanticValues &sv) -> DeckFilter {
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) {
auto matchesFilter = [&deck, &info](const std::any &query) {
return std::any_cast<DeckFilter>(query)(deck, info);
};
return [=](const DeckSearchData &data) {
auto matchesFilter = [&data](const std::any &query) { return std::any_cast<DeckFilter>(query)(data); };
return std::any_of(sv.begin(), sv.end(), matchesFilter);
};
};
@ -71,9 +67,7 @@ static void setupParserRules()
search["QueryPart"] = passthru;
search["NotQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
const auto dependent = std::any_cast<DeckFilter>(sv[0]);
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) -> bool {
return !dependent(deck, info);
};
return [=](const DeckSearchData &data) -> bool { return !dependent(data); };
};
search["String"] = [](const peg::SemanticValues &sv) -> QString {
@ -125,9 +119,9 @@ static void setupParserRules()
auto cardFilter = FilterString(std::any_cast<QString>(sv[0]));
auto numberMatcher = sv.size() > 1 ? std::any_cast<NumberMatcher>(sv[1]) : [](int count) { return count > 0; };
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) -> bool {
return [=](const DeckSearchData &data) -> bool {
int count = 0;
auto cardNodes = deck->deckLoader->getDeck().deckList.getCardNodes();
auto cardNodes = data.deck->deckList.getCardNodes();
for (auto node : cardNodes) {
auto cardInfoPtr = CardDatabaseManager::query()->getCardInfo(node->getName());
if (!cardInfoPtr.isNull() && cardFilter.check(cardInfoPtr)) {
@ -146,53 +140,49 @@ static void setupParserRules()
search["DeckNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto name = std::any_cast<QString>(sv[0]);
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) {
return deck->deckLoader->getDeck().deckList.getName().contains(name, Qt::CaseInsensitive);
return [=](const DeckSearchData &data) {
return data.deck->deckList.getName().contains(name, Qt::CaseInsensitive);
};
};
search["FileNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto name = std::any_cast<QString>(sv[0]);
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) {
auto filename = QFileInfo(deck->filePath).fileName();
return [=](const DeckSearchData &data) {
auto filename = QFileInfo(data.filePath).fileName();
return filename.contains(name, Qt::CaseInsensitive);
};
};
search["PathQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto name = std::any_cast<QString>(sv[0]);
return [=](const DeckPreviewWidget *, const ExtraDeckSearchInfo &info) {
return info.relativeFilePath.contains(name, Qt::CaseInsensitive);
};
return [=](const DeckSearchData &data) { return data.relativeFilePath.contains(name, Qt::CaseInsensitive); };
};
search["FormatQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto format = std::any_cast<QString>(sv[0]);
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) {
auto gameFormat = deck->deckLoader->getDeck().deckList.getGameFormat();
return [=](const DeckSearchData &data) {
auto gameFormat = data.deck->deckList.getGameFormat();
return QString::compare(format, gameFormat, Qt::CaseInsensitive) == 0;
};
};
search["CommentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto value = std::any_cast<QString>(sv[0]);
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) {
auto comments = deck->deckLoader->getDeck().deckList.getComments();
return [=](const DeckSearchData &data) {
auto comments = data.deck->deckList.getComments();
return comments.contains(value, Qt::CaseInsensitive);
};
};
search["GenericQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto name = std::any_cast<QString>(sv[0]);
return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) {
return deck->getDisplayName().contains(name, Qt::CaseInsensitive);
};
return [=](const DeckSearchData &data) { return data.displayName.contains(name, Qt::CaseInsensitive); };
};
}
DeckFilterString::DeckFilterString()
{
filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; };
filter = [](const DeckSearchData &) { return false; };
_error = "Not initialized";
}
@ -205,7 +195,7 @@ DeckFilterString::DeckFilterString(const QString &expr)
_error = QString();
if (ba.isEmpty()) {
filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return true; };
filter = [](const DeckSearchData &) { return true; };
return;
}
@ -215,6 +205,6 @@ DeckFilterString::DeckFilterString(const QString &expr)
if (!search.parse(ba.data(), filter)) {
qCInfo(DeckFilterStringLog).nospace() << "DeckFilterString error for " << expr << "; " << qPrintable(_error);
filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; };
filter = [](const DeckSearchData &) { return false; };
}
}
}

View file

@ -7,7 +7,7 @@
#ifndef DECK_FILTER_STRING_H
#define DECK_FILTER_STRING_H
#include "../interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h"
#include "../interface/deck_loader/loaded_deck.h"
#include <QLoggingCategory>
#include <QString>
@ -16,26 +16,29 @@
inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string");
/**
* Extra info relevant to filtering that isn't present in the DeckPreviewWidget
* The data a deck search expression is evaluated against.
*
* This is a data view rather than a widget pointer, so the same filter
* expression can be evaluated against a model or a live widget.
*/
struct ExtraDeckSearchInfo
struct DeckSearchData
{
/**
* The relative filepath starting from the deck folder
*/
QString relativeFilePath;
const LoadedDeck *deck = nullptr; ///< The loaded deck. Must not be null.
QString filePath; ///< Absolute path of the deck file.
QString displayName; ///< Deck name, or the file name if the deck has no name.
QString relativeFilePath; ///< File path relative to the deck folder.
};
typedef std::function<bool(const DeckPreviewWidget *, const ExtraDeckSearchInfo &)> DeckFilter;
typedef std::function<bool(const DeckSearchData &data)> DeckFilter;
class DeckFilterString
{
public:
DeckFilterString();
explicit DeckFilterString(const QString &expr);
bool check(const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) const
bool check(const DeckSearchData &data) const
{
return filter(deck, info);
return filter(data);
}
[[nodiscard]] bool valid() const

View file

@ -31,7 +31,7 @@ AbstractClient *AbstractGame::getClientForPlayer(int playerId) const
}
}
void AbstractGame::loadReplay(GameReplay *replay)
void AbstractGame::loadReplay(const GameReplay *replay)
{
gameMetaInfo->setFromProto(replay->game_info());
gameMetaInfo->setSpectatorsOmniscient(true);

View file

@ -53,7 +53,7 @@ public:
AbstractClient *getClientForPlayer(int playerId) const;
void loadReplay(GameReplay *replay);
void loadReplay(const GameReplay *replay);
CardItem *getCard(int playerId, const QString &zoneName, int cardId) const;

View file

@ -287,6 +287,9 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
if (!game->getGameMetaInfo()->proto().share_decklists_on_load()) {
continue;
}
if (!playerInfo.has_deck_list()) {
continue;
}
opponentDecksToDisplay.append(
qMakePair(playerId, qMakePair(playerName, QString::fromStdString(playerInfo.deck_list()))));

View file

@ -1,8 +1,16 @@
/**
* @file game_event_handler.h
* @ingroup GameLogic
* @brief Game-level command sender and event dispatcher.
*
* GameEventHandler sends commands initiated by the local client to the server
* and processes incoming game-wide events. It bridges the networking layer
* (protobuf events received via AbstractClient) with the game model and UI
* (GameState, PlayerManager, logging, widgets).
*
* Player-scoped events are forwarded to PlayerEventHandler instances, while
* spectator and global game events are handled directly here.
*/
//! \todo Document this file.
#ifndef COCKATRICE_GAME_EVENT_HANDLER_H
#define COCKATRICE_GAME_EVENT_HANDLER_H
@ -15,92 +23,310 @@
#include <libcockatrice/protocol/pb/serverinfo_player.pb.h>
class AbstractClient;
class Response;
class AbstractGame;
class CommandContainer;
class GameCommand;
class GameEventContainer;
class GameEventContext;
class GameCommand;
class GameState;
class MessageLogWidget;
class CommandContainer;
class Event_GameJoined;
class PendingCommand;
class PlayerLogic;
class Response;
class Event_GameStateChanged;
class Event_PlayerPropertiesChanged;
class Event_Join;
class Event_Leave;
class Event_GameHostChanged;
class Event_GameClosed;
class Event_GameStart;
class Event_SetActivePlayer;
class Event_SetActivePhase;
class Event_Ping;
class Event_GameSay;
class Event_Kicked;
class Event_ReverseTurn;
class AbstractGame;
class PendingCommand;
class PlayerLogic;
class Event_Ping;
inline Q_LOGGING_CATEGORY(GameEventHandlerLog, "game_event_handler");
/**
* @class GameEventHandler
* @brief Central dispatcher for game-wide commands and events.
*
* This class owns no game state itself. Instead, it:
* - Sends commands to the server on behalf of local players
* - Receives and dispatches server-side game events
* - Updates the game model indirectly via Player, GameState, and PlayerManager
* - Emits high-level signals for UI updates and logging
*/
class GameEventHandler : public QObject
{
Q_OBJECT
private:
/** Pointer to the owning game instance. */
AbstractGame *game;
public:
/** @name Construction
* Lifecycle and ownership.
* @{
*/
/**
* @brief Construct a GameEventHandler.
*
* The handler is owned by the AbstractGame instance and uses it to
* access the game state, players, and network clients.
*
* @param _game Owning game instance (also used as QObject parent).
*/
explicit GameEventHandler(AbstractGame *_game);
/** @} */
/** @name Outgoing game commands
* Commands initiated locally and sent to the server.
*
* These methods construct and send protobuf commands corresponding
* to user actions in the UI.
* @{
*/
/** @brief Request advancing the game to the next turn. */
void handleNextTurn();
/** @brief Request reversing the current turn order. */
void handleReverseTurn();
/** @brief Concede the game for the currently active local player. */
void handleActiveLocalPlayerConceded();
/** @brief Undo a previous concede for the active local player. */
void handleActiveLocalPlayerUnconceded();
/**
* @brief Set the active phase of the game.
*
* Typically triggered by the active player selecting a new phase.
*
* @param phase Phase identifier.
*/
void handleActivePhaseChanged(int phase);
/** @brief Leave the current game session. */
void handleGameLeft();
/**
* @brief Send a chat message to all players and spectators.
*
* @param chatMessage Message text.
*/
void handleChatMessageSent(const QString &chatMessage);
/**
* @brief Delete an existing arrow.
*
* @param arrowId Unique identifier of the arrow to delete.
*/
void handleArrowDeletion(int creatorId, int arrowId);
void handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId);
/** @} */
/** @name Incoming event processing
* Entry points for server-sent events.
* @{
*/
/**
* @brief Process a container of game events received from the server.
*
* This is the main dispatch function for incoming game events.
* Events are routed to spectator handlers, game-level handlers,
* or forwarded to PlayerEventHandler instances as appropriate.
*
* @param cont Game event container from the server.
* @param client Client that received the container.
* @param options Processing flags (e.g. silent, replay).
*/
void
processGameEventContainer(const GameEventContainer &cont, AbstractClient *client, EventProcessingOptions options);
/** @} */
/** @name Command preparation helpers
* Internal helpers for building command containers.
* @{
*/
/**
* @brief Wrap a single protobuf command in a PendingCommand.
*
* @param cmd Protobuf command message.
* @return Newly allocated PendingCommand (caller takes ownership).
*/
PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd);
/**
* @brief Wrap multiple protobuf commands in a single PendingCommand.
*
* Ownership of the messages in cmdList is transferred to the handler.
*
* @param cmdList List of protobuf command messages.
* @return Newly allocated PendingCommand.
*/
PendingCommand *prepareGameCommand(const QList<const ::google::protobuf::Message *> &cmdList);
/** @} */
/** @name Spectator event handlers
* Events originating from spectators.
* @{
*/
/**
* @brief Handle a spectator chat message.
*/
void eventSpectatorSay(const Event_GameSay &event, int eventPlayerId, const GameEventContext &context);
/**
* @brief Handle a spectator leaving the game.
*/
void eventSpectatorLeave(const Event_Leave &event, int eventPlayerId, const GameEventContext &context);
/** @} */
/** @name Game state event handlers
* Events that affect global game state.
* @{
*/
/**
* @brief Handle a full game state update from the server.
*
* Used during game startup, reconnection, and resynchronization.
*/
void eventGameStateChanged(const Event_GameStateChanged &event, int eventPlayerId, const GameEventContext &context);
/**
* @brief Update card attachment relationships for all players.
*
* Called after a game state update to ensure attachments are resolved
* consistently across all zones.
*/
void processCardAttachmentsForPlayers(const Event_GameStateChanged &event);
/** @brief Handle a change in game host. */
void eventGameHostChanged(const Event_GameHostChanged &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle the game being closed by the server. */
void eventGameClosed(const Event_GameClosed &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle a change of the active player. */
void eventSetActivePlayer(const Event_SetActivePlayer &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle a change of the active phase. */
void eventSetActivePhase(const Event_SetActivePhase &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle a turn reversal event. */
void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle ping / latency updates. */
void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context);
/** @} */
/** @name Player lifecycle and property handlers
* Events related to players joining, leaving, or changing state.
* @{
*/
/**
* @brief Handle updates to a player's properties.
*
* Includes readiness, concede state, deck selection, sideboard lock,
* and connection state changes.
*/
void eventPlayerPropertiesChanged(const Event_PlayerPropertiesChanged &event,
int eventPlayerId,
const GameEventContext &context);
/** @brief Handle a player or spectator joining the game. */
void eventJoin(const Event_Join &event, int eventPlayerId, const GameEventContext &context);
/** @brief Handle a player leaving the game. */
void eventLeave(const Event_Leave &event, int eventPlayerId, const GameEventContext &context);
QString getLeaveReason(Event_Leave::LeaveReason reason);
/** @brief Handle the local player being kicked from the game. */
void eventKicked(const Event_Kicked &event, int eventPlayerId, const GameEventContext &context);
void eventGameHostChanged(const Event_GameHostChanged &event, int eventPlayerId, const GameEventContext &context);
void eventGameClosed(const Event_GameClosed &event, int eventPlayerId, const GameEventContext &context);
void eventSetActivePlayer(const Event_SetActivePlayer &event, int eventPlayerId, const GameEventContext &context);
void eventSetActivePhase(const Event_SetActivePhase &event, int eventPlayerId, const GameEventContext &context);
void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context);
void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext & /*context*/);
/**
* @brief Convert a leave reason enum to a human-readable string.
*/
QString getLeaveReason(Event_Leave::LeaveReason reason);
void commandFinished(const Response &response);
/** @} */
void
processGameEventContainer(const GameEventContainer &cont, AbstractClient *client, EventProcessingOptions options);
PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd);
PendingCommand *prepareGameCommand(const QList<const ::google::protobuf::Message *> &cmdList);
public slots:
/** @name Command dispatch slots
* Low-level command transmission.
* @{
*/
/**
* @brief Send a prepared PendingCommand.
*
* @param pend Pending command to send.
* @param playerId Player whose client should send the command.
*/
void sendGameCommand(PendingCommand *pend, int playerId = -1);
/**
* @brief Send a single protobuf command.
*
* @param command Protobuf command message.
* @param playerId Player whose client should send the command.
*/
void sendGameCommand(const ::google::protobuf::Message &command, int playerId = -1);
/**
* @brief Called when a PendingCommand finishes execution.
*
* Used to detect server-side errors such as chat flood protection.
*/
void commandFinished(const Response &response);
/** @} */
signals:
/** @name Core state signals
* @{
*/
void emitUserEvent();
void containerProcessingStarted(GameEventContext context);
void containerProcessingDone();
void gameFlooded();
void setContextJudgeName(QString judgeName);
/** @} */
/** @name Player and spectator signals
* @{
*/
void addPlayerToAutoCompleteList(QString playerName);
void localPlayerDeckSelected(PlayerLogic *localPlayer, int playerId, ServerInfo_Player playerInfo);
void remotePlayerDeckSelected(QString deckList, int playerId, QString playerName);
void remotePlayersDecksSelected(QVector<QPair<int, QPair<QString, QString>>> opponentDecks);
void localPlayerSideboardLocked(int playerId, bool sideboardLocked);
void localPlayerReadyStateChanged(int playerId, bool ready);
/** @} */
/** @name Game flow signals
* @{
*/
void gameStopped();
void gameClosed();
void playerPropertiesChanged(const ServerInfo_PlayerProperties &prop, int playerId);
@ -109,11 +335,15 @@ signals:
void playerKicked();
void spectatorJoined(const ServerInfo_PlayerProperties &spectatorInfo);
void spectatorLeft(int leavingSpectatorId);
void gameFlooded();
void containerProcessingStarted(GameEventContext context);
void setContextJudgeName(QString judgeName);
void containerProcessingDone();
void arrowDeleted(int creatorId, int arrowId);
/** @} */
/** @name Logging signals
* Signals consumed by MessageLogWidget.
* @{
*/
void logSpectatorSay(ServerInfo_User userInfo, QString message);
void logSpectatorLeave(QString name, QString reason);
void logGameStart();
@ -132,6 +362,8 @@ signals:
void logActivePhaseChanged(int activePhase);
void logConcede(int playerId);
void logUnconcede(int playerId);
/** @} */
};
#endif // COCKATRICE_GAME_EVENT_HANDLER_H

View file

@ -27,8 +27,11 @@
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
#include <libcockatrice/protocol/pb/command_undo_draw.pb.h>
#include <libcockatrice/protocol/pb/context_move_card.pb.h>
#include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/clamped_arithmetic.h>
#include <libcockatrice/utility/counter_limits.h>
#include <libcockatrice/utility/expression.h>
#include <libcockatrice/utility/trice_limits.h>
#include <libcockatrice/utility/zone_names.h>
// milliseconds in between triggers of the move top cards until action
@ -66,7 +69,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown)
const CardInfo &info = exactCard.getInfo();
int tableRow = info.getUiAttributes().tableRow;
bool playToStack = SettingsCache::instance().getPlayToStack();
bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
QString currentZone = card->getZone()->getName();
if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) {
cmd.set_target_zone(ZoneNames::GRAVE);
@ -309,7 +312,7 @@ void PlayerActions::actDrawCard()
void PlayerActions::actRequestMulliganDialog()
{
int startSize = SettingsCache::instance().getStartingHandSize();
int startSize = SettingsCache::instance().userInterface().getStartingHandSize();
int handSize = player->getHandZone()->getCards().size();
int deckSize = player->getDeckZone()->getCards().size() + handSize;
@ -325,7 +328,7 @@ void PlayerActions::actMulligan(int number)
}
doMulligan(number);
SettingsCache::instance().setStartingHandSize(number);
SettingsCache::instance().userInterface().setStartingHandSize(number);
}
void PlayerActions::actMulliganSameSize()
@ -882,7 +885,8 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate)
ExactCard correctedCard = CardDatabaseManager::query()->guessCard({lastTokenInfo.name, lastTokenInfo.providerId});
if (correctedCard) {
lastTokenInfo.name = correctedCard.getName();
lastTokenTableRow = TableZone::tableRowToGridY(correctedCard.getInfo().getUiAttributes().tableRow);
int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow;
lastTokenTableRow = TableZone::tableRowToGridY(tableRow);
if (lastTokenInfo.pt.isEmpty()) {
lastTokenInfo.pt = correctedCard.getInfo().getPowTough();
}
@ -928,13 +932,13 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo)
return;
}
lastTokenInfo = {.name = cardInfo->getName(),
.color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(),
.pt = cardInfo->getPowTough(),
.annotation = SettingsCache::instance().getAnnotateTokens() ? cardInfo->getText() : "",
.destroy = true,
.providerId =
SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())};
lastTokenInfo = {
.name = cardInfo->getName(),
.color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(),
.pt = cardInfo->getPowTough(),
.annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "",
.destroy = true,
.providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())};
lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow);
@ -1017,8 +1021,9 @@ void PlayerActions::actCreateAllRelatedCards()
if (!cardRelationAll->getDoesAttach() && !cardRelationAll->getIsVariable()) {
dbName = cardRelationAll->getName();
bool persistent = cardRelationAll->getIsPersistent();
bool faceDown = cardRelationAll->getIsFaceDown();
for (int i = 0; i < cardRelationAll->getDefaultCount(); ++i) {
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent);
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown);
}
++tokensTypesCreated;
if (tokensTypesCreated == 1) {
@ -1033,8 +1038,9 @@ void PlayerActions::actCreateAllRelatedCards()
if (!cardRelationNotExcluded->getDoesAttach() && !cardRelationNotExcluded->getIsVariable()) {
dbName = cardRelationNotExcluded->getName();
bool persistent = cardRelationNotExcluded->getIsPersistent();
bool faceDown = cardRelationNotExcluded->getIsFaceDown();
for (int i = 0; i < cardRelationNotExcluded->getDefaultCount(); ++i) {
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent);
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown);
}
++tokensTypesCreated;
if (tokensTypesCreated == 1) {
@ -1072,6 +1078,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard,
const QString dbName = cardRelation->getName();
const bool persistent = cardRelation->getIsPersistent();
const bool faceDown = cardRelation->getIsFaceDown();
// Variable relations always use DoesNotAttach, regardless of the count the user
// entered.
@ -1080,7 +1087,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard,
return false;
}
for (int i = 0; i < variableCount; ++i) {
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent);
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown);
}
return true;
}
@ -1089,7 +1096,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard,
if (count > 1) {
for (int i = 0; i < count; ++i) {
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent);
createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown);
}
return true;
}
@ -1109,7 +1116,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard,
playCardToTable(sourceCard, false);
}
createCard(sourceCard, dbName, attachType, persistent);
createCard(sourceCard, dbName, attachType, persistent, faceDown);
return true;
}
@ -1136,7 +1143,8 @@ void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardR
void PlayerActions::createCard(const CardItem *sourceCard,
const QString &dbCardName,
CardRelationType attachType,
bool persistent)
bool persistent,
bool faceDown)
{
CardInfoPtr cardInfo = CardDatabaseManager::query()->getCardInfo(dbCardName);
@ -1163,7 +1171,7 @@ void PlayerActions::createCard(const CardItem *sourceCard,
}
cmd.set_pt(cardInfo->getPowTough().toStdString());
if (SettingsCache::instance().getAnnotateTokens()) {
if (SettingsCache::instance().userInterface().getAnnotateTokens()) {
cmd.set_annotation(cardInfo->getText().toStdString());
} else {
cmd.set_annotation("");
@ -1171,6 +1179,7 @@ void PlayerActions::createCard(const CardItem *sourceCard,
cmd.set_destroy_on_zone_change(!persistent);
cmd.set_x(gridPoint.x());
cmd.set_y(gridPoint.y());
cmd.set_face_down(faceDown);
ExactCard relatedCard =
CardDatabaseManager::query()->getCardFromSameSet(cardInfo->getName(), sourceCard->getCard().getPrinting());
@ -1345,11 +1354,7 @@ void PlayerActions::actSetPT(QList<CardItem *> selectedCards, const QString &pt)
const auto oldpt = CardItem::parsePT(card->getPT());
int ptIter = 0;
for (const auto &_item : ptList) {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
if (_item.typeId() == QMetaType::Type::Int) {
#else
if (_item.type() == QVariant::Int) {
#endif
int oldItem = ptIter < oldpt.size() ? oldpt.at(ptIter).toInt() : 0;
newpt += '/' + QString::number(oldItem + _item.toInt());
} else {
@ -1524,12 +1529,15 @@ void PlayerActions::offsetCardCounter(QList<CardItem *> selectedCards, int count
QList<const ::google::protobuf::Message *> commandList;
for (auto card : selectedCards) {
int oldValue = card->getCounters().value(counterId, 0);
int newValue = oldValue + offset;
// Early exit optimization: server enforces [0, MAX_COUNTERS_ON_CARD].
// Compare clamped value to allow recovery from invalid states.
int clampedValue = qBound(0, newValue, MAX_COUNTERS_ON_CARD);
if (clampedValue != oldValue) {
// Overflow-safe clamp to the server-enforced range [0, MAX_COUNTER_VALUE];
// a result differing from oldValue also corrects an out-of-range cached value.
// Callers only ever pass offset == ±1 (actAddCardCounter / actRemoveCardCounter).
// This client-side clamp is a defense-in-depth UX check, consistent with
// actSetCardCounter and actIncrementAllCardCounters; the server remains the
// authoritative enforcer of the bounds.
int newValue = addClamped(oldValue, offset, 0, MAX_COUNTER_VALUE);
if (newValue != oldValue) {
auto *cmd = new Command_SetCardCounter;
cmd->set_zone(card->getZone()->getName().toStdString());
cmd->set_card_id(card->getId());
@ -1562,7 +1570,7 @@ void PlayerActions::actSetCardCounter(QList<CardItem *> selectedCards, int count
Expression exp(oldValue);
double parsed = exp.parse(counterValue);
// Clamp in double precision first to avoid UB, then cast
int number = static_cast<int>(qBound(0.0, parsed, static_cast<double>(MAX_COUNTERS_ON_CARD)));
int number = static_cast<int>(qBound(0.0, parsed, static_cast<double>(MAX_COUNTER_VALUE)));
auto *cmd = new Command_SetCardCounter;
cmd->set_zone(card->getZone()->getName().toStdString());
@ -1592,7 +1600,7 @@ void PlayerActions::actIncrementAllCardCounters(QList<CardItem *> cardsToUpdate)
counterIterator.next();
int counterId = counterIterator.key();
int currentValue = counterIterator.value();
if (currentValue >= MAX_COUNTERS_ON_CARD) {
if (currentValue >= MAX_COUNTER_VALUE) {
continue;
}

View file

@ -240,7 +240,8 @@ private:
void createCard(const CardItem *sourceCard,
const QString &dbCardName,
CardRelationType attach = CardRelationType::DoesNotAttach,
bool persistent = false);
bool persistent = false,
bool faceDown = false);
void playSelectedCards(QList<CardItem *> selectedCards, bool faceDown = false);

View file

@ -1,11 +1,24 @@
/**
* @file player_event_handler.h
* @ingroup GameLogicPlayers
* @brief Player-scoped game event handler.
*
* PlayerEventHandler applies game events that affect a single Players
* board state, zones, cards, counters, arrows, and related UI/log output.
*
* It is invoked by GameEventHandler after basic routing and validation.
* Each instance is bound 1:1 to a Player and must never mutate state
* belonging to other players except where explicitly required by events
* (e.g. moving cards between players, attaching cards, arrows).
*
* This class is intentionally stateful and tightly coupled to Player,
* PlayerActions, and the board/zones implementation. It performs both
* model mutation and UI-side bookkeeping (zone views, arrows, menus).
*/
//! \todo Document this file.
#ifndef COCKATRICE_PLAYER_EVENT_HANDLER_H
#define COCKATRICE_PLAYER_EVENT_HANDLER_H
#include "event_processing_options.h"
#include <QObject>
@ -16,6 +29,7 @@
class CardItem;
class CardZoneLogic;
class PlayerLogic;
class Event_AttachCard;
class Event_ChangeZoneProperties;
class Event_CreateArrow;
@ -37,11 +51,176 @@ class Event_SetCounter;
class Event_Shuffle;
class Event_GameLogNotice;
/**
* @class PlayerEventHandler
* @brief Applies player-specific game events and emits corresponding log signals.
*
* Design notes:
* - All event handlers assume events are authoritative and already validated
* by the server.
* - Most handlers mutate both logical state (CardItem, CardZoneLogic, counters)
* and visual/UI state (views, arrows, menus).
* - Logging signals are emitted *after* or *during* state mutation, depending
* on whether later mutations would invalidate log data.
*/
class PlayerEventHandler : public QObject
{
Q_OBJECT
public:
/**
* @brief Construct a PlayerEventHandler bound to a Player.
* @param player Owning player instance.
*/
explicit PlayerEventHandler(PlayerLogic *player);
/** @name Event dispatch
* @{
*/
/**
* @brief Dispatch a generic GameEvent to the appropriate handler.
*
* This is the single entry point used by GameEventHandler. It extracts
* the correct protobuf extension and forwards the event to a typed
* handler method.
*
* @param type Game event type enum.
* @param event Generic protobuf container.
* @param context Additional context (undo, judge, etc.).
* @param options Processing options (UI suppression, reveal behavior).
*/
void processGameEvent(GameEvent::GameEventType type,
const GameEvent &event,
const GameEventContext &context,
EventProcessingOptions options);
/** @} */
/** @name Chat and randomization events
* @{
*/
/// Handle in-game chat messages from this player.
void eventGameSay(const Event_GameSay &event);
/// Handle zone shuffle events (typically libraries).
void eventShuffle(const Event_Shuffle &event);
/// Handle die roll events.
void eventRollDie(const Event_RollDie &event);
/** @} */
/** @name Arrow and targeting events
* @{
*/
/// Create a visual arrow between cards or players.
void eventCreateArrow(const Event_CreateArrow &event);
/// Delete an existing arrow.
void eventDeleteArrow(const Event_DeleteArrow &event);
/** @} */
/** @name Token and card creation
* @{
*/
/// Create a token card in a target zone.
void eventCreateToken(const Event_CreateToken &event);
/** @} */
/** @name Card attribute and counter updates
* @{
*/
/**
* @brief Set a card attribute (tapped, PT, annotation, etc.).
*
* May apply to a single card or all cards in a zone if no card ID
* is provided by the event.
*/
void
eventSetCardAttr(const Event_SetCardAttr &event, const GameEventContext &context, EventProcessingOptions options);
/// Update a counter attached to a card.
void eventSetCardCounter(const Event_SetCardCounter &event);
/// Create a player-level counter.
void eventCreateCounter(const Event_CreateCounter &event);
/// Set a player-level counter value.
void eventSetCounter(const Event_SetCounter &event);
/// Delete a player-level counter.
void eventDelCounter(const Event_DelCounter &event);
/** @} */
/** @name Zone-level operations
* @{
*/
/// Log a zone dump (e.g. reveal graveyard/library contents).
void eventDumpZone(const Event_DumpZone &event);
/**
* @brief Move a card between zones and/or players.
*
* This is one of the most complex handlers:
* - Removes the card from the start zone
* - Updates card identity and ownership if needed
* - Handles attachments and arrows
* - Emits appropriate move or undo-draw logs
* - Inserts the card into the target zone
*/
void eventMoveCard(const Event_MoveCard &event, const GameEventContext &context);
/// Flip a card face up or face down.
void eventFlipCard(const Event_FlipCard &event);
/// Destroy a card and clean up attachments.
void eventDestroyCard(const Event_DestroyCard &event);
/// Attach or detach a card to/from another card.
void eventAttachCard(const Event_AttachCard &event);
/** @} */
/** @name Draw and reveal operations
* @{
*/
/// Draw one or more cards from the deck.
void eventDrawCards(const Event_DrawCards &event);
/**
* @brief Reveal cards from a zone.
*
* Handles peeking, in-place top-card reveals, full reveal windows,
* and write-access granting.
*/
void eventRevealCards(const Event_RevealCards &event, EventProcessingOptions options);
/** @} */
/** @name Zone configuration
* @{
*/
/// Update zone visibility and reveal behavior.
void eventChangeZoneProperties(const Event_ChangeZoneProperties &event);
/** @} */
void eventGameLogNotice(const Event_GameLogNotice &event);
signals:
/** @name Logging signals
* @{
*/
void logSay(PlayerLogic *player, QString message);
void logShuffle(PlayerLogic *player, CardZoneLogic *zone, int start, int end);
void logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls);
@ -83,40 +262,13 @@ signals:
bool isLentToAnotherPlayer = false);
void logAlwaysRevealTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal);
void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal);
/** @} */
void cardZoneChanged(CardItem *card, bool sameZone);
void requestCardMenuUpdate(const CardItem *card);
public:
PlayerEventHandler(PlayerLogic *player);
void processGameEvent(GameEvent::GameEventType type,
const GameEvent &event,
const GameEventContext &context,
EventProcessingOptions options);
void eventGameSay(const Event_GameSay &event);
void eventShuffle(const Event_Shuffle &event);
void eventRollDie(const Event_RollDie &event);
void eventCreateArrow(const Event_CreateArrow &event);
void eventDeleteArrow(const Event_DeleteArrow &event);
void eventCreateToken(const Event_CreateToken &event);
void
eventSetCardAttr(const Event_SetCardAttr &event, const GameEventContext &context, EventProcessingOptions options);
void eventSetCardCounter(const Event_SetCardCounter &event);
void eventCreateCounter(const Event_CreateCounter &event);
void eventSetCounter(const Event_SetCounter &event);
void eventDelCounter(const Event_DelCounter &event);
void eventDumpZone(const Event_DumpZone &event);
void eventMoveCard(const Event_MoveCard &event, const GameEventContext &context);
void eventFlipCard(const Event_FlipCard &event);
void eventDestroyCard(const Event_DestroyCard &event);
void eventAttachCard(const Event_AttachCard &event);
void eventDrawCards(const Event_DrawCards &event);
void eventRevealCards(const Event_RevealCards &event, EventProcessingOptions options);
void eventChangeZoneProperties(const Event_ChangeZoneProperties &event);
void eventGameLogNotice(const Event_GameLogNotice &event);
private:
/** Owning player instance. */
PlayerLogic *player;
void setCardAttrHelper(const GameEventContext &context,

View file

@ -2,7 +2,7 @@
#include "../interface/widgets/tabs/tab_game.h"
Replay::Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent)
Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent)
{
gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false);
connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged);

View file

@ -15,7 +15,7 @@ class Replay : public AbstractGame
Q_OBJECT
public:
explicit Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame);
explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame);
};
#endif // COCKATRICE_REPLAY_H

View file

@ -3,6 +3,7 @@
#include "../../client/settings/cache_settings.h"
#include "../../game_graphics/board/card_item.h"
#include <libcockatrice/settings/interface_settings.h>
/**
* @param _player the player that the cards are revealed to.
* @param _origZone the zone the cards were revealed from.
@ -57,7 +58,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x)
// autoclose check is done both here and in removeCard
if (cards.isEmpty() && !doInsert && SettingsCache::instance().getCloseEmptyCardView()) {
if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) {
emit closeView();
}
@ -144,7 +145,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone)
// card gets dragged within the view.
// Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an
// unrevealed portion of the same zone.
if (cards.isEmpty() && SettingsCache::instance().getCloseEmptyCardView() && toNewZone) {
if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) {
emit closeView();
return;
}

View file

@ -0,0 +1,26 @@
#ifndef ANIMATED_ITEM_H
#define ANIMATED_ITEM_H
/**
* @file animated_item.h
* @ingroup GameGraphics
* @brief Interface for scene items driven by GameScene's shared animation timer.
*
* Items that want per-tick animation while a single QBasicTimer runs (instead of
* owning their own QTimer) implement this interface and register with the scene
* via GameScene::registerAnimationItem.
*/
class IAnimatedItem
{
public:
virtual ~IAnimatedItem() = default;
/**
* @brief Advances the item's animation by one timer tick.
* @return true while the animation is still running, false once it has finished.
*/
virtual bool animationEvent() = 0;
};
#endif

View file

@ -7,6 +7,7 @@
#include <QDebug>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <libcockatrice/settings/cards_display_settings.h>
const QColor GHOST_MASK = QColor(255, 255, 255, 50);
@ -34,12 +35,13 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
setCacheMode(DeviceCoordinateCache);
connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
[this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
prepareGeometryChange();
update();
});
prepareGeometryChange();
update();
});
connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater);
}
@ -47,7 +49,8 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
QPainterPath AbstractCardDragItem::shape() const
{
QPainterPath shape;
qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
qreal cardCornerRadius =
SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius);
return shape;
}

View file

@ -12,6 +12,9 @@
#include <algorithm>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/debug_settings.h>
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
@ -21,15 +24,17 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
setFlag(ItemIsSelectable);
setCacheMode(DeviceCoordinateCache);
connect(&SettingsCache::instance(), &SettingsCache::displayCardNamesChanged, this, [this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); });
refreshCardInfo();
connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
[this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
prepareGeometryChange();
update();
});
prepareGeometryChange();
update();
});
}
AbstractCardItem::~AbstractCardItem()
@ -45,7 +50,8 @@ QRectF AbstractCardItem::boundingRect() const
QPainterPath AbstractCardItem::shape() const
{
QPainterPath shape;
qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
qreal cardCornerRadius =
SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius);
return shape;
}
@ -101,7 +107,7 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const
void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle)
{
const int MAX_FONT_SIZE = SettingsCache::instance().getMaxFontSize();
const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize();
const int fontSize = std::max(9, MAX_FONT_SIZE);
QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect());
@ -151,7 +157,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
painter->drawPath(shape());
}
if (translatedPixmap.isNull() || SettingsCache::instance().getDisplayCardNames() || facedown) {
if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) {
painter->save();
transformPainter(painter, translatedSize, angle);
painter->setPen(Qt::white);
@ -234,7 +240,7 @@ void AbstractCardItem::setHovered(bool _hovered)
isHovered = _hovered;
setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue);
setScale(_hovered && SettingsCache::instance().getScaleCards() ? 1.1 : 1);
setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1);
setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0);
update();
}
@ -287,7 +293,7 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate)
}
tapped = _tapped;
if (SettingsCache::instance().getTapAnimation() && canAnimate) {
if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) {
static_cast<GameScene *>(scene())->registerAnimationItem(this);
} else {
tapAngle = tapped ? 90 : 0;
@ -299,6 +305,11 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate)
}
}
bool AbstractCardItem::animationEvent()
{
return false;
}
void AbstractCardItem::setFaceDown(bool _facedown)
{
facedown = _facedown;

View file

@ -7,6 +7,7 @@
#ifndef ABSTRACTCARDITEM_H
#define ABSTRACTCARDITEM_H
#include "../animated_item.h"
#include "../card_dimensions.h"
#include "arrow_target.h"
#include "graphics_item_type.h"
@ -16,7 +17,7 @@
class PlayerLogic;
class AbstractCardItem : public ArrowTarget
class AbstractCardItem : public ArrowTarget, public IAnimatedItem
{
Q_OBJECT
protected:
@ -126,6 +127,9 @@ public:
emit deleteCardInfoPopup(cardRef.name);
}
/** @brief Default: no per-tick animation. Subclasses override to animate. */
bool animationEvent() override;
protected:
void transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle);
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;

View file

@ -1,6 +1,7 @@
#include "abstract_counter.h"
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/shortcuts_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game_graphics/board/translate_counter_name.h"
@ -28,8 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state,
{
setAcceptHoverEvents(true);
connect(state, &CounterState::valueChanged, this, [this](int, int newValue) {
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
value = newValue;
onValueChanged(oldValue, newValue);
update();
});
@ -227,3 +229,9 @@ void AbstractCounterDialog::changeValue(int diff)
curValue += diff;
setTextValue(QString::number(curValue));
}
void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/)
{
// Default: no feedback. Subclasses such as PlayerCounter override this to
// flash the counter on meaningful changes (life gain/loss).
}

View file

@ -35,6 +35,13 @@ protected:
bool hovered = false;
bool useNameForShortcut;
/**
* @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash).
*
* Called whenever the counter's value changes, before the item repaints.
*/
virtual void onValueChanged(int oldValue, int newValue);
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;

View file

@ -18,6 +18,7 @@
#include <libcockatrice/protocol/pb/command_attach_card.pb.h>
#include <libcockatrice/protocol/pb/command_create_arrow.pb.h>
#include <libcockatrice/protocol/pb/command_delete_arrow.pb.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/color.h>
#include <libcockatrice/utility/zone_names.h>
@ -261,7 +262,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (startZone->getName() == ZoneNames::HAND) {
startCard->playCard(false);
CardInfoPtr ci = startCard->getCard().getCardPtr();
bool playToStack = SettingsCache::instance().getPlayToStack();
bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) ||
(playToStack && ci->getUiAttributes().tableRow != 0 &&
startCard->getZone()->getName() != ZoneNames::STACK))) {

View file

@ -1,6 +1,7 @@
#include "card_item.h"
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/card_counter_settings.h"
#include "../../game/phase.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
@ -19,6 +20,7 @@
#include <QPainter>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/settings/interface_settings.h>
CardItem::CardItem(PlayerLogic *_owner,
QGraphicsItem *parent,
@ -301,7 +303,7 @@ void CardItem::drawArrow(const QColor &arrowColor)
auto *game = owner->getGame();
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
int phase = 0; // 0 means to not set the phase
if (SettingsCache::instance().getDoNotDeleteArrowsInSubPhases()) {
if (SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()) {
int currentPhase = game->getGameState()->getCurrentPhase();
phase = Phases::getLastSubphase(currentPhase) + 1;
}
@ -420,7 +422,7 @@ void CardItem::playCard(bool faceDown)
if (tz) {
emit tz->toggleTapped();
} else {
if (SettingsCache::instance().getClickPlaysAllSelected()) {
if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
if (faceDown) {
emit playSelectedFaceDown(this);
} else {
@ -484,7 +486,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone)
void CardItem::handleClickedToPlay(bool shiftHeld)
{
if (isUnwritableRevealZone(state->getZone())) {
if (SettingsCache::instance().getClickPlaysAllSelected()) {
if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
emit hideSelected(this);
} else {
state->getZone()->removeCard(this);
@ -501,7 +503,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
return;
}
if ((event->modifiers() != Qt::AltModifier) && (event->button() == Qt::LeftButton) &&
(!SettingsCache::instance().getDoubleClickToPlay())) {
(!SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
}
if (owner != nullptr) {
@ -513,7 +515,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
(SettingsCache::instance().getDoubleClickToPlay())) {
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
}
event->accept();

View file

@ -12,7 +12,6 @@
#include "abstract_card_item.h"
#include <libcockatrice/network/server/remote/game/server_card.h>
#include <libcockatrice/utility/trice_limits.h>
class CardDatabase;
class CardDragItem;
@ -143,7 +142,7 @@ public:
void resetState(bool keepAnnotations = false);
void processCardInfo(const ServerInfo_Card &_info);
bool animationEvent();
bool animationEvent() override;
CardDragItem *createDragItem(int _id, const QPointF &_pos, const QPointF &_scenePos, bool forceFaceDown);
void deleteDragItem();
void drawArrow(const QColor &arrowColor);

View file

@ -11,6 +11,7 @@
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
const QPointF &_hotSpot,
@ -77,11 +78,12 @@ DeckViewCard::DeckViewCard(QGraphicsItem *parent, const CardRef &cardRef, const
{
setAcceptHoverEvents(true);
connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
[this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
update();
});
update();
});
}
DeckViewCard::~DeckViewCard()
@ -99,7 +101,8 @@ void DeckViewCard::paint(QPainter *painter, const QStyleOptionGraphicsItem *opti
pen.setJoinStyle(Qt::MiterJoin);
pen.setColor(originZone == DECK_ZONE_MAIN ? Qt::green : Qt::red);
painter->setPen(pen);
qreal cardRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * (CardDimensions::WIDTH_F - 3) : 0.0;
qreal cardRadius =
SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * (CardDimensions::WIDTH_F - 3) : 0.0;
painter->drawRoundedRect(QRectF(1.5, 1.5, CardDimensions::WIDTH_F - 3, CardDimensions::HEIGHT_F - 3), cardRadius,
cardRadius);
painter->restore();
@ -360,6 +363,16 @@ void DeckViewScene::rebuildTree()
return;
}
QStringList requiredZones = {DECK_ZONE_MAIN, DECK_ZONE_SIDE};
for (const QString &zoneName : requiredZones) {
if (!cardContainers.contains(zoneName)) {
auto *container = new DeckViewCardContainer(zoneName);
cardContainers.insert(zoneName, container);
addItem(container);
}
}
for (auto *currentZone : deck->getZoneNodes()) {
DeckViewCardContainer *container = cardContainers.value(currentZone->getName(), 0);
if (!container) {

View file

@ -1,6 +1,7 @@
#include "deck_view_container.h"
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/shortcuts_settings.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/deck_loader/deck_loader.h"
#include "../../interface/widgets/dialogs/dlg_load_deck.h"
@ -19,7 +20,8 @@
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/trice_limits.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
#include <libcockatrice/utility/string_limits.h>
ToggleButton::ToggleButton(QWidget *parent) : QPushButton(parent), state(false)
{
@ -95,8 +97,8 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
&DeckViewContainer::refreshShortcuts);
refreshShortcuts();
connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageInGameChanged, this,
&DeckViewContainer::setVisualDeckStorageExists);
connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged,
this, &DeckViewContainer::setVisualDeckStorageExists);
switchToDeckSelectView();
}
@ -138,7 +140,7 @@ static void setVisibility(QPushButton *button, bool visible)
void DeckViewContainer::switchToDeckSelectView()
{
if (SettingsCache::instance().getVisualDeckStorageInGame()) {
if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()) {
deckView->setHidden(true);
tryCreateVisualDeckStorageWidget();
@ -209,6 +211,7 @@ void DeckViewContainer::refreshShortcuts()
loadLocalButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadLocalButton"));
loadRemoteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadRemoteButton"));
loadFromClipboardButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromClipboardButton"));
loadFromWebsiteButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/loadFromWebsiteButton"));
unloadDeckButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/unloadDeckButton"));
readyStartButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/readyStartButton"));
sideboardLockButton->setShortcut(shortcuts.getSingleShortcut("DeckViewContainer/sideboardLockButton"));

View file

@ -20,7 +20,10 @@
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/models/database/card_database_model.h>
#include <libcockatrice/models/database/token/token_display_model.h>
#include <libcockatrice/utility/trice_limits.h>
#include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/layouts_settings.h>
#include <libcockatrice/utility/string_limits.h>
DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *parent)
: QDialog(parent), predefinedTokens(_predefinedTokens)
@ -186,7 +189,7 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex &current, const QMo
const QChar cardColor = cardInfo->getColorChar();
colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString));
ptEdit->setText(cardInfo->getPowTough());
if (SettingsCache::instance().getAnnotateTokens()) {
if (SettingsCache::instance().userInterface().getAnnotateTokens()) {
annotationEdit->setText(cardInfo->getText());
}
} else {

View file

@ -5,7 +5,7 @@
#include <QSpinBox>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/utility/trice_limits.h>
#include <libcockatrice/utility/dice_limits.h>
DlgRollDice::DlgRollDice(QWidget *parent) : QDialog(parent)
{

View file

@ -17,8 +17,8 @@
#include <QDebug>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QSet>
#include <QtMath>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
#include <numeric>
@ -36,7 +36,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
{
animationTimer = new QBasicTimer;
addItem(phasesToolbar);
connect(&SettingsCache::instance(), &SettingsCache::minPlayersForMultiColumnLayoutChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
&GameScene::rearrange);
rearrange();
@ -44,7 +44,25 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
GameScene::~GameScene()
{
// Sever all incoming connections (animated item destroy-tracking) before the
// members below are destroyed: the base QGraphicsScene destructor destroys the
// remaining items, and their destroyed() signals must not reach slots that
// reference members that no longer exist.
disconnect(this);
delete animationTimer;
animationTimer = nullptr;
// Delete all ArrowItems before QGraphicsScene's base destructor runs.
// QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order.
// If a PlayerTarget is destroyed before an ArrowItem pointing to it,
// ArrowItem::onTargetDestroyed fires and emits on the partially-destroyed
// GameScene, causing a segfault.
for (auto *item : items()) {
if (auto *arrow = qgraphicsitem_cast<ArrowItem *>(item)) {
delete arrow;
}
}
// DO NOT call clearViews() here
// clearViews calls close() on the zoneViews, which sends signals; sending signals in destructors leads to segfaults
@ -324,7 +342,7 @@ QList<PlayerLogic *> GameScene::rotatePlayers(const QList<PlayerLogic *> &active
int GameScene::determineColumnCount(int playerCount)
{
return playerCount < SettingsCache::instance().getMinPlayersForMultiColumnLayout() ? 1 : 2;
return playerCount < SettingsCache::instance().userInterface().getMinPlayersForMultiColumnLayout() ? 1 : 2;
}
/**
@ -529,7 +547,9 @@ void GameScene::clearArrowsForPlayer(int playerId)
void GameScene::clearArrowsForPlayerLocally(int playerId)
{
for (int arrowId : arrowRegistry.idsForPlayer(playerId)) {
arrowRegistry.take(playerId, arrowId)->delArrow();
if (auto *arrow = arrowRegistry.take(playerId, arrowId)) {
arrow->delArrow();
}
}
}
@ -722,30 +742,45 @@ bool GameScene::event(QEvent *event)
void GameScene::timerEvent(QTimerEvent * /*event*/)
{
QMutableSetIterator<CardItem *> i(cardsToAnimate);
QMutableHashIterator<QObject *, IAnimatedItem *> i(animatedItems);
while (i.hasNext()) {
i.next();
if (!i.value()->animationEvent()) {
i.remove();
}
}
if (cardsToAnimate.isEmpty()) {
if (animatedItems.isEmpty()) {
animationTimer->stop();
}
}
void GameScene::registerAnimationItem(AbstractCardItem *card)
void GameScene::registerAnimationItem(IAnimatedItem *item)
{
cardsToAnimate.insert(static_cast<CardItem *>(card));
if (!animationTimer->isActive()) {
auto *object = dynamic_cast<QObject *>(item);
if (!object) {
return;
}
if (!animatedItems.contains(object)) {
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem);
}
animatedItems.insert(object, item);
if (animationTimer && !animationTimer->isActive()) {
animationTimer->start(10, this);
}
}
void GameScene::unregisterAnimationItem(AbstractCardItem *card)
void GameScene::unregisterAnimationItem(IAnimatedItem *item)
{
cardsToAnimate.remove(static_cast<CardItem *>(card));
if (cardsToAnimate.isEmpty()) {
animatedItems.remove(dynamic_cast<QObject *>(item));
if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop();
}
}
void GameScene::removeAnimatedItem(QObject *item)
{
animatedItems.remove(item);
if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop();
}
}

View file

@ -4,13 +4,14 @@
#include "../game/arrow_registry.h"
#include "../game/board/arrow_data.h"
#include "../game/zones/card_zone_logic.h"
#include "animated_item.h"
#include "board/arrow_item.h"
#include <QGraphicsScene>
#include <QHash>
#include <QList>
#include <QLoggingCategory>
#include <QPointer>
#include <QSet>
inline Q_LOGGING_CATEGORY(GameSceneLog, "game_scene");
inline Q_LOGGING_CATEGORY(GameScenePlayerAdditionRemovalLog, "game_scene.player_addition_removal");
@ -24,6 +25,7 @@ class CardItem;
class ServerInfo_Card;
class PhasesToolbar;
class QBasicTimer;
class QObject;
/**
* @class GameScene
@ -50,8 +52,8 @@ private:
QList<ZoneViewWidget *> zoneViews; ///< Active zone view widgets
QSize viewSize; ///< Current view size
QPointer<CardItem> hoveredCard; ///< Currently hovered card
QBasicTimer *animationTimer; ///< Timer for card animations
QSet<CardItem *> cardsToAnimate; ///< Cards currently animating
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
int playerRotation; ///< Rotation offset for player layout
/**
@ -182,15 +184,24 @@ public:
/** @brief Updates hovered card highlighting. */
void updateHoveredCard(CardItem *newCard);
/** @brief Registers a card for animation updates. */
void registerAnimationItem(AbstractCardItem *card);
/**
* @brief Registers an item for animation updates with the shared scene timer.
*
* The item must inherit QObject; it is unregistered automatically when it is
* destroyed, so it may be deleted mid-animation without a dangling pointer.
*/
void registerAnimationItem(IAnimatedItem *item);
/** @brief Unregisters a card from animation updates. */
void unregisterAnimationItem(AbstractCardItem *card);
/** @brief Unregisters an item from animation updates. */
void unregisterAnimationItem(IAnimatedItem *item);
void startRubberBand(const QPointF &selectionOrigin);
void resizeRubberBand(const QPointF &cursorPoint, int selectedCount);
void stopRubberBand();
private slots:
/** @brief Removes a destroyed item from the animation set. */
void removeAnimatedItem(QObject *item);
public slots:
void onCardSelectionChanged(AbstractCardItem *card, bool selected);
void onCardRightClicked(AbstractCardItem *card, QPoint screenPos);

View file

@ -1,12 +1,17 @@
#include "game_view.h"
#include "../client/settings/cache_settings.h"
#include "../client/settings/shortcuts_settings.h"
#include "game_scene.h"
#include <QAction>
#include <QGridLayout>
#include <QLabel>
#include <QLayout>
#include <QResizeEvent>
#include <QRubberBand>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/qt_utils.h>
// QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings.
// This subclass disables that behavior so dragCountLabel can appear above it.
@ -34,7 +39,6 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
{
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
setFocusPolicy(Qt::ClickFocus);
setViewportUpdateMode(BoundingRectViewportUpdate);
connect(scene, &GameScene::sceneRectChanged, this, &GameView::updateSceneRect);
@ -43,6 +47,12 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
connect(scene, &GameScene::sigResizeRubberBand, this, &GameView::resizeRubberBand);
connect(scene, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand);
connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); });
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::tallyTypeChanged, this,
[this] { updateTotalSelectionCount(); });
setFocusDisabled(SettingsCache::instance().userInterface().getKeepGameChatFocus());
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, this,
&GameView::setFocusDisabled);
aCloseMostRecentZoneView = new QAction(this);
@ -53,31 +63,40 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
refreshShortcuts();
rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this);
const QString countLabelStyle = "color: white; "
"font-size: 14px; "
"font-weight: bold; "
"background-color: rgba(0, 0, 0, 160); "
"border-radius: 3px; "
"padding: 1px 2px;";
const QString baseProperties = "color: white; "
"font-family: monospace; "
"background-color: rgba(0, 0, 0, 160); "
"border-radius: 3px; "
"padding: 1px 2px; "
"white-space: pre;";
const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;";
const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;";
const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;";
dragCountLabel = new QLabel(this);
dragCountLabel->setStyleSheet(countLabelStyle);
dragCountLabel->setStyleSheet(dragCountLabelStyle);
dragCountLabel->hide();
dragCountLabel->raise();
totalCountLabel = new QLabel(this);
totalCountLabel->setStyleSheet(countLabelStyle);
totalCountLabel->setStyleSheet(totalCountLabelStyle);
totalCountLabel->hide();
tallyContainer = new QWidget(this);
tallyContainer->setStyleSheet(subtypeTallyLabelStyle);
tallyLayout = new QGridLayout(tallyContainer);
tallyLayout->setContentsMargins(2, 2, 2, 2);
tallyLayout->setSpacing(2);
tallyContainer->hide();
}
void GameView::resizeEvent(QResizeEvent *event)
{
QGraphicsView::resizeEvent(event);
GameScene *s = dynamic_cast<GameScene *>(scene());
if (s) {
s->processViewSizeChange(event->size());
}
GameScene *s = static_cast<GameScene *>(scene());
s->processViewSizeChange(event->size());
updateSceneRect(scene()->sceneRect());
updateTotalSelectionCount(event->size());
@ -111,7 +130,7 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount)
QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized();
rubberBand->setGeometry(rect);
if (!SettingsCache::instance().getShowDragSelectionCount()) {
if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) {
dragCountLabel->hide();
return;
}
@ -162,27 +181,117 @@ void GameView::refreshShortcuts()
SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView"));
}
void GameView::clearTallyLabels()
{
QtUtils::clearLayoutRec(tallyLayout);
}
QSize GameView::rebuildTallyLabels(const QList<TallyRow> &entries)
{
clearTallyLabels();
const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;");
const QString countStyle =
QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;");
int totalHeight = 0;
int maxNameWidth = 0;
int maxCountWidth = 0;
int row = 0;
for (const TallyRow &entry : entries) {
auto *nameLabel = new QLabel(entry.name, tallyContainer);
nameLabel->setStyleSheet(nameStyle);
nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
tallyLayout->addWidget(nameLabel, row, 0);
auto *countLabel = new QLabel(entry.value, tallyContainer);
countLabel->setStyleSheet(countStyle);
countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
tallyLayout->addWidget(countLabel, row, 1);
QSize nameSize = nameLabel->sizeHint();
QSize countSize = countLabel->sizeHint();
maxNameWidth = qMax(maxNameWidth, nameSize.width());
maxCountWidth = qMax(maxCountWidth, countSize.width());
totalHeight += qMax(nameSize.height(), countSize.height());
++row;
}
int spacing = tallyLayout->spacing();
int margins = tallyLayout->contentsMargins().left() + tallyLayout->contentsMargins().right();
int verticalMargins = tallyLayout->contentsMargins().top() + tallyLayout->contentsMargins().bottom();
int width = maxNameWidth + spacing + maxCountWidth + margins;
int height = totalHeight + (row - 1) * spacing + verticalMargins;
return QSize(width, height);
}
void GameView::updateTotalSelectionCount(const QSize &viewSize)
{
if (!SettingsCache::instance().getShowTotalSelectionCount()) {
totalCountLabel->hide();
return;
}
constexpr int kMarginInPixels = 10;
constexpr int kSpacingBetweenLabels = 4;
int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width();
int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height();
int count = scene()->selectedItems().count();
if (count > 1) {
if (!SettingsCache::instance().userInterface().getShowTotalSelectionCount() || count <= 1) {
totalCountLabel->hide();
} else {
totalCountLabel->setText(QString::number(count));
totalCountLabel->adjustSize();
constexpr int kMarginInPixels = 10;
int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width();
int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height();
int x = availableWidth - totalCountLabel->width() - kMarginInPixels;
int y = availableHeight - totalCountLabel->height() - kMarginInPixels;
totalCountLabel->move(x, y);
totalCountLabel->show();
} else {
totalCountLabel->hide();
}
TallyType tallyType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType());
GameScene *gameScene = static_cast<GameScene *>(scene());
QList<TallyRow> entries = Tally::compute(gameScene->selectedCards(), tallyType);
if (entries.isEmpty()) {
tallyContainer->hide();
cachedTallyRows.clear();
return;
}
// Only rebuild labels if entries changed
QSize containerSize;
if (entries != cachedTallyRows) {
cachedTallyRows = entries;
containerSize = rebuildTallyLabels(entries);
tallyContainer->resize(containerSize);
} else {
containerSize = tallyContainer->size();
}
int x = availableWidth - containerSize.width() - kMarginInPixels;
int y;
if (totalCountLabel->isVisible()) {
y = totalCountLabel->y() - containerSize.height() - kSpacingBetweenLabels;
} else {
y = availableHeight - containerSize.height() - kMarginInPixels;
}
y = qMax(kMarginInPixels, y);
tallyContainer->move(x, y);
tallyContainer->show();
}
/**
* Disabling focus on the game view will allow chat to maintain the autofocusing behavior of pre 2.10.3,
* at the cost of disabling the zone view search bar.
*/
void GameView::setFocusDisabled(bool disabled)
{
setFocusPolicy(disabled ? Qt::NoFocus : Qt::ClickFocus);
}

View file

@ -7,9 +7,12 @@
#ifndef GAMEVIEW_H
#define GAMEVIEW_H
#include "tally/tally.h"
#include <QGraphicsView>
class GameScene;
class QGridLayout;
class QLabel;
class QRubberBand;
@ -21,7 +24,13 @@ private:
QRubberBand *rubberBand;
QLabel *dragCountLabel;
QLabel *totalCountLabel;
QWidget *tallyContainer;
QGridLayout *tallyLayout;
QPointF selectionOrigin;
QList<TallyRow> cachedTallyRows; ///< Cached entries to avoid redundant rebuilds
QSize rebuildTallyLabels(const QList<TallyRow> &entries);
void clearTallyLabels();
protected:
void resizeEvent(QResizeEvent *event) override;
@ -31,6 +40,7 @@ private slots:
void stopRubberBand();
void refreshShortcuts();
void updateTotalSelectionCount(const QSize &viewSize = QSize());
void setFocusDisabled(bool disabled);
public slots:
void updateSceneRect(const QRectF &rect);

View file

@ -1,6 +1,7 @@
#include "card_menu.h"
#include "../../../client/settings/card_counter_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../interface/widgets/tabs/tab_game.h"
#include "../../board/card_item.h"
#include "../../game/player/player_actions.h"

View file

@ -1,5 +1,6 @@
#include "grave_menu.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../game/abstract_game.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"

View file

@ -1,5 +1,6 @@
#include "move_menu.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../card_menu_action_type.h"

View file

@ -1,5 +1,6 @@
#include "player_menu.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../game_graphics/zones/hand_zone.h"
#include "../../../game_graphics/zones/pile_zone.h"
#include "../../../game_graphics/zones/table_zone.h"
@ -44,6 +45,8 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_
utilityMenu = nullptr;
}
tallyMenu = addManagedMenu<TallyMenu>();
if (player->getLogic()->getPlayerInfo()->getLocal()) {
sayMenu = addManagedMenu<SayMenu>(player);
} else {

View file

@ -15,11 +15,13 @@
#include "rfg_menu.h"
#include "say_menu.h"
#include "sideboard_menu.h"
#include "tally_menu.h"
#include "utility_menu.h"
#include <QList>
#include <QMenu>
#include <QObject>
#include <libcockatrice/utility/card_ref.h>
class CardItem;
class CardMenu;
@ -87,6 +89,7 @@ private:
GraveyardMenu *graveMenu;
RfgMenu *rfgMenu;
UtilityMenu *utilityMenu;
TallyMenu *tallyMenu;
SayMenu *sayMenu;
CustomZoneMenu *customZonesMenu;

View file

@ -1,5 +1,6 @@
#include "pt_menu.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"

View file

@ -5,6 +5,7 @@
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"
#include <libcockatrice/settings/message_settings.h>
SayMenu::SayMenu(PlayerGraphicsItem *_player) : player(_player)
{
connect(&SettingsCache::instance().messages(), &MessageSettings::messageMacrosChanged, this, &SayMenu::initSayMenu);

View file

@ -1,5 +1,6 @@
#include "sideboard_menu.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../player_graphics_item.h"

View file

@ -0,0 +1,57 @@
#include "tally_menu.h"
#include "../../../client/settings/cache_settings.h"
#include <QActionGroup>
TallyMenu::TallyMenu()
{
actionGroup = new QActionGroup(this);
actionGroup->setExclusive(true);
aTallyNone = createTallyAction(TallyType::None);
aTallySubtypes = createTallyAction(TallyType::Subtypes);
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
addAction(aTallyNone);
addSeparator();
addAction(aTallySubtypes);
addAction(aTallyTotalPower);
retranslateUi();
}
QAction *TallyMenu::createTallyAction(TallyType tallyType)
{
TallyType currentType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType());
QAction *action = new QAction(this);
action->setCheckable(true);
action->setChecked(tallyType == currentType);
connect(action, &QAction::triggered, &SettingsCache::instance().userInterface(),
[tallyType] { SettingsCache::instance().userInterface().setTallyType(static_cast<int>(tallyType)); });
actionGroup->addAction(action);
return action;
}
void TallyMenu::setShortcutsActive()
{
// no-op because we haven't decided if we're adding shortcuts for tally types
}
void TallyMenu::setShortcutsInactive()
{
// no-op because we haven't decided if we're adding shortcuts for tally types
}
void TallyMenu::retranslateUi()
{
setTitle(tr("Tally"));
aTallyNone->setText(tr("None"));
aTallySubtypes->setText(tr("Subtypes"));
aTallyTotalPower->setText(tr("Total Power"));
}

View file

@ -0,0 +1,31 @@
#ifndef COCKATRICE_TALLY_MENU_H
#define COCKATRICE_TALLY_MENU_H
#include "../../../interface/widgets/menus/tearoff_menu.h"
#include "../../tally/tally.h"
#include "abstract_player_component.h"
#include <QMenu>
class TallyMenu : public TearOffMenu, public AbstractPlayerComponent
{
Q_OBJECT
public:
TallyMenu();
void setShortcutsActive() override;
void setShortcutsInactive() override;
void retranslateUi() override;
private:
QActionGroup *actionGroup = nullptr;
QAction *aTallyNone = nullptr;
QAction *aTallySubtypes = nullptr;
QAction *aTallyTotalPower = nullptr;
QAction *createTallyAction(TallyType tallyType);
};
#endif // COCKATRICE_TALLY_MENU_H

View file

@ -1,5 +1,6 @@
#include "utility_menu.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../interface/deck_loader/deck_loader.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"

View file

@ -8,6 +8,7 @@
#include <QInputDialog>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/utility/string_limits.h>
PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_playerActions)
: QObject(_player), player(_player), playerActions(_playerActions)

View file

@ -13,12 +13,13 @@
#include "player_dialogs.h"
#include <QGraphicsView>
#include <libcockatrice/settings/interface_settings.h>
PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
{
connect(&SettingsCache::instance(), &SettingsCache::horizontalHandChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::horizontalHandChanged, this,
&PlayerGraphicsItem::rearrangeZones);
connect(&SettingsCache::instance(), &SettingsCache::handJustificationChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::handJustificationChanged, this,
&PlayerGraphicsItem::rearrangeZones);
connect(player, &PlayerLogic::rearrangeCounters, this, &PlayerGraphicsItem::rearrangeCounters);
connect(player, &PlayerLogic::activeChanged, this, &PlayerGraphicsItem::onPlayerActiveChanged);
@ -59,6 +60,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
initializeZones();
connect(player, &PlayerLogic::addViewCustomZoneActionToCustomZoneMenu, this,
&PlayerGraphicsItem::onCustomZoneAdded);
playerMenu->setMenusForGraphicItems();
connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect);
@ -121,6 +125,19 @@ void PlayerGraphicsItem::initializeZones()
connect(handZoneGraphicsItem->getLogic(), &HandZoneLogic::cardCountChanged, handCounter,
&HandCounter::updateNumber);
connect(handCounter, &HandCounter::showContextMenu, handZoneGraphicsItem, &HandZone::showContextMenu);
zoneGraphicsItems.insert(player->getDeckZone()->getName(), deckZoneGraphicsItem);
zoneGraphicsItems.insert(player->getGraveZone()->getName(), graveyardZoneGraphicsItem);
zoneGraphicsItems.insert(player->getRfgZone()->getName(), rfgZoneGraphicsItem);
zoneGraphicsItems.insert(player->getSideboardZone()->getName(), sideboardGraphicsItem);
zoneGraphicsItems.insert(player->getTableZone()->getName(), tableZoneGraphicsItem);
zoneGraphicsItems.insert(player->getStackZone()->getName(), stackZoneGraphicsItem);
zoneGraphicsItems.insert(player->getHandZone()->getName(), handZoneGraphicsItem);
}
void PlayerGraphicsItem::onCustomZoneAdded(QString customZoneName)
{
zoneGraphicsItems.insert(customZoneName, nullptr); // Custom zone view goes here, if we ever implement it.
}
QRectF PlayerGraphicsItem::boundingRect() const
@ -132,7 +149,7 @@ qreal PlayerGraphicsItem::getMinimumWidth() const
{
qreal result = tableZoneGraphicsItem->getMinimumWidth() + CardDimensions::HEIGHT_F + 15 + counterAreaWidth +
stackZoneGraphicsItem->boundingRect().width();
if (!SettingsCache::instance().getHorizontalHand()) {
if (!SettingsCache::instance().userInterface().getHorizontalHand()) {
result += handZoneGraphicsItem->boundingRect().width();
}
return result;
@ -149,7 +166,7 @@ void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth)
// Extend table (and hand, if horizontal) to accommodate the new player width.
qreal tableWidth = newPlayerWidth - CardDimensions::HEIGHT_F - 15 - counterAreaWidth -
stackZoneGraphicsItem->boundingRect().width();
if (!SettingsCache::instance().getHorizontalHand()) {
if (!SettingsCache::instance().userInterface().getHorizontalHand()) {
tableWidth -= handZoneGraphicsItem->boundingRect().width();
}
@ -171,6 +188,11 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state)
AbstractCounter *widget;
if (state->getName() == "life") {
widget = playerTarget->addCounter(state);
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
if (newValue < oldValue) {
tableZoneGraphicsItem->triggerDamageShimmer();
}
});
} else {
widget = new GeneralCounter(state, player, true, this);
}
@ -217,7 +239,7 @@ void PlayerGraphicsItem::rearrangeCounters()
void PlayerGraphicsItem::rearrangeZones()
{
auto base = QPointF(CardDimensions::HEIGHT_F + counterAreaWidth + 15, 0);
if (SettingsCache::instance().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
if (mirrored) {
if (player->getHandZone()->contentsKnown()) {
handVisible = true;
@ -268,7 +290,7 @@ void PlayerGraphicsItem::updateBoundingRect()
{
prepareGeometryChange();
qreal width = CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width();
if (SettingsCache::instance().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
qreal handHeight = handVisible ? handZoneGraphicsItem->boundingRect().height() : 0;
bRect = QRectF(0, 0, width + tableZoneGraphicsItem->boundingRect().width(),
tableZoneGraphicsItem->boundingRect().height() + handHeight);

View file

@ -77,6 +77,11 @@ public:
return playerTarget;
}
CardZone *getZoneGraphicsItem(const QString &name) const
{
return zoneGraphicsItems.value(name, nullptr);
}
[[nodiscard]] PileZone *getDeckZoneGraphicsItem() const
{
return deckZoneGraphicsItem;
@ -110,6 +115,7 @@ public:
public slots:
void onPlayerActiveChanged(bool _active);
void onCustomZoneAdded(QString customZoneName);
void onCounterAdded(CounterState *state);
void onCounterRemoved(int counterId);
void rearrangeCounters();
@ -128,6 +134,7 @@ private:
PlayerArea *playerArea;
PlayerTarget *playerTarget;
QMap<int, AbstractCounter *> counterWidgets;
QMap<QString, CardZone *> zoneGraphicsItems;
PileZone *deckZoneGraphicsItem;
PileZone *sideboardGraphicsItem;
PileZone *graveyardZoneGraphicsItem;

View file

@ -25,11 +25,7 @@ bool PlayerListItemDelegate::editorEvent(QEvent *event,
if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) {
auto *const mouseEvent = static_cast<QMouseEvent *>(event);
if (mouseEvent->button() == Qt::RightButton) {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
static_cast<PlayerListWidget *>(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index);
#else
static_cast<PlayerListWidget *>(parent())->showContextMenu(mouseEvent->globalPos(), index);
#endif
return true;
}
}

View file

@ -1,8 +1,11 @@
#include "player_target.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_logic.h"
#include "../../interface/pixel_map_generator.h"
#include "../game_scene.h"
#include <QApplication>
#include <QDebug>
#include <QPainter>
#include <QPixmapCache>
@ -21,17 +24,24 @@ QRectF PlayerCounter::boundingRect() const
void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
const int radius = 8;
const qreal border = 1;
QPainterPath path(QPointF(50 - border / 2, border / 2));
path.lineTo(radius, border / 2);
path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90);
path.lineTo(border / 2, 30 - border / 2);
path.lineTo(50 - border / 2, 30 - border / 2);
path.closeSubpath();
const int radius = 15;
const qreal border = 1.5;
// The box is drawn with a border-wide stroke straddling the path, so the
// visible outline spans [inset, inset + border]. Fills that must not cover
// the outline (e.g. the life-change flash) use a path inset by `border`.
const auto makePath = [](qreal inset) {
QPainterPath path(QPointF(50 - inset, inset));
path.lineTo(radius, inset);
path.arcTo(inset, inset, 2 * radius, 2 * radius, 90, 90);
path.lineTo(inset, 30 - inset);
path.lineTo(50 - inset, 30 - inset);
path.closeSubpath();
return path;
};
QPainterPath path = makePath(border / 2);
QPen pen(QColor(100, 100, 100));
pen.setWidth(border);
pen.setWidthF(border);
painter->setPen(pen);
painter->setBrush(hovered ? QColor(50, 50, 50, 160) : QColor(0, 0, 0, 160));
@ -45,6 +55,48 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
painter->setFont(font);
painter->setPen(Qt::white);
painter->drawText(translatedRect, Qt::AlignCenter, QString::number(value));
// Life-change flash: emerald on gain, red on loss, decaying over a few ticks.
if (flashAlpha > 0) {
painter->save();
QColor flashColor = flashDelta > 0 ? QColor(52, 224, 122) : QColor(239, 68, 68);
flashColor.setAlphaF(0.45 * flashAlpha);
painter->setPen(Qt::NoPen);
painter->setBrush(flashColor);
painter->setOpacity(0.85);
painter->drawPath(makePath(border));
painter->restore();
}
}
void PlayerCounter::onValueChanged(int oldValue, int newValue)
{
flashDelta = newValue - oldValue;
if (flashDelta == 0) {
return;
}
if (!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) {
flashAlpha = 0.0;
return;
}
flashAlpha = 1.0;
flashClock.start();
if (scene()) {
static_cast<GameScene *>(scene())->registerAnimationItem(this);
}
}
bool PlayerCounter::animationEvent()
{
flashAlpha = 1.0 - flashClock.elapsed() / flashDurationMs;
if (flashAlpha <= 0.0) {
flashAlpha = 0.0;
return false;
}
update();
return true;
}
PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem)

View file

@ -7,21 +7,34 @@
#ifndef PLAYERTARGET_H
#define PLAYERTARGET_H
#include "../animated_item.h"
#include "../board/abstract_counter.h"
#include "../board/arrow_target.h"
#include "../board/graphics_item_type.h"
#include <QElapsedTimer>
#include <QPixmap>
class PlayerLogic;
class PlayerCounter : public AbstractCounter
class PlayerCounter : public AbstractCounter, public IAnimatedItem
{
Q_OBJECT
protected:
void onValueChanged(int oldValue, int newValue) override;
private:
static constexpr qreal flashDurationMs = 450.0;
QElapsedTimer flashClock;
qreal flashAlpha = 0.0;
int flashDelta = 0;
public:
PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent);
QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
bool animationEvent() override;
};
class PlayerTarget : public ArrowTarget

View file

@ -0,0 +1,36 @@
#include "stats_tally.h"
#include "../board/card_item.h"
#include <QCoreApplication>
#include <QList>
#include <algorithm>
static int sumPowers(const QList<CardItem *> &cards)
{
// calculate total power;
int total = 0;
for (auto card : cards) {
QVariantList parsed = CardItem::parsePT(card->getPT());
if (!parsed.isEmpty()) {
int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int
total += qMax(power, 0);
}
}
return total;
}
QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
{
// don't bother if none of the cards have pt
bool hasPT =
std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); });
if (!hasPT) {
return {};
}
int total = sumPowers(cards);
QString name = QCoreApplication::translate("StatsTally", "Total Power");
return {TallyRow{name, QString::number(total)}};
}

View file

@ -0,0 +1,21 @@
#ifndef COCKATRICE_STATS_TALLY_H
#define COCKATRICE_STATS_TALLY_H
#include "tally.h"
/**
* @brief Extracts and tallies stats from selected cards.
*/
namespace StatsTally
{
/**
* @brief Sums the power of all selected cards
*
* @param cards The list of selected card items to analyze.
* @return A single row containing the total, or an empty list if none of the cards have pt
*/
QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards);
} // namespace StatsTally
#endif // COCKATRICE_STATS_TALLY_H

View file

@ -0,0 +1,77 @@
#include "subtype_tally.h"
#include "../board/card_item.h"
#include <QMap>
#include <algorithm>
namespace
{
/** @brief Extracts subtypes from a single card face's type line. */
QStringList extractSubtypesFromFace(const QString &faceType)
{
// Card type format: "Creature — Goblin Warrior" or "Legendary Enchantment — Saga"
QStringList parts = faceType.split(QStringLiteral(""));
if (parts.size() > 1) {
return parts[1].split(QStringLiteral(" "), Qt::SkipEmptyParts);
}
return {};
}
/** @brief A single subtype (e.g., "Goblin", "Warrior") with its occurrence count. */
struct SubtypeEntry
{
QString name;
int count;
};
} // anonymous namespace
namespace SubtypeTally
{
QList<TallyRow> countSubtypes(const QList<CardItem *> &cards)
{
QMap<QString, int> subtypeCounts;
for (CardItem *card : cards) {
if (card->getFaceDown() || card->getCard().isEmpty()) {
continue;
}
QString cardType = card->getCardInfo().getCardType();
// Handle double-faced cards: "Creature — Human // Creature — Werewolf"
QStringList cardFaces = cardType.split(QStringLiteral(" // "));
for (const QString &face : cardFaces) {
QStringList subtypes = extractSubtypesFromFace(face);
for (const QString &subtype : subtypes) {
subtypeCounts[subtype]++;
}
}
}
QList<SubtypeEntry> entries;
for (auto it = subtypeCounts.constBegin(); it != subtypeCounts.constEnd(); ++it) {
entries.append({it.key(), it.value()});
}
// Sort by count ascending, then alphabetically (lowest counts at bottom of display)
std::sort(entries.begin(), entries.end(), [](const SubtypeEntry &a, const SubtypeEntry &b) {
if (a.count != b.count) {
return a.count < b.count;
}
return a.name < b.name;
});
// convert entries into TallyRows
QList<TallyRow> rows;
rows.reserve(entries.size());
std::transform(entries.begin(), entries.end(), std::back_inserter(rows),
[](const SubtypeEntry &e) { return TallyRow{e.name, QString::number(e.count)}; });
return rows;
}
} // namespace SubtypeTally

View file

@ -0,0 +1,26 @@
#ifndef COCKATRICE_SUBTYPE_TALLY_H
#define COCKATRICE_SUBTYPE_TALLY_H
#include "tally.h"
#include <QList>
#include <QString>
class CardItem;
/**
* @brief Extracts and tallies subtypes from selected cards.
*/
namespace SubtypeTally
{
/**
* @brief Parses card type lines and counts each subtype occurrence.
*
* Skips face-down cards and cards without type info.
* @param cards The list of selected card items to analyze.
* @return Entries sorted by count ascending, then alphabetically.
*/
QList<TallyRow> countSubtypes(const QList<CardItem *> &cards);
} // namespace SubtypeTally
#endif

View file

@ -0,0 +1,26 @@
#include "tally.h"
#include "stats_tally.h"
#include "subtype_tally.h"
TallyType Tally::intToType(int value)
{
if (value < static_cast<int>(TallyType::None) || value > static_cast<int>(TallyType::MaxValue)) {
return TallyType::None;
}
return static_cast<TallyType>(value);
}
QList<TallyRow> Tally::compute(const QList<CardItem *> &cards, const TallyType type)
{
switch (type) {
case TallyType::None:
return {};
case TallyType::Subtypes:
return SubtypeTally::countSubtypes(cards);
case TallyType::TotalPower:
return StatsTally::computeTotalPower(cards);
}
return {};
}

View file

@ -0,0 +1,50 @@
#ifndef COCKATRICE_TALLY_H
#define COCKATRICE_TALLY_H
#include <QString>
class CardItem;
/** @brief A single row of the tally output. */
struct TallyRow
{
QString name; ///< The row name (displayed on the left)
QString value; ///< Value for the row (displayed on the right)
bool operator==(const TallyRow &) const = default;
};
/**
* The tally type
*/
enum class TallyType
{
None,
Subtypes,
TotalPower,
MaxValue = TotalPower // sentinel value
};
namespace Tally
{
/**
* Safely converts an int into the corresponding TallyType.
*
* @param value The int value
* @return The TallyType. Returns TallyType::None if the value is not within range
*/
TallyType intToType(int value);
/**
* @brief Analyzes the selected cards according to the tally type and builds the resulting tally rows.
* This forwards the cards to the code for that tally type.
*
* @param cards The list of selected card items to analyze.
* @param type The type of tally to do
* @return Rows sorted in top-to-bottom display order
*/
QList<TallyRow> compute(const QList<CardItem *> &cards, TallyType type);
} // namespace Tally
#endif // COCKATRICE_TALLY_H

View file

@ -9,6 +9,7 @@
#include <QPainter>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/settings/interface_settings.h>
HandZone::HandZone(HandZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent)
: SelectZone(_logic, parent), zoneHeight(_zoneHeight)
@ -33,7 +34,7 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
QPoint point = dropPoint + scenePos().toPoint();
int x = -1;
if (SettingsCache::instance().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
for (x = 0; x < getLogic()->getCards().size(); x++) {
if (point.x() < static_cast<CardItem *>(getLogic()->getCards().at(x))->scenePos().x()) {
break;
@ -60,7 +61,7 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
QRectF HandZone::boundingRect() const
{
if (SettingsCache::instance().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
return QRectF(0, 0, width, CardDimensions::HEIGHT_F + 10);
} else {
return QRectF(0, 0, CardDimensions::WIDTH_F * 1.5, zoneHeight);
@ -77,8 +78,8 @@ void HandZone::reorganizeCards()
{
if (!getLogic()->getCards().isEmpty()) {
const int cardCount = getLogic()->getCards().size();
if (SettingsCache::instance().getHorizontalHand()) {
bool leftJustified = SettingsCache::instance().getLeftJustified();
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified();
qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width();
const int xPadding = leftJustified ? cardWidth * 1.4 : 5;
qreal totalWidth =
@ -126,7 +127,7 @@ void HandZone::sortHand(const QList<CardList::SortOption> &options)
void HandZone::setWidth(qreal _width)
{
if (SettingsCache::instance().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
prepareGeometryChange();
width = _width;
reorganizeCards();

View file

@ -1,5 +1,6 @@
#include "pile_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/pile_zone_logic.h"
@ -11,6 +12,7 @@
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/settings/cards_display_settings.h>
PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_logic, parent)
{
@ -23,12 +25,13 @@ PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_log
.rotate(90)
.translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F));
connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
[this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
prepareGeometryChange();
update();
});
prepareGeometryChange();
update();
});
}
QRectF PileZone::boundingRect() const
@ -39,7 +42,8 @@ QRectF PileZone::boundingRect() const
QPainterPath PileZone::shape() const
{
QPainterPath shape;
qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
qreal cardCornerRadius =
SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius);
return shape;
}

View file

@ -7,10 +7,11 @@
#include <QGraphicsRectItem>
#include <QGraphicsSceneMouseEvent>
#include <QtMath>
#include <libcockatrice/settings/cards_display_settings.h>
static qreal stackingOffset(qreal cardHeight)
{
const qreal overlapPercent = SettingsCache::instance().getStackCardOverlapPercent();
const qreal overlapPercent = SettingsCache::instance().cardsDisplay().getStackCardOverlapPercent();
return cardHeight * (100.0 - overlapPercent) / 100.0;
}
@ -21,40 +22,32 @@ SelectZone::ZoneLayout SelectZone::computeZoneLayout(const StackLayoutParams &pa
}
qreal effectiveOffset = params.desiredOffset;
if (params.cardCount > 1) {
qreal fitOffset;
if (params.totalHeight < params.cardHeight && params.minOffset > 0.0) {
// Zone is shorter than a card (e.g. minimized). Compress offsets so
// every card has at least minOffset pixels of its top visible.
fitOffset = (params.totalHeight - params.minOffset) / (params.cardCount - 1);
effectiveOffset = qMax(0.0, qMin(params.desiredOffset, fitOffset));
qreal reservedForBottomCard;
if (params.allowBottomOverflow) {
// Allow the bottom card to partially overflow in tight zones, scaling the
// overflow allowance by sqrt(cardCount-1) so offsets decrease smoothly
// as cards are added rather than dropping by 1/(n-1) each time.
// The 0.75 ratio was tuned experimentally to balance card visibility vs. overflow.
constexpr qreal bottomCardZoneRatio = 0.75;
const qreal adjustedRatio = bottomCardZoneRatio / qSqrt(static_cast<qreal>(params.cardCount - 1));
reservedForBottomCard = qMin(params.cardHeight, params.totalHeight * adjustedRatio);
} else {
qreal reservedForBottomCard;
if (params.allowBottomOverflow) {
// Allow the bottom card to partially overflow in tight zones, scaling the
// overflow allowance by sqrt(cardCount-1) so offsets decrease smoothly
// as cards are added rather than dropping by 1/(n-1) each time.
// The 0.75 ratio was tuned experimentally to balance card visibility vs. overflow.
constexpr qreal bottomCardZoneRatio = 0.75;
const qreal adjustedRatio = bottomCardZoneRatio / qSqrt(static_cast<qreal>(params.cardCount - 1));
reservedForBottomCard = qMin(params.cardHeight, params.totalHeight * adjustedRatio);
} else {
// No overflow: reserve full card height for the bottom card
reservedForBottomCard = params.cardHeight;
}
fitOffset = (params.totalHeight - reservedForBottomCard) / (params.cardCount - 1);
// No overflow: reserve full card height for the bottom card
reservedForBottomCard = params.cardHeight;
}
qreal fitOffset = (params.totalHeight - reservedForBottomCard) / (params.cardCount - 1);
if (!params.allowBottomOverflow) {
// Constrain offset so all card tops remain within zone bounds.
// With start=0, last card top at (cardCount-1) * effectiveOffset must be < totalHeight.
qreal maxOffsetForTops = params.totalHeight / (params.cardCount - 1);
fitOffset = qMin(fitOffset, maxOffsetForTops);
}
if (!params.allowBottomOverflow) {
// Constrain offset so all card tops remain within zone bounds.
// With start=0, last card top at (cardCount-1) * effectiveOffset must be < totalHeight.
qreal maxOffsetForTops = params.totalHeight / (params.cardCount - 1);
fitOffset = qMin(fitOffset, maxOffsetForTops);
}
// Apply minOffset only if it fits; otherwise compress further to keep all card tops visible.
effectiveOffset = qMin(params.desiredOffset, fitOffset);
if (fitOffset >= params.minOffset) {
effectiveOffset = qMax(params.minOffset, effectiveOffset);
}
// Apply minOffset only if it fits; otherwise compress further to keep all card tops visible.
effectiveOffset = qMin(params.desiredOffset, fitOffset);
if (fitOffset >= params.minOffset) {
effectiveOffset = qMax(params.minOffset, effectiveOffset);
}
}
qreal stackHeight = (params.cardCount - 1) * effectiveOffset + params.cardHeight;

View file

@ -43,12 +43,18 @@ void StackZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
return;
}
int index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE);
// Same-zone no-op: don't move a card onto itself
const auto &cards = getLogic()->getCards();
if (!cards.isEmpty() && startZone == getLogic() && cards.at(index)->getId() == dragItems.at(0)->getId()) {
return;
int index;
if (startZone == getLogic()) {
// Reordering within the zone: use drop position
index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE);
// Same-zone no-op: don't move a card onto itself
if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) {
return;
}
} else {
// Coming from another zone: append at end (top of stack, rendered on top)
index = static_cast<int>(cards.size());
}
Command_MoveCard cmd;

View file

@ -8,6 +8,7 @@
#include "../board/arrow_item.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
#include "../game_scene.h"
#include "../z_values.h"
#include <QGraphicsScene>
@ -15,6 +16,7 @@
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
const QColor TableZone::BACKGROUND_COLOR = QColor(100, 100, 100);
@ -28,7 +30,7 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare
connect(_logic, &TableZoneLogic::contentSizeChanged, this, &TableZone::resizeToContents);
connect(_logic, &TableZoneLogic::toggleTapped, this, &TableZone::toggleTapped);
connect(themeManager, &ThemeManager::themeChanged, this, &TableZone::updateBg);
connect(&SettingsCache::instance(), &SettingsCache::invertVerticalCoordinateChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::invertVerticalCoordinateChanged, this,
&TableZone::reorganizeCards);
updateBg();
@ -46,6 +48,31 @@ void TableZone::updateBg()
update();
}
void TableZone::triggerDamageShimmer()
{
if (!SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()) {
damageShimmerAlpha = 0.0;
return;
}
damageShimmerAlpha = 1.0;
shimmerClock.start();
if (scene()) {
static_cast<GameScene *>(scene())->registerAnimationItem(this);
}
}
bool TableZone::animationEvent()
{
damageShimmerAlpha = 1.0 - shimmerClock.elapsed() / shimmerDurationMs;
if (damageShimmerAlpha <= 0.0) {
damageShimmerAlpha = 0.0;
return false;
}
update();
return true;
}
QRectF TableZone::boundingRect() const
{
return QRectF(0, 0, width, height);
@ -59,8 +86,8 @@ void TableZone::setMirrored(bool isMirrored)
bool TableZone::isInverted() const
{
return ((mirrored && !SettingsCache::instance().getInvertVerticalCoordinate()) ||
(!mirrored && SettingsCache::instance().getInvertVerticalCoordinate()));
return ((mirrored && !SettingsCache::instance().userInterface().getInvertVerticalCoordinate()) ||
(!mirrored && SettingsCache::instance().userInterface().getInvertVerticalCoordinate()));
}
void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
@ -76,6 +103,13 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
painter->fillRect(boundingRect(), FADE_MASK);
}
// Decaying crimson wash from taking damage.
if (damageShimmerAlpha > 0.0) {
QColor shimmerColor(239, 68, 68);
shimmerColor.setAlphaF(0.22 * damageShimmerAlpha);
painter->fillRect(boundingRect(), shimmerColor);
}
paintLandDivider(painter);
}

View file

@ -8,16 +8,19 @@
#define TABLEZONE_H
#include "../../game/zones/table_zone_logic.h"
#include "../animated_item.h"
#include "../board/abstract_card_item.h"
#include "select_zone.h"
#include <QElapsedTimer>
/**
* @brief TableZone is the grid based rect where CardItems may be placed.
*
* It is the main play zone and can be customized with background images.
*/
//! \todo Refactor methods to make more readable, extract logic to private methods (especially reorganizeCards()).
class TableZone : public SelectZone
class TableZone : public SelectZone, public IAnimatedItem
{
Q_OBJECT
@ -121,6 +124,16 @@ public:
*/
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
/**
Flashes the table surface after a player loses life.
Wired up through the life counter so the battlefield glows when life drops.
*/
void triggerDamageShimmer();
/** @brief Decays the damage shimmer by one timer tick. */
bool animationEvent() override;
/**
Toggles the selected items as tapped.
*/
@ -185,6 +198,11 @@ public:
}
private:
static constexpr qreal shimmerDurationMs = 450.0;
QElapsedTimer shimmerClock;
qreal damageShimmerAlpha = 0.0;
void paintZoneOutline(QPainter *painter);
void paintLandDivider(QPainter *painter);

View file

@ -21,6 +21,7 @@
#include <QStyle>
#include <QStyleOption>
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
#include <libcockatrice/settings/interface_settings.h>
namespace
{
@ -65,7 +66,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });
if (SettingsCache::instance().getFocusCardViewSearchBar()) {
if (SettingsCache::instance().userInterface().getFocusCardViewSearchBar()) {
this->setActive(true);
searchEdit.setFocus();
}
@ -75,6 +76,11 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
searchEditProxy->setZValue(ZValues::DRAG_ITEM);
vbox->addItem(searchEditProxy);
// hide search bar if chat autofocus setting is enabled, since typing into it will no longer work anyway
searchEditProxy->setVisible(!SettingsCache::instance().userInterface().getKeepGameChatFocus());
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged,
searchEditProxy, [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); });
// top row
QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal);
@ -153,9 +159,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
connect(&sortBySelector, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this,
&ZoneViewWidget::processSortBy);
connect(&pileViewCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &ZoneViewWidget::processSetPileView);
groupBySelector.setCurrentIndex(SettingsCache::instance().getZoneViewGroupByIndex());
sortBySelector.setCurrentIndex(SettingsCache::instance().getZoneViewSortByIndex());
pileViewCheckBox.setChecked(SettingsCache::instance().getZoneViewPileView());
groupBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewGroupByIndex());
sortBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewSortByIndex());
pileViewCheckBox.setChecked(SettingsCache::instance().userInterface().getZoneViewPileView());
if (CardList::NoSort == static_cast<CardList::SortOption>(groupBySelector.currentData().toInt())) {
pileViewCheckBox.setEnabled(false);
@ -185,7 +191,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
void ZoneViewWidget::processGroupBy(int index)
{
auto option = static_cast<CardList::SortOption>(groupBySelector.itemData(index).toInt());
SettingsCache::instance().setZoneViewGroupByIndex(index);
SettingsCache::instance().userInterface().setZoneViewGroupByIndex(index);
zone->setGroupBy(option);
// disable pile view checkbox if we're not grouping by anything
@ -209,13 +215,13 @@ void ZoneViewWidget::processSortBy(int index)
return;
}
SettingsCache::instance().setZoneViewSortByIndex(index);
SettingsCache::instance().userInterface().setZoneViewSortByIndex(index);
zone->setSortBy(option);
}
void ZoneViewWidget::processSetPileView(QT_STATE_CHANGED_T value)
{
SettingsCache::instance().setZoneViewPileView(value);
SettingsCache::instance().userInterface().setZoneViewPileView(value);
zone->setPileView(value);
}
@ -472,7 +478,7 @@ static qreal rowsToHeight(int rows)
**/
static qreal calcMaxInitialHeight()
{
return rowsToHeight(SettingsCache::instance().getCardViewInitialRowsMax());
return rowsToHeight(SettingsCache::instance().userInterface().getCardViewInitialRowsMax());
}
/**
@ -554,7 +560,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const
void ZoneViewWidget::expandWindow()
{
qreal maxInitialHeight = calcMaxInitialHeight();
qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().getCardViewExpandedRowsMax());
qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax());
qreal height = rect().height() - extraHeight - 10;
qreal maxHeight = maximumHeight() - extraHeight - 10;

View file

@ -1,6 +1,8 @@
#include "card_picture_loader.h"
#include "../../client/settings/cache_settings.h"
#include "card_picture_loader_cache_method.h"
#include "card_picture_loader_local_schemes.h"
#include <QApplication>
#include <QBuffer>
@ -16,16 +18,23 @@
#include <QStatusBar>
#include <QThread>
#include <algorithm>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <utility>
// never cache more than 300 cards at once for a single deck
#define CACHED_CARD_PER_DECK_MAX 300
// wait at least this long before retrying a card whose picture failed to load
static constexpr int RETRY_FAILED_CARDS_SECS = 300;
CardPictureLoader::CardPictureLoader() : QObject(nullptr)
{
worker = new CardPictureLoaderWorker;
connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this, &CardPictureLoader::picsPathChanged);
connect(&SettingsCache::instance(), &SettingsCache::picDownloadChanged, this,
connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this,
&CardPictureLoader::picsPathChanged);
connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this,
&CardPictureLoader::picDownloadChanged);
qRegisterMetaType<ExactCard>();
@ -129,7 +138,14 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize
QPixmap bigPixmap;
if (QPixmapCache::find(key, &bigPixmap)) {
if (bigPixmap.isNull()) {
qCDebug(CardPictureLoaderLog) << "Cached pixmap for key" << key << "is NULL!";
getCardBackLoadingFailedPixmap(pixmap, size);
QDateTime failedAtTime = getInstance().failedAt.value(key);
if (!failedAtTime.isValid() ||
failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) {
getInstance().failedAt.remove(key);
QPixmapCache::remove(key);
getInstance().worker->enqueueImageLoad(card);
}
return;
}
@ -153,8 +169,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
QPixmap finalPixmap;
if (image.isNull()) {
getInstance().failedAt.insert(card.getPixmapCacheKey(), QDateTime::currentDateTime());
qCDebug(CardPictureLoaderLog) << "Caching NULL pixmap for" << card.getName();
} else {
getInstance().failedAt.remove(card.getPixmapCacheKey());
if (card.getInfo().getUiAttributes().upsideDownArt) {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
QImage mirrorImage = image.flipped(Qt::Horizontal | Qt::Vertical);
@ -169,7 +187,8 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
QPixmapCache::insert(card.getPixmapCacheKey(), finalPixmap);
if (SettingsCache::instance().getCardPictureLoaderCacheMethod() ==
if (static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::FILESYSTEM_CACHE) {
saveCardImageToLocalStorage(card, finalPixmap);
}
@ -177,8 +196,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
// imageLoaded should only be reached if the exactCard isn't already in cache.
// (plus there's a deduplication mechanism in CardPictureLoaderWorker)
// It should be safe to connect the CardInfo here without worrying about redundant connections.
connect(card.getCardPtr().data(), &QObject::destroyed, this,
[cacheKey = card.getPixmapCacheKey()] { QPixmapCache::remove(cacheKey); });
connect(card.getCardPtr().data(), &QObject::destroyed, this, [cacheKey = card.getPixmapCacheKey()] {
QPixmapCache::remove(cacheKey);
getInstance().failedAt.remove(cacheKey);
});
card.emitPixmapUpdated();
}
@ -189,9 +210,9 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const
return;
}
const QString picsRoot = SettingsCache::instance().getPicsPath();
CardPictureLoaderLocalSchemes::NamingScheme scheme =
SettingsCache::instance().getLocalCardImageStorageNamingScheme();
const QString picsRoot = SettingsCache::instance().paths().getPicsPath();
CardPictureLoaderLocalSchemes::NamingScheme scheme = static_cast<CardPictureLoaderLocalSchemes::NamingScheme>(
SettingsCache::instance().cacheStorage().getLocalCardImageStorageNamingScheme());
QString pattern;
@ -306,7 +327,7 @@ void CardPictureLoader::picsPathChanged()
bool CardPictureLoader::hasCustomArt()
{
auto picsPath = SettingsCache::instance().getPicsPath();
auto picsPath = SettingsCache::instance().paths().getPicsPath();
QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot);
// Check if there is at least one non-directory file in the pics path, other

View file

@ -4,6 +4,8 @@
#include "card_picture_loader_status_bar.h"
#include "card_picture_loader_worker.h"
#include <QDateTime>
#include <QHash>
#include <QLoggingCategory>
inline Q_LOGGING_CATEGORY(CardPictureLoaderLog, "card_picture_loader");
@ -56,6 +58,7 @@ private:
CardPictureLoaderWorker *worker; ///< Worker thread for async image loading
CardPictureLoaderStatusBar *statusBar; ///< Status bar widget showing load progress
QHash<QString, QDateTime> failedAt; ///< Timestamp of the last failed load attempt per pixmap cache key
public:
/**

View file

@ -7,15 +7,16 @@
#include <QDirIterator>
#include <QMovie>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/paths_settings.h>
static constexpr int REFRESH_INTERVAL_MS = 10 * 1000;
CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent)
: QObject(parent), picsPath(SettingsCache::instance().getPicsPath()),
customPicsPath(SettingsCache::instance().getCustomPicsPath())
: QObject(parent), picsPath(SettingsCache::instance().paths().getPicsPath()),
customPicsPath(SettingsCache::instance().paths().getCustomPicsPath())
{
// Hook up signals to settings
connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this,
connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this,
&CardPictureLoaderLocal::picsPathChanged);
refreshIndex();
@ -127,6 +128,6 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName,
void CardPictureLoaderLocal::picsPathChanged()
{
picsPath = SettingsCache::instance().getPicsPath();
customPicsPath = SettingsCache::instance().getCustomPicsPath();
picsPath = SettingsCache::instance().paths().getPicsPath();
customPicsPath = SettingsCache::instance().paths().getCustomPicsPath();
}

View file

@ -1,6 +1,7 @@
#include "card_picture_loader_worker.h"
#include "../../client/settings/cache_settings.h"
#include "card_picture_loader_cache_method.h"
#include "card_picture_loader_local.h"
#include "card_picture_loader_worker_work.h"
@ -9,13 +10,19 @@
#include <QNetworkDiskCache>
#include <QNetworkReply>
#include <QThread>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <utility>
#include <version_string.h>
static constexpr int MAX_REQUESTS_PER_SEC = 10;
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
CardPictureLoaderWorker::CardPictureLoaderWorker()
: QObject(nullptr), picDownload(SettingsCache::instance().getPicDownload()), requestQuota(MAX_REQUESTS_PER_SEC)
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
requestQuota(MAX_REQUESTS_PER_SEC)
{
networkManager = new QNetworkAccessManager(this);
// We need a timeout to ensure requests don't hang indefinitely in case of
@ -25,13 +32,14 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
cache = new QNetworkDiskCache(this);
cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath());
cache->setMaximumCacheSize(1024L * 1024L *
static_cast<qint64>(SettingsCache::instance().getNetworkCacheSizeInMB()));
static_cast<qint64>(SettingsCache::instance().cacheStorage().getNetworkCacheSizeInMB()));
connect(&SettingsCache::instance(), &SettingsCache::networkCacheSizeChanged, cache, [this](int newSizeInMB) {
if (cache) {
cache->setMaximumCacheSize(1024L * 1024L * static_cast<qint64>(newSizeInMB));
}
});
connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::networkCacheSizeChanged, cache,
[this](int newSizeInMB) {
if (cache) {
cache->setMaximumCacheSize(1024L * 1024L * static_cast<qint64>(newSizeInMB));
}
});
networkManager->setCache(cache);
@ -39,7 +47,7 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
// We can't use NoLessSafeRedirectPolicy because it is not applied with AlwaysCache
networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
cacheFilePath = SettingsCache::instance().getRedirectCachePath() + REDIRECT_CACHE_FILENAME;
cacheFilePath = SettingsCache::instance().paths().getRedirectCachePath() + REDIRECT_CACHE_FILENAME;
loadRedirectCache();
cleanStaleEntries();
@ -72,7 +80,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor
queueRequest(cachedRedirect, worker);
return;
}
if (SettingsCache::instance().getCardPictureLoaderCacheMethod() ==
if (static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
cache->metaData(url).isValid()) {
// If we hit a cached url, we get to make the request for free, since it won't contribute towards the
@ -98,8 +107,10 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
bool useNetworkCache = !picDownload && SettingsCache::instance().getCardPictureLoaderCacheMethod() ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE;
bool useNetworkCache =
!picDownload && static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE;
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork);
@ -115,6 +126,19 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
void CardPictureLoaderWorker::resetRequestQuota()
{
requestQuota = MAX_REQUESTS_PER_SEC;
QDateTime now = QDateTime::currentDateTime();
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
}
}
for (const auto &request : requestLoadQueue) {
const QString host = request.first.host();
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
}
processQueuedRequests();
}
@ -127,14 +151,26 @@ void CardPictureLoaderWorker::processQueuedRequests()
bool CardPictureLoaderWorker::processSingleRequest()
{
if (!requestLoadQueue.isEmpty()) {
auto request = requestLoadQueue.takeFirst();
makeRequest(request.first, request.second);
return true;
for (int i = 0; i < requestLoadQueue.size(); ++i) {
const auto &request = requestLoadQueue.at(i);
QString host = request.first.host();
int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
if (allowance > 0) {
hostQuotaRemaining.insert(host, allowance - 1);
makeRequest(request.first, request.second);
requestLoadQueue.removeAt(i);
return true;
}
}
return false;
}
void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
{
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
hostLast429.insert(host, QDateTime::currentDateTime());
}
void CardPictureLoaderWorker::enqueueImageLoad(const ExactCard &card)
{
// Send call through a connection to ensure the handling is run on the pictureLoader thread
@ -229,7 +265,7 @@ void CardPictureLoaderWorker::cleanStaleEntries()
auto it = redirectCache.begin();
while (it != redirectCache.end()) {
if (it.value().second.addDays(SettingsCache::instance().getRedirectCacheTtl()) < now) {
if (it.value().second.addDays(SettingsCache::instance().cacheStorage().getRedirectCacheTtl()) < now) {
it = redirectCache.erase(it); // Remove stale entry
} else {
++it;

View file

@ -5,6 +5,8 @@
#include "card_picture_loader_worker_work.h"
#include "card_picture_to_load.h"
#include <QDateTime>
#include <QHash>
#include <QLoggingCategory>
#include <QMutex>
#include <QNetworkAccessManager>
@ -66,6 +68,12 @@ public:
*/
void queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker);
/**
* @brief Handles a server returning HTTP 429 by reducing that host's request quota.
* @param host The host that returned 429
*/
void onHostRateLimited(const QString &host);
/** @brief Clears the network cache and redirect cache. */
void clearNetworkCache();
@ -110,8 +118,11 @@ private:
bool picDownload; ///< Whether downloading images from network is enabled
QQueue<QPair<QUrl, CardPictureLoaderWorkerWork *>> requestLoadQueue; ///< Queue of pending network requests
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited
CardPictureLoaderLocal *localLoader; ///< Loader for local images
QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded

View file

@ -8,8 +8,13 @@
#include <QLoggingCategory>
#include <QMovie>
#include <QNetworkReply>
#include <QRandomGenerator>
#include <QThread>
#include <QThreadPool>
#include <QTimer>
ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter;
#include <libcockatrice/settings/download_settings.h>
// Card back returned by gatherer when card is not found
static const QStringList MD5_BLACKLIST = {
@ -19,7 +24,7 @@ static const QStringList MD5_BLACKLIST = {
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
picDownload(SettingsCache::instance().getPicDownload())
picDownload(SettingsCache::instance().downloads().getPicDownload())
{
// Hook up signals to the orchestrator
connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest);
@ -29,19 +34,48 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoader
connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded);
connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker,
&CardPictureLoaderWorker::imageRequestSucceeded);
connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited);
// Hook up signals to settings
connect(&SettingsCache::instance(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged()));
connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged()));
startNextPicDownload();
}
void CardPictureLoaderWorkerWork::startNextPicDownload()
{
QDateTime now = QDateTime::currentDateTime();
while (!cardToDownload.getCurrentUrl().isEmpty() &&
s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) {
QString host = QUrl(cardToDownload.getCurrentUrl()).host();
if (s_rateLimiter.rounds(host) == 1) {
// First 429 round for this server: wait out the backoff and give it
// one more chance instead of immediately falling through to a worse
// source. A second 429 makes us fall through instead.
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: Waiting out backoff for " << host << " to retry "
<< cardToDownload.getCurrentUrl();
scheduleDeferredRetry();
return;
}
// The server has already 429'd us at least twice, so further retries are
// unlikely to succeed: move on to the other configured sources.
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: Skipping rate-limited URL "
<< cardToDownload.getCurrentUrl() << " (server " << host << " still rate limiting)";
if (!cardToDownload.nextUrl() && !cardToDownload.nextSet()) {
scheduleDeferredRetry();
return;
}
}
QString picUrl = cardToDownload.getCurrentUrl();
if (picUrl.isEmpty()) {
picDownloadFailed();
scheduleDeferredRetry();
} else {
QUrl url(picUrl);
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
@ -107,7 +141,41 @@ static bool imageIsBlackListed(const QByteArray &picData)
void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply)
{
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 429) {
qCWarning(CardPictureLoaderWorkerWorkLog) << "Too many requests.";
QString host = reply->url().host();
QDateTime now = QDateTime::currentDateTime();
qint64 retryAfterMs = 0;
const QByteArray retryAfterHeader = reply->rawHeader("Retry-After");
if (!retryAfterHeader.isEmpty()) {
bool ok = false;
int seconds = retryAfterHeader.toInt(&ok);
if (ok && seconds > 0) {
retryAfterMs = static_cast<qint64>(seconds) * 1000;
} else {
QDateTime retryAfterDate =
QDateTime::fromString(QString::fromLatin1(retryAfterHeader), Qt::RFC2822Date);
if (retryAfterDate.isValid()) {
retryAfterMs = qMax<qint64>(0, now.msecsTo(retryAfterDate));
}
}
}
QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs);
emit rateLimited(host);
if (s_rateLimiter.rounds(host) == 1) {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getName()
<< " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
<< ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url";
scheduleDeferredRetry();
} else {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getName()
<< " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
<< ", retry already attempted, falling through to other sources";
picDownloadFailed();
}
} else {
bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool();
@ -148,6 +216,9 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply)
return;
}
// A non-redirect successful response means the server is not rate limiting us anymore.
s_rateLimiter.onSuccess(reply->url().host());
// peek is used to keep the data in the buffer for use by QImageReader
const QByteArray &picData = reply->peek(reply->size());
@ -202,6 +273,42 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply)
return imgReader.read();
}
void CardPictureLoaderWorkerWork::scheduleDeferredRetry()
{
QDateTime now = QDateTime::currentDateTime();
// Prefer waiting on the current URL's server so we retry the same source.
QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host();
QDateTime backoffUntil = s_rateLimiter.deadline(currentHost);
if (!s_rateLimiter.isRateLimited(currentHost, now)) {
backoffUntil = s_rateLimiter.earliestDeadline(now);
}
if (!backoffUntil.isValid()) {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, no servers in backoff: BAILING OUT";
concludeImageLoad(QImage());
return;
}
qint64 waitMs = qMax<qint64>(0, now.msecsTo(backoffUntil));
// Add some jitter to desynchronize concurrent retries and avoid a thundering herd.
waitMs += QRandomGenerator::global()->bounded(5000);
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs
<< "ms";
QTimer::singleShot(waitMs, this, [this] {
s_rateLimiter.clearExpired(QDateTime::currentDateTime());
cardToDownload.resetIndices();
startNextPicDownload();
});
}
void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image)
{
emit imageLoaded(cardToDownload.getCard(), image);
@ -210,5 +317,5 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image)
void CardPictureLoaderWorkerWork::picDownloadChanged()
{
picDownload = SettingsCache::instance().getPicDownload();
picDownload = SettingsCache::instance().downloads().getPicDownload();
}

View file

@ -4,12 +4,15 @@
#include "card_picture_loader_worker.h"
#include "card_picture_to_load.h"
#include <QDateTime>
#include <QLoggingCategory>
#include <QMutex>
#include <QNetworkAccessManager>
#include <QObject>
#include <QRandomGenerator>
#include <QThread>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/utility/server_rate_limiter.h>
inline Q_LOGGING_CATEGORY(CardPictureLoaderWorkerWorkLog, "card_picture_loader.worker");
@ -50,6 +53,8 @@ public slots:
private:
bool picDownload; ///< Whether network downloading is enabled
static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state
/** @brief Starts downloading the next URL for this card. */
void startNextPicDownload();
@ -77,6 +82,16 @@ private:
*/
void concludeImageLoad(const QImage &image);
/**
* @brief Schedules a deferred retry after the relevant server backoff expires.
*
* Waits on the current URL's server when it is the reason we are blocked,
* otherwise on the earliest active backoff. If no servers are in backoff,
* concludes with failure. Otherwise resets the CardPictureToLoad indices and
* retries after the backoff period.
*/
void scheduleDeferredRetry();
private slots:
/** @brief Updates the picDownload setting when it changes. */
void picDownloadChanged();
@ -100,6 +115,9 @@ signals:
/** @brief Emitted when a URL has been redirected. */
void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl);
/** @brief Emitted when a server returned HTTP 429. */
void rateLimited(const QString &host);
/** @brief Emitted when a cached URL is invalid and must be removed. */
void cachedUrlInvalidated(const QUrl &url);
};

View file

@ -9,14 +9,17 @@
#include <algorithm>
#include <libcockatrice/card/set/card_set_comparator.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h>
CardPictureToLoad::CardPictureToLoad(const ExactCard &_card)
: card(_card), urlTemplates(SettingsCache::instance().downloads().getAllURLs())
{
if (card) {
sortedSets = extractSetsSorted(card);
// The first time called, nextSet will also populate the Urls for the first set.
nextSet();
currentSetIndex = 0;
currentSet = sortedSets.first();
populateSetUrls();
}
}
@ -34,7 +37,7 @@ QList<CardSetPtr> CardPictureToLoad::extractSetsSorted(const ExactCard &card)
std::sort(sortedSets.begin(), sortedSets.end(), SetPriorityComparator());
// If the user hasn't disabled arts other than their personal preference...
if (!SettingsCache::instance().getOverrideAllCardArtWithPersonalPreference()) {
if (!SettingsCache::instance().cardsDisplay().getOverrideAllCardArtWithPersonalPreference()) {
// If the pixmapCacheKey corresponds to a specific set, we have to try to load it first.
qsizetype setIndex = sortedSets.indexOf(card.getPrinting().getSet());
if (setIndex > 0) { // we don't need to move the set if it's already first
@ -99,15 +102,19 @@ void CardPictureToLoad::populateSetUrls()
}
}
/* Call nextUrl to make sure currentUrl is up-to-date
but we don't need the result here. */
(void)nextUrl();
currentUrlIndex = 0;
if (!currentSetUrls.isEmpty()) {
currentUrl = currentSetUrls.first();
} else {
currentUrl = QString();
}
}
bool CardPictureToLoad::nextSet()
{
if (!sortedSets.isEmpty()) {
currentSet = sortedSets.takeFirst();
currentSetIndex++;
if (currentSetIndex < sortedSets.size()) {
currentSet = sortedSets.at(currentSetIndex);
populateSetUrls();
return true;
}
@ -117,8 +124,9 @@ bool CardPictureToLoad::nextSet()
bool CardPictureToLoad::nextUrl()
{
if (!currentSetUrls.isEmpty()) {
currentUrl = currentSetUrls.takeFirst();
currentUrlIndex++;
if (currentUrlIndex < currentSetUrls.size()) {
currentUrl = currentSetUrls.at(currentUrlIndex);
return true;
}
currentUrl = QString();
@ -134,6 +142,28 @@ QString CardPictureToLoad::getSetName() const
}
}
QString CardPictureToLoad::peekNextUrl() const
{
int nextIndex = currentUrlIndex + 1;
if (nextIndex < currentSetUrls.size()) {
return currentSetUrls.at(nextIndex);
}
return QString();
}
void CardPictureToLoad::resetIndices()
{
currentSetIndex = 0;
if (!sortedSets.isEmpty()) {
currentSet = sortedSets.first();
populateSetUrls();
} else {
currentSet = {};
currentSetUrls.clear();
currentUrl = QString();
}
}
static int parse(const QString &urlTemplate,
const QString &propType,
const QString &cardName,

View file

@ -25,6 +25,8 @@ private:
QList<QString> currentSetUrls; ///< URLs for the current set being attempted
QString currentUrl; ///< Currently active URL to download
CardSetPtr currentSet; ///< Currently active set
int currentSetIndex = 0; ///< Current position in sortedSets
int currentUrlIndex = 0; ///< Current position in currentSetUrls
public:
/**
@ -56,6 +58,9 @@ public:
/** @return The short name of the current set, or empty string if no set. */
[[nodiscard]] QString getSetName() const;
/** @return The next URL in the current set's list without advancing, or empty if at end. */
[[nodiscard]] QString peekNextUrl() const;
/**
* @brief Transforms a URL template into a concrete URL for this card/set.
* @param urlTemplate The URL template to transform
@ -88,6 +93,14 @@ public:
*/
void populateSetUrls();
/**
* @brief Resets iteration indices to the beginning.
*
* Restarts URL/set iteration from the first set and first URL.
* Used for deferred retry after server backoff expires.
*/
void resetIndices();
/**
* @brief Extract all sets from the card and sort them by priority.
* @param card The card to extract sets from

View file

@ -0,0 +1,14 @@
#ifndef COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
#define COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
#include <QString>
struct ContextConnectToServer
{
QString hostname;
QString port;
QString username;
QString password;
};
#endif // COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H

View file

@ -0,0 +1,11 @@
#ifndef COCKATRICE_CONTEXT_JOIN_GAME_H
#define COCKATRICE_CONTEXT_JOIN_GAME_H
#include "context_join_room.h"
struct ContextJoinGame
{
ContextJoinRoom roomContext;
int gameId;
};
#endif // COCKATRICE_CONTEXT_JOIN_GAME_H

Some files were not shown because too many files have changed in this diff Show more