mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-25 02:43:02 -07:00
Compare commits
7 commits
da924c2bd6
...
e2eb36f19f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2eb36f19f | ||
|
|
25a9e37ff8 | ||
|
|
bb0a96984d | ||
|
|
976546fbe0 | ||
|
|
f425dcc93e | ||
|
|
c42fb6691d | ||
|
|
63a970045a |
19 changed files with 395 additions and 108 deletions
|
|
@ -12,9 +12,9 @@ Available pre-compiled binaries for installation:
|
|||
• <kbd>Windows 10+</kbd>
|
||||
|
||||
<b>macOS</b>
|
||||
• <kbd>macOS 15+</kbd> <sub><i>Sequoia</i></sub> <sub>Apple M</sub>
|
||||
• <kbd>macOS 14+</kbd> <sub><i>Sonoma</i></sub> <sub>Apple M</sub>
|
||||
• <kbd>macOS 13+</kbd> <sub><i>Ventura</i></sub> <sub>Intel</sub>
|
||||
• <kbd>macOS 15+</kbd> <sub><i>Sequoia</i></sub>
|
||||
• <kbd>macOS 14+</kbd> <sub><i>Sonoma</i></sub>
|
||||
• <kbd>macOS 13+</kbd> <sub><i>Ventura (x86)</i></sub>
|
||||
|
||||
<b>Linux</b>
|
||||
• <kbd>Ubuntu 26.04 LTS</kbd> <sub><i>Resolute Racoon</i></sub>
|
||||
|
|
@ -24,10 +24,10 @@ Available pre-compiled binaries for installation:
|
|||
• <kbd>Fedora 44</kbd>
|
||||
• <kbd>Fedora 43</kbd>
|
||||
|
||||
<sub>We are also packaged in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>, courtesy of @FFY00.</sub>
|
||||
<sub>General Linux support is available via a <kbd>flatpak</kbd> package at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a>!</sub>
|
||||
<sub>General Linux support is available via a <kbd>flatpak</kbd> package hosted at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a> (x86 & ARM)!</sub>
|
||||
<sub>Thanks to courtesy of @FFY00, the app is also available in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>.</sub>
|
||||
|
||||
<sub>We provide a <kbd>Docker</kbd> image for "Servatrice" in <a href="https://github.com/Cockatrice/Cockatrice/pkgs/container/servatrice">GHCR</a>. You can docker pull it or use our Docker Compose files!</sub>
|
||||
<sub>We maintain a <kbd>Docker</kbd> image for "Servatrice" in <a href="https://github.com/Cockatrice/Cockatrice/pkgs/container/servatrice">GHCR</a> (x86 & ARM). You can docker pull it or use our Docker Compose files!</sub>
|
||||
</pre>
|
||||
|
||||
|
||||
|
|
|
|||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -9,8 +9,8 @@
|
|||
name: CodeQL
|
||||
|
||||
permissions:
|
||||
security-events: write # needed to post results
|
||||
contents: read
|
||||
security-events: write # needed to post results
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
|
|||
3
.github/workflows/desktop-lint.yml
vendored
3
.github/workflows/desktop-lint.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
178
.github/workflows/docker-release.yml
vendored
178
.github/workflows/docker-release.yml
vendored
|
|
@ -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: type=gha,mode=max,scope=${{ 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@v7
|
||||
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"
|
||||
|
|
|
|||
3
.github/workflows/documentation-build.yml
vendored
3
.github/workflows/documentation-build.yml
vendored
|
|
@ -1,5 +1,8 @@
|
|||
name: Generate Docs
|
||||
|
||||
permissions:
|
||||
contents: read # write permission to the destination repo come from 'deploy_key'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
|
|
|
|||
4
.github/workflows/translations-pull.yml
vendored
4
.github/workflows/translations-pull.yml
vendored
|
|
@ -1,5 +1,9 @@
|
|||
name: Update Translations
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
|
|
|
|||
4
.github/workflows/translations-push.yml
vendored
4
.github/workflows/translations-push.yml
vendored
|
|
@ -1,5 +1,9 @@
|
|||
name: Update Translation Source
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
|
|
|
|||
52
Dockerfile
52
Dockerfile
|
|
@ -3,35 +3,45 @@ 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 \
|
||||
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 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
|
|||
|
|
@ -187,8 +187,8 @@ void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem
|
|||
|
||||
QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop);
|
||||
|
||||
const QRectF srcRect = computeArtSourceRect(playmatPixmap.size(), playmatParams);
|
||||
const QRectF dstRect = coverFitRect(combinedArea, srcRect.size());
|
||||
const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams);
|
||||
const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size());
|
||||
|
||||
painter->save();
|
||||
painter->setClipRect(combinedArea);
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
|
|||
// Stack is ~20% width on the left, table is ~80% on the right
|
||||
const QRectF playArea = cardRect.adjusted(6, 4, -4, -4);
|
||||
|
||||
const QRectF srcRect = computeArtSourceRect(sourcePixmap.size(), params);
|
||||
const QRectF dstRect = coverFitRect(playArea, srcRect.size());
|
||||
const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params);
|
||||
const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size());
|
||||
|
||||
painter.setClipRect(playArea.toRect());
|
||||
painter.drawPixmap(dstRect, sourcePixmap, srcRect);
|
||||
|
|
|
|||
|
|
@ -6,42 +6,96 @@
|
|||
#include <QSizeF>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
|
||||
namespace PlaymatUtils
|
||||
{
|
||||
|
||||
/** @brief Upper bound for zooming into the playmat art. */
|
||||
constexpr qreal MAX_ZOOM = 4.0;
|
||||
|
||||
/**
|
||||
* @brief Computes the source region of the full-resolution card image to use as a playmat.
|
||||
* @brief Width of the outer viewing window: full card width trimmed by the
|
||||
* horizontal margins. Guarded against margins summing to >= 1.
|
||||
*/
|
||||
inline qreal playmatVisibleWidth(const QSize &fullCardSize, const PlaymatParams ¶ms)
|
||||
{
|
||||
const qreal srcW = fullCardSize.width();
|
||||
const qreal marginL = params.marginPctL * srcW;
|
||||
const qreal marginR = params.marginPctR * srcW;
|
||||
return qMax(0.0, srcW - marginL - marginR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Zoom clamped to the range where every step renders differently.
|
||||
*
|
||||
* Parameters are relative to the full card image: horizontal margins trim the card
|
||||
* borders, the vertical offset positions a square viewing window, and zoom scales
|
||||
* into that window. The result is clamped to the card image bounds.
|
||||
* The square sampling window is visibleWidth / zoom, zooming out past
|
||||
* visibleWidth / min(card width, height) would sample beyond the card itself,
|
||||
* which both looks broken and makes whole ranges of the parameter dead. The
|
||||
* floor is therefore derived from the actual image instead of a static value,
|
||||
* and is shared verbatim by the render path and the editor's gesture math so
|
||||
* the two can never disagree.
|
||||
*/
|
||||
inline qreal playmatClampedZoom(const QSize &fullCardSize, const PlaymatParams ¶ms)
|
||||
{
|
||||
const qreal minDim = qMin<qreal>(fullCardSize.width(), fullCardSize.height());
|
||||
const qreal visibleW = playmatVisibleWidth(fullCardSize, params);
|
||||
const qreal zoomOutFloor = (minDim > 0.0 && visibleW > 0.0) ? visibleW / minDim : 1.0;
|
||||
// The floor deliberately bypasses MAX_ZOOM: when the art is much wider
|
||||
// than tall, keeping the square window inside it requires more than 4x
|
||||
// zoom-out, and honoring that larger floor keeps side within
|
||||
// min(card width, height). Zooming IN is still capped at MAX_ZOOM.
|
||||
return qMin(MAX_ZOOM, qMax(params.zoom, zoomOutFloor));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Side of the square sampling window actually rendered for these
|
||||
* parameters. Never exceeds either card dimension, so the source rect
|
||||
* always lies within the image (vertical travel remains for panning
|
||||
* whenever the art is taller than it is wide).
|
||||
*/
|
||||
inline qreal playmatWindowSide(const QSize &fullCardSize, const PlaymatParams ¶ms)
|
||||
{
|
||||
const qreal visibleW = playmatVisibleWidth(fullCardSize, params);
|
||||
if (visibleW <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return visibleW / playmatClampedZoom(fullCardSize, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Computes the source region of the full resolution card image to use as a playmat.
|
||||
*
|
||||
* Parameters are relative to the full card image. horizontal margins trim the
|
||||
* card borders (shifting them pans the window), verticalOffset places the top
|
||||
* edge of the sampling window within its available travel, and zoom scales
|
||||
* into the trimmed span. The result always lies within the card image bounds.
|
||||
*
|
||||
* @param fullCardSize Size of the full card image.
|
||||
* @param params Positioning parameters.
|
||||
* @return Source rectangle in full-card image pixel coordinates.
|
||||
* @return Source rectangle in full card image pixel coordinates.
|
||||
*/
|
||||
inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams ¶ms)
|
||||
{
|
||||
const qreal srcW = fullCardSize.width();
|
||||
const qreal srcH = fullCardSize.height();
|
||||
|
||||
const qreal marginL = params.marginPctL * srcW;
|
||||
const qreal marginR = params.marginPctR * srcW;
|
||||
// Guard against margins summing to >= 1 (both are individually in range),
|
||||
// which would otherwise make the viewing window negative or zero.
|
||||
const qreal visibleW = qMax(0.0, srcW - marginL - marginR);
|
||||
const qreal visibleH = visibleW; // square viewing window, keeps art unskewed
|
||||
// Square sampling window, keeps art unskewed, never exceeds the card on
|
||||
// either axis thanks to the zoom floor in playmatWindowSide().
|
||||
const qreal side = playmatWindowSide(fullCardSize, params);
|
||||
|
||||
const qreal vCenter = params.verticalOffset * srcH;
|
||||
qreal srcY = vCenter - visibleH / 2.0;
|
||||
srcY = qBound(0.0, srcY, srcH - visibleH);
|
||||
// verticalOffset places the TOP edge of the sampling window itself within
|
||||
// its travel, so the full [0, 1] parameter range is live at every zoom and
|
||||
// the window can always reach the very top (0.0) and bottom (1.0) of the
|
||||
// art.
|
||||
const qreal offset = qBound(0.0, params.verticalOffset, 1.0);
|
||||
const qreal y = offset * qMax(0.0, srcH - side);
|
||||
|
||||
// Guard the zoom divisor; everything that produces params clamps zoom to
|
||||
// [0.1, 4.0] already, this keeps the render path self-contained.
|
||||
const qreal zoom = qBound(0.1, params.zoom, 4.0);
|
||||
const qreal zoomedW = visibleW / zoom;
|
||||
const qreal zoomedH = visibleH / zoom;
|
||||
const qreal zoomedX = marginL + (visibleW - zoomedW) / 2.0;
|
||||
const qreal zoomedY = srcY + (visibleH - zoomedH) / 2.0;
|
||||
// Horizontally the sampling window sits centered inside the trimmed span
|
||||
// (margins pan it), zooming out can make it wider than that span, so it
|
||||
// is then kept within the image, an edge stop, never an invalid rect.
|
||||
const qreal outerW = playmatVisibleWidth(fullCardSize, params);
|
||||
const qreal x = qBound(0.0, params.marginPctL * srcW + (outerW - side) / 2.0, qMax(0.0, srcW - side));
|
||||
|
||||
return QRectF(zoomedX, zoomedY, zoomedW, zoomedH);
|
||||
return QRectF(x, y, side, side);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +103,7 @@ inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParam
|
|||
* ratio into dstArea using "cover" semantics (no distortion, overflows cropped).
|
||||
*
|
||||
* @param dstArea Area to fill.
|
||||
* @param srcSize Size of the source; only its aspect ratio matters.
|
||||
* @param srcSize Size of the source, only its aspect ratio matters.
|
||||
* @return Destination rectangle centered in dstArea.
|
||||
*/
|
||||
inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize)
|
||||
|
|
@ -66,4 +120,21 @@ inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize)
|
|||
return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fits a rectangle of the given aspect ratio into dstArea, centered,
|
||||
* touching the constraining dimension ("aspect fit" of the FRAME
|
||||
* itself, not of a source image).
|
||||
*/
|
||||
inline QRectF aspectFitRect(const QRectF &dstArea, qreal aspect)
|
||||
{
|
||||
if (aspect <= 0.0) {
|
||||
return dstArea;
|
||||
}
|
||||
qreal w = qMin(dstArea.width(), dstArea.height() * aspect);
|
||||
qreal h = w / aspect;
|
||||
return QRectF(dstArea.left() + (dstArea.width() - w) / 2.0, dstArea.top() + (dstArea.height() - h) / 2.0, w, h);
|
||||
}
|
||||
|
||||
} // namespace PlaymatUtils
|
||||
|
||||
#endif // COCKATRICE_PLAYMAT_UTILS_H
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@ UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initia
|
|||
if (!initial.cardName.isEmpty()) {
|
||||
searchBar->setText(initial.cardName);
|
||||
onCardNameChanged(initial.cardName);
|
||||
|
||||
// onCardNameChanged leaves the printing combo on the first printing in
|
||||
// the database, which would silently change the stored banner card on
|
||||
// accept. Restore the stored printing when it resolves locally.
|
||||
const int storedPrintingIndex = providerComboBox->findData(initial.cardProviderId);
|
||||
if (storedPrintingIndex != -1) {
|
||||
providerComboBox->setCurrentIndex(storedPrintingIndex);
|
||||
} else {
|
||||
// Stored printing not in the local database: keep it rather than
|
||||
// silently substituting the first printing.
|
||||
currentParams.cardProviderId = initial.cardProviderId;
|
||||
reloadPreview();
|
||||
}
|
||||
}
|
||||
marginLSpin->setValue(initial.marginPctL);
|
||||
marginRSpin->setValue(initial.marginPctR);
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ void UserInfoBox::actBannerCard()
|
|||
if (hasUserInfo && currentUserInfo.has_card_art_params()) {
|
||||
const auto &cap = currentUserInfo.card_art_params();
|
||||
initial.cardName = QString::fromStdString(cap.card_name());
|
||||
initial.cardProviderId = QString::fromStdString(cap.card_provider_id());
|
||||
initial.marginPctL = cap.margin_pct_l();
|
||||
initial.marginPctR = cap.margin_pct_r();
|
||||
initial.verticalOffset = cap.vertical_offset();
|
||||
|
|
|
|||
|
|
@ -189,22 +189,31 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
|
|||
|
||||
// ── Card art background ───────────────────────────────────────────────────
|
||||
if (!cardArt.isNull()) {
|
||||
// Same DPR normalization as UserListPainter::drawCardArt: the cache
|
||||
// carries screen scaled pixmaps on HiDPI displays, the math below is
|
||||
// in raw pixels.
|
||||
QPixmap art = cardArt;
|
||||
art.setDevicePixelRatio(1.0);
|
||||
|
||||
const int w = rect.width();
|
||||
const int h = rect.height();
|
||||
const int mL = qRound(w * params.marginPctL);
|
||||
const int mR = qRound(w * params.marginPctR);
|
||||
const int dW = w - mL - mR;
|
||||
|
||||
const double base = qMax(double(dW) / cardArt.width(), double(h) / cardArt.height());
|
||||
const double base = qMax(double(dW) / art.width(), double(h) / art.height());
|
||||
const double scale = base * params.zoom;
|
||||
const int sW = qRound(cardArt.width() * scale);
|
||||
const int sH = qRound(cardArt.height() * scale);
|
||||
const int sW = qRound(art.width() * scale);
|
||||
const int sH = qRound(art.height() * scale);
|
||||
|
||||
const QPixmap scaled = cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
const int srcX = (sW - dW) / 2;
|
||||
const int srcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h));
|
||||
const QPixmap scaled = art.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
// Clamp against stored zoom < 1, which can push srcX negative and silently
|
||||
// underfill the strip with transparent padding
|
||||
const int safeSrcX = qBound(0, (sW - dW) / 2, qMax(0, sW - dW));
|
||||
const int safeSrcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h));
|
||||
|
||||
QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
||||
QImage img =
|
||||
scaled.copy(safeSrcX, safeSrcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
||||
{
|
||||
QPainter mask(&img);
|
||||
mask.setCompositionMode(QPainter::CompositionMode_DestinationIn);
|
||||
|
|
@ -361,7 +370,7 @@ void UserInfoPopup::buildUi()
|
|||
header = new UserInfoHeaderWidget(this);
|
||||
root->addWidget(header);
|
||||
|
||||
// Action area — rebuilt per user
|
||||
// Action area, rebuilt per user
|
||||
actionArea = new QWidget(this);
|
||||
root->addWidget(actionArea);
|
||||
|
||||
|
|
@ -402,7 +411,7 @@ void UserInfoPopup::buildUi()
|
|||
|
||||
root->addWidget(gamesView);
|
||||
|
||||
// Close button — positioned absolutely in the top-right corner
|
||||
// Close button, positioned absolutely in the top right corner
|
||||
closeBtn = new QPushButton(QStringLiteral("✕"), this);
|
||||
closeBtn->setFixedSize(22, 22);
|
||||
closeBtn->setFlat(true);
|
||||
|
|
@ -673,7 +682,7 @@ void UserInfoPopup::showForUser(const QString &userName,
|
|||
gamesStatus->setText(tr("Loading games…"));
|
||||
gamesStatus->show();
|
||||
|
||||
// Close button — top-right corner, above everything
|
||||
// Close button, top right corner, above everything
|
||||
closeBtn->move(PopupWidth - closeBtn->width() - 6, 6);
|
||||
closeBtn->raise();
|
||||
|
||||
|
|
@ -702,7 +711,7 @@ void UserInfoPopup::fetchGames()
|
|||
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
|
||||
{
|
||||
if (forUser != currentUser) {
|
||||
return; // stale response — different user showing now
|
||||
return; // stale response, different user showing now
|
||||
}
|
||||
|
||||
gamesModel->clear();
|
||||
|
|
@ -763,4 +772,4 @@ void UserInfoPopup::leaveEvent(QEvent *e)
|
|||
{
|
||||
QFrame::leaveEvent(e);
|
||||
emit mouseLeftPopup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,12 @@ void UserListPainter::drawCardArt(QPainter *painter,
|
|||
return;
|
||||
}
|
||||
|
||||
// CardPictureLoader::getPixmap tags its output with the screen's
|
||||
// devicePixelRatio on HiDPI displays. Every calculation below is in raw
|
||||
// pixels, so normalize to 1.0 or the crop renders at 1/dpr scale anchored
|
||||
// to the top left corner of the row.
|
||||
art.setDevicePixelRatio(1.0);
|
||||
|
||||
const int cardH = rect.height() - 4;
|
||||
const int totalW = cardRight - rect.left();
|
||||
const int marginL = qRound(totalW * params.marginPctL);
|
||||
|
|
@ -172,11 +178,14 @@ void UserListPainter::drawCardArt(QPainter *painter,
|
|||
const int srcX = (scaledW - drawW) / 2;
|
||||
const int srcY = qRound((scaledH - cardH) * params.verticalOffset);
|
||||
|
||||
// Clamp srcY so we never copy outside the pixmap bounds
|
||||
// Clamp so we never copy outside the pixmap bounds. srcX can go negative
|
||||
// for stored zoom values below 1, which would silently underfill the
|
||||
// strip with transparent padding.
|
||||
const int safeSrcX = qBound(0, srcX, qMax(0, scaledW - drawW));
|
||||
const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH));
|
||||
|
||||
QImage img =
|
||||
scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
||||
scaled.copy(safeSrcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
||||
|
||||
{
|
||||
QPainter mask(&img);
|
||||
|
|
@ -403,4 +412,4 @@ void UserListPainter::paint(QPainter *painter,
|
|||
drawBadges(painter, option, rect, cardRight, badges, online, style);
|
||||
|
||||
painter->restore();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ set(HEADERS
|
|||
game/server_deck_validation_strategy.h
|
||||
game/server_game.h
|
||||
game/server_game_lifecycle_strategy.h
|
||||
game/server_match_result_strategy.h
|
||||
game/server_player.h
|
||||
game/server_spectator.h
|
||||
server.h
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
|
|||
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
|
||||
inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false),
|
||||
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
|
||||
lifecycleStrategy(new Server_DefaultLifecycleStrategy), gameMutex()
|
||||
deckValidationStrategy(new Server_DefaultDeckValidationStrategy),
|
||||
lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy),
|
||||
gameMutex()
|
||||
{
|
||||
currentReplay = new GameReplay;
|
||||
currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId());
|
||||
|
|
@ -391,10 +393,12 @@ void Server_Game::stopGameIfFinished()
|
|||
QMutexLocker locker(&gameMutex);
|
||||
|
||||
int playing = 0;
|
||||
Server_AbstractPlayer *lastPlayer = nullptr;
|
||||
auto players = getPlayers();
|
||||
for (auto *player : players.values()) {
|
||||
if (!player->getConceded()) {
|
||||
++playing;
|
||||
lastPlayer = player;
|
||||
}
|
||||
}
|
||||
if (playing > 1) {
|
||||
|
|
@ -410,6 +414,16 @@ void Server_Game::stopGameIfFinished()
|
|||
|
||||
sendGameStateToPlayers();
|
||||
|
||||
bool matchDecided = matchResultStrategy->onGameFinished(this, playing, lastPlayer);
|
||||
if (matchDecided) {
|
||||
locker.unlock();
|
||||
|
||||
sendGameEventContainer(prepareGameEvent(Event_GameClosed(), -1));
|
||||
gameClosed = true;
|
||||
deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
locker.unlock();
|
||||
|
||||
ServerInfo_Game gameInfo;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
#include "game_config.h"
|
||||
#include "server_deck_validation_strategy.h"
|
||||
#include "server_game_lifecycle_strategy.h"
|
||||
#include "server_match_result_strategy.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QMap>
|
||||
|
|
@ -86,6 +87,8 @@ private:
|
|||
|
||||
QScopedPointer<Server_GameLifecycleStrategy> lifecycleStrategy;
|
||||
|
||||
QScopedPointer<Server_MatchResultStrategy> matchResultStrategy;
|
||||
|
||||
void createGameStateChangedEvent(Event_GameStateChanged *event,
|
||||
Server_AbstractParticipant *recipient,
|
||||
bool omniscient,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
#ifndef SERVER_MATCH_RESULT_STRATEGY_H
|
||||
#define SERVER_MATCH_RESULT_STRATEGY_H
|
||||
|
||||
class Server_AbstractPlayer;
|
||||
class Server_Game;
|
||||
|
||||
/**
|
||||
* @brief Strategy hook invoked when a game has finished to decide the match result.
|
||||
*
|
||||
* Subclasses can report the match outcome (e.g. to a tournament backend) and decide
|
||||
* whether the game should be closed permanently; the default implementation never
|
||||
* closes the game, preserving the normal return-to-lobby behavior.
|
||||
*/
|
||||
class Server_MatchResultStrategy
|
||||
{
|
||||
public:
|
||||
virtual ~Server_MatchResultStrategy() = default;
|
||||
|
||||
/**
|
||||
* @brief Called when a game has finished.
|
||||
* @return Whether the game has been decided and should be closed.
|
||||
*/
|
||||
virtual bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Default match result strategy that never closes the game.
|
||||
*/
|
||||
class Server_NullMatchResultStrategy : public Server_MatchResultStrategy
|
||||
{
|
||||
public:
|
||||
bool onGameFinished(Server_Game *, int, Server_AbstractPlayer *) override
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue