diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile
index 36cf5c4ae..b08e568f3 100644
--- a/.ci/Arch/Dockerfile
+++ b/.ci/Arch/Dockerfile
@@ -8,10 +8,13 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
gtest \
mariadb-libs \
ninja \
+ openssl \
protobuf \
qt6-base \
+ qt6-declarative \
qt6-imageformats \
qt6-multimedia \
+ qt6-shadertools \
qt6-svg \
qt6-tools \
qt6-translations \
diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile
index 202405b84..fc756aac2 100644
--- a/.ci/Debian12/Dockerfile
+++ b/.ci/Debian12/Dockerfile
@@ -15,11 +15,14 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
+ qt6-declarative-dev \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
+ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile
index d7ab6ac86..bdecb56df 100644
--- a/.ci/Debian13/Dockerfile
+++ b/.ci/Debian13/Dockerfile
@@ -16,11 +16,14 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
+ qt6-declarative-dev \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
+ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile
index 27570cf99..463da5a51 100644
--- a/.ci/Fedora43/Dockerfile
+++ b/.ci/Fedora43/Dockerfile
@@ -7,8 +7,9 @@ RUN dnf install -y \
git \
mariadb-devel \
ninja-build \
+ openssl-devel \
protobuf-devel \
- qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
+ qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \
qt6-qtimageformats \
rpm-build \
xz-devel \
diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile
index e6c8da7f3..62238e760 100644
--- a/.ci/Fedora44/Dockerfile
+++ b/.ci/Fedora44/Dockerfile
@@ -7,8 +7,9 @@ RUN dnf install -y \
git \
mariadb-devel \
ninja-build \
+ openssl-devel \
protobuf-devel \
- qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
+ qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \
qt6-qtimageformats \
rpm-build \
xz-devel \
diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile
index 21f6a036e..321aa7c0f 100644
--- a/.ci/Servatrice_Debian12/Dockerfile
+++ b/.ci/Servatrice_Debian12/Dockerfile
@@ -12,6 +12,7 @@ RUN apt-get update && \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
qt6-tools-dev \
diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile
index 809b2e43a..715997474 100644
--- a/.ci/Ubuntu24.04/Dockerfile
+++ b/.ci/Ubuntu24.04/Dockerfile
@@ -15,11 +15,14 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
+ qt6-declarative-dev \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
+ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile
index 7b0cd389f..96dd10763 100644
--- a/.ci/Ubuntu26.04/Dockerfile
+++ b/.ci/Ubuntu26.04/Dockerfile
@@ -16,11 +16,14 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
+ qt6-declarative-dev \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
+ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
diff --git a/.ci/compile.sh b/.ci/compile.sh
index 8a16d3243..f20432893 100755
--- a/.ci/compile.sh
+++ b/.ci/compile.sh
@@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then
fi
if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1")
+ # PCH-aware caching is required or ccache refuses to cache any TU that
+ # consumes a precompiled header, silently recompiling everything on every run.
+ ccache --set-config sloppiness=pch_defines,time_macros
if [[ $CCACHE_SIZE ]]; then
# note, this setting persists after running the script
ccache --max-size "$CCACHE_SIZE"
@@ -324,4 +327,32 @@ if [[ $MAKE_PACKAGE ]]; then
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
echo "::endgroup::"
fi
+
+ if [[ $RUNNER_OS == Windows ]]; then
+ echo "::group::Check installer for build-tree artifacts"
+ cd "$BUILD_DIR"
+ package="$(find . -maxdepth 1 -type f -name 'Cockatrice-*.exe' -print -quit)"
+ if [[ ! $package ]]; then
+ echo "::error file=$0::Could not find installer to inspect"
+ exit 1
+ fi
+ seven_zip="$(command -v 7z || true)"
+ if [[ ! $seven_zip ]]; then
+ seven_zip="/c/Program Files/7-Zip/7z.exe"
+ fi
+ if [[ ! -f $seven_zip ]]; then
+ echo "::warning file=$0::7-Zip not found, skipping installer content check"
+ else
+ echo "Inspecting $package"
+ # Fail the build if the installer contains any path left behind by the MSBuild or
+ # Qt AUTOMOC tooling (build-tree artifacts must live in the build dir, not the install)
+ if "$seven_zip" l "$package" |
+ grep -E "_autogen|\.dir[\\/]|\.tlog|(^|[\\/])x64[\\/]|(^|[\\/])\.qt[\\/]|(^|[\\/])\.qsb[\\/]|(^|[\\/])\.lupdate[\\/]|CMakeFiles"; then
+ echo "::error file=$0::Installer contains build-tree artifacts"
+ exit 1
+ fi
+ echo "Installer content is clean"
+ fi
+ echo "::endgroup::"
+ fi
fi
diff --git a/.ci/release_template.md b/.ci/release_template.md
index ac78a193a..23b475150 100644
--- a/.ci/release_template.md
+++ b/.ci/release_template.md
@@ -12,9 +12,9 @@ Available pre-compiled binaries for installation:
• Windows 10+
macOS
- • macOS 15+ Sequoia Apple M
- • macOS 14+ Sonoma Apple M
- • macOS 13+ Ventura Intel
+ • macOS 15+ Sequoia
+ • macOS 14+ Sonoma
+ • macOS 13+ Ventura (x86)
Linux
• Ubuntu 26.04 LTS Resolute Racoon
@@ -24,10 +24,10 @@ Available pre-compiled binaries for installation:
• Fedora 44
• Fedora 43
-We are also packaged in Arch Linux's official extra repository, courtesy of @FFY00.
-General Linux support is available via a flatpak package at Flathub!
+ General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)!
+ Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository.
-We provide a Docker image for "Servatrice" in GHCR. You can docker pull it or use our Docker Compose files!
+ We maintain a Docker image for "Servatrice" in GHCR (x86 & ARM). You can docker pull it or use our Docker Compose files!
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..75fbc59f1
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,95 @@
+# GitHub Docs on Code Scanning:
+# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning
+# https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration
+# https://docs.github.com/en/code-security/reference/code-scanning/workflow-configuration-options
+#
+# CodeQL Docs:
+# https://codeql.github.com/docs/
+
+name: CodeQL
+
+permissions:
+ contents: read
+ security-events: write # needed to post results
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+# Cancel earlier, unfinished runs of this workflow on the same branch
+concurrency:
+ group: "${{ github.workflow }} @ ${{ github.ref_name }}"
+ cancel-in-progress: true
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # https://codeql.github.com/docs/codeql-overview/supported-languages-and-frameworks/
+ - language: cpp
+ build-mode: manual
+ - language: actions
+ build-mode: none
+
+ steps:
+ - name: "Checkout repository"
+ uses: actions/checkout@v7
+
+ - name: "Initialize CodeQL"
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+ # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/c-cpp-built-in-queries
+ # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/actions-built-in-queries
+ queries: security-extended
+ dependency-caching: true
+
+ - name: "[C++] Install dependencies"
+ if: matrix.language == 'cpp' && matrix.build-mode == 'manual'
+ shell: bash
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ cmake \
+ g++ \
+ libgl-dev \
+ liblzma-dev \
+ libmariadb-dev-compat \
+ libprotobuf-dev \
+ ninja-build \
+ protobuf-compiler \
+ qt6-declarative-dev \
+ qt6-multimedia-dev \
+ qt6-shadertools-dev \
+ qt6-svg-dev \
+ qt6-tools-dev \
+ qt6-tools-dev-tools \
+ qt6-websockets-dev
+
+# Minimize dependency install
+# Add ccache usage for faster compilation, (install ccache dep, actions/cache step + append DUSE_CCACHE=1 in cmake config, CCACHE env values)
+
+ - name: "[C++] Configure CMake"
+ if: matrix.language == 'cpp' && matrix.build-mode == 'manual'
+ shell: bash
+ run: cmake -S . -B build -G Ninja -DWITH_SERVER=1 -DCMAKE_BUILD_TYPE=Release
+
+ - name: "[C++] Build application"
+ if: matrix.language == 'cpp' && matrix.build-mode == 'manual'
+ shell: bash
+ run: cmake --build build
+
+ - name: "Perform CodeQL Analysis"
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index f1846ecf6..bd528f245 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -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:
@@ -34,7 +34,7 @@ on:
- 'vcpkg.json'
- 'vcpkg' # needed to match submodule bumps (gitlink)
-# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release)
+# Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release)
concurrency:
group: "${{ github.workflow }} @ ${{ github.ref_name }}"
cancel-in-progress: ${{ github.ref_type != 'tag' }}
@@ -42,7 +42,7 @@ concurrency:
jobs:
configure:
name: Configure
- runs-on: ubuntu-slim
+ runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
outputs:
tag: ${{ steps.configure.outputs.tag }}
sha: ${{ steps.configure.outputs.sha }}
@@ -146,13 +146,13 @@ jobs:
name: ${{ matrix.distro }} ${{ matrix.version }}
needs: configure
- runs-on: ubuntu-latest
+ runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
timeout-minutes: 70
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
- CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
+ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
@@ -161,14 +161,11 @@ jobs:
uses: actions/checkout@v7
- name: "Restore compiler cache (ccache)"
- id: ccache_restore
+ id: restore_ccache
uses: actions/cache/restore@v6
- env:
- BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
with:
- key: ccache-${{ matrix.distro }}${{ matrix.version }}-${{ env.BRANCH_NAME }}
+ key: ccache-${{ matrix.distro }}${{ matrix.version }}
path: ${{ env.CACHE }}
- restore-keys: ccache-${{ matrix.distro }}${{ matrix.version }}-
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
shell: bash
@@ -179,8 +176,12 @@ jobs:
shell: bash
run: |
source .ci/docker.sh
- RUN --server --debug --test --ccache "$CCACHE_SIZE" \
- --cmake-generator "$CMAKE_GENERATOR"
+ args=()
+ [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
+ args+=(--ccache "$CCACHE_SIZE")
+ args+=(--cmake-generator "$CMAKE_GENERATOR")
+
+ RUN --server --debug --test "${args[@]}"
- name: "Build release package"
id: build
@@ -203,10 +204,10 @@ jobs:
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "Delete remote compiler cache (ccache)"
- if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
+ if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
env:
- CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
+ CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
run: |
if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
@@ -217,7 +218,7 @@ jobs:
if: github.ref == 'refs/heads/master'
uses: actions/cache/save@v6
with:
- key: ${{ steps.ccache_restore.outputs.cache-primary-key }}
+ key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CACHE }}
- name: "Upload artifact"
@@ -262,74 +263,77 @@ jobs:
matrix:
include:
- os: macOS
- target: 13
- runner: macos-15-intel
+ target: 13 # EOL 2025-09-15
+ runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
+ # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 13
package_suffix: "-macOS13_Intel"
- qt_version: 6.11.0
- qt_modules: qtimageformats qtmultimedia qtwebsockets
+ qt_version: 6.11.*
+ qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Intel
type: Release
use_ccache: 1
- xcode: "16.4"
+ xcode: "26.3"
- os: macOS
- target: 14
- runner: macos-14
+ target: 14 # EOL 2026-??
+ runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
+ override_target: 14
package_suffix: "-macOS14"
- qt_version: 6.11.0
- qt_modules: qtimageformats qtmultimedia qtwebsockets
+ qt_version: 6.11.*
+ qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple
type: Release
use_ccache: 1
- xcode: "15.4"
+ xcode: "26.6"
- os: macOS
target: 15
- runner: macos-15
+ runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
+ override_target: 15
package_suffix: "-macOS15"
- qt_version: 6.11.0
- qt_modules: qtimageformats qtmultimedia qtwebsockets
+ qt_version: 6.11.*
+ qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple
type: Release
use_ccache: 1
- xcode: "16.4"
+ xcode: "26.6"
- os: macOS
- target: 15
- runner: macos-15
+ target: 26
+ runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
ccache_eviction_age: 7d
cmake_generator: Ninja
- qt_version: 6.11.0
- qt_modules: qtimageformats qtmultimedia qtwebsockets
+ qt_version: 6.11.*
+ qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple
type: Debug
use_ccache: 1
- xcode: "16.4"
+ xcode: "26.6"
- os: Windows
target: 10
- runner: windows-2025
+ runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
cmake_generator: "Visual Studio 18 2026"
cmake_generator_platform: x64
make_package: 1
package_suffix: "-Win10"
- qt_version: 6.11.0
- qt_modules: qtimageformats qtmultimedia qtwebsockets
+ qt_version: 6.11.*
+ qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
@@ -338,7 +342,7 @@ jobs:
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
- CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
+ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
steps:
- name: "Checkout"
@@ -348,7 +352,6 @@ jobs:
- name: "[Windows] Add msbuild to PATH"
if: matrix.os == 'Windows'
- id: add-msbuild
uses: microsoft/setup-msbuild@v3
with:
msbuild-architecture: x64
@@ -359,54 +362,54 @@ jobs:
- name: "[macOS] Restore compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1
- id: ccache_restore
+ id: restore_ccache
uses: actions/cache/restore@v6
- env:
- BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
with:
- key: ccache-${{ matrix.runner }}-${{ matrix.soc }}-${{ matrix.type }}-${{ env.BRANCH_NAME }}
+ key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }}
path: ${{ env.CCACHE_DIR }}
- restore-keys: ccache-${{ matrix.runner }}-${{ matrix.soc }}-${{ matrix.type }}-
- - name: "Install aqtinstall"
+ - name: "[macOS] Install aqtinstall"
+ if: matrix.os == 'macOS'
run: pipx install aqtinstall
# 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"
+ - name: "[macOS] Resolve latest Qt from ${{ matrix.qt_version }} input"
+ if: matrix.os == 'macOS'
env:
QT_VERSION: ${{ matrix.qt_version }}
id: resolve_qt_version
shell: bash
run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION"
- - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries"
+ - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}"
if: matrix.os == 'macOS'
id: restore_qt
uses: actions/cache/restore@v6
with:
- key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }}
+ key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin
path: ${{ github.workspace }}/Qt
# Using jurplel/install-qt-action to install Qt without using brew
# Qt build using vcpkg either just fails or takes too long to build
- - name: "[macOS] Install fat Qt ${{ steps.resolve_qt_version.outputs.version }}"
+ - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}"
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
uses: jurplel/install-qt-action@v4
with:
cache: false
- dir: ${{ github.workspace }}
+ # cache-key-prefix: Qt
+ dir: ${{ github.workspace }} # thinning script depends on this location
modules: ${{ matrix.qt_modules }}
- version: ${{ steps.resolve_qt_version.outputs.version }}
+ version: ${{ matrix.qt_version }}
- name: "[macOS] Create thin Qt libraries"
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
run: .ci/thin_macos_qtlib.sh
- name: "[macOS] Cache thin Qt libraries"
- if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
+ if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6
with:
- key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }}
+ key: ${{ steps.restore_qt.outputs.cache-primary-key }}
path: ${{ github.workspace }}/Qt
- name: "[Windows] Install Qt ${{ matrix.qt_version }}"
@@ -415,9 +418,10 @@ 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
- cache: true
+ cache: ${{ github.ref == 'refs/heads/master' }}
+ cache-key-prefix: Qt
modules: ${{ matrix.qt_modules }}
- version: ${{ steps.resolve_qt_version.outputs.version }}
+ version: ${{ matrix.qt_version }}
- name: "[Windows] Install NSIS"
if: matrix.os == 'Windows'
@@ -440,6 +444,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 }}
@@ -448,27 +453,28 @@ jobs:
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }}
USE_CCACHE: ${{ matrix.use_ccache }}
- VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite'
+ VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
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
- name: "[macOS] Delete remote compiler cache (ccache)"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit
+ if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
env:
- CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }}
+ CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
GH_TOKEN: ${{ github.token }}
run: |
if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
echo "Cache deleted successfully"
fi
- - name: "[macOS] Save updated compiler cache (ccache)"
+ - name: "[macOS] Cache updated compiler cache (ccache)"
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6
with:
- key: ${{ steps.ccache_restore.outputs.cache-primary-key }}
+ key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
path: ${{ env.CCACHE_DIR }}
- name: "[macOS] Sign app bundle"
diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml
index 5f31ea59c..93ba79464 100644
--- a/.github/workflows/desktop-lint.yml
+++ b/.github/workflows/desktop-lint.yml
@@ -1,5 +1,8 @@
name: Code Style (C++)
+permissions:
+ contents: read
+
on:
# Push trigger not needed for linting, we do not allow direct pushes to master
pull_request:
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 5384c9e64..967d94c58 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -1,8 +1,8 @@
-name: Build Docker Image
+name: Build Docker
permissions:
- contents: read
- packages: write
+ contents: read # needed to checkout repo
+ packages: write # needed for interacting with GHCR
on:
push:
@@ -13,7 +13,10 @@ on:
- master
paths:
- '.github/workflows/docker-release.yml'
+ - '.dockerignore'
- 'Dockerfile'
+ - 'docker-compose.yml'
+ - 'docker-compose.yml.windows'
release:
types:
- released # publishing of stable releases
@@ -23,36 +26,38 @@ concurrency:
group: "${{ github.workflow }} @ ${{ github.ref_name }}"
cancel-in-progress: ${{ github.event_name != 'release' }}
+env:
+ GHCR_IMAGE: ghcr.io/cockatrice/servatrice
+ OCI_DESCRIPTION: Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games
+ OCI_TITLE: Servatrice
+ OCI_URL: https://cockatrice.github.io/
+
jobs:
- docker:
- name: amd64 & arm64
- if: ${{ github.repository_owner == 'Cockatrice' }}
- runs-on: ubuntu-latest
-
+ # Create one platform-specific image and publish its OCI image manifest per matrix job
+ build:
+ name: "Servatrice (${{ matrix.label }})"
+ if: github.repository_owner == 'Cockatrice'
+ runs-on: ${{ matrix.runner }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - label: x86
+ platform: linux/amd64
+ runner: ubuntu-latest # https://github.com/actions/runner-images
+
+ - label: arm
+ platform: linux/arm64
+ runner: ubuntu-24.04-arm # https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Arm64-Readme.md, replace with "ubuntu-latest-arm" once available
+
+ env:
+ CACHE_SCOPE: servatrice-${{ matrix.label }}
+
steps:
- name: "Checkout"
uses: actions/checkout@v7
- - name: "Docker metadata"
- id: metadata
- uses: docker/metadata-action@v6
- env:
- DOCKER_METADATA_ANNOTATIONS_LEVELS: index # needed for GHCR
- with:
- annotations: |
- org.opencontainers.image.title=Servatrice
- org.opencontainers.image.url=https://cockatrice.github.io/
- org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games
- images: |
- ghcr.io/cockatrice/servatrice
- labels: |
- org.opencontainers.image.title=Servatrice
- org.opencontainers.image.url=https://cockatrice.github.io/
- org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games
-
- - name: "Set up QEMU"
- uses: docker/setup-qemu-action@v4
-
- name: "Set up Docker buildx"
uses: docker/setup-buildx-action@v4
@@ -61,18 +66,117 @@ jobs:
id: login
uses: docker/login-action@v4
with:
- password: ${{ github.token }}
registry: ghcr.io
username: ${{ github.actor }}
+ password: ${{ github.token }}
- - name: "Build and push Docker image"
+ # Don't push for non-release triggers
+ - name: "Build image"
+ if: steps.login.outcome != 'success'
uses: docker/build-push-action@v7
with:
- annotations: ${{ steps.metadata.outputs.annotations }}
- cache-from: type=gha,scope=servatrice
- cache-to: type=gha,mode=max,scope=servatrice
+ cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
+ cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }}
context: .
- labels: ${{ steps.metadata.outputs.labels }}
- platforms: linux/amd64,linux/arm64
- push: ${{ steps.login.outcome == 'success' }}
- tags: ${{ steps.metadata.outputs.tags }}
+ platforms: ${{ matrix.platform }}
+ push: false
+
+ # Add OCI labels and push single-platform image by digest (without tags)
+ - name: "Build image and push by digest"
+ if: steps.login.outcome == 'success'
+ id: build
+ uses: docker/build-push-action@v7
+ with:
+ cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
+ cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
+ context: .
+ labels: |
+ org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }}
+ org.opencontainers.image.title=${{ env.OCI_TITLE }}
+ org.opencontainers.image.url=${{ env.OCI_URL }}
+ outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true
+ platforms: ${{ matrix.platform }}
+ provenance: mode=max # Do not pass secrets as build arguments with this option
+ sbom: true
+
+ - name: "Export digest"
+ if: steps.login.outcome == 'success'
+ env:
+ DIGEST: ${{ steps.build.outputs.digest }}
+ run: |
+ mkdir -p "$RUNNER_TEMP/digests"
+ touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
+
+ - name: "Upload digest"
+ if: steps.login.outcome == 'success'
+ uses: actions/upload-artifact@v7
+ with:
+ archive: false
+ if-no-files-found: error
+ name: digest-${{ matrix.label }}
+ path: ${{ runner.temp }}/digests/*
+ retention-days: 1
+
+
+ # Create an OCI image index from the platform-specific image manifests
+ index:
+ name: "Publish multi-platform Servatrice image"
+ if: github.repository_owner == 'Cockatrice' && github.event_name == 'release' && github.event.release.prerelease == false
+ needs: build
+ runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
+
+ steps:
+ - name: "Download digests"
+ uses: actions/download-artifact@v8
+ with:
+ path: ${{ runner.temp }}/digests
+ pattern: digest-*
+ merge-multiple: true
+
+ - name: "Login to GitHub Container Registry (GHCR)"
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ github.token }}
+
+ - name: "Docker metadata"
+ id: metadata
+ uses: docker/metadata-action@v6
+ with:
+ images: ${{ env.GHCR_IMAGE }}
+ flavor: |
+ latest=auto
+ tags: |
+ type=ref,event=tag # if semver, also: type=semver,pattern={{version}} / {{major}}.{{minor}}
+
+ # Add OCI annotations to image index and publish tags
+ - name: "Create image index"
+ env:
+ DOCKER_TAGS: ${{ steps.metadata.outputs.tags }}
+ working-directory: ${{ runner.temp }}/digests
+ run: |
+ TAG_ARGS=()
+ while IFS= read -r tag; do
+ TAG_ARGS+=(--tag "$tag")
+ done <<< "$DOCKER_TAGS"
+
+ DIGEST_ARGS=()
+ for digest in *; do
+ DIGEST_ARGS+=("$GHCR_IMAGE@sha256:$digest")
+ done
+
+ docker buildx imagetools create \
+ --prefer-index=true \
+ --annotation "index:org.opencontainers.image.description=$OCI_DESCRIPTION" \
+ --annotation "index:org.opencontainers.image.title=$OCI_TITLE" \
+ --annotation "index:org.opencontainers.image.url=$OCI_URL" \
+ "${TAG_ARGS[@]}" \
+ "${DIGEST_ARGS[@]}"
+
+ - name: "Inspect images"
+ env:
+ GITHUB_TAG: ${{ github.ref_name }}
+ run: |
+ docker buildx imagetools inspect "$GHCR_IMAGE:latest"
+ docker buildx imagetools inspect "$GHCR_IMAGE:$GITHUB_TAG"
diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml
index 4b9ca79ab..419cbfbfb 100644
--- a/.github/workflows/documentation-build.yml
+++ b/.github/workflows/documentation-build.yml
@@ -1,5 +1,8 @@
name: Generate Docs
+permissions:
+ contents: read # write permission to the destination repo come from 'deploy_key'
+
on:
pull_request:
paths:
@@ -11,6 +14,11 @@ on:
- published # publishing of stable releases and pre-releases
workflow_dispatch:
+# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release)
+concurrency:
+ group: "${{ github.workflow }} @ ${{ github.ref_name }}"
+ cancel-in-progress: ${{ github.event_name != 'release' }}
+
env:
COCKATRICE_REF: ${{ github.ref_name }} # tag name if the commit is tagged, otherwise branch name
diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml
index a3db5f86d..71b0b4c22 100644
--- a/.github/workflows/translations-pull.yml
+++ b/.github/workflows/translations-pull.yml
@@ -1,5 +1,9 @@
name: Update Translations
+permissions:
+ contents: read
+ pull-requests: write
+
on:
pull_request:
paths:
diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml
index c4d3f61fb..41a7aef40 100644
--- a/.github/workflows/translations-push.yml
+++ b/.github/workflows/translations-push.yml
@@ -1,5 +1,9 @@
name: Update Translation Source
+permissions:
+ contents: read
+ pull-requests: write
+
on:
pull_request:
paths:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 27fecc979..293e25dd9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -5,23 +5,23 @@
# This file sets all the variables shared between the projects
# like the installation path, compilation flags etc..
-# cmake 3.16 is required if using qt6
-cmake_minimum_required(VERSION 3.10)
+# 3.16 required for Qt6 and target_precompile_headers()
+cmake_minimum_required(VERSION 3.16)
-# Early detect ccache
+# Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" ON)
# Treat warnings as errors (Debug builds only)
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
# Check for translation updates
option(UPDATE_TRANSLATIONS "Update translations on compile" OFF)
-# Compile servatrice
-option(WITH_SERVER "build servatrice" OFF)
-# Compile cockatrice
-option(WITH_CLIENT "build cockatrice" ON)
-# Compile oracle
-option(WITH_ORACLE "build oracle" ON)
+# Compile Cockatrice
+option(WITH_CLIENT "Build Cockatrice client" ON)
+# Compile Oracle
+option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
+# Compile Servatrice
+option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile tests
-option(TEST "build tests" OFF)
+option(TEST "Build tests" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@@ -39,13 +39,24 @@ else()
)
endif()
-if(USE_CCACHE)
+# ccache does not support MSVC and must not auto-engage on Windows
+# (it is installed unintentionally on the Windows CI runner).
+# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
+# also opts out of ccache even though the GNUCXX branch below supports it.
+if(USE_CCACHE AND NOT WIN32)
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
# Support Unix Makefiles and Ninja
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
+ # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
+ # to cache any TU that consumes a precompiled header, so every PCH-backed
+ # target recompiles from scratch on each build.
+ execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif()
+elseif(USE_CCACHE AND WIN32)
+ # An explicit opt-in must not disappear silently on Windows.
+ message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
endif()
if(WIN32 OR USE_VCPKG)
@@ -184,6 +195,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
endif()
endforeach()
+
+ # Reduce compiler I/O by using pipes between stages instead of temp files
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else()
# other: osx/llvm, bsd/llvm
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
@@ -192,6 +206,9 @@ else()
else()
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra")
endif()
+
+ # Reduce compiler I/O by using pipes between stages instead of temp files
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif()
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
@@ -239,11 +256,6 @@ if(WIN32)
find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS})
- else()
- message(
- WARNING
- "Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime."
- )
endif()
endif()
@@ -281,7 +293,7 @@ if(UNIX)
if(CPACK_GENERATOR STREQUAL "RPM")
set(CPACK_RPM_PACKAGE_LICENSE "GPLv2")
set(CPACK_RPM_MAIN_COMPONENT "cockatrice")
- set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
+ set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qtimageformats, qt6-qtmultimedia, qt6-qtsvg, qt6-qttools")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice")
# stop directories from making package conflicts
@@ -299,7 +311,7 @@ if(UNIX)
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice")
- set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
+ set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-image-formats-plugins, qt6-qpa-plugins")
set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
endif()
endif()
diff --git a/Dockerfile b/Dockerfile
index 7c5c773c9..7d3deb5fb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,35 +3,47 @@ FROM ubuntu:26.04 AS build
ARG DEBIAN_FRONTEND=noninteractive
-RUN apt-get update && apt-get install -y --no-install-recommends \
- build-essential \
- cmake \
- file \
- g++ \
- git \
- libmariadb-dev-compat \
- libprotobuf-dev \
- libqt6sql6-mysql \
- qt6-websockets-dev \
- protobuf-compiler \
- qt6-tools-dev \
- qt6-tools-dev-tools
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ build-essential \
+ cmake \
+ ninja-build \
+ file \
+ g++ \
+ git \
+ libmariadb-dev-compat \
+ libprotobuf-dev \
+ libqt6sql6-mysql \
+ libssl-dev \
+ qt6-websockets-dev \
+ protobuf-compiler \
+ qt6-tools-dev \
+ qt6-tools-dev-tools
WORKDIR /src
COPY . .
-RUN mkdir build && cd build && \
- cmake .. -DWITH_SERVER=1 -DWITH_CLIENT=0 -DWITH_ORACLE=0 && \
- make -j$(nproc) && \
- make install
+RUN cmake \
+ -S . \
+ -B build \
+ -G Ninja \
+ -DWITH_CLIENT=0 \
+ -DWITH_ORACLE=0 \
+ -DWITH_SERVER=1 \
+ && cmake --build build \
+ && cmake --install build
# -------- Runtime Stage (clean) --------
FROM ubuntu:26.04
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libprotobuf32t64 \
- libqt6sql6-mysql \
- libqt6websockets6 \
+ARG DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ libprotobuf32t64 \
+ libqt6sql6-mysql \
+ libqt6websockets6 \
+ libssl3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
diff --git a/README.md b/README.md
index f22df461f..5935bb540 100644
--- a/README.md
+++ b/README.md
@@ -149,15 +149,15 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`:
-| Flag | Description |
-| --- | --- |
-| `-DWITH_SERVER=1` | Build Servatrice server |
-| `-DWITH_CLIENT=0` | Don't build Cockatrice client |
-| `-DWITH_ORACLE=0` | Don't build Oracle card database tool |
-| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings |
-| `-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
**Note:** `make clean` will remove the .ts files |
-| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available |
+| Flag | Description |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `-DWITH_SERVER=1` | Build Servatrice server |
+| `-DWITH_CLIENT=0` | Don't build Cockatrice client |
+| `-DWITH_ORACLE=0` | Don't build Oracle card database tool |
+| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings |
+| `-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
**Note:** `make clean` will remove the .ts files |
+| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available |
# Run
diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake
index 8a3050813..971c9094d 100644
--- a/cmake/FindQtRuntime.cmake
+++ b/cmake/FindQtRuntime.cmake
@@ -18,14 +18,17 @@ if(WITH_CLIENT)
Multimedia
Network
PrintSupport
+ ShaderTools
Svg
WebSockets
Widgets
Xml
+ Quick
+ QuickWidgets
)
endif()
if(WITH_ORACLE)
- set(_ORACLE_NEEDED Concurrent Network Svg Widgets)
+ set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml)
endif()
if(TEST)
# Union of Qt modules required across all test targets (independent of application targets).
diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in
index 5af116470..b3cbcece8 100644
--- a/cmake/NSIS.template.in
+++ b/cmake/NSIS.template.in
@@ -387,19 +387,14 @@ SectionEnd
Section "un.Application" UnSecApplication
SetShellVarContext all
- RMDir /r "$INSTDIR\plugins"
- RMDir /r "$INSTDIR\sounds"
- RMDir /r "$INSTDIR\themes"
- RMDir /r "$INSTDIR\translations"
- Delete "$INSTDIR\*.exe"
- Delete "$INSTDIR\*.dll"
- Delete "$INSTDIR\qt.conf"
- Delete "$INSTDIR\qdebug.txt"
- Delete "$INSTDIR\servatrice.sql"
- Delete "$INSTDIR\servatrice.ini.example"
- RMDir "$INSTDIR"
- RMDir "$SMPROGRAMS\Cockatrice"
+ ; Remove the entire application directory so any file that is not part of
+ ; the installed payload (e.g. build-tree artifacts such as *.dir folders,
+ ; *_autogen and *.tlog files from a build) cannot survive between an
+ ; uninstall and a fresh reinstall.
+ RMDir /r "$INSTDIR"
+
+ RMDir /r "$SMPROGRAMS\Cockatrice"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
SectionEnd
diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h
new file mode 100644
index 000000000..cc3dd12ee
--- /dev/null
+++ b/cmake/pch/qtcore_pch.h
@@ -0,0 +1,24 @@
+/** @file qtcore_pch.h
+ * @brief Precompiled header for all Qt targets (Qt Core only).
+ *
+ * Safe for every target that links Qt Core, including the headless
+ * Servatrice binary. Keep this header free of any widget/gui types.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h
new file mode 100644
index 000000000..2c63f450e
--- /dev/null
+++ b/cmake/pch/qtwidgets_pch.h
@@ -0,0 +1,30 @@
+/** @file qtwidgets_pch.h
+ * @brief Precompiled header for GUI targets (Cockatrice client, Oracle).
+ *
+ * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
+ * Network layers that virtually every client translation unit re-parses.
+ * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
+ */
+
+#include "qtcore_pch.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index ed8e49f2d..9b31310e6 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -15,6 +15,9 @@ set(cockatrice_SOURCES
src/client/network/update/client/client_update_checker.cpp
src/client/network/update/client/release_channel.cpp
src/client/network/update/card_spoiler/spoiler_background_updater.cpp
+ src/client/latency_graph_widget.cpp
+ src/client/latency_status_widget.cpp
+ src/client/lag_monitor.cpp
src/client/sound_engine.cpp
src/client/settings/cache_settings.cpp
src/client/settings/card_counter_settings.cpp
@@ -36,14 +39,20 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp
src/interface/widgets/dialogs/dlg_forgot_password_request.cpp
src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp
+ src/interface/widgets/dialogs/dlg_invite_to_game.cpp
src/interface/widgets/dialogs/dlg_load_deck.cpp
src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp
src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp
src/interface/widgets/dialogs/dlg_load_remote_deck.cpp
src/interface/widgets/dialogs/dlg_local_game_options.cpp
+ src/interface/widgets/dialogs/dlg_login_prompt.cpp
src/interface/widgets/dialogs/dlg_manage_sets.cpp
+ src/interface/widgets/dialogs/dlg_my_reports.cpp
src/interface/widgets/dialogs/dlg_register.cpp
+ src/interface/widgets/dialogs/dlg_report_user.cpp
src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp
+ src/interface/widgets/dialogs/dlg_share_deck.cpp
+ src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
@@ -51,6 +60,9 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp
src/interface/widgets/dialogs/tip_of_the_day.cpp
+ src/interface/widgets/deck_share/deck_share_utils.cpp
+ src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
+ src/interface/widgets/deck_share/share_bar_widget.cpp
src/filters/deck_filter_string.cpp
src/filters/filter_builder.cpp
src/filters/filter_tree_model.cpp
@@ -143,7 +155,11 @@ set(cockatrice_SOURCES
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
@@ -153,8 +169,11 @@ set(cockatrice_SOURCES
src/interface/palette_editor/palette_grid_widget.cpp
src/interface/palette_editor/palette_editor_dialog.cpp
src/interface/widgets/cards/additional_info/color_identity_widget.cpp
+ src/interface/widgets/cards/additional_info/deck_color_identity.cpp
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
+ src/interface/widgets/cards/art_crop_attribution.cpp
+ src/interface/widgets/cards/card_art_utils.cpp
src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp
@@ -203,6 +222,7 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
src/interface/widgets/deck_editor/deck_state_manager.cpp
+ src/interface/widgets/deck_editor/deck_zone_dialog.cpp
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp
@@ -219,6 +239,7 @@ set(cockatrice_SOURCES
src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp
src/interface/widgets/general/display/charts/pies/color_pie.cpp
src/interface/widgets/general/home_styled_button.cpp
+ src/interface/widgets/general/home_tab_button_color.h
src/interface/widgets/general/home_widget.cpp
src/interface/widgets/general/layout_containers/flow_widget.cpp
src/interface/widgets/general/layout_containers/overlap_control_widget.cpp
@@ -234,6 +255,10 @@ set(cockatrice_SOURCES
src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp
src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp
src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp
+ src/interface/widgets/playmat/playmat_collection_dialog.cpp
+ src/interface/widgets/playmat/playmat_collection_dialog.h
+ src/interface/widgets/playmat/playmat_preview_widget.cpp
+ src/interface/widgets/playmat/playmat_settings_dialog.cpp
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
@@ -242,6 +267,7 @@ set(cockatrice_SOURCES
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_link.cpp
src/interface/widgets/server/game_selector.cpp
src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp
src/interface/widgets/server/games_model.cpp
@@ -254,8 +280,10 @@ set(cockatrice_SOURCES
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_dialog.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_panel_widget.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
@@ -270,6 +298,8 @@ set(cockatrice_SOURCES
src/interface/widgets/settings_page/user_interface_settings_page.cpp
src/interface/widgets/utility/custom_line_edit.cpp
src/interface/widgets/utility/get_text_with_max.cpp
+ src/interface/widgets/utility/report_utils.cpp
+ src/interface/widgets/utility/report_utils.h
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
@@ -294,9 +324,13 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp
+ src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp
+ src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
+ src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
+ src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
@@ -357,10 +391,14 @@ set(cockatrice_SOURCES
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_developer.cpp
src/interface/widgets/tabs/tab_game.cpp
src/interface/widgets/tabs/tab_home.cpp
src/interface/widgets/tabs/tab_logs.cpp
src/interface/widgets/tabs/tab_message.cpp
+ src/interface/widgets/tabs/tab_moderation.cpp
+ src/interface/widgets/tabs/tab_public_decks.cpp
+ src/interface/widgets/tabs/tab_report.cpp
src/interface/widgets/tabs/tab_replays.cpp
src/interface/widgets/tabs/tab_room.cpp
src/interface/widgets/tabs/tab_server.cpp
@@ -374,6 +412,28 @@ set(cockatrice_SOURCES
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/onboarding/banner_shader_config.h
+ src/interface/widgets/onboarding/brand_colors.h
+ src/interface/widgets/onboarding/first_run_wizard.cpp
+ src/interface/widgets/onboarding/first_run_wizard.h
+ src/interface/widgets/onboarding/first_run_wizard_page.cpp
+ src/interface/widgets/onboarding/first_run_wizard_page.h
+ src/interface/widgets/onboarding/pages/account_setup_page.cpp
+ src/interface/widgets/onboarding/pages/account_setup_page.h
+ src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
+ src/interface/widgets/onboarding/pages/card_database_setup_page.h
+ src/interface/widgets/onboarding/pages/finish_page.cpp
+ src/interface/widgets/onboarding/pages/finish_page.h
+ src/interface/widgets/onboarding/pages/preferences_setup_page.cpp
+ src/interface/widgets/onboarding/pages/preferences_setup_page.h
+ src/interface/widgets/onboarding/pages/theme_setup_page.cpp
+ src/interface/widgets/onboarding/pages/theme_setup_page.h
+ src/interface/widgets/onboarding/pages/welcome_page.cpp
+ src/interface/widgets/onboarding/pages/welcome_page.h
+ src/interface/widgets/onboarding/shader_banner_widget.cpp
+ src/interface/widgets/onboarding/shader_banner_widget.h
+ src/interface/widgets/onboarding/step_indicator_widget.cpp
+ src/interface/widgets/onboarding/step_indicator_widget.h
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
@@ -392,6 +452,10 @@ set(cockatrice_SOURCES
src/interface/intents/intent_join_server_room.h
src/interface/intents/intent_login.cpp
src/interface/intents/intent_login.h
+ src/interface/intents/intent_open_server_room_by_name.cpp
+ src/interface/intents/intent_open_server_room_by_name.h
+ src/interface/intents/intent_open_shared_deck.cpp
+ src/interface/intents/intent_open_shared_deck.h
src/interface/intents/url_parser.cpp
src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp
@@ -468,6 +532,31 @@ qt6_add_executable(
MANUAL_FINALIZATION
)
+target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
+
+qt6_add_shaders(
+ cockatrice
+ "onboarding_shaders"
+ PREFIX
+ "/onboarding/shaders"
+ BASE
+ "src/interface/widgets/onboarding/shaders"
+ FILES
+ src/interface/widgets/onboarding/shaders/brand_banner.frag
+ src/interface/widgets/onboarding/shaders/brand_plate.frag
+)
+
+qt6_add_resources(
+ cockatrice
+ "onboarding_qml"
+ PREFIX
+ "/onboarding/qml"
+ BASE
+ "src/interface/widgets/onboarding/qml"
+ FILES
+ src/interface/widgets/onboarding/qml/BrandBanner.qml
+)
+
target_link_libraries(
cockatrice
PUBLIC libcockatrice_card
@@ -579,18 +668,35 @@ if(WIN32)
set(qtconf_dest_dir .)
install(
- DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/"
+ DIRECTORY "$/"
DESTINATION ./
FILES_MATCHING
PATTERN "*.dll"
+ PATTERN "*.pdb" EXCLUDE
+ PATTERN "*.dir*" EXCLUDE
+ PATTERN "*_autogen*" EXCLUDE
+ PATTERN "*.tlog*" EXCLUDE
+ PATTERN "CMakeFiles*" EXCLUDE
+ PATTERN "x64*" EXCLUDE
+ PATTERN ".qt*" EXCLUDE
+ PATTERN ".qsb*" EXCLUDE
+ PATTERN ".lupdate*" EXCLUDE
)
install(
DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/"
DESTINATION ./
FILES_MATCHING
- PATTERN "CMakeFiles" EXCLUDE
PATTERN "*.ini"
+ PATTERN "CMakeFiles*" EXCLUDE
+ PATTERN "*.dir*" EXCLUDE
+ PATTERN "*_autogen*" EXCLUDE
+ PATTERN "*.tlog*" EXCLUDE
+ PATTERN "*.pdb" EXCLUDE
+ PATTERN "x64*" EXCLUDE
+ PATTERN ".qt*" EXCLUDE
+ PATTERN ".qsb*" EXCLUDE
+ PATTERN ".lupdate*" EXCLUDE
)
# Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls
@@ -643,10 +749,6 @@ Data = Resources\")
"
COMPONENT Runtime
)
-
- if(OPENSSL_FOUND)
- install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./)
- endif()
endif()
if(Qt6LinguistTools_FOUND)
diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc
index 9c34929b7..14cf15b2f 100644
--- a/cockatrice/cockatrice.qrc
+++ b/cockatrice/cockatrice.qrc
@@ -2,6 +2,7 @@
resources/cardback.svg
resources/cockatrice.svg
+ resources/cockatrice-logo-white.svg
resources/hand.svg
resources/hr.jpg
@@ -62,6 +63,8 @@
resources/icons/mana/W.svg
resources/backgrounds/home.png
+ resources/backgrounds/home-dark.png
+ resources/backgrounds/home-light.png
resources/backgrounds/card_triplet.svg
resources/backgrounds/placeholder_printing_selector.svg
@@ -362,6 +365,8 @@
resources/usericons/pawn_single.svg
resources/usericons/pawn_double.svg
+ resources/usericons/pawn_dev_single.svg
+ resources/usericons/pawn_dev_double.svg
resources/usericons/pawn_donator_single.svg
resources/usericons/pawn_donator_double.svg
resources/usericons/pawn_judge_single.svg
diff --git a/cockatrice/resources/backgrounds/home-dark.png b/cockatrice/resources/backgrounds/home-dark.png
new file mode 100644
index 000000000..68f48e2c2
Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.png differ
diff --git a/cockatrice/resources/backgrounds/home-light.png b/cockatrice/resources/backgrounds/home-light.png
new file mode 100644
index 000000000..eaaaba932
Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.png differ
diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png
index 68f48e2c2..eaaaba932 100644
Binary files a/cockatrice/resources/backgrounds/home.png and b/cockatrice/resources/backgrounds/home.png differ
diff --git a/cockatrice/resources/cockatrice-logo-white.svg b/cockatrice/resources/cockatrice-logo-white.svg
new file mode 100644
index 000000000..b3b31077f
--- /dev/null
+++ b/cockatrice/resources/cockatrice-logo-white.svg
@@ -0,0 +1,21 @@
+
+
+
+
diff --git a/cockatrice/resources/cockatrice.svg b/cockatrice/resources/cockatrice.svg
index d2e22da31..89ba62dcf 100644
--- a/cockatrice/resources/cockatrice.svg
+++ b/cockatrice/resources/cockatrice.svg
@@ -2,20 +2,20 @@
diff --git a/cockatrice/resources/countries/cn.svg b/cockatrice/resources/countries/cn.svg
index f510cf049..45b608127 100644
--- a/cockatrice/resources/countries/cn.svg
+++ b/cockatrice/resources/countries/cn.svg
@@ -52,8 +52,7 @@
id="defs8">
-
+
diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md
index 0c8bdb450..fd0a12507 100644
--- a/cockatrice/resources/help/search.md
+++ b/cockatrice/resources/help/search.md
@@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
+[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/resources/usericons/pawn_dev_double.svg b/cockatrice/resources/usericons/pawn_dev_double.svg
new file mode 100644
index 000000000..57ed5c2da
--- /dev/null
+++ b/cockatrice/resources/usericons/pawn_dev_double.svg
@@ -0,0 +1,343 @@
+
+
+
+
diff --git a/cockatrice/resources/usericons/pawn_dev_single.svg b/cockatrice/resources/usericons/pawn_dev_single.svg
new file mode 100644
index 000000000..f7c4e7018
--- /dev/null
+++ b/cockatrice/resources/usericons/pawn_dev_single.svg
@@ -0,0 +1,211 @@
+
+
+
+
diff --git a/cockatrice/src/client/lag_monitor.cpp b/cockatrice/src/client/lag_monitor.cpp
new file mode 100644
index 000000000..383d64644
--- /dev/null
+++ b/cockatrice/src/client/lag_monitor.cpp
@@ -0,0 +1,63 @@
+#include "lag_monitor.h"
+
+#include
+#include
+#include
+
+LagMonitor::LagMonitor(QObject *parent) : QObject(parent)
+{
+ qApp->installEventFilter(this);
+
+ timer = new QTimer(this);
+ timer->setInterval(TICK_INTERVAL_MS);
+ connect(timer, &QTimer::timeout, this, &LagMonitor::checkTick);
+ tickClock.start();
+ timer->start();
+}
+
+QList LagMonitor::recentStalls() const
+{
+ return stalls;
+}
+
+void LagMonitor::clearStalls()
+{
+ stalls.clear();
+}
+
+bool LagMonitor::eventFilter(QObject *obj, QEvent *event)
+{
+ if (event->type() == QEvent::ApplicationStateChange) {
+ // The transition may span a suspend or an arbitrary unfocused period;
+ // discard the gap so it cannot be mistaken for a stall.
+ tickClock.restart();
+ }
+ return QObject::eventFilter(obj, event);
+}
+
+void LagMonitor::checkTick()
+{
+ recordGap(tickClock.restart());
+}
+
+void LagMonitor::recordGap(qint64 gapMs)
+{
+ if (gapMs <= STALL_THRESHOLD_MS) {
+ return;
+ }
+
+ if (gapMs > MAX_PLAUSIBLE_STALL_MS) {
+ qCDebug(LagMonitorLog, "Ignoring implausible %lld ms gap (likely suspend)", static_cast(gapMs));
+ return;
+ }
+
+ const StallRecord record{.timestampMsSinceEpoch = QDateTime::currentMSecsSinceEpoch(), .durationMs = gapMs};
+
+ stalls.append(record);
+ while (stalls.size() > MAX_RECORDED_STALLS) {
+ stalls.removeFirst();
+ }
+
+ qCWarning(LagMonitorLog, "Event loop stalled for %lld ms (threshold: %d ms)", static_cast(gapMs),
+ STALL_THRESHOLD_MS);
+}
diff --git a/cockatrice/src/client/lag_monitor.h b/cockatrice/src/client/lag_monitor.h
new file mode 100644
index 000000000..9fdf6b283
--- /dev/null
+++ b/cockatrice/src/client/lag_monitor.h
@@ -0,0 +1,87 @@
+/**
+ * @file lag_monitor.h
+ * @ingroup Client
+ */
+
+#ifndef LAG_MONITOR_H
+#define LAG_MONITOR_H
+
+#include
+#include
+#include
+#include
+#include
+
+inline Q_LOGGING_CATEGORY(LagMonitorLog, "lag_monitor");
+
+class QEvent;
+class QTimer;
+
+/**
+ * @brief Detects main-thread event loop stalls ("UI freezes") from the inside.
+ *
+ * A timer is expected to fire every TICK_INTERVAL_MS of wall time. When the
+ * observed gap greatly exceeds that interval, some other task blocked the
+ * event loop for roughly the overshooting duration. This is what separates
+ * "my client froze" from "the network is lagging" in user reports.
+ *
+ * Gaps that span an application state change (suspend, minimize, focus
+ * loss) are discarded, and implausibly huge gaps are dropped, so operating
+ * system power events do not fabricate stalls. This handling is load-bearing
+ * on Windows, where the monotonic clock used by Qt counts sleep time.
+ *
+ * Healthy operation costs one timer wakeup per tick and two integer
+ * comparisons. Allocations happen only when a stall is actually recorded.
+ */
+class LagMonitor : public QObject
+{
+ Q_OBJECT
+
+public:
+ struct StallRecord
+ {
+ qint64 timestampMsSinceEpoch = 0; ///< when the stalled period ended
+ qint64 durationMs = 0; ///< approximate length of the freeze; measured tick to tick, so it can exceed the true
+ ///< stall by up to TICK_INTERVAL_MS
+ };
+
+ static constexpr int TICK_INTERVAL_MS = 500;
+ static constexpr int STALL_THRESHOLD_MS = 2000;
+ static constexpr int MAX_RECORDED_STALLS = 32;
+
+ /// Gaps beyond this are treated as suspend artifacts rather than stalls.
+ static constexpr qint64 MAX_PLAUSIBLE_STALL_MS = 600000;
+
+ explicit LagMonitor(QObject *parent = nullptr);
+
+ /**
+ * @brief Stalls recorded during this session, oldest first.
+ *
+ * Intended consumers are log output and the diagnostics export. The list
+ * holds at most MAX_RECORDED_STALLS entries.
+ */
+ QList recentStalls() const;
+
+ void clearStalls();
+
+ /**
+ * @brief Feeds a measured tick-to-tick gap through the detection logic.
+ *
+ * Split out of checkTick so threshold, plausibility, and trim behavior
+ * stay unit-testable without real timing.
+ */
+ void recordGap(qint64 gapMs);
+
+protected:
+ bool eventFilter(QObject *obj, QEvent *event) override;
+
+private slots:
+ void checkTick();
+
+private:
+ QTimer *timer;
+ QElapsedTimer tickClock; ///< monotonic clock, so wall clock steps do not fabricate stalls
+ QList stalls;
+};
+
+#endif
diff --git a/cockatrice/src/client/latency_graph_widget.cpp b/cockatrice/src/client/latency_graph_widget.cpp
new file mode 100644
index 000000000..46d6ccdd3
--- /dev/null
+++ b/cockatrice/src/client/latency_graph_widget.cpp
@@ -0,0 +1,51 @@
+/**
+ * @file latency_graph_widget.cpp
+ * @ingroup Client
+ */
+
+#include "latency_graph_widget.h"
+
+#include
+
+LatencyGraphWidget::LatencyGraphWidget(QWidget *parent) : QWidget(parent)
+{
+}
+
+void LatencyGraphWidget::setSamples(const QList &samplesMs)
+{
+ samples = samplesMs;
+ update();
+}
+
+void LatencyGraphWidget::paintEvent(QPaintEvent * /* event */)
+{
+ if (samples.isEmpty()) {
+ return;
+ }
+
+ QPainter painter(this);
+
+ // Heights are relative to the window's own worst sample (floored at
+ // MinScaleMs) so the shape of the variance stays readable even when every
+ // value is small.
+ qint64 heightScaleMs = MinScaleMs;
+ for (int sample : samples) {
+ heightScaleMs = qMax(heightScaleMs, static_cast(sample));
+ }
+
+ const qreal widthPerBar = static_cast(width()) / samples.size();
+ for (int i = 0; i < samples.size(); ++i) {
+ const qreal heightRatio = qBound(0.0, static_cast(samples.at(i)) / heightScaleMs, 1.0);
+ const qreal barHeight = heightRatio * height();
+
+ // Colors follow an absolute quality ramp: a steady good ping stays
+ // green no matter how uniform the window is.
+ const qreal colorRatio = qBound(0.0, static_cast(samples.at(i)) / ColorScaleMs, 1.0);
+ QColor color;
+ color.setHsv(qRound(120.0 * (1.0 - colorRatio)), 255, 255);
+
+ const QRectF bar(static_cast(i) * widthPerBar + 1.0, static_cast(height()) - barHeight,
+ qMax(1.0, widthPerBar - 2.0), barHeight);
+ painter.fillRect(bar, color);
+ }
+}
diff --git a/cockatrice/src/client/latency_graph_widget.h b/cockatrice/src/client/latency_graph_widget.h
new file mode 100644
index 000000000..4f6f38f8b
--- /dev/null
+++ b/cockatrice/src/client/latency_graph_widget.h
@@ -0,0 +1,43 @@
+/**
+ * @file latency_graph_widget.h
+ * @ingroup Client
+ */
+
+#ifndef LATENCY_GRAPH_WIDGET_H
+#define LATENCY_GRAPH_WIDGET_H
+
+#include
+#include
+
+/**
+ * @brief Bar graph of recent network round-trip samples.
+ *
+ * Draws one bar per sample, oldest on the left. Bar height is relative to the
+ * window's own scale so the shape of the variance stays readable, while bar
+ * color maps each sample onto an absolute quality ramp (green at rest through
+ * red at ColorScaleMs) so a steady good ping never looks alarming. Size
+ * agnostic: the status bar embeds a small instance while the latency detail
+ * popup shows a large one.
+ */
+class LatencyGraphWidget : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit LatencyGraphWidget(QWidget *parent = nullptr);
+
+ /// Sample in milliseconds that maps to a fully red bar.
+ static constexpr qint64 ColorScaleMs = 500;
+
+ void setSamples(const QList &samplesMs);
+
+protected:
+ void paintEvent(QPaintEvent *event) override;
+
+private:
+ /// Floor of the vertical scale in milliseconds. Keeps small windows readable.
+ static constexpr qint64 MinScaleMs = 100;
+
+ QList samples;
+};
+
+#endif
diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp
new file mode 100644
index 000000000..779c726eb
--- /dev/null
+++ b/cockatrice/src/client/latency_status_widget.cpp
@@ -0,0 +1,111 @@
+/**
+ * @file latency_status_widget.cpp
+ * @ingroup Client
+ */
+
+#include "latency_status_widget.h"
+
+#include "latency_graph_widget.h"
+
+#include
+#include
+#include
+#include
+
+LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent)
+{
+ pingLabel = new QLabel(this);
+ pingLabel->setAccessibleName(tr("Ping"));
+
+ latencyGraph = new LatencyGraphWidget(this);
+ latencyGraph->setFixedSize(90, 14);
+
+ auto *layout = new QHBoxLayout(this);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->setSpacing(4);
+ layout->addWidget(latencyGraph);
+ layout->addWidget(pingLabel);
+
+ // Clicking anywhere in the area opens the detail view.
+ for (QObject *child : QList{pingLabel, latencyGraph}) {
+ child->installEventFilter(this);
+ }
+ setCursor(Qt::PointingHandCursor);
+
+ hide();
+}
+
+void LatencyStatusWidget::updateData(const LatencyTracker::Stats &stats, const QList &samplesMs)
+{
+ latestSamples = samplesMs;
+ latencyGraph->setSamples(samplesMs);
+ if (popup && popup->isVisible() && detailGraph) {
+ detailGraph->setSamples(samplesMs);
+ }
+
+ if (stats.sampleCount == 0) {
+ hide();
+ return;
+ }
+
+ const QString statsStr = statsText(stats);
+
+ pingLabel->setText(tr("Ping: %1 ms").arg(stats.lastMs));
+ pingLabel->setToolTip(statsStr);
+ pingLabel->setAccessibleDescription(statsStr);
+ if (popup && popup->isVisible() && detailLabel) {
+ detailLabel->setText(statsStr);
+ }
+ show();
+}
+
+bool LatencyStatusWidget::eventFilter(QObject *watched, QEvent *event)
+{
+ if ((watched == pingLabel || watched == latencyGraph) && event->type() == QEvent::MouseButtonPress) {
+ togglePopup();
+ return true;
+ }
+ return QWidget::eventFilter(watched, event);
+}
+
+void LatencyStatusWidget::togglePopup()
+{
+ if (!popup) {
+ popup = new QWidget(this, Qt::Popup | Qt::FramelessWindowHint);
+ auto *layout = new QVBoxLayout(popup);
+ layout->setContentsMargins(8, 8, 8, 8);
+
+ detailLabel = new QLabel(popup);
+ detailLabel->setAccessibleName(tr("Connection latency details"));
+ detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ detailGraph = new LatencyGraphWidget(popup);
+ detailGraph->setFixedSize(280, 80);
+
+ layout->addWidget(detailLabel, 0, Qt::AlignLeft);
+ layout->addWidget(detailGraph, 0, Qt::AlignHCenter);
+ }
+
+ if (popup->isVisible()) {
+ popup->hide();
+ return;
+ }
+
+ // Qt::Popup closes itself on any outside click, so just position and show.
+ if (latestSamples.isEmpty()) {
+ return;
+ }
+ detailGraph->setSamples(latestSamples);
+ detailLabel->setText(pingLabel->toolTip());
+ popup->adjustSize();
+ const QPoint anchor = mapToGlobal(QPoint(width() / 2, 0));
+ popup->move(anchor.x() - popup->width() / 2, anchor.y() - popup->height() - 6);
+ popup->show();
+}
+
+QString LatencyStatusWidget::statsText(const LatencyTracker::Stats &stats) const
+{
+ return tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" +
+ tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" +
+ tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs);
+}
diff --git a/cockatrice/src/client/latency_status_widget.h b/cockatrice/src/client/latency_status_widget.h
new file mode 100644
index 000000000..d9e1d130c
--- /dev/null
+++ b/cockatrice/src/client/latency_status_widget.h
@@ -0,0 +1,50 @@
+/**
+ * @file latency_status_widget.h
+ * @ingroup Client
+ */
+
+#ifndef LATENCY_STATUS_WIDGET_H
+#define LATENCY_STATUS_WIDGET_H
+
+#include
+#include
+#include
+
+class QLabel;
+class LatencyGraphWidget;
+
+/**
+ * @brief Status bar presentation of server round-trip health.
+ *
+ * Combines the textual "Ping" readout with a small LatencyGraphWidget
+ * sparkline of the rolling sample window. Clicking anywhere in the area opens
+ * a popup with a larger graph and the numeric statistics. It closes on any
+ * outside click. Stays hidden while disconnected or before any samples exist.
+ * Owns all latency display state so MainWindow only needs to forward one
+ * signal here.
+ */
+class LatencyStatusWidget : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit LatencyStatusWidget(QWidget *parent = nullptr);
+
+public slots:
+ void updateData(const LatencyTracker::Stats &stats, const QList &samplesMs);
+
+protected:
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
+private:
+ void togglePopup();
+ QString statsText(const LatencyTracker::Stats &stats) const;
+
+ QLabel *pingLabel = nullptr;
+ LatencyGraphWidget *latencyGraph = nullptr;
+ QWidget *popup = nullptr;
+ LatencyGraphWidget *detailGraph = nullptr;
+ QLabel *detailLabel = nullptr;
+ QList latestSamples;
+};
+
+#endif
diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp
index 4e425fb66..c1598bd25 100644
--- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp
+++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp
@@ -1,5 +1,6 @@
#include "remote_connection_controller.h"
+#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
@@ -44,6 +45,8 @@ void ConnectionController::wireClientSignals()
connect(remoteClient, &RemoteClient::statusChanged, this, &ConnectionController::onStatusChanged);
+ connect(remoteClient, &AbstractClient::pingStatsUpdated, this, &ConnectionController::pingStatsUpdated);
+
connect(remoteClient, &RemoteClient::userInfoChanged, this, &ConnectionController::onUserInfoReceived,
Qt::BlockingQueuedConnection);
@@ -178,7 +181,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve
"games will be lost.\nReason for shutdown: %1",
"", event.minutes())
.arg(QString::fromStdString(event.reason())));
- serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64));
+ serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64));
serverShutdownMessageBox.setText(tr("Scheduled server shutdown"));
serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal);
serverShutdownMessageBox.setVisible(true);
@@ -296,6 +299,15 @@ void ConnectionController::onLoginError(int r,
return;
}
+ case Response::RespPasswordChangeRequired: {
+ QMessageBox::information(
+ dialogParent, tr("Password Change Required"),
+ tr("An administrator has reset your password. Please contact your server administrator to obtain "
+ "your temporary password, then log in and change it via Account -> Change Password."));
+ remoteClient->disconnectFromServer();
+ return;
+ }
+
case Response::RespServerFull: {
QMessageBox::critical(dialogParent, tr("Server Full"),
tr("The server has reached its maximum user capacity, please check back later."));
diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h
index 7486bc81a..bae99a3e0 100644
--- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h
+++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h
@@ -54,6 +54,10 @@ signals:
// action enable/disable logic
void statusChanged(ClientStatus status);
+ // Forwarded from AbstractClient::pingStatsUpdated. See that signal for the
+ // meaning of the parameters.
+ void pingStatsUpdated(const LatencyTracker::Stats &stats, const QList &samplesMs);
+
private slots:
// Slots wired directly to RemoteClient signals
void onStatusChanged(ClientStatus status);
diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp
index 8689a19e9..42292b2aa 100644
--- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp
+++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp
@@ -72,7 +72,7 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList
{
auto copyIfNotAToken = [&destination](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
- if (dbCard && !dbCard->getIsToken()) {
+ if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) {
DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1);
addedCard->setNumber(card->getNumber());
}
diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp
index 5dc77fa2c..627b7fe34 100644
--- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp
+++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp
@@ -99,7 +99,7 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi
{
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
- if (!dbCard || dbCard->getIsToken()) {
+ if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}
diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h
index 95155b8d1..f4ebc204e 100644
--- a/cockatrice/src/client/settings/shortcuts_settings.h
+++ b/cockatrice/src/client/settings/shortcuts_settings.h
@@ -786,6 +786,10 @@ private:
ShortcutGroup::Tabs)},
{"Tabs/aTabLogs",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Logs"), parseSequenceString(""), ShortcutGroup::Tabs)},
+ {"Tabs/aTabReport",
+ ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Report Queue"), parseSequenceString(""), ShortcutGroup::Tabs)},
+ {"Tabs/aTabModeration",
+ ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Moderation"), parseSequenceString(""), ShortcutGroup::Tabs)},
};
};
diff --git a/cockatrice/src/client/sound_engine.cpp b/cockatrice/src/client/sound_engine.cpp
index 18de2264d..96cafa3d3 100644
--- a/cockatrice/src/client/sound_engine.cpp
+++ b/cockatrice/src/client/sound_engine.cpp
@@ -94,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes()
QDir dir;
availableThemes.clear();
- // load themes from user profile dir
+ // Load themes from user profile dir
dir.setPath(SettingsCache::instance().getDataPath() + "/sounds");
@@ -104,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes()
}
}
- // load themes from cockatrice system dir
+ // Load themes from Cockatrice system dir
dir.setPath(qApp->applicationDirPath() +
#ifdef Q_OS_MAC
"/../Resources/sounds"
diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp
index dd873cfa5..a2b7519a8 100644
--- a/cockatrice/src/filters/deck_filter_string.cpp
+++ b/cockatrice/src/filters/deck_filter_string.cpp
@@ -43,6 +43,12 @@ NumericValue <- [0-9]+
static std::once_flag init;
+// The peglib parser is a single permanent object, so the rule actions below cannot see
+// per-instance state. The card language that the nested [[card name]] search matches
+// against is passed through this thread-local context, which is live only while a
+// DeckFilterString is being parsed, and copied into the nested FilterString closures.
+thread_local CardSearchLanguage deckSearchLanguageContext;
+
static void setupParserRules()
{
// plumbing
@@ -52,18 +58,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(query)(deck, info);
- };
+ return [=](const DeckSearchData &data) {
+ auto matchesFilter = [&data](const std::any &query) { return std::any_cast(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(query)(deck, info);
- };
+ return [=](const DeckSearchData &data) {
+ auto matchesFilter = [&data](const std::any &query) { return std::any_cast(query)(data); };
return std::any_of(sv.begin(), sv.end(), matchesFilter);
};
};
@@ -71,9 +73,7 @@ static void setupParserRules()
search["QueryPart"] = passthru;
search["NotQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
const auto dependent = std::any_cast(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 {
@@ -122,12 +122,12 @@ static void setupParserRules()
// actual functionality
search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
- auto cardFilter = FilterString(std::any_cast(sv[0]));
+ auto cardFilter = FilterString(std::any_cast(sv[0]), deckSearchLanguageContext);
auto numberMatcher = sv.size() > 1 ? std::any_cast(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,57 +146,53 @@ static void setupParserRules()
search["DeckNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto name = std::any_cast(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(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(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(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(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(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";
}
-DeckFilterString::DeckFilterString(const QString &expr)
+DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage)
{
QByteArray ba = expr.simplified().toUtf8();
@@ -205,16 +201,18 @@ DeckFilterString::DeckFilterString(const QString &expr)
_error = QString();
if (ba.isEmpty()) {
- filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return true; };
+ filter = [](const DeckSearchData &) { return true; };
return;
}
+ deckSearchLanguageContext = searchLanguage;
+
search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
_error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
});
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; };
}
-}
\ No newline at end of file
+}
diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h
index 916b629ee..5b1419004 100644
--- a/cockatrice/src/filters/deck_filter_string.h
+++ b/cockatrice/src/filters/deck_filter_string.h
@@ -7,35 +7,39 @@
#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
#include
#include
+#include
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 DeckFilter;
+typedef std::function DeckFilter;
class DeckFilterString
{
public:
DeckFilterString();
- explicit DeckFilterString(const QString &expr);
- bool check(const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) const
+ explicit DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage = {});
+ bool check(const DeckSearchData &data) const
{
- return filter(deck, info);
+ return filter(data);
}
[[nodiscard]] bool valid() const
diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp
index 785f753e7..f109fbcd3 100644
--- a/cockatrice/src/filters/filter_builder.cpp
+++ b/cockatrice/src/filters/filter_builder.cpp
@@ -1,5 +1,6 @@
#include "filter_builder.h"
+#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include
@@ -21,7 +22,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i));
}
- QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString());
+ QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString());
ok->setObjectName("ok");
ok->setMaximumSize(20, 20);
diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp
index 6da18b662..0970e4272 100644
--- a/cockatrice/src/game/board/counter_state.cpp
+++ b/cockatrice/src/game/board/counter_state.cpp
@@ -13,12 +13,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject
convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent);
}
-void CounterState::setValue(int newValue)
+void CounterState::setValue(int newValue, bool skipDamageAnimation)
{
if (newValue == value) {
return;
}
int old = value;
value = newValue;
- emit valueChanged(old, newValue);
+ emit valueChanged(old, newValue, skipDamageAnimation);
}
\ No newline at end of file
diff --git a/cockatrice/src/game/board/counter_state.h b/cockatrice/src/game/board/counter_state.h
index 0f2f16b55..4c7b34473 100644
--- a/cockatrice/src/game/board/counter_state.h
+++ b/cockatrice/src/game/board/counter_state.h
@@ -35,10 +35,23 @@ public:
return value;
}
- void setValue(int newValue);
+ /**
+ * @brief Set the counter value.
+ * @param newValue The new value.
+ * @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views
+ * suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay
+ * rewinds.
+ */
+ void setValue(int newValue, bool skipDamageAnimation = false);
signals:
- void valueChanged(int oldValue, int newValue);
+ /**
+ * @brief Emitted whenever the value changes.
+ * @param oldValue The previous value.
+ * @param newValue The new value.
+ * @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views.
+ */
+ void valueChanged(int oldValue, int newValue, bool skipDamageAnimation);
private:
int id;
diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp
index 4a96eebdb..f146cdbb4 100644
--- a/cockatrice/src/game/game_event_handler.cpp
+++ b/cockatrice/src/game/game_event_handler.cpp
@@ -229,7 +229,11 @@ void GameEventHandler::handleArrowDeletion(int creatorId, int arrowId)
void GameEventHandler::handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId)
{
- if (response.response_code() == Response::RespNameNotFound) {
+ // The server confirms the arrow no longer exists whether it deleted it itself
+ // (RespOk, followed by an Event_DeleteArrow broadcast) or never had it
+ // (RespNameNotFound). In both cases the local copy has to go. deleteArrow is
+ // a no-op if the arrow was already removed by the event broadcast.
+ if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) {
emit arrowDeleted(creatorId, arrowId);
}
}
@@ -281,12 +285,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
emit playerJoined(prop);
}
player->processPlayerInfo(playerInfo);
+ // Extract playmat from player properties for opponent display
+ if (prop.has_playmat_params()) {
+ player->setPlaymatFromProperties(prop);
+ }
if (player->getPlayerInfo()->getLocal()) {
emit localPlayerDeckSelected(player, playerId, playerInfo);
} else {
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()))));
@@ -344,6 +355,11 @@ void GameEventHandler::eventPlayerPropertiesChanged(const Event_PlayerProperties
const ServerInfo_PlayerProperties &prop = event.player_properties();
emit playerPropertiesChanged(prop, eventPlayerId);
+ // Update playmat from player properties
+ if (prop.has_playmat_params()) {
+ player->setPlaymatFromProperties(prop);
+ }
+
const auto contextType = static_cast(getPbExtension(context));
switch (contextType) {
case GameEventContext::READY_START: {
@@ -414,12 +430,13 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
QString playerName = QString::fromStdString(playerInfo.user_info().name());
emit addPlayerToAutoCompleteList(playerName);
- if (game->getPlayerManager()->getPlayers().contains(playerId)) {
+ PlayerManager *playerManager = game->getPlayerManager();
+ if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) {
return;
}
if (playerInfo.spectator()) {
- game->getPlayerManager()->addSpectator(playerId, playerInfo);
+ playerManager->addSpectator(playerId, playerInfo);
emit logJoinSpectator(playerName);
emit spectatorJoined(playerInfo);
} else {
diff --git a/cockatrice/src/game/player/event_processing_options.h b/cockatrice/src/game/player/event_processing_options.h
index 4c7663789..06238d77e 100644
--- a/cockatrice/src/game/player/event_processing_options.h
+++ b/cockatrice/src/game/player/event_processing_options.h
@@ -13,7 +13,8 @@
enum EventProcessingOption
{
SKIP_REVEAL_WINDOW = 0x0001,
- SKIP_TAP_ANIMATION = 0x0002
+ SKIP_TAP_ANIMATION = 0x0002,
+ SKIP_DAMAGE_ANIMATION = 0x0004
};
// Wrap it in a QFlags typedef
diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp
index bc48298f7..277b8b1d4 100644
--- a/cockatrice/src/game/player/player_event_handler.cpp
+++ b/cockatrice/src/game/player/player_event_handler.cpp
@@ -262,14 +262,15 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event)
player->addCounter(event.counter_info());
}
-void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event)
+void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options)
{
CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr);
if (!ctr) {
return;
}
int oldValue = ctr->getValue();
- ctr->setValue(event.value());
+ const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION);
+ ctr->setValue(event.value(), skipDamageAnimation);
emit logSetCounter(player, ctr->getName(), event.value(), oldValue);
}
@@ -625,7 +626,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type,
eventCreateCounter(event.GetExtension(Event_CreateCounter::ext));
break;
case GameEvent::SET_COUNTER:
- eventSetCounter(event.GetExtension(Event_SetCounter::ext));
+ eventSetCounter(event.GetExtension(Event_SetCounter::ext), options);
break;
case GameEvent::DEL_COUNTER:
eventDelCounter(event.GetExtension(Event_DelCounter::ext));
diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h
index 48ad85e88..300cacd08 100644
--- a/cockatrice/src/game/player/player_event_handler.h
+++ b/cockatrice/src/game/player/player_event_handler.h
@@ -153,7 +153,7 @@ public:
void eventCreateCounter(const Event_CreateCounter &event);
/// Set a player-level counter value.
- void eventSetCounter(const Event_SetCounter &event);
+ void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options);
/// Delete a player-level counter.
void eventDelCounter(const Event_DelCounter &event);
diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp
index 485e2fc5c..143df5c57 100644
--- a/cockatrice/src/game/player/player_logic.cpp
+++ b/cockatrice/src/game/player/player_logic.cpp
@@ -175,7 +175,15 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info)
const ServerInfo_Card &cardInfo = zoneInfo.card_list(j);
auto *card = new CardItem(this);
card->processCardInfo(cardInfo);
- zone->addCard(card, false, cardInfo.x(), cardInfo.y());
+ // Zones without coordinates (hand, piles, stack) preserve the order
+ // they arrive in on the server in the positions of their cards list.
+ // The x coordinate of such cards is always 0, so inserting at it
+ // would reverse the list on reconnect. Append instead.
+ if (zoneInfo.with_coords()) {
+ zone->addCard(card, false, cardInfo.x(), cardInfo.y());
+ } else {
+ zone->addCard(card, false, -1);
+ }
}
}
if (zoneInfo.has_always_reveal_top_card()) {
@@ -250,6 +258,22 @@ void PlayerLogic::setDeck(const DeckList &_deck)
emit deckChanged();
}
+void PlayerLogic::setPlaymatFromProperties(const ServerInfo_PlayerProperties &props)
+{
+ if (props.has_playmat_params() && !props.playmat_params().card_name().empty()) {
+ const auto &pp = props.playmat_params();
+ remotePlaymatCard = {QString::fromStdString(pp.card_name()), QString::fromStdString(pp.card_provider_id())};
+ remotePlaymatParams = {qBound(0.0, pp.margin_pct_l(), 0.95), qBound(0.0, pp.margin_pct_r(), 0.95),
+ qBound(0.0, pp.vertical_offset(), 1.0), qBound(0.1, pp.zoom(), 4.0)};
+ hasRemotePlaymat = true;
+ } else {
+ remotePlaymatCard = CardRef{};
+ remotePlaymatParams = PlaymatParams{};
+ hasRemotePlaymat = false;
+ }
+ emit playmatChanged();
+}
+
CounterState *PlayerLogic::addCounter(const ServerInfo_Counter &counter)
{
return addCounter(counter.id(), QString::fromStdString(counter.name()),
diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h
index a89cb6eed..6923b3afe 100644
--- a/cockatrice/src/game/player/player_logic.h
+++ b/cockatrice/src/game/player/player_logic.h
@@ -17,6 +17,7 @@
#include "../zones/table_zone_logic.h"
#include "player_event_handler.h"
#include "player_info.h"
+#include "player_manager.h"
#include
#include
@@ -72,6 +73,8 @@ signals:
const QList &cardList,
bool withWritePermission);
void deckChanged();
+ /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */
+ void playmatChanged();
void newCardAdded(AbstractCardItem *card);
void requestCardMenuUpdate(const CardItem *card);
void counterAdded(CounterState *state);
@@ -226,6 +229,20 @@ public:
void setZoneId(int _zoneId);
+ void setPlaymatFromProperties(const ServerInfo_PlayerProperties &props);
+ const CardRef &getRemotePlaymatCard() const
+ {
+ return remotePlaymatCard;
+ }
+ const PlaymatParams &getRemotePlaymatParams() const
+ {
+ return remotePlaymatParams;
+ }
+ bool getHasRemotePlaymat() const
+ {
+ return hasRemotePlaymat;
+ }
+
private:
AbstractGame *game;
PlayerInfo *playerInfo;
@@ -243,6 +260,11 @@ private:
bool dialogSemaphore;
QList cardsToDelete;
+
+ // Playmat from player properties (for opponent display)
+ CardRef remotePlaymatCard;
+ PlaymatParams remotePlaymatParams;
+ bool hasRemotePlaymat = false;
};
class AnnotationDialog : public QInputDialog
diff --git a/cockatrice/src/game/player/player_manager.cpp b/cockatrice/src/game/player/player_manager.cpp
index 6772d3ff1..8486efbeb 100644
--- a/cockatrice/src/game/player/player_manager.cpp
+++ b/cockatrice/src/game/player/player_manager.cpp
@@ -75,6 +75,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const
return player;
}
+void PlayerManager::clearSpectators()
+{
+ const QList spectatorIds = spectators.keys();
+ for (int spectatorId : spectatorIds) {
+ removeSpectator(spectatorId);
+ }
+}
+
void PlayerManager::onPlayerConceded(int playerId, bool conceded)
{
// Everything else cares about this
diff --git a/cockatrice/src/game/player/player_manager.h b/cockatrice/src/game/player/player_manager.h
index 2f8b87af8..504e65396 100644
--- a/cockatrice/src/game/player/player_manager.h
+++ b/cockatrice/src/game/player/player_manager.h
@@ -100,6 +100,9 @@ public:
emit spectatorRemoved(spectatorId, spectatorInfo);
}
+ /** @brief Remove all spectators, emitting the removal signal for each. */
+ void clearSpectators();
+
[[nodiscard]] AbstractGame *getGame() const
{
return game;
diff --git a/cockatrice/src/game_graphics/animated_item.h b/cockatrice/src/game_graphics/animated_item.h
new file mode 100644
index 000000000..700e0f62d
--- /dev/null
+++ b/cockatrice/src/game_graphics/animated_item.h
@@ -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
diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
index e0029ee2d..3969b7d03 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
@@ -1,6 +1,7 @@
#include "abstract_card_item.h"
#include "../../client/settings/cache_settings.h"
+#include "../../interface/card_localization.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../game_scene.h"
#include "../z_values.h"
@@ -26,6 +27,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); });
+ connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
+ [this] { update(); });
refreshCardInfo();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
@@ -171,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
if (SettingsCache::instance().debug().getShowCardId()) {
prefix = "#" + QString::number(id) + " ";
}
- nameStr = prefix + cardRef.name;
+ nameStr = prefix + CardLocalization::displayName(getCardInfo());
}
painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor,
translatedSize.height() - 6 * scaleFactor),
@@ -305,6 +308,11 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate)
}
}
+bool AbstractCardItem::animationEvent()
+{
+ return false;
+}
+
void AbstractCardItem::setFaceDown(bool _facedown)
{
facedown = _facedown;
diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h
index bdb5f7cf1..8cbe95282 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_item.h
+++ b/cockatrice/src/game_graphics/board/abstract_card_item.h
@@ -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;
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp
index a20fb1b3c..4ba04804f 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp
@@ -29,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, bool skipDamageAnimation) {
value = newValue;
+ onValueChanged(oldValue, newValue, skipDamageAnimation);
update();
});
@@ -228,3 +229,9 @@ void AbstractCounterDialog::changeValue(int diff)
curValue += diff;
setTextValue(QString::number(curValue));
}
+
+void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/)
+{
+ // Default: no feedback. Subclasses such as PlayerCounter override this to
+ // flash the counter on meaningful changes (life gain/loss).
+}
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h
index b319a722d..67b5b4074 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.h
+++ b/cockatrice/src/game_graphics/board/abstract_counter.h
@@ -35,6 +35,14 @@ 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.
+ * @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds).
+ */
+ virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation);
+
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp
index ce8967bb5..664d44ecc 100644
--- a/cockatrice/src/game_graphics/board/arrow_item.cpp
+++ b/cockatrice/src/game_graphics/board/arrow_item.cpp
@@ -4,12 +4,14 @@
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
+#include "../game_scene.h"
#include "../player/player_target.h"
#include "../z_values.h"
#include "../zones/card_zone.h"
#include "card_item.h"
#include
+#include
#include
#include
#include
@@ -18,10 +20,27 @@
#include
#include
#include
+#include
#include
#include
#include
+namespace
+{
+constexpr qreal kMinStrokeDurationMs = 200.0;
+constexpr qreal kMaxStrokeDurationMs = 450.0;
+constexpr qreal kMsPerPixel = 0.8;
+constexpr qreal kGlowFadeDurationMs = 120.0;
+constexpr qreal kSheenHalfWidth = 14.0;
+
+/// @brief Ease-out cubic, for a natural "slow in / slow out" reveal.
+qreal easeOutCubic(qreal t)
+{
+ const qreal inverse = 1.0 - t;
+ return 1.0 - inverse * inverse * inverse;
+}
+} // namespace
+
ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem)
: data(std::move(_data)), startItem(_startItem), targetItem(_targetItem)
{
@@ -47,8 +66,23 @@ ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startI
}
}
+ArrowItem::~ArrowItem()
+{
+ if (auto *scene = qobject_cast(this->scene())) {
+ scene->unregisterAnimationItem(this);
+ }
+}
+
void ArrowItem::onTargetDestroyed()
{
+ if (data->id == -1) {
+ // Drag and attach arrows are never inserted into the arrow registry and
+ // have no server-side counterpart, so no deletion event can clean them
+ // up. Delete them locally when either endpoint is destroyed.
+ delArrow();
+ return;
+ }
+
emit requestDeletion(data->creatorId, data->id);
}
@@ -91,16 +125,21 @@ void ArrowItem::updatePath(const QPointF &endPoint)
prepareGeometryChange();
if (lineLength < 30) {
path = QPainterPath();
+ bodyPath = QPainterPath();
+ headPath = QPainterPath();
+ shaftOutlinePath = QPainterPath();
+ centerLine = QPainterPath();
+ headBaseFraction = 1.0;
} else {
QPointF c(lineLength / 2, qTan(phi * M_PI / 180) * lineLength);
- QPainterPath centerLine;
+ centerLine = QPainterPath();
centerLine.moveTo(0, 0);
centerLine.quadTo(c, QPointF(lineLength, 0));
- double percentage = 1 - headLength / lineLength;
- QPointF arrowBodyEndPoint = centerLine.pointAtPercent(percentage);
- QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(percentage + 0.001));
+ headBaseFraction = 1 - headLength / lineLength;
+ QPointF arrowBodyEndPoint = centerLine.pointAtPercent(headBaseFraction);
+ QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(headBaseFraction + 0.001));
qreal alpha = testLine.angle() - 90;
QPointF endPoint1 =
arrowBodyEndPoint + arrowWidth / 2 * QPointF(qCos(alpha * M_PI / 180), -qSin(alpha * M_PI / 180));
@@ -111,20 +150,89 @@ void ArrowItem::updatePath(const QPointF &endPoint)
QPointF point2 =
endPoint2 + (headWidth - arrowWidth) / 2 * QPointF(-qCos(alpha * M_PI / 180), qSin(alpha * M_PI / 180));
- path = QPainterPath(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)));
+ QPointF start1 = -arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180));
+ QPointF start2 = arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180));
+
+ path = QPainterPath(start1);
path.quadTo(c, endPoint1);
path.lineTo(point1);
path.lineTo(QPointF(lineLength, 0));
path.lineTo(point2);
path.lineTo(endPoint2);
- path.quadTo(c, arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)));
- path.lineTo(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)));
+ path.quadTo(c, start2);
+ path.lineTo(start1);
+
+ bodyPath = QPainterPath(start1);
+ bodyPath.quadTo(c, endPoint1);
+ bodyPath.lineTo(endPoint2);
+ bodyPath.quadTo(c, start2);
+ bodyPath.lineTo(start1);
+
+ headPath = QPainterPath(endPoint1);
+ headPath.lineTo(point1);
+ headPath.lineTo(QPointF(lineLength, 0));
+ headPath.lineTo(point2);
+ headPath.lineTo(endPoint2);
+
+ shaftOutlinePath = QPainterPath(start1);
+ shaftOutlinePath.quadTo(c, endPoint1);
+ shaftOutlinePath.moveTo(endPoint2);
+ shaftOutlinePath.quadTo(c, start2);
+ shaftOutlinePath.lineTo(start1);
}
setPos(startPoint);
setTransform(QTransform().rotate(-line.angle()));
}
+void ArrowItem::startDrawAnimation()
+{
+ if (!SettingsCache::instance().cardsDisplay().getArrowDrawAnimation() || centerLine.isEmpty()) {
+ return;
+ }
+
+ strokeDurationMs = qBound(kMinStrokeDurationMs, centerLine.length() * kMsPerPixel, kMaxStrokeDurationMs);
+ glowFadeDurationMs = kGlowFadeDurationMs;
+ // The clock is started on the first animationEvent() tick so that t=0
+ // corresponds to the first rendered frame. Starting it here would count
+ // the time spent before the item's first paint (event-loop delays, bursts
+ // of arrows created together), making the arrow appear already partway
+ // drawn when it first shows up.
+ animationStarted = false;
+ drawProgress = 0.0;
+ glowAlpha = 1.0;
+ update();
+ if (auto *scene = qobject_cast(this->scene())) {
+ scene->registerAnimationItem(this);
+ }
+}
+
+bool ArrowItem::animationEvent()
+{
+ if (!animationStarted) {
+ animationClock.start();
+ animationStarted = true;
+ }
+
+ const qint64 elapsed = animationClock.elapsed();
+ if (elapsed >= strokeDurationMs + glowFadeDurationMs) {
+ drawProgress = 1.0;
+ glowAlpha = 0.0;
+ update();
+ return false;
+ }
+
+ if (elapsed < strokeDurationMs) {
+ drawProgress = easeOutCubic(qBound(0.0, elapsed / strokeDurationMs, 1.0));
+ glowAlpha = 1.0;
+ } else {
+ drawProgress = 1.0;
+ glowAlpha = 1.0 - (elapsed - strokeDurationMs) / glowFadeDurationMs;
+ }
+ update();
+ return true;
+}
+
void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
QColor paintColor(data->color);
@@ -133,8 +241,66 @@ void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
} else {
paintColor.setAlpha(150);
}
+
+ painter->save();
+ const QPen outlinePen = painter->pen();
painter->setBrush(paintColor);
- painter->drawPath(path);
+
+ const auto drawShaft = [this, painter, &outlinePen, paintColor]() {
+ painter->setPen(Qt::NoPen);
+ painter->drawPath(bodyPath);
+ painter->setPen(outlinePen);
+ painter->setBrush(Qt::NoBrush);
+ painter->drawPath(shaftOutlinePath);
+ painter->setBrush(paintColor);
+ };
+
+ if (drawProgress >= 1.0 || path.isEmpty()) {
+ painter->drawPath(path);
+ } else if (drawProgress < headBaseFraction) {
+ // The reveal edge and the sheen share the same arc-length parameterization,
+ // so the stroke stays exactly in sync with the trailing sheen.
+ const qreal revealX = centerLine.pointAtPercent(drawProgress).x();
+ QPainterPath clip;
+ clip.addRect(QRectF(-glowExtent, path.boundingRect().top() - glowExtent, revealX + glowExtent,
+ path.boundingRect().height() + 2 * glowExtent));
+ painter->setClipPath(clip);
+ drawShaft();
+ } else {
+ // Once the reveal reaches the head base, pop the whole head in with a fade
+ // instead of slicing the triangle into a growing stub.
+ drawShaft();
+ const qreal headFadeIn = (drawProgress - headBaseFraction) / (1.0 - headBaseFraction);
+ painter->setOpacity(headFadeIn);
+ painter->setPen(Qt::NoPen);
+ painter->drawPath(headPath);
+ painter->setPen(outlinePen);
+ painter->setBrush(Qt::NoBrush);
+ painter->drawPath(headPath);
+ painter->setOpacity(1.0);
+ painter->setBrush(paintColor);
+ }
+
+ if (glowAlpha > 0.0 && !centerLine.isEmpty()) {
+ // Sweep a bright band across the arrow. Clipping to the
+ // silhouette keeps it flat against the shaft so it reads as a light reflection.
+ const qreal anticipation = qMin(1.0, drawProgress / 0.08);
+ const QPointF sweep = centerLine.pointAtPercent(qMin(drawProgress, 1.0));
+ QLinearGradient sheen(sweep.x() - kSheenHalfWidth, 0.0, sweep.x() + kSheenHalfWidth, 0.0);
+ sheen.setColorAt(0.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0));
+ sheen.setColorAt(0.5, QColor(255, 255, 255, 200));
+ sheen.setColorAt(1.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0));
+ painter->save();
+ painter->setPen(Qt::NoPen);
+ painter->setClipPath(path);
+ painter->setBrush(sheen);
+ painter->setOpacity(glowAlpha * anticipation);
+ painter->drawRect(QRectF(sweep.x() - kSheenHalfWidth - glowExtent, path.boundingRect().top() - glowExtent,
+ (kSheenHalfWidth + glowExtent) * 2.0,
+ path.boundingRect().height() + glowExtent * 2.0));
+ painter->restore();
+ }
+ painter->restore();
}
void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
@@ -226,6 +392,12 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
if (!startItem) {
+ // The source card was destroyed while the arrow was being drawn.
+ // Clean up the arrow and its children instead of leaking them.
+ delArrow();
+ for (auto *child : childArrows) {
+ child->mouseReleaseEvent(event);
+ }
return;
}
@@ -349,6 +521,12 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
if (!startItem) {
+ // The source card was destroyed while the arrow was being drawn.
+ // Clean up the arrow and its children instead of leaking them.
+ delArrow();
+ for (auto *child : childArrows) {
+ child->mouseReleaseEvent(event);
+ }
return;
}
diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h
index 1c306e065..76a2d5d6c 100644
--- a/cockatrice/src/game_graphics/board/arrow_item.h
+++ b/cockatrice/src/game_graphics/board/arrow_item.h
@@ -2,9 +2,13 @@
#define ARROWITEM_H
#include "../../game/board/arrow_data.h"
+#include "../animated_item.h"
#include "arrow_target.h"
+#include "graphics_item_type.h"
+#include
#include
+#include
#include
#include
@@ -12,7 +16,7 @@ class CardItem;
class QGraphicsSceneMouseEvent;
class PlayerLogic;
-class ArrowItem : public QObject, public QGraphicsItem
+class ArrowItem : public QObject, public QGraphicsItem, public IAnimatedItem
{
Q_OBJECT
Q_INTERFACES(QGraphicsItem)
@@ -21,6 +25,19 @@ signals:
private:
QPainterPath path;
+ QPainterPath bodyPath;
+ QPainterPath headPath;
+ QPainterPath shaftOutlinePath;
+ QPainterPath centerLine;
+ qreal headBaseFraction = 1.0;
+ QElapsedTimer animationClock;
+ qreal strokeDurationMs = 0;
+ qreal glowFadeDurationMs = 0;
+ qreal drawProgress = 1.0;
+ qreal glowAlpha = 0.0;
+ bool animationStarted = false;
+
+ static constexpr qreal glowExtent = 12.0;
protected:
QSharedPointer data;
@@ -32,17 +49,28 @@ protected:
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
public:
+ enum
+ {
+ Type = typeArrow
+ };
+ [[nodiscard]] int type() const override
+ {
+ return Type;
+ }
ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem);
+ ~ArrowItem() override;
void onTargetDestroyed();
void delArrow();
void updatePath();
void updatePath(const QPointF &endPoint);
+ void startDrawAnimation();
+ bool animationEvent() override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] QRectF boundingRect() const override
{
- return path.boundingRect();
+ return path.boundingRect().adjusted(-glowExtent, -glowExtent, glowExtent, glowExtent);
}
[[nodiscard]] QPainterPath shape() const override
{
@@ -106,4 +134,4 @@ protected:
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
};
-#endif
\ No newline at end of file
+#endif
diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp
index c40c8c214..c2dc455cc 100644
--- a/cockatrice/src/game_graphics/board/card_item.cpp
+++ b/cockatrice/src/game_graphics/board/card_item.cpp
@@ -316,7 +316,7 @@ void CardItem::drawAttachArrow()
for (const auto &item : scene()->selectedItems()) {
CardItem *card = qgraphicsitem_cast(item);
- if (card == nullptr) {
+ if (card == nullptr || card == this) {
continue;
}
if (card->getZone() != state->getZone()) {
diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h
index 37f3bab50..2ba43d03d 100644
--- a/cockatrice/src/game_graphics/board/card_item.h
+++ b/cockatrice/src/game_graphics/board/card_item.h
@@ -137,7 +137,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);
diff --git a/cockatrice/src/game_graphics/board/graphics_item_type.h b/cockatrice/src/game_graphics/board/graphics_item_type.h
index 7eac132b0..afac7881f 100644
--- a/cockatrice/src/game_graphics/board/graphics_item_type.h
+++ b/cockatrice/src/game_graphics/board/graphics_item_type.h
@@ -16,7 +16,8 @@ enum GraphicsItemType
typeZone = QGraphicsItem::UserType + 3,
typePlayerTarget = QGraphicsItem::UserType + 4,
typeDeckViewCardContainer = QGraphicsItem::UserType + 5,
- typeOther = QGraphicsItem::UserType + 6
+ typeOther = QGraphicsItem::UserType + 6,
+ typeArrow = QGraphicsItem::UserType + 7
};
#endif // COCKATRICE_GRAPHICS_ITEM_TYPE_H
diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp
index 1278737a0..1acd02a75 100644
--- a/cockatrice/src/game_graphics/deckview/deck_view.cpp
+++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp
@@ -10,7 +10,6 @@
#include
#include
#include
-#include
#include
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
@@ -381,12 +380,10 @@ void DeckViewScene::rebuildTree()
addItem(container);
}
- for (int j = 0; j < currentZone->size(); j++) {
- auto *currentCard = dynamic_cast(currentZone->at(j));
- if (!currentCard) {
- continue;
- }
-
+ // Cards in custom zones nested under a board are regular board cards in-game.
+ // They are collected recursively (like every other consumer) and reported with
+ // the top-level board zone as their origin, so that sideboard plans keep working.
+ for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) {
for (int k = 0; k < currentCard->getNumber(); ++k) {
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
container->addCard(newCard);
diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp
index 69941040f..bc07ac183 100644
--- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp
+++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp
@@ -9,17 +9,21 @@
#include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h"
#include "../../interface/widgets/dialogs/dlg_load_remote_deck.h"
#include "../../interface/widgets/tabs/tab_game.h"
+#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h"
#include "deck_view.h"
#include
#include
#include
+#include
#include
#include
+#include
#include
#include
#include
#include
+#include
#include
#include
@@ -100,6 +104,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged,
this, &DeckViewContainer::setVisualDeckStorageExists);
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this,
+ &DeckViewContainer::onPlaymatSettingsChanged);
+
switchToDeckSelectView();
}
@@ -277,6 +284,8 @@ void DeckViewContainer::loadDeckFromFile(const QString &filePath)
void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck)
{
+ currentDeck = deck;
+
QString deckString = deck.writeToString_Native();
if (deckString.length() > MAX_FILE_LENGTH) {
@@ -289,6 +298,52 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck)
PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId);
+
+ resolveAndSendPlaymat();
+}
+
+void DeckViewContainer::resolveAndSendPlaymat()
+{
+ if (currentDeck.getCardRefList().isEmpty() && currentDeck.getPlaymat().card.isEmpty()) {
+ return;
+ }
+
+ const auto &settings = SettingsCache::instance().userInterface();
+ const auto fallbackBehavior = static_cast(settings.getPlaymatFallbackBehavior());
+
+ QList fallbackList = settings.getPlaymatFallbackList();
+
+ // In random mode with 2+ entries, remove the last-resolved mat to avoid repeats.
+ if (fallbackBehavior == PlaymatFallbackModeRandom && fallbackList.size() > 1) {
+ fallbackList.removeAll(lastResolvedPlaymat);
+ }
+
+ const PlaymatInfo resolved =
+ resolvePlaymatForDeck(currentDeck, fallbackList, static_cast(settings.getPlaymatMode()),
+ fallbackBehavior, playmatRotationIndex);
+
+ lastResolvedPlaymat = resolved;
+
+ Command_SetPlaymat playmatCmd;
+ auto *pp = playmatCmd.mutable_playmat_params();
+ pp->set_card_name(resolved.card.name.toStdString());
+ pp->set_card_provider_id(resolved.card.providerId.toStdString());
+ pp->set_margin_pct_l(resolved.params.marginPctL);
+ pp->set_margin_pct_r(resolved.params.marginPctR);
+ pp->set_vertical_offset(resolved.params.verticalOffset);
+ pp->set_zoom(resolved.params.zoom);
+ PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd);
+ parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId);
+}
+
+void DeckViewContainer::onPlaymatSettingsChanged()
+{
+ resolveAndSendPlaymat();
+}
+
+void DeckViewContainer::advancePlaymatRotation()
+{
+ playmatRotationIndex++;
}
void DeckViewContainer::loadRemoteDeck()
@@ -379,6 +434,10 @@ void DeckViewContainer::sideboardPlanChanged()
*/
void DeckViewContainer::sendReadyStartCommand(bool ready)
{
+ if (ready) {
+ resolveAndSendPlaymat();
+ }
+
Command_ReadyStart cmd;
cmd.set_ready(ready);
parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId);
@@ -416,6 +475,7 @@ void DeckViewContainer::setSideboardLocked(bool locked)
void DeckViewContainer::setDeck(const DeckList &deck)
{
+ currentDeck = deck;
deckView->setDeck(deck);
switchToDeckLoadedView();
}
\ No newline at end of file
diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h
index ec024bace..b5317c39a 100644
--- a/cockatrice/src/game_graphics/deckview/deck_view_container.h
+++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h
@@ -57,6 +57,9 @@ private:
VisualDeckStorageWidget *visualDeckStorageWidget;
TabGame *parentGame;
int playerId;
+ int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode.
+ DeckList currentDeck; ///< Cached deck for live settings re-resolution.
+ PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode.
void tryCreateVisualDeckStorageWidget();
void sendReadyStartCommand(bool ready);
@@ -75,6 +78,7 @@ private slots:
void sideboardLockButtonClicked();
void updateSideboardLockButtonText();
void refreshShortcuts();
+ void onPlaymatSettingsChanged();
signals:
void newCardAdded(AbstractCardItem *card);
void notIdle();
@@ -87,6 +91,8 @@ public:
void setSideboardLocked(bool locked);
void setDeck(const DeckList &deck);
void setVisualDeckStorageExists(bool exists);
+ void advancePlaymatRotation();
+ void resolveAndSendPlaymat();
public slots:
void loadDeckFromFile(const QString &filePath);
diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp
index b311d2ebd..6d8ad0534 100644
--- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp
+++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp
@@ -16,11 +16,13 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -88,6 +90,17 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa
cardDatabaseDisplayModel = new TokenDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
+ const auto applyCardSearchLanguage = [this]() {
+ const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
+ cardDatabaseDisplayModel->setSearchLanguage(CardSearchLanguage{
+ cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())});
+ };
+ applyCardSearchLanguage();
+ connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
+ applyCardSearchLanguage);
+ connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
+ applyCardSearchLanguage);
+
chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens"));
connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll);
chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck"));
diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp
index db2088104..17af7618b 100644
--- a/cockatrice/src/game_graphics/game_scene.cpp
+++ b/cockatrice/src/game_graphics/game_scene.cpp
@@ -17,7 +17,6 @@
#include
#include
#include
-#include
#include
#include
#include
@@ -45,7 +44,19 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
GameScene::~GameScene()
{
+ // Sever all destroyed->removeAnimatedItem connections 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. The connection handle overload is used because the string-based
+ // disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be
+ // nullptr) and would otherwise fail to sever these pointer-to-member connections.
+ for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) {
+ QObject::disconnect(*it);
+ }
+ animationItemConnections.clear();
+
delete animationTimer;
+ animationTimer = nullptr;
// Delete all ArrowItems before QGraphicsScene's base destructor runs.
// QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order.
@@ -210,7 +221,12 @@ void GameScene::removePlayer(PlayerLogic *player)
clearArrowsForPlayer(player->getPlayerInfo()->getId());
- for (ZoneViewWidget *zone : zoneViews) {
+ // Closing a view removes it from zoneViews synchronously, so iterate over a
+ // copy: otherwise a player with several open views (e.g. library and hand)
+ // only has the first one closed here and the remaining views are left
+ // pointing at a player that is about to be deleted.
+ const QList zoneViewCopy = zoneViews;
+ for (ZoneViewWidget *zone : zoneViewCopy) {
if (zone->getPlayer() == player) {
zone->close();
}
@@ -246,17 +262,27 @@ void GameScene::adjustPlayerRotation(int rotationAdjustment)
*/
void GameScene::rearrange()
{
- int firstPlayerIndex = 0;
- auto playersPlaying = collectActivePlayers(firstPlayerIndex);
- playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex);
+ if (rearranging) {
+ needsReArrange = true;
+ return;
+ }
+ rearranging = true;
+ do {
+ needsReArrange = false;
- int columns = determineColumnCount(playersPlaying.size());
- QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns);
+ int firstPlayerIndex = 0;
+ auto playersPlaying = collectActivePlayers(firstPlayerIndex);
+ playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex);
- phasesToolbar->setHeight(sceneSize.height());
- setSceneRect(0, 0, sceneSize.width(), sceneSize.height());
+ int columns = determineColumnCount(playersPlaying.size());
+ QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns);
- processViewSizeChange(viewSize);
+ phasesToolbar->setHeight(sceneSize.height());
+ setSceneRect(0, 0, sceneSize.width(), sceneSize.height());
+
+ processViewSizeChange(viewSize);
+ } while (needsReArrange);
+ rearranging = false;
}
// ---------- View Size ----------
@@ -453,8 +479,14 @@ void GameScene::resizeColumnsAndPlayers(const QList &minWidthByColumn, qr
qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size();
qreal newx = phasesToolbar->getWidth();
- for (int col = 0; col < playersByColumn.size(); ++col) {
- for (PlayerGraphicsItem *player : playersByColumn[col]) {
+ // Snapshot the columns: resizing a player's table can synchronously trigger
+ // GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect ->
+ // sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating
+ // the live container across that re-entrant call would use invalidated iterators.
+ const QList> columns = playersByColumn;
+
+ for (int col = 0; col < columns.size(); ++col) {
+ for (PlayerGraphicsItem *player : columns[col]) {
player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn);
player->setPos(newx, player->y());
}
@@ -496,6 +528,7 @@ void GameScene::addArrow(QSharedPointer data)
auto *arrow = new ArrowItem(data, startCard, targetItem);
addItem(arrow);
+ arrow->startDrawAnimation();
arrowRegistry.insert(data, arrow);
connect(arrow, &ArrowItem::requestDeletion, this, &GameScene::requestArrowDeletion);
}
@@ -636,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList &items,
*/
void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed)
{
- for (auto &view : zoneViews) {
+ // Closing a view removes it from zoneViews synchronously, so iterate over a
+ // copy to make sure every already-open matching view is closed.
+ const QList zoneViewCopy = zoneViews;
+ for (auto *view : zoneViewCopy) {
ZoneViewZone *temp = view->getZone();
if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player &&
qobject_cast(temp->getLogic())->getNumberCards() == numberCards) {
@@ -736,30 +772,53 @@ bool GameScene::event(QEvent *event)
void GameScene::timerEvent(QTimerEvent * /*event*/)
{
- QMutableSetIterator i(cardsToAnimate);
+ QMutableHashIterator 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(card));
- if (!animationTimer->isActive()) {
+ auto *object = dynamic_cast(item);
+ if (!object) {
+ return;
+ }
+ // Guard against duplicate connections using the connection map, not
+ // animatedItems: the animation timer removes entries from animatedItems when an
+ // animation completes, but the destroyed->removeAnimatedItem connection must
+ // persist until the object is destroyed. Relying on animatedItems here would let
+ // a re-registered item (e.g. a life counter that flashes repeatedly) accumulate
+ // duplicate destroyed connections, the older ones of which would survive teardown.
+ if (!animationItemConnections.contains(object)) {
+ animationItemConnections.insert(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(card));
- if (cardsToAnimate.isEmpty()) {
+ animatedItems.remove(dynamic_cast(item));
+ if (animationTimer && animatedItems.isEmpty()) {
+ animationTimer->stop();
+ }
+}
+
+void GameScene::removeAnimatedItem(QObject *item)
+{
+ animatedItems.remove(item);
+ animationItemConnections.remove(item);
+ if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop();
}
}
diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h
index 74e979556..859d7a6eb 100644
--- a/cockatrice/src/game_graphics/game_scene.h
+++ b/cockatrice/src/game_graphics/game_scene.h
@@ -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
+#include
#include
#include
#include
-#include
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,9 +52,13 @@ private:
QList zoneViews; ///< Active zone view widgets
QSize viewSize; ///< Current view size
QPointer hoveredCard; ///< Currently hovered card
- QBasicTimer *animationTimer; ///< Timer for card animations
- QSet cardsToAnimate; ///< Cards currently animating
- int playerRotation; ///< Rotation offset for player layout
+ QBasicTimer *animationTimer; ///< Timer for scene animations
+ QHash animatedItems; ///< Items currently animating
+ QHash
+ animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item
+ int playerRotation; ///< Rotation offset for player layout
+ bool rearranging = false; ///< Guard against re-entrant rearrange
+ bool needsReArrange = false; ///< Pending rearrange requested during a pass
/**
* @brief Updates which card is currently hovered based on scene coordinates.
@@ -182,15 +188,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);
diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp
index b768c8317..ed6355157 100644
--- a/cockatrice/src/game_graphics/game_view.cpp
+++ b/cockatrice/src/game_graphics/game_view.cpp
@@ -114,6 +114,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin)
}
selectionOrigin = _selectionOrigin;
+ previousBandRect = QRect();
rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0)));
rubberBand->show();
}
@@ -128,7 +129,17 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount)
QPoint cursor = cursorPoint.toPoint();
QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized();
+
rubberBand->setGeometry(rect);
+ if (viewport()) {
+ // Repaint the union of the previous and current band rects: the vacated
+ // strip of a child widget is not reliably invalidated on all platforms
+ // (notably macOS), leaving stale pixels under the selection.
+ QRect dirty = previousBandRect.isNull() ? rect : previousBandRect.united(rect);
+ dirty.adjust(-1, -1, 1, 1);
+ viewport()->update(dirty);
+ previousBandRect = rect;
+ }
if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) {
dragCountLabel->hide();
@@ -171,7 +182,13 @@ void GameView::stopRubberBand()
return;
}
+ // Same rationale as resizeRubberBand: repaint the last known band area
+ // since hiding a child widget doesn't reliably invalidate its region.
rubberBand->hide();
+ if (viewport() && !previousBandRect.isNull()) {
+ viewport()->update(previousBandRect.adjusted(-1, -1, 1, 1));
+ previousBandRect = QRect();
+ }
dragCountLabel->hide();
}
diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h
index 3f6b60dbc..a23655513 100644
--- a/cockatrice/src/game_graphics/game_view.h
+++ b/cockatrice/src/game_graphics/game_view.h
@@ -27,6 +27,7 @@ private:
QWidget *tallyContainer;
QGridLayout *tallyLayout;
QPointF selectionOrigin;
+ QRect previousBandRect; ///< Last rubber-band rect for targeted repaint
QList cachedTallyRows; ///< Cached entries to avoid redundant rebuilds
QSize rebuildTallyLabels(const QList &entries);
diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp
index 35989ff38..8dcbcfdaa 100644
--- a/cockatrice/src/game_graphics/hand_counter.cpp
+++ b/cockatrice/src/game_graphics/hand_counter.cpp
@@ -1,5 +1,6 @@
#include "hand_counter.h"
+#include "../interface/pixel_map_generator.h"
#include "zones/card_zone.h"
#include
@@ -32,7 +33,8 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op
QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize();
QPixmap cachedPixmap;
if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) {
- cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ cachedPixmap =
+ themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap);
}
resetPainterTransform(painter);
diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
index 7eb3945b3..08cb6cac9 100644
--- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
+++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
@@ -12,11 +12,13 @@ TallyMenu::TallyMenu()
aTallyNone = createTallyAction(TallyType::None);
aTallySubtypes = createTallyAction(TallyType::Subtypes);
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
+ aTallyTotalToughness = createTallyAction(TallyType::TotalToughness);
addAction(aTallyNone);
addSeparator();
addAction(aTallySubtypes);
addAction(aTallyTotalPower);
+ addAction(aTallyTotalToughness);
retranslateUi();
}
@@ -54,4 +56,5 @@ void TallyMenu::retranslateUi()
aTallyNone->setText(tr("None"));
aTallySubtypes->setText(tr("Subtypes"));
aTallyTotalPower->setText(tr("Total Power"));
+ aTallyTotalToughness->setText(tr("Total Toughness"));
}
diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h
index acd1daf67..11802fd20 100644
--- a/cockatrice/src/game_graphics/player/menu/tally_menu.h
+++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h
@@ -24,6 +24,7 @@ private:
QAction *aTallyNone = nullptr;
QAction *aTallySubtypes = nullptr;
QAction *aTallyTotalPower = nullptr;
+ QAction *aTallyTotalToughness = nullptr;
QAction *createTallyAction(TallyType tallyType);
};
diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
index d443853ce..122ab83be 100644
--- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp
+++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
@@ -1,6 +1,10 @@
#include "player_graphics_item.h"
#include "../../game/player/player_actions.h"
+#include "../../interface/card_picture_loader/card_picture_loader.h"
+#include "../../interface/widgets/cards/art_crop_attribution.h"
+#include "../../interface/widgets/cards/card_art_utils.h"
+#include "../../interface/widgets/playmat/playmat_utils.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../board/abstract_card_item.h"
#include "../board/counter_general.h"
@@ -13,6 +17,9 @@
#include "player_dialogs.h"
#include
+#include
+#include
+#include
#include
PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
@@ -28,6 +35,10 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
connect(player, &PlayerLogic::counterAdded, this, &PlayerGraphicsItem::onCounterAdded);
connect(player, &PlayerLogic::counterRemoved, this, &PlayerGraphicsItem::onCounterRemoved);
+ connect(player, &PlayerLogic::deckChanged, this, &PlayerGraphicsItem::updatePlaymat);
+ connect(player, &PlayerLogic::playmatChanged, this, &PlayerGraphicsItem::updatePlaymat);
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatVisibilityChanged, this,
+ [this](int) { updatePlaymat(); });
playerMenu = new PlayerMenu(this);
@@ -67,6 +78,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect);
+ connect(this, &PlayerGraphicsItem::playmatChanged, tableZoneGraphicsItem, &TableZone::onPlaymatChanged);
+ connect(this, &PlayerGraphicsItem::playmatChanged, stackZoneGraphicsItem, &StackZone::onPlaymatChanged);
+
updateBoundingRect();
rearrangeZones();
@@ -112,7 +126,6 @@ void PlayerGraphicsItem::initializeZones()
rfgZoneGraphicsItem->setPos(base + QPointF(0, 2 * h + h2 + 10));
tableZoneGraphicsItem = new TableZone(player->getTableZone(), mirrored, this);
- connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect);
connect(this, &PlayerGraphicsItem::mirroredChanged, tableZoneGraphicsItem, &TableZone::setMirrored);
stackZoneGraphicsItem =
@@ -155,10 +168,61 @@ qreal PlayerGraphicsItem::getMinimumWidth() const
return result;
}
-void PlayerGraphicsItem::paint(QPainter * /*painter*/,
- const QStyleOptionGraphicsItem * /*option*/,
- QWidget * /*widget*/)
+void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *)
{
+ if (!hasPlaymat || playmatPixmap.isNull()) {
+ return;
+ }
+
+ // Calculate the combined bounding rect of stack + table zones
+ QPointF stackPos = stackZoneGraphicsItem->pos();
+ QPointF tablePos = tableZoneGraphicsItem->pos();
+ QSizeF stackSize = stackZoneGraphicsItem->boundingRect().size();
+ QSizeF tableSize = tableZoneGraphicsItem->boundingRect().size();
+
+ // Combined area: from stack left edge to table right edge
+ double combinedLeft = qMin(stackPos.x(), tablePos.x());
+ double combinedTop = qMin(stackPos.y(), tablePos.y());
+ double combinedRight = qMax(stackPos.x() + stackSize.width(), tablePos.x() + tableSize.width());
+ double combinedBottom = qMax(stackPos.y() + stackSize.height(), tablePos.y() + tableSize.height());
+
+ QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop);
+
+ const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams);
+ const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size());
+
+ painter->save();
+ painter->setClipRect(combinedArea);
+ painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
+
+ // Render from a down-scaled copy of the art so the full-resolution source
+ // pixmap is never re-sampled at a tiny device size (also much cheaper than
+ // scaling it on every frame).
+ const QPixmap scaledPixmap = scaledPlaymatFor(srcRect, painter->worldTransform().mapRect(dstRect).size());
+ painter->drawPixmap(dstRect, scaledPixmap, QRectF(scaledPixmap.rect()));
+
+ painter->restore();
+
+ if (!playmatAttribution.isEmpty()) {
+ paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8);
+ }
+}
+
+QPixmap PlayerGraphicsItem::scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize)
+{
+ // Bucket the render size so the source pixmap is re-scaled at most once per
+ // zoom step instead of once per frame.
+ constexpr int bucketSize = 32;
+ const QSize target = QSize(qMax(1, qRound(deviceDstSize.width() / bucketSize) * bucketSize),
+ qMax(1, qRound(deviceDstSize.height() / bucketSize) * bucketSize))
+ .boundedTo(srcRect.toAlignedRect().size());
+
+ if (scaledPlaymatKey != target) {
+ const QPixmap crop = playmatPixmap.copy(srcRect.toAlignedRect());
+ scaledPlaymatPixmap = crop.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ scaledPlaymatKey = target;
+ }
+ return scaledPlaymatPixmap;
}
void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth)
@@ -188,6 +252,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, bool skipDamageAnimation) {
+ if (newValue < oldValue && !skipDamageAnimation) {
+ tableZoneGraphicsItem->triggerDamageShimmer();
+ }
+ });
} else {
widget = new GeneralCounter(state, player, true, this);
}
@@ -298,3 +367,100 @@ void PlayerGraphicsItem::updateBoundingRect()
emit sizeChanged();
}
+
+void PlayerGraphicsItem::updatePlaymat()
+{
+ int visibility = SettingsCache::instance().userInterface().getPlaymatVisibility();
+
+ // "Don't use playmats" — never show
+ if (visibility == PlaymatVisibilityNone) {
+ clearPlaymat();
+ return;
+ }
+
+ // "Show own playmat only" — hide playmats for remote players
+ if (visibility == PlaymatVisibilityOwnOnly && !player->getPlayerInfo()->getLocal()) {
+ clearPlaymat();
+ return;
+ }
+
+ CardRef playmatCard;
+ PlaymatParams params;
+
+ if (player->getHasRemotePlaymat()) {
+ // Prefer the server-confirmed playmat (updated by Command_SetPlaymat).
+ playmatCard = player->getRemotePlaymatCard();
+ params = player->getRemotePlaymatParams();
+ } else if (player->getPlayerInfo()->getLocal()) {
+ // Local player without a server broadcast yet: apply the full
+ // settings-based resolution chain (mode, fallback list, behavior).
+ const auto &settings = SettingsCache::instance().userInterface();
+ const PlaymatInfo resolved = resolvePlaymatForDeck(
+ player->getDeck(), settings.getPlaymatFallbackList(), static_cast(settings.getPlaymatMode()),
+ static_cast(settings.getPlaymatFallbackBehavior()), 0);
+ playmatCard = resolved.card;
+ params = resolved.params;
+ } else {
+ // Opponent without a server broadcast: use the deck-embedded playmat.
+ const DeckList &deck = player->getDeck();
+ const PlaymatInfo &deckPlaymat = deck.getPlaymat();
+ if (!deckPlaymat.card.isEmpty()) {
+ playmatCard = deckPlaymat.card;
+ params = deckPlaymat.params;
+ }
+ }
+
+ if (playmatCard.isEmpty()) {
+ clearPlaymat();
+ return;
+ }
+
+ playmatParams = params;
+ scaledPlaymatKey = QSize(); // the art crop depends on the params, drop any cached scale
+
+ ExactCard card = CardDatabaseManager::query()->getCard(playmatCard);
+ if (!card) {
+ clearPlaymat();
+ return;
+ }
+
+ playmatAttribution = buildArtAttribution(card);
+
+ QPixmap fullRes;
+ CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
+
+ if (fullRes.isNull()) {
+ disconnect(playmatPixmapConnection);
+ CardInfo *cardInfo = card.getCardPtr().data();
+ if (cardInfo) {
+ playmatPixmapConnection =
+ connect(cardInfo, &CardInfo::pixmapUpdated, this, &PlayerGraphicsItem::onPlaymatPixmapReady);
+ }
+ return;
+ }
+
+ if (!hasPlaymat) {
+ hasPlaymat = true;
+ emit playmatChanged(true);
+ }
+ playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card);
+ update();
+}
+
+void PlayerGraphicsItem::clearPlaymat()
+{
+ disconnect(playmatPixmapConnection);
+ playmatAttribution.clear();
+ if (hasPlaymat) {
+ hasPlaymat = false;
+ playmatPixmap = QPixmap();
+ scaledPlaymatKey = QSize();
+ emit playmatChanged(false);
+ update();
+ }
+}
+
+void PlayerGraphicsItem::onPlaymatPixmapReady()
+{
+ updatePlaymat();
+}
diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.h b/cockatrice/src/game_graphics/player/player_graphics_item.h
index d02234ded..e5ae59a61 100644
--- a/cockatrice/src/game_graphics/player/player_graphics_item.h
+++ b/cockatrice/src/game_graphics/player/player_graphics_item.h
@@ -11,6 +11,7 @@
#include "../game_scene.h"
#include
+#include
class HandZone;
class PileZone;
@@ -126,6 +127,7 @@ signals:
void playerCountChanged();
void mirroredChanged(bool isMirrored);
void cardInfoRequested(const CardRef &cardRef);
+ void playmatChanged(bool hasPlaymat);
private:
PlayerLogic *player;
@@ -146,9 +148,23 @@ private:
bool mirrored;
bool handVisible = false;
+ QPixmap playmatPixmap;
+ QPixmap scaledPlaymatPixmap; // down-scaled copy of playmatPixmap for the current render size
+ QSize scaledPlaymatKey; // size bucket scaledPlaymatPixmap was rendered for
+ PlaymatParams playmatParams;
+ QString playmatAttribution;
+ bool hasPlaymat = false;
+ QMetaObject::Connection playmatPixmapConnection;
+
private slots:
void updateBoundingRect();
void rearrangeZones();
+ void clearPlaymat();
+ void updatePlaymat();
+ void onPlaymatPixmapReady();
+
+private:
+ QPixmap scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize);
};
#endif // COCKATRICE_PLAYER_GRAPHICS_ITEM_H
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp
index 4268e1019..a7ec2e4a9 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.cpp
+++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp
@@ -53,13 +53,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
- readyIcon = QPixmap("theme:icons/ready_start");
- notReadyIcon = QPixmap("theme:icons/not_ready_start");
- concededIcon = QPixmap("theme:icons/conceded");
+ readyIcon = themePixmap(QStringLiteral("icons/ready_start"));
+ notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start"));
+ concededIcon = themePixmap(QStringLiteral("icons/conceded"));
playerIcon = loadColorAdjustedPixmap("theme:icons/player");
judgeIcon = loadColorAdjustedPixmap("theme:icons/scales");
spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator");
- lockIcon = QPixmap("theme:icons/lock");
+ lockIcon = themePixmap(QStringLiteral("icons/lock"));
if (tabSupervisor) {
itemDelegate = new PlayerListItemDelegate(this);
@@ -92,6 +92,11 @@ void PlayerListWidget::retranslateUi()
void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player)
{
+ if (players.contains(player.player_id())) {
+ updatePlayerProperties(player);
+ return;
+ }
+
QTreeWidgetItem *newPlayer = new PlayerListTWI;
players.insert(player.player_id(), newPlayer);
updatePlayerProperties(player);
@@ -176,6 +181,17 @@ void PlayerListWidget::removePlayer(int playerId)
delete takeTopLevelItem(indexOfTopLevelItem(player));
}
+void PlayerListWidget::clearSpectators()
+{
+ const QList playerIds = players.keys();
+ for (int playerId : playerIds) {
+ QTreeWidgetItem *player = players.value(playerId, 0);
+ if (player && !player->data(1, Qt::UserRole).toBool()) {
+ removePlayer(playerId);
+ }
+ }
+}
+
void PlayerListWidget::setActivePlayer(int playerId)
{
QMapIterator i(players);
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.h b/cockatrice/src/game_graphics/player/player_list_widget.h
index a53cfa989..f2f0be5fd 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.h
+++ b/cockatrice/src/game_graphics/player/player_list_widget.h
@@ -66,6 +66,7 @@ public slots:
void addPlayer(const ServerInfo_PlayerProperties &player);
void removePlayer(int playerId);
void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1);
+ void clearSpectators();
};
#endif
diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp
index 567f3d44d..d6c28370d 100644
--- a/cockatrice/src/game_graphics/player/player_target.cpp
+++ b/cockatrice/src/game_graphics/player/player_target.cpp
@@ -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
#include
#include
#include
@@ -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,53 @@ 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, bool skipDamageAnimation)
+{
+ flashDelta = newValue - oldValue;
+ if (flashDelta == 0) {
+ return;
+ }
+
+ if (!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) {
+ flashAlpha = 0.0;
+ return;
+ }
+
+ if (skipDamageAnimation) {
+ flashAlpha = 0.0;
+ return;
+ }
+
+ flashAlpha = 1.0;
+ flashClock.start();
+ if (scene()) {
+ static_cast(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)
@@ -80,8 +137,18 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect);
QSize translatedSize = translatedRect.size().toSize();
QPixmap cachedPixmap;
+ // The key must cover everything the generated pawn depends on: the rendered
+ // size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is
+ // 0 for every null pixmap, so the default-pawn branch additionally needs the
+ // pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors
+ // in the key — otherwise two players without a custom avatar (and the same
+ // user level) would share one cached pawn.
const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" +
- QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey());
+ QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) +
+ "_" + QString::number(fullPixmap.cacheKey()) + "_" +
+ QString::fromStdString(info->privlevel()).toLower() + "_" +
+ QString::fromStdString(info->pawn_colors().left_side()) + "_" +
+ QString::fromStdString(info->pawn_colors().right_side());
if (!QPixmapCache::find(cacheKey, &cachedPixmap)) {
cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height());
diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h
index 67e155660..1d06c6274 100644
--- a/cockatrice/src/game_graphics/player/player_target.h
+++ b/cockatrice/src/game_graphics/player/player_target.h
@@ -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
#include
class PlayerLogic;
-class PlayerCounter : public AbstractCounter
+class PlayerCounter : public AbstractCounter, public IAnimatedItem
{
Q_OBJECT
+protected:
+ void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) 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
diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp
index e7a6621fa..7e05c3fb1 100644
--- a/cockatrice/src/game_graphics/tally/stats_tally.cpp
+++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp
@@ -34,3 +34,31 @@ QList StatsTally::computeTotalPower(const QList &cards)
QString name = QCoreApplication::translate("StatsTally", "Total Power");
return {TallyRow{name, QString::number(total)}};
}
+
+static int sumToughness(const QList &cards)
+{
+ int total = 0;
+ for (auto card : cards) {
+ QVariantList parsed = CardItem::parsePT(card->getPT());
+ if (parsed.size() == 2) {
+ int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int
+ total += qMax(toughness, 0);
+ }
+ }
+ return total;
+}
+
+QList StatsTally::computeTotalToughness(const QList &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 = sumToughness(cards);
+
+ QString name = QCoreApplication::translate("StatsTally", "Total Toughness");
+ return {TallyRow{name, QString::number(total)}};
+}
diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h
index 4c3d93b56..e499587eb 100644
--- a/cockatrice/src/game_graphics/tally/stats_tally.h
+++ b/cockatrice/src/game_graphics/tally/stats_tally.h
@@ -16,6 +16,14 @@ namespace StatsTally
*/
QList computeTotalPower(const QList &cards);
+/**
+ * @brief Sums the toughness 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 computeTotalToughness(const QList &cards);
+
} // namespace StatsTally
#endif // COCKATRICE_STATS_TALLY_H
diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp
index aa2cae024..21806ee84 100644
--- a/cockatrice/src/game_graphics/tally/tally.cpp
+++ b/cockatrice/src/game_graphics/tally/tally.cpp
@@ -21,6 +21,8 @@ QList Tally::compute(const QList &cards, const TallyType t
return SubtypeTally::countSubtypes(cards);
case TallyType::TotalPower:
return StatsTally::computeTotalPower(cards);
+ case TallyType::TotalToughness:
+ return StatsTally::computeTotalToughness(cards);
}
return {};
}
diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h
index 97406cddb..84c54918f 100644
--- a/cockatrice/src/game_graphics/tally/tally.h
+++ b/cockatrice/src/game_graphics/tally/tally.h
@@ -21,7 +21,8 @@ enum class TallyType
None,
Subtypes,
TotalPower,
- MaxValue = TotalPower // sentinel value
+ TotalToughness,
+ MaxValue = TotalToughness // sentinel value
};
namespace Tally
diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp
index b52a4955a..1a8f7a910 100644
--- a/cockatrice/src/game_graphics/zones/hand_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp
@@ -41,7 +41,8 @@ void HandZone::handleDropEvent(const QList &dragItems,
}
}
} else {
- x = calcDropIndexFromY(dropPoint.y());
+ bool sameZone = startZone == getLogic();
+ x = calcDropIndexFromY(dropPoint.y(), !sameZone);
}
Command_MoveCard cmd;
diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp
index c58c41b92..470c70fcf 100644
--- a/cockatrice/src/game_graphics/zones/select_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/select_zone.cpp
@@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons
return {cardCount, boundingRect().height(), cardHeight, offset, minOffset};
}
-int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
+int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const
{
const auto &cards = getLogic()->getCards();
if (cards.isEmpty()) {
@@ -94,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
if (effectiveOffset <= 0.0) {
return 0;
}
- return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1);
+ int max = allowCountExpand ? params.cardCount : params.cardCount - 1;
+ return qBound(0, qRound((dropY - start) / effectiveOffset), max);
}
void SelectZone::restoreStaleEscapedCards()
diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h
index 7408f29b6..b5d3ca37a 100644
--- a/cockatrice/src/game_graphics/zones/select_zone.h
+++ b/cockatrice/src/game_graphics/zones/select_zone.h
@@ -104,8 +104,12 @@ protected:
/**
* @brief Computes the card index at a given y-coordinate within the zone's vertical layout.
* Returns 0 if the zone has no cards or the offset is zero.
+ *
+ * @param dropY The y-coordinate that the card was dropped at
+ * @param allowCountExpand If false, clamps the index at the number of cards minus 1
+ * @param minOffset Minimum offset to preserve
*/
- int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const;
+ int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const;
/**
* @brief Positions cards vertically with alternating left/right x-offsets.
diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp
index 184f96d62..ff62097c7 100644
--- a/cockatrice/src/game_graphics/zones/stack_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp
@@ -31,8 +31,22 @@ QRectF StackZone::boundingRect() const
void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
- QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId());
- painter->fillRect(boundingRect(), brush);
+ if (playmatActive) {
+ // Subtle overlay to distinguish stack zone from table zone (slightly darker)
+ painter->fillRect(boundingRect(), QColor(0, 0, 0, 80));
+ } else {
+ QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId());
+ painter->fillRect(boundingRect(), brush);
+ }
+}
+
+void StackZone::onPlaymatChanged(bool active)
+{
+ playmatActive = active;
+ // See TableZone::onPlaymatChanged for the rationale. Translucent overlay
+ // over a dynamic playmat should not be held in the device cache.
+ setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache);
+ update();
}
void StackZone::handleDropEvent(const QList &dragItems,
@@ -43,18 +57,14 @@ void StackZone::handleDropEvent(const QList &dragItems,
return;
}
- const auto &cards = getLogic()->getCards();
- int index;
- if (startZone == getLogic()) {
- // Reordering within the zone: use drop position
- index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE);
+ bool sameZone = startZone == getLogic();
+ int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE);
+ if (sameZone) {
// Same-zone no-op: don't move a card onto itself
+ const auto &cards = getLogic()->getCards();
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(cards.size());
}
Command_MoveCard cmd;
diff --git a/cockatrice/src/game_graphics/zones/stack_zone.h b/cockatrice/src/game_graphics/zones/stack_zone.h
index 147c3e2fc..96b3f96a6 100644
--- a/cockatrice/src/game_graphics/zones/stack_zone.h
+++ b/cockatrice/src/game_graphics/zones/stack_zone.h
@@ -15,9 +15,13 @@ class StackZone : public SelectZone
Q_OBJECT
private:
qreal zoneHeight;
+ bool playmatActive = false;
private slots:
void updateBg();
+public slots:
+ void onPlaymatChanged(bool active);
+
public:
StackZone(StackZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent);
/** @brief Resizes the stack zone height, e.g. when sharing vertical space with the command zone. */
diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp
index 4ef01853f..88e9abe6c 100644
--- a/cockatrice/src/game_graphics/zones/table_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/table_zone.cpp
@@ -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
@@ -47,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(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);
@@ -66,20 +92,43 @@ bool TableZone::isInverted() const
void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
- QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId());
- painter->fillRect(boundingRect(), brush);
+ if (playmatActive) {
+ // Subtle overlay to distinguish table zone from stack zone
+ painter->fillRect(boundingRect(), QColor(0, 0, 0, 60));
+ } else {
+ QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId());
+ painter->fillRect(boundingRect(), brush);
+ }
if (active) {
paintZoneOutline(painter);
} else {
// inactive player gets a darker table zone with a semi transparent black mask
- // this means if the user provides a custom background it will fade
+ // this means if the user provides a custom background or playmat it will fade
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);
}
+void TableZone::onPlaymatChanged(bool active)
+{
+ playmatActive = active;
+ // While a playmat is shown the zone paints a translucent overlay over the
+ // dynamic playmat behind it. Keep it out of the device cache so the cached
+ // pixels are never stale relative to the playmat (and to avoid compositing
+ // artifacts of cached translucent content on some platforms).
+ setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache);
+ update();
+}
+
/**
Render a soft outline around the edge of the TableZone.
diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h
index 0d7e58206..92915a2ed 100644
--- a/cockatrice/src/game_graphics/zones/table_zone.h
+++ b/cockatrice/src/game_graphics/zones/table_zone.h
@@ -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
+
/**
* @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
@@ -83,6 +86,7 @@ private:
*/
bool active = false;
bool mirrored = false;
+ bool playmatActive = false;
[[nodiscard]] bool isInverted() const;
@@ -92,6 +96,9 @@ private slots:
*/
void updateBg();
+public slots:
+ void onPlaymatChanged(bool active);
+
public slots:
/**
Reorganizes CardItems in the TableZone
@@ -121,6 +128,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.
*/
@@ -171,8 +188,17 @@ public:
}
void setWidth(qreal _width)
{
+ // The width is stored as an int; truncate to match the previous implicit conversion.
+ const int newWidth = static_cast(_width);
+ if (width == newWidth) {
+ return;
+ }
prepareGeometryChange();
- width = _width;
+ width = newWidth;
+ // The parent player item's boundingRect (which clips the playmat painting) is
+ // derived from this zone's size. Without this signal the playmat is cut off at
+ // the stale boundingRect edge whenever the scene is resized wider.
+ emit sizeChanged();
}
[[nodiscard]] qreal getWidth() const
{
@@ -185,6 +211,11 @@ public:
}
private:
+ static constexpr qreal shimmerDurationMs = 450.0;
+
+ QElapsedTimer shimmerClock;
+ qreal damageShimmerAlpha = 0.0;
+
void paintZoneOutline(QPainter *painter);
void paintLandDivider(QPainter *painter);
diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp
index baf7b8b30..5bd5d262f 100644
--- a/cockatrice/src/game_graphics/zones/view_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/view_zone.cpp
@@ -1,5 +1,6 @@
#include "view_zone.h"
+#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/view_zone_logic.h"
@@ -11,11 +12,13 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
/**
* @param parent the parent QGraphicsWidget containing the reveal zone
@@ -253,7 +256,10 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
void ZoneViewZone::setFilterString(const QString &_filterString)
{
- filterString = FilterString(_filterString);
+ const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
+ filterString = FilterString(
+ _filterString, CardSearchLanguage{cardsDisplay.getCardLang(),
+ static_cast(cardsDisplay.getCardSearchLanguage())});
reorganizeCards();
}
diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
index 17118e80d..fa6733413 100644
--- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
+++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
@@ -21,6 +21,7 @@
#include
#include
#include
+#include
#include