mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 06:55:41 +00:00
Compare commits
210
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e331b40b | ||
|
|
0d832ea8f2 | ||
|
|
e296a31033 | ||
|
|
239d626ac1 | ||
|
|
bb1f3929c3 | ||
|
|
8fb218d8ee | ||
|
|
c85973edda | ||
|
|
ae970a6693 | ||
|
|
6df165cc5a | ||
|
|
c26b531792 | ||
|
|
4682784737 | ||
|
|
139eb89edb | ||
|
|
c6c31cd28e | ||
|
|
d5665c9570 | ||
|
|
0719d01819 | ||
|
|
a4d7ac4283 | ||
|
|
cf16c594a2 | ||
|
|
1073a208de | ||
|
|
e96c0704ed | ||
|
|
ef1a447431 | ||
|
|
f874231e04 | ||
|
|
5acb4c4f14 | ||
|
|
2dc6f4d0bb | ||
|
|
bbef0c1430 | ||
|
|
f929672f83 | ||
|
|
7eb362a09b | ||
|
|
e4b7d8e8a8 | ||
|
|
a56bea82f1 | ||
|
|
8bc22baa7c | ||
|
|
2f31d94313 | ||
|
|
7b46295e17 | ||
|
|
6c154e827c | ||
|
|
040b0cf580 | ||
|
|
3ae3f6ba16 | ||
|
|
6da0636731 | ||
|
|
53ab903568 | ||
|
|
e7745cc9ec | ||
|
|
a3f185b6f6 | ||
|
|
8f3bee853f | ||
|
|
b4234debce | ||
|
|
7f562de2a8 | ||
|
|
bc5d86fb1e | ||
|
|
10a747114a | ||
|
|
a94a48a4bf | ||
|
|
945511153a | ||
|
|
d131aa50f0 | ||
|
|
d0d84e98e0 | ||
|
|
0e3f1bcb87 | ||
|
|
37fe57be71 | ||
|
|
1a147b0a19 | ||
|
|
37a9a29eaf | ||
|
|
0ff7d51f2c | ||
|
|
0354fefdca | ||
|
|
6fb14ef9af | ||
|
|
eaf00de74d | ||
|
|
f2743fe9cc | ||
|
|
0bdc4d46ce | ||
|
|
0e9dc4121f | ||
|
|
e9281f66fe | ||
|
|
336d008f26 | ||
|
|
5a56f71c4c | ||
|
|
6e66b88929 | ||
|
|
eee10dc209 | ||
|
|
caa843f763 | ||
|
|
bb6de75ad0 | ||
|
|
ad418c8848 | ||
|
|
093316b1c6 | ||
|
|
55ad8a8278 | ||
|
|
0361464db1 | ||
|
|
31649c73a7 | ||
|
|
c24509e0b2 | ||
|
|
ca2a13e17b | ||
|
|
65a9cf1f97 | ||
|
|
dd9d6d046d | ||
|
|
738dd12c7c | ||
|
|
1d98df6cbc | ||
|
|
f74f61c18e | ||
|
|
88bd7ade50 | ||
|
|
99721d963a | ||
|
|
4296d5046a | ||
|
|
353eb3907c | ||
|
|
ded06ba815 | ||
|
|
70392b109a | ||
|
|
821e3f1a4a | ||
|
|
ebe819a5a1 | ||
|
|
3e868d0305 | ||
|
|
e46867333c | ||
|
|
2d59d4ff51 | ||
|
|
d493182184 | ||
|
|
8ce4e4c4a9 | ||
|
|
9729110ea8 | ||
|
|
2224e78a36 | ||
|
|
007a57eea2 | ||
|
|
3febf2a6cd | ||
|
|
26204cc879 | ||
|
|
7032260a31 | ||
|
|
23ab715ffc | ||
|
|
73f42d5b51 | ||
|
|
3cb61f69f4 | ||
|
|
62fff3c0ea | ||
|
|
ca86926a25 | ||
|
|
1d334c91ae | ||
|
|
64e1e46e0a | ||
|
|
4722a40384 | ||
|
|
6653a14660 | ||
|
|
056571651e | ||
|
|
d8224e7886 | ||
|
|
9cc9a65e4a | ||
|
|
025563b607 | ||
|
|
030f6b2c2e | ||
|
|
1d5dd7b971 | ||
|
|
649e80c4ac | ||
|
|
a1b4e41553 | ||
|
|
1c24474a0b | ||
|
|
26eec6cabc | ||
|
|
d134f40438 | ||
|
|
a638dd26ca | ||
|
|
5e93b0eaa0 | ||
|
|
6940312d3f | ||
|
|
243f0d43e7 | ||
|
|
1ddd015449 | ||
|
|
da85d0983a | ||
|
|
ae574e6ff5 | ||
|
|
c1ac57b929 | ||
|
|
be51daa148 | ||
|
|
97605d0a63 | ||
|
|
9cca922b70 | ||
|
|
6eab9ffc3c | ||
|
|
08eaffb927 | ||
|
|
a2bc55e0f5 | ||
|
|
19861377a6 | ||
|
|
cc70087efc | ||
|
|
b2383c8384 | ||
|
|
03958b6914 | ||
|
|
4cbf3ea39f | ||
|
|
3dbbe74830 | ||
|
|
dab4365f16 | ||
|
|
0b3cd4f1c6 | ||
|
|
49d42a599a | ||
|
|
5ae7d8a9cc | ||
|
|
ee47f7d6bc | ||
|
|
fe3c2d3e79 | ||
|
|
de21646a37 | ||
|
|
040f2d55b9 | ||
|
|
5b32712b0f | ||
|
|
bdec4a0015 | ||
|
|
c2c7b85da5 | ||
|
|
9e287ba3bb | ||
|
|
1a0cfe8578 | ||
|
|
a841720c75 | ||
|
|
064a606d13 | ||
|
|
b84af5798b | ||
|
|
440af18210 | ||
|
|
722e107d53 | ||
|
|
9cc83fa2f5 | ||
|
|
cd9db60b80 | ||
|
|
5c0be5e3a3 | ||
|
|
fdc6c7f8cf | ||
|
|
37ac0dd19f | ||
|
|
13037aec56 | ||
|
|
2acfec76a9 | ||
|
|
ad105d60d9 | ||
|
|
a8048fbef3 | ||
|
|
ad72f11e29 | ||
|
|
14c03f34ef | ||
|
|
eb63977ca2 | ||
|
|
1cd23b566b | ||
|
|
779e7eceec | ||
|
|
1ed5252d44 | ||
|
|
836c09c357 | ||
|
|
6f73ac71ab | ||
|
|
5e68fc344d | ||
|
|
2690332b10 | ||
|
|
99109e7e60 | ||
|
|
bd9b850d45 | ||
|
|
867b4ac66e | ||
|
|
752f078c74 | ||
|
|
4348579000 | ||
|
|
a3dfaf6445 | ||
|
|
aadb517771 | ||
|
|
ff74504a26 | ||
|
|
1e72e53f0e | ||
|
|
66419dee0b | ||
|
|
0726c41609 | ||
|
|
472b33e8de | ||
|
|
908716c8af | ||
|
|
1b52c48e5b | ||
|
|
f6e5235382 | ||
|
|
138a8a76cd | ||
|
|
ffd2b63397 | ||
|
|
8400b44acd | ||
|
|
06db8a9059 | ||
|
|
0262aa5a87 | ||
|
|
60e0c8fc96 | ||
|
|
d5a836db79 | ||
|
|
20f3d14721 | ||
|
|
4d7824656b | ||
|
|
5b18e275af | ||
|
|
ca39d16710 | ||
|
|
6e47e836c2 | ||
|
|
75668cdc58 | ||
|
|
fad349bf35 | ||
|
|
956728670a | ||
|
|
d6bb7a23f2 | ||
|
|
241426c0fa | ||
|
|
69923e12b9 | ||
|
|
3f921c663f | ||
|
|
d49b402988 | ||
|
|
898af3b858 | ||
|
|
043302ed6a |
@@ -24,7 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
@@ -105,10 +105,23 @@ jobs:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy update-env script to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
source: "deploy/update-env.sh,deploy/env.template"
|
||||
target: /opt/eagle0/
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
@@ -116,34 +129,23 @@ jobs:
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Update AUTH_IMAGE in .env file (preserve other vars)
|
||||
if [ -f .env ]; then
|
||||
# Remove old AUTH_IMAGE line and add new one
|
||||
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
|
||||
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
|
||||
# Also update OAuth credentials
|
||||
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
|
||||
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
|
||||
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
|
||||
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
|
||||
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
|
||||
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
|
||||
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
|
||||
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
|
||||
# Update JWT private key (JWK format for auth service bootstrap)
|
||||
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
|
||||
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
|
||||
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
|
||||
chmod 600 .env
|
||||
else
|
||||
echo "ERROR: .env file not found. Run main deploy first."
|
||||
exit 1
|
||||
fi
|
||||
# Update env vars using shared script (preserves vars set by other workflows)
|
||||
chmod +x update-env.sh
|
||||
./update-env.sh \
|
||||
"AUTH_IMAGE=${AUTH_IMAGE}" \
|
||||
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
|
||||
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
|
||||
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
|
||||
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
|
||||
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
|
||||
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
@@ -26,7 +26,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Build Protos
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/main/protobuf/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- name: Run tests
|
||||
run: ./scripts/build_protos.sh
|
||||
@@ -53,26 +53,26 @@ jobs:
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== AMD64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
build-sysroot-arm64:
|
||||
@@ -114,24 +114,24 @@ jobs:
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== ARM64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot_arm64\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Client Presigner
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
client-presigner:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build Client Presigner
|
||||
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
|
||||
- name: Archive presigner binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: client_download
|
||||
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
|
||||
+202
-467
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
# Note: C++ changes trigger shardok_arm64_build.yml instead
|
||||
- 'src/main/go/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
@@ -22,30 +22,63 @@ on:
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
# Only allow one deployment at a time to prevent race conditions
|
||||
concurrency:
|
||||
group: docker-build-deploy
|
||||
cancel-in-progress: false # Don't cancel running deployments, queue new ones
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-eagle:
|
||||
runs-on: self-hosted
|
||||
# Single consolidated build job - builds all images with one bazel invocation
|
||||
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
|
||||
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
|
||||
build-all:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
|
||||
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
|
||||
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
|
||||
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Eagle Docker image
|
||||
id: build-eagle
|
||||
- name: Build all Docker images
|
||||
id: build-all
|
||||
run: |
|
||||
set -ex
|
||||
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
# Build ALL images in a single bazel command - Bazel parallelizes internally
|
||||
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
|
||||
echo "=== Building Docker images ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:eagle_server_image \
|
||||
//ci:admin_server_image \
|
||||
//ci:jfr_sidecar_image \
|
||||
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
|
||||
# Copy warmup binary to scripts/ for deployment
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
# Save all image paths before any other bazel command changes bazel-bin symlink
|
||||
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
|
||||
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
||||
|
||||
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
|
||||
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
|
||||
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Image paths ==="
|
||||
echo "Eagle: $EAGLE_PATH"
|
||||
echo "Admin: $ADMIN_PATH"
|
||||
echo "JFR Sidecar: $JFR_PATH"
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
@@ -55,381 +88,70 @@ jobs:
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
# Also set for current directory in case Bazel uses different home
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Eagle image to DO registry
|
||||
id: push-eagle
|
||||
- name: Push all images to DO registry
|
||||
id: push-images
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Use cross-compiled image path from build step
|
||||
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
|
||||
echo "Using Eagle image: $EAGLE_IMAGE"
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
|
||||
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug: show OCI layout contents
|
||||
echo "=== OCI Layout Contents ==="
|
||||
cat "$EAGLE_IMAGE/index.json"
|
||||
echo ""
|
||||
echo "=== Blobs ==="
|
||||
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
|
||||
|
||||
# Verify OCI layout consistency before pushing
|
||||
echo "=== Verifying OCI layout consistency ==="
|
||||
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
|
||||
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
|
||||
if [ ! -f "$blob_path" ]; then
|
||||
echo "ERROR: Blob not found: $blob_path"
|
||||
exit 1
|
||||
fi
|
||||
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
|
||||
if [ "$digest" != "$actual_digest" ]; then
|
||||
echo "ERROR: Digest mismatch for $blob_path"
|
||||
echo " Index says: $digest"
|
||||
echo " Actual: $actual_digest"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Verified: $digest"
|
||||
done
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
# Get crane from push target runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Use crane directly for push (avoids OCI->Docker digest mismatch)
|
||||
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
||||
echo "Pushing eagle image: $IMAGE_TAG"
|
||||
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Verify push by checking what's in the registry
|
||||
echo "=== Verifying push ==="
|
||||
$CRANE manifest "$IMAGE_TAG" | head -50
|
||||
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
|
||||
echo "Registry reports digest: $PUSHED_DIGEST"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience (but deploy won't use it)
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
|
||||
|
||||
build-shardok:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok binary (cross-compile for Linux)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Step 1: Build JUST the binary with cross-compilation
|
||||
# We need --extra_toolchains to force the Linux toolchain to be used
|
||||
# because toolchains_llvm registers with dev_dependency=True
|
||||
echo "=== Building shardok-server binary for linux-x86_64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Step 2: Check the binary directly from bazel-bin
|
||||
# bazel-bin is a symlink that points to the correct output directory
|
||||
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
|
||||
echo "=== Checking binary at: $LINUX_BIN ==="
|
||||
|
||||
if [ ! -f "$LINUX_BIN" ]; then
|
||||
echo "ERROR: Binary not found at $LINUX_BIN"
|
||||
exit 1
|
||||
if [ ! -e "$CRANE" ]; then
|
||||
# Fallback: find any Darwin crane
|
||||
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
# Debug: show what bazel-bin points to
|
||||
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
|
||||
|
||||
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
|
||||
echo "=== Verifying binary format ==="
|
||||
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
|
||||
echo "Binary magic bytes: $MAGIC"
|
||||
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary is ELF format (Linux)"
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
echo ""
|
||||
echo "Debug info:"
|
||||
echo "- bazel-bin points to: $(readlink bazel-bin)"
|
||||
file "$LINUX_BIN" || true
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build the OCI image with cross-compilation flags
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:shardok_server_image
|
||||
|
||||
# The image is output to bazel-bin which is a symlink.
|
||||
# Resolve it now before any other bazel commands change where it points.
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_TAR"
|
||||
# Extract just the first 4 bytes of the binary from the tar
|
||||
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
|
||||
echo "Binary magic in tar: $MAGIC"
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary in tar is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary in tar is NOT ELF format!"
|
||||
echo "This means pkg_tar is packaging the wrong binary."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Could not find $BINARY_TAR"
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
# Also set for current directory in case Bazel uses different home
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Shardok image to DO registry
|
||||
id: push-shardok
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Use cross-compiled image path from build step
|
||||
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
|
||||
echo "Using cross-compiled image: $CROSS_IMAGE"
|
||||
|
||||
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
|
||||
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get crane from Eagle push target (which doesn't need cross-compilation)
|
||||
# This gives us a macOS crane binary we can actually run.
|
||||
# We can't build shardok_server_push with platform flags because it would
|
||||
# download a Linux crane that can't run on macOS.
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Find the Darwin crane binary (may be a symlink)
|
||||
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
if [ -z "$CRANE" ]; then
|
||||
# Fallback to any crane
|
||||
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
|
||||
echo "ERROR: crane not found. Listing runfiles:"
|
||||
find "$RUNFILES" -name crane 2>/dev/null || true
|
||||
echo "ERROR: crane not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push the cross-compiled image with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
|
||||
echo "Pushing shardok image: $IMAGE_TAG"
|
||||
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
|
||||
# Push Eagle image
|
||||
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
|
||||
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
||||
echo "Pushing Eagle: $EAGLE_TAG"
|
||||
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
|
||||
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
|
||||
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
# Push Admin image
|
||||
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
|
||||
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
||||
echo "Pushing Admin: $ADMIN_TAG"
|
||||
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
|
||||
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
|
||||
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience (but deploy won't use it)
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
|
||||
# Push JFR Sidecar image
|
||||
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
|
||||
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
||||
echo "Pushing JFR Sidecar: $JFR_TAG"
|
||||
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
|
||||
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
build-admin:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-admin.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Admin Server Docker image
|
||||
id: build-admin
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:admin_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Admin image to DO registry
|
||||
id: push-admin
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
|
||||
echo "Using Admin image: $ADMIN_IMAGE"
|
||||
|
||||
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
|
||||
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:admin_server_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
||||
echo "Pushing admin image: $IMAGE_TAG"
|
||||
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
|
||||
|
||||
build-jfr-sidecar:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build JFR Sidecar Docker image
|
||||
id: build-jfr-sidecar
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:jfr_sidecar_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push JFR Sidecar image to DO registry
|
||||
id: push-jfr-sidecar
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
|
||||
echo "Using JFR Sidecar image: $JFR_IMAGE"
|
||||
|
||||
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
|
||||
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:jfr_sidecar_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
||||
echo "Pushing JFR sidecar image: $IMAGE_TAG"
|
||||
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
echo "=== All images pushed successfully ==="
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-all]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
|
||||
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
|
||||
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
|
||||
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
@@ -443,135 +165,148 @@ jobs:
|
||||
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
||||
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Build warmup tool
|
||||
run: |
|
||||
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
- name: Copy config files to droplet
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
source: "docker-compose.prod.yml,nginx/nginx.conf"
|
||||
target: "/opt/eagle0"
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
|
||||
set -e
|
||||
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
|
||||
rm -f /opt/eagle0/scripts/bin/warmup
|
||||
SETUP_DIRS
|
||||
|
||||
# Copy files
|
||||
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
||||
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
|
||||
|
||||
- name: Deploy to production droplet
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,SHARDOK_ADDRESS,SHARDOK_AUTH_TOKEN,SENTRY_DSN
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Check Docker has IPv6 support for connecting to Hetzner Shardok
|
||||
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
|
||||
fi
|
||||
# Environment variables passed via heredoc
|
||||
EAGLE_IMAGE="${EAGLE_IMAGE}"
|
||||
ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
|
||||
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
|
||||
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
||||
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
|
||||
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
||||
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
||||
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
||||
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
||||
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
|
||||
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
|
||||
SENTRY_DSN="${SENTRY_DSN}"
|
||||
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
||||
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
||||
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
|
||||
# Write env vars to .env file for docker-compose
|
||||
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
|
||||
EXISTING_AUTH_IMAGE=""
|
||||
if [ -f .env ]; then
|
||||
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
|
||||
fi
|
||||
# Check Docker has IPv6 support
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
fi
|
||||
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
|
||||
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
|
||||
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
|
||||
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
|
||||
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
|
||||
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
|
||||
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
SHARDOK_ADDRESS=${SHARDOK_ADDRESS:-shardok:40042}
|
||||
SHARDOK_AUTH_TOKEN=${SHARDOK_AUTH_TOKEN:-}
|
||||
SENTRY_DSN=${SENTRY_DSN:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
# Update env vars
|
||||
chmod +x deploy/update-env.sh
|
||||
cd deploy && ./update-env.sh \
|
||||
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
|
||||
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
|
||||
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
|
||||
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
|
||||
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
|
||||
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
|
||||
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
|
||||
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
|
||||
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
|
||||
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
|
||||
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
|
||||
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
|
||||
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
|
||||
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
|
||||
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
|
||||
"SENTRY_DSN=\${SENTRY_DSN}" \
|
||||
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
|
||||
cd ..
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
# Login to registry
|
||||
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
||||
|
||||
# Use exact image tags passed from build jobs (no :latest fallback)
|
||||
# Note: AUTH_IMAGE is managed separately by auth_build.yml
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
|
||||
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Use crane to pull images (handles OCI format correctly) then load into Docker
|
||||
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
|
||||
echo "Installing crane..."
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
# Install crane for pulling OCI images
|
||||
echo "Installing crane..."
|
||||
rm -f crane
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
# crane uses Docker config for auth
|
||||
echo "Pulling Eagle image with crane..."
|
||||
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
|
||||
echo "Loading Eagle image into Docker..."
|
||||
docker load -i eagle.tar
|
||||
rm eagle.tar
|
||||
# Pull and load all images
|
||||
echo "Pulling Eagle image..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
|
||||
|
||||
echo "Pulling Shardok image with crane..."
|
||||
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
|
||||
echo "Loading Shardok image into Docker..."
|
||||
docker load -i shardok.tar
|
||||
rm shardok.tar
|
||||
echo "Pulling Admin image..."
|
||||
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
|
||||
|
||||
echo "Pulling Admin image with crane..."
|
||||
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
|
||||
echo "Loading Admin image into Docker..."
|
||||
docker load -i admin.tar
|
||||
rm admin.tar
|
||||
echo "Pulling JFR Sidecar image..."
|
||||
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
|
||||
|
||||
echo "Pulling JFR Sidecar image with crane..."
|
||||
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
|
||||
echo "Loading JFR Sidecar image into Docker..."
|
||||
docker load -i jfr-sidecar.tar
|
||||
rm jfr-sidecar.tar
|
||||
# Pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
rm ./crane
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Also pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
# Stop local shardok container if running (now runs on Hetzner)
|
||||
docker stop shardok-server 2>/dev/null || true
|
||||
docker rm shardok-server 2>/dev/null || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
|
||||
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
|
||||
|
||||
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
|
||||
# This prevents restarting auth service during every Eagle deploy
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
|
||||
# Ensure auth is running
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
# Verify
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# Restart nginx to pick up new container IPs
|
||||
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
|
||||
|
||||
# Cleanup orphaned containers
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Verify containers are using correct images
|
||||
echo "=== Verifying container image tags ==="
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
# Cleanup
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -10,13 +10,14 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/win/installer/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -29,6 +30,19 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Inject manifest public key
|
||||
env:
|
||||
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
|
||||
run: |
|
||||
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
|
||||
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
|
||||
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
|
||||
echo "Injected manifest public key into configuration.txt"
|
||||
cat "$CONFIG_FILE"
|
||||
else
|
||||
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
|
||||
fi
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
|
||||
|
||||
@@ -68,15 +82,30 @@ jobs:
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
# Create installer manifest content
|
||||
# Create installer manifest content
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
|
||||
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
|
||||
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
|
||||
|
||||
|
||||
echo "=== Installer manifest content ==="
|
||||
cat /tmp/installer_manifest.txt
|
||||
echo "=================================="
|
||||
|
||||
# Update the unified manifest
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
|
||||
|
||||
# Write signing key to temp file (if available)
|
||||
SIGNING_ARGS=""
|
||||
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
|
||||
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
|
||||
chmod 600 /tmp/manifest_signing_key
|
||||
SIGNING_ARGS="/tmp/manifest_signing_key"
|
||||
echo "Manifest signing key available"
|
||||
else
|
||||
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
|
||||
fi
|
||||
|
||||
# Update the unified manifest (with optional signing)
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt $SIGNING_ARGS
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/manifest_signing_key
|
||||
@@ -0,0 +1,268 @@
|
||||
name: Mac Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/go/net/eagle0/build/mac_build_handler/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_submit.sh"
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/go/net/eagle0/build/mac_build_handler/**"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_submit.sh"
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_signing:
|
||||
description: 'Skip code signing, notarization, and deploy (build only)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-sign:
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
outputs:
|
||||
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
|
||||
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
clean: false
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Restore Library/
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
|
||||
- name: Build Mac Unity
|
||||
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
|
||||
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
|
||||
- name: Inject Sparkle Framework
|
||||
if: success()
|
||||
env:
|
||||
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
|
||||
run: |
|
||||
chmod +x ./scripts/inject_sparkle.sh
|
||||
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Check if should deploy
|
||||
id: check-deploy
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.skip_signing }}" == "true" ]]; then
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
# Generate random keychain password (only used within this workflow run)
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
|
||||
|
||||
# Decode certificate
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
|
||||
# Delete any existing keychain from previous runs
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
# Create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Import certificate
|
||||
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
|
||||
|
||||
# Allow codesign to access keychain
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Code Sign App
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
||||
run: |
|
||||
chmod +x ./scripts/codesign_mac_app.sh
|
||||
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
|
||||
|
||||
- name: Submit for Notarization
|
||||
id: notarize-submit
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_submit.sh
|
||||
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
- name: Zip signed app for artifact
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload signed app
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: /tmp/eagle0/editor_mac.log
|
||||
|
||||
wait-notarization:
|
||||
needs: build-and-sign
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, notarize]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Download signed app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Unzip signed app
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
|
||||
|
||||
- name: Wait for Notarization and Staple
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_wait.sh
|
||||
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Zip notarized app for artifact
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
rm -f eagle0.app.zip
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload notarized app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
|
||||
deploy:
|
||||
needs: [build-and-sign, wait-notarization]
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # For version numbering
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Download notarized app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Unzip notarized app
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
|
||||
- name: Deploy Mac Build
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
|
||||
run: |
|
||||
# Write private key to temp file for signing
|
||||
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
|
||||
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
VERSION=$(git describe --tags --always)
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD)
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
|
||||
"/tmp/eagle0/eagle0MAC/eagle0.app" \
|
||||
"$VERSION" \
|
||||
"$BUILD_NUMBER" \
|
||||
"$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Mac History Editor Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-history-build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build the mac history
|
||||
run: ./ci/github_actions/build_mac_history.sh
|
||||
@@ -40,13 +40,15 @@ jobs:
|
||||
echo ""
|
||||
|
||||
# List of repositories to clean
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header)
|
||||
# Use tail to skip header row in case --no-header doesn't work
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
|
||||
# Get all manifests with their tags and dates
|
||||
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null || echo "")
|
||||
# Filter out header row and empty lines
|
||||
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo " No manifests found"
|
||||
@@ -54,8 +56,8 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
|
||||
# Skip if no digest
|
||||
if [ -z "$DIGEST" ]; then
|
||||
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
|
||||
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
@@ -159,51 +159,63 @@ jobs:
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
deploy-hetzner:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-shardok-arm64]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
|
||||
chmod 600 ~/.ssh/hetzner_deploy
|
||||
# Add host key to known_hosts to avoid prompt
|
||||
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy to Hetzner
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HETZNER_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.HETZNER_SSH_KEY }}
|
||||
protocol: tcp6
|
||||
script_stop: true
|
||||
envs: SHARDOK_IMAGE
|
||||
script: |
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
run: |
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying Shardok ARM64: $SHARDOK_IMAGE"
|
||||
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Pull the new image
|
||||
docker pull "$SHARDOK_IMAGE"
|
||||
# Pull the new image
|
||||
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Stop and remove existing container
|
||||
docker stop shardok-ai 2>/dev/null || true
|
||||
docker rm shardok-ai 2>/dev/null || true
|
||||
# Stop and remove any container using port 40042 or named shardok*
|
||||
docker ps -q --filter "publish=40042" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
|
||||
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
|
||||
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name shardok-ai \
|
||||
--restart unless-stopped \
|
||||
-p 40042:40042 \
|
||||
-v /opt/eagle0/data:/data \
|
||||
"$SHARDOK_IMAGE"
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name shardok-ai \
|
||||
--restart unless-stopped \
|
||||
-p 40042:40042 \
|
||||
-v /opt/eagle0/data:/data \
|
||||
-v /etc/shardok:/etc/shardok:ro \
|
||||
-v /etc/letsencrypt:/etc/letsencrypt:ro \
|
||||
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
|
||||
-e SHARDOK_RESOURCES_PATH=/app/resources \
|
||||
-e SHARDOK_MAPS_PATH=/app/resources/maps \
|
||||
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
echo "=== Hetzner deployment complete ==="
|
||||
echo "=== Hetzner deployment complete ==="
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/hetzner_deploy
|
||||
|
||||
@@ -28,7 +28,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -6,7 +6,11 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
@@ -15,12 +19,16 @@ on:
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
@@ -29,14 +37,13 @@ on:
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
- "src/main/proto/**/BUILD.bazel"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-unity:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, macOS, unity-windows]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -63,7 +70,24 @@ jobs:
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
# Write signing key to temp file (if available)
|
||||
SIGNING_ARGS=""
|
||||
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
|
||||
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
|
||||
chmod 600 /tmp/manifest_signing_key
|
||||
SIGNING_ARGS="/tmp/manifest_signing_key"
|
||||
echo "Manifest signing key available"
|
||||
else
|
||||
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
|
||||
fi
|
||||
|
||||
# Update the unified manifest (with optional signing)
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt $SIGNING_ARGS
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/manifest_signing_key
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -38,3 +38,4 @@ scripts/refresh_name_layers/refresh_name_layers.zip
|
||||
api_keys.txt
|
||||
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
node_modules/
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
|
||||
|
||||
**ALWAYS use this workflow:**
|
||||
1. Create a feature branch from origin/main
|
||||
2. Commit to that branch
|
||||
3. Create a PR with `gh pr create`
|
||||
4. Wait for user to merge (DO NOT run `gh pr merge`)
|
||||
|
||||
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
|
||||
|
||||
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
|
||||
|
||||
---
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
+40
-15
@@ -13,8 +13,8 @@ AWS_SDK_VERSION = "2.28.1"
|
||||
# Core Build Tools
|
||||
#
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.8.1")
|
||||
bazel_dep(name = "rules_pkg", version = "1.1.0")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.9.0")
|
||||
bazel_dep(name = "rules_pkg", version = "1.2.0")
|
||||
|
||||
#
|
||||
# Language Support - Scala
|
||||
@@ -88,22 +88,22 @@ sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
# ARM64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot_arm64",
|
||||
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
|
||||
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
go_sdk.download(version = "1.23.3")
|
||||
@@ -127,8 +127,15 @@ use_repo(
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
|
||||
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
|
||||
|
||||
# Register Apple CC toolchain for Objective-C compilation
|
||||
apple_cc_configure = use_extension(
|
||||
"@build_bazel_apple_support//crosstool:setup.bzl",
|
||||
"apple_cc_configure_extension",
|
||||
)
|
||||
use_repo(apple_cc_configure, "local_config_apple_cc")
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
@@ -137,7 +144,7 @@ bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rule
|
||||
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
bazel_dep(name = "flatbuffers", version = "25.9.23")
|
||||
|
||||
#
|
||||
# Testing
|
||||
@@ -149,8 +156,8 @@ bazel_dep(name = "googletest", version = "1.17.0")
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.2.6")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
|
||||
bazel_dep(name = "rules_oci", version = "2.2.7")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
|
||||
@@ -186,7 +193,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.3")
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.9")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
maven.install(
|
||||
@@ -293,12 +300,27 @@ http_archive(
|
||||
],
|
||||
)
|
||||
|
||||
# Sparkle framework for macOS auto-updates
|
||||
SPARKLE_VERSION = "2.6.4"
|
||||
|
||||
http_archive(
|
||||
name = "sparkle",
|
||||
build_file = "@//external:BUILD.sparkle",
|
||||
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
|
||||
strip_prefix = "",
|
||||
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
|
||||
)
|
||||
|
||||
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
|
||||
# https://busybox.net/downloads/binaries/
|
||||
# Primary: DigitalOcean Spaces (public, reliable)
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
|
||||
urls = [
|
||||
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
|
||||
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
@@ -306,7 +328,10 @@ http_file(
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
|
||||
urls = [
|
||||
# TODO: Upload aarch64 binary to GitHub release when needed
|
||||
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
Generated
+119
-30
@@ -27,9 +27,9 @@
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/MODULE.bazel": "a05cbd9bc16712a58dc27ffe0dceaefd0da59a9bd87a227379b2a934b26a39ab",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/source.json": "9780bc57f521968ee82b7c3e85b7d0c71518fb7ce83ed7a9e5077ce20923207b",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
|
||||
@@ -39,11 +39,11 @@
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/1.39.0/MODULE.bazel": "aece421d479e3c31dc3e5f6d49a12acc2700457c03c556650ec7a0ff23fc0d95",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/MODULE.bazel": "47cc48eec374d69dced3cf9b9e5926beac2f927441acfb1a3568bbb709b25666",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/source.json": "6b0fe67780c101430be087381b7a79d75eeebe1a1eae6a2cee937713603634ac",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/MODULE.bazel": "74bf20a7a6bd5f2be09607fdb4196cfd6f203422ea271752ec2b1afe95426101",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/source.json": "411ec9d79d6f5fe8a083359588c21d01a5b48d88a2cbd334a4c90365015b7836",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/MODULE.bazel": "5b554d5de90d96ee14117527c0519037713dd33884f3212eae391beccb2e94ff",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/source.json": "9ada3722b716853b6dccdb7b650d8e776a23bc8a190de0c59bd15f21afea6f8a",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/MODULE.bazel": "d0045b5eabb012be550a609589b3e5e47eba682344b19cfd9365d4d896ed07df",
|
||||
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/source.json": "5593e3f1cd0dd5147f7748e163307fd5c2e1077913d6945b58739ad8d770a290",
|
||||
"https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
|
||||
@@ -57,12 +57,15 @@
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
|
||||
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
|
||||
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
|
||||
@@ -77,7 +80,8 @@
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
|
||||
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
|
||||
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
|
||||
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
|
||||
@@ -104,8 +108,8 @@
|
||||
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20241214-918efc9/MODULE.bazel": "24e05f6f52f37be63a795192848555a2c8c855e7814dbc1ed419fb04a7005464",
|
||||
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/MODULE.bazel": "1fe72489212c530086e3ffb0e018b2bfef4663200ca03571570f9f006bef1d75",
|
||||
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/MODULE.bazel": "32753ba60bf3bacfe7737c0f3e8e3e55624b19af5d398c485580d57492d145d8",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/source.json": "a2116f0017f6896353fd3abf65ef2b89b0a257e8a87f395c5000f53934829f31",
|
||||
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
|
||||
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
|
||||
@@ -115,8 +119,8 @@
|
||||
"https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.37.0/MODULE.bazel": "d1327ba0907d0275ed5103bfbbb13518f6c04955b402213319d0d6c0ce9839d4",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.45.0/MODULE.bazel": "ecd19ebe9f8e024e1ccffb6d997cc893a974bcc581f1ae08f386bdd448b10687",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.45.0/source.json": "111d182facc5f5e80f0b823d5f077b74128f40c3fd2eccc89a06f34191bd3392",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6",
|
||||
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
|
||||
"https://bcr.bazel.build/modules/google_benchmark/1.8.4/MODULE.bazel": "c6d54a11dcf64ee63545f42561eda3fd94c1b5f5ebe1357011de63ae33739d5e",
|
||||
"https://bcr.bazel.build/modules/google_benchmark/1.8.5/MODULE.bazel": "9ba9b31b984022828a950e3300410977eda2e35df35584c6b0b2d0c2e52766b7",
|
||||
@@ -176,6 +180,7 @@
|
||||
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
|
||||
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
|
||||
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
|
||||
@@ -229,9 +234,9 @@
|
||||
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
|
||||
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
|
||||
"https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
|
||||
"https://bcr.bazel.build/modules/rules_apple/3.16.1/MODULE.bazel": "8294474defa70af2534a558ab905c083d69203344145e6f7d544d5098611ec7d",
|
||||
"https://bcr.bazel.build/modules/rules_apple/3.16.1/source.json": "9190fd9d34a5d048bfbba8a530a57f2c2bf3f61e5634a9ab0b6ab005458857f9",
|
||||
"https://bcr.bazel.build/modules/rules_apple/3.5.1/MODULE.bazel": "3d1bbf65ad3692003d36d8a29eff54d4e5c1c5f4bfb60f79e28646a924d9101c",
|
||||
"https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498",
|
||||
"https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312",
|
||||
"https://bcr.bazel.build/modules/rules_buf/0.1.1/MODULE.bazel": "6189aec18a4f7caff599ad41b851ab7645d4f1e114aa6431acf9b0666eb92162",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
|
||||
@@ -246,6 +251,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
|
||||
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
|
||||
@@ -263,8 +270,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_go/0.48.0/MODULE.bazel": "d00ebcae0908ee3f5e6d53f68677a303d6d59a77beef879598700049c3980a03",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.56.1/MODULE.bazel": "d5b835c548ac917345f1780cd2da52edc1130a908fe091c92096895303ae78a0",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.56.1/source.json": "0c902f7272e8d4e47e459af97be472bc19dadbbe6023a0719d1adce8483ac75a",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.59.0/source.json": "1df17bb7865cfc029492c30163cee891d0dd8658ea0d5bfdf252c4b6db5c1ef6",
|
||||
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
|
||||
"https://bcr.bazel.build/modules/rules_java/5.1.0/MODULE.bazel": "324b6478b0343a3ce7a9add8586ad75d24076d6d43d2f622990b9c1cfd8a1b15",
|
||||
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
|
||||
@@ -292,7 +299,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_jvm_external/6.0/MODULE.bazel": "37c93a5a78d32e895d52f86a8d0416176e915daabd029ccb5594db422e87c495",
|
||||
"https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
|
||||
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
|
||||
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
|
||||
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274",
|
||||
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4",
|
||||
"https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
|
||||
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
|
||||
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
|
||||
@@ -306,12 +314,12 @@
|
||||
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
|
||||
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
|
||||
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.7/MODULE.bazel": "f6150e4b224d459f7f6523ef65967464ca4efdd266c7fbf2f5a2a51011957e0c",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.7/source.json": "b099f02af330f47f19dc67fc9300ef6e1937a8c86882690db0e7a2fcea8c7f6b",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc",
|
||||
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
|
||||
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
|
||||
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
|
||||
@@ -334,7 +342,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.3.0/source.json": "25932f917cd279c7baefa6cb1d3fa8750a7a29de522024449b19af6eab51f4a0",
|
||||
"https://bcr.bazel.build/modules/rules_rust/0.45.1/MODULE.bazel": "a69d0db3a958fab2c6520961e1b2287afcc8b36690fd31bbc4f6f7391397150d",
|
||||
"https://bcr.bazel.build/modules/rules_scala/7.1.1/MODULE.bazel": "b1f80c52ae49b27d41b9291d8b328b69247de2b7596d35d09afe6147b82cf562",
|
||||
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
|
||||
@@ -345,8 +354,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
|
||||
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.3.1/MODULE.bazel": "0b42093600d9226bcbdb31fb86d25d4204293d716fdbb2e50a1852547032a660",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.3.1/source.json": "87d28609c37d2061db2f6fc3aae8ab7fbda9adf556cd88fbd0c7d520b8d81391",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.4.0/source.json": "a6577f57f9febbdc015a01f2a8f3487422032f134d6c61d18ed8e8ca3b9acc7c",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
|
||||
@@ -360,6 +369,7 @@
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
|
||||
@@ -386,7 +396,7 @@
|
||||
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
|
||||
"usagesDigest": "TOb4CUri5UsTKxgIDTNzR0ddIc21eYLCRIm+jqQmjlg=",
|
||||
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -413,7 +423,7 @@
|
||||
},
|
||||
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
|
||||
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
|
||||
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
@@ -492,6 +502,7 @@
|
||||
"extra_build_content": "",
|
||||
"generate_bzl_library_targets": false,
|
||||
"extract_full_archive": false,
|
||||
"exclude_package_contents": [],
|
||||
"system_tar": "auto"
|
||||
}
|
||||
},
|
||||
@@ -516,11 +527,17 @@
|
||||
"package_visibility": [
|
||||
"//visibility:public"
|
||||
],
|
||||
"replace_package": ""
|
||||
"replace_package": "",
|
||||
"exclude_package_contents": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_lib",
|
||||
"bazel_lib~"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_skylib",
|
||||
@@ -531,6 +548,11 @@
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"tar.bzl",
|
||||
"tar.bzl~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_esbuild~",
|
||||
"aspect_rules_js",
|
||||
@@ -546,6 +568,11 @@
|
||||
"aspect_bazel_lib",
|
||||
"aspect_bazel_lib~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"aspect_rules_js",
|
||||
"aspect_rules_js~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"bazel_skylib",
|
||||
@@ -555,6 +582,31 @@
|
||||
"aspect_rules_js~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"bazel_lib~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"bazel_lib~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"tar.bzl~",
|
||||
"aspect_bazel_lib",
|
||||
"aspect_bazel_lib~"
|
||||
],
|
||||
[
|
||||
"tar.bzl~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"tar.bzl~",
|
||||
"tar.bzl",
|
||||
"tar.bzl~"
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1162,7 +1214,7 @@
|
||||
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
|
||||
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
|
||||
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1292,8 +1344,8 @@
|
||||
},
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
|
||||
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
|
||||
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1580,6 +1632,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_python~//python/uv:uv.bzl%uv": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "Xpqjnjzy6zZ90Es9Wa888ZLHhn7IsNGbph/e6qoxzw8=",
|
||||
"usagesDigest": "qI5PVlIum/YAnGJg5oXGHzDkMFWt2aNSUZY4G8PBbic=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"uv": {
|
||||
"bzlFile": "@@rules_python~//python/uv/private:uv_toolchains_repo.bzl",
|
||||
"ruleClassName": "uv_toolchains_repo",
|
||||
"attributes": {
|
||||
"toolchain_type": "'@@rules_python~//python/uv:uv_toolchain_type'",
|
||||
"toolchain_names": [
|
||||
"none"
|
||||
],
|
||||
"toolchain_implementations": {
|
||||
"none": "'@@rules_python~//python:none'"
|
||||
},
|
||||
"toolchain_compatible_with": {
|
||||
"none": [
|
||||
"@platforms//:incompatible"
|
||||
]
|
||||
},
|
||||
"toolchain_target_settings": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"rules_python~",
|
||||
"platforms",
|
||||
"platforms"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
|
||||
@@ -5056,7 +5145,7 @@
|
||||
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
|
||||
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
|
||||
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
. ./ci/unity_version.sh
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
BUILD_DIR=$1
|
||||
LOG_PATH=$2
|
||||
|
||||
echo "Building Mac in $BUILD_DIR"
|
||||
|
||||
echo "Cleaning up $BUILD_DIR"
|
||||
/bin/rm -rf "$BUILD_DIR"
|
||||
/bin/mkdir -p "$BUILD_DIR"
|
||||
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
|
||||
/bin/echo "build protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
/bin/echo "build Mac plugin"
|
||||
./scripts/build_mac_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Mac"
|
||||
LOG_PATH="/tmp/eagle0/editor_mac.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
|
||||
@@ -1,6 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
set -uxo pipefail
|
||||
|
||||
/bin/echo "persist Library/"
|
||||
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
|
||||
|
||||
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
|
||||
# temporary files vanish during the copy. This is acceptable for a cache.
|
||||
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
|
||||
rsync_exit=$?
|
||||
|
||||
if [ $rsync_exit -eq 0 ]; then
|
||||
exit 0
|
||||
elif [ $rsync_exit -eq 23 ]; then
|
||||
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
|
||||
exit 0
|
||||
else
|
||||
echo "Error: rsync failed with exit code $rsync_exit"
|
||||
exit $rsync_exit
|
||||
fi
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<!-- Allow unsigned executable memory (required for Unity) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Disable library validation (required for plugins) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Allow outgoing network connections -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,40 @@
|
||||
# Environment template for production deployment
|
||||
# This file defines all env vars used by docker-compose.prod.yml
|
||||
# Workflows should update their specific vars without overwriting others
|
||||
|
||||
# Container images (managed by respective build workflows)
|
||||
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
|
||||
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
|
||||
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
|
||||
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
|
||||
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
|
||||
|
||||
# OpenAI / LLM
|
||||
OPENAI_API_KEY=
|
||||
GPT_MODEL_NAME=gpt-4o
|
||||
|
||||
# DigitalOcean Spaces (S3-compatible storage)
|
||||
EAGLE_ENABLE_S3=false
|
||||
DO_SPACES_ACCESS_KEY=
|
||||
DO_SPACES_SECRET_KEY=
|
||||
|
||||
# JWT authentication
|
||||
JWT_PRIVATE_KEY=
|
||||
|
||||
# OAuth providers
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Shardok connection (Hetzner ARM64 server)
|
||||
SHARDOK_ADDRESS=
|
||||
SHARDOK_AUTH_TOKEN=
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=
|
||||
|
||||
# Email (Fastmail JMAP)
|
||||
FASTMAIL_API_TOKEN=
|
||||
FASTMAIL_FROM_EMAIL=
|
||||
FASTMAIL_FROM_NAME=
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Update .env file without losing other variables
|
||||
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates .env from template if it doesn't exist
|
||||
# 2. Updates only the specified KEY=value pairs
|
||||
# 3. Preserves all other existing values
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
|
||||
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
|
||||
|
||||
# Create .env from template if it doesn't exist
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f "$TEMPLATE_FILE" ]; then
|
||||
echo "Creating .env from template..."
|
||||
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
|
||||
else
|
||||
echo "Creating empty .env..."
|
||||
touch "$ENV_FILE"
|
||||
fi
|
||||
chmod 600 "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Process each KEY=VALUE argument
|
||||
for arg in "$@"; do
|
||||
# Skip empty args
|
||||
[ -z "$arg" ] && continue
|
||||
|
||||
# Parse KEY=VALUE
|
||||
KEY="${arg%%=*}"
|
||||
VALUE="${arg#*=}"
|
||||
|
||||
# Skip if no key
|
||||
[ -z "$KEY" ] && continue
|
||||
|
||||
# Skip setting empty values (keeps existing value)
|
||||
if [ -z "$VALUE" ]; then
|
||||
echo "Skipping $KEY (empty value)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove existing line for this key and add new one
|
||||
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
|
||||
# Key exists, update it
|
||||
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
|
||||
echo "Updated $KEY"
|
||||
else
|
||||
# Key doesn't exist, add it
|
||||
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
|
||||
echo "Added $KEY"
|
||||
fi
|
||||
done
|
||||
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "Done updating $ENV_FILE"
|
||||
+104
-37
@@ -1,16 +1,22 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
|
||||
|
||||
services:
|
||||
eagle:
|
||||
# Blue-green deployment: eagle-blue is the primary (production) instance
|
||||
# eagle-green is the staging instance for zero-downtime deployments
|
||||
# See scripts/deploy-blue-green.sh for deployment workflow
|
||||
|
||||
eagle-blue:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-server
|
||||
container_name: eagle-blue
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
@@ -29,7 +35,7 @@ services:
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
|
||||
# Auth token for Shardok on Hetzner (required)
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
# Use persistent volume for save data (users, games, etc.)
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
@@ -39,11 +45,10 @@ services:
|
||||
volumes:
|
||||
- ./saves:/app/saves # Game saves and user database
|
||||
- ./archived:/app/archived # Archived completed games
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- shardok
|
||||
- auth
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
@@ -58,6 +63,59 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
eagle-green:
|
||||
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS:-shardok:40042}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40034:40032" # Different host port for staging
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves # Same save directory as blue
|
||||
- ./archived:/app/archived # Same archive directory as blue
|
||||
- ./jfr:/app/jfr # JFR recordings (same as blue)
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- auth
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 60s
|
||||
|
||||
# Backward compatibility alias - for scripts that reference 'eagle' service
|
||||
eagle:
|
||||
extends:
|
||||
service: eagle-blue
|
||||
|
||||
auth:
|
||||
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
container_name: auth-server
|
||||
@@ -80,6 +138,12 @@ services:
|
||||
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
|
||||
JWT_KEYS_PATH: "/etc/eagle0/keys"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
# Fastmail JMAP API for sending invitation emails
|
||||
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
|
||||
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
|
||||
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
|
||||
# Require invitation codes for new user registration
|
||||
REQUIRE_INVITATION_CODE: "true"
|
||||
# Note: port 40033 is exposed via nginx, not directly
|
||||
volumes:
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
|
||||
@@ -98,30 +162,8 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
shardok:
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
|
||||
container_name: shardok-server
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
environment:
|
||||
SHARDOK_RESOURCES_PATH: "/app/resources"
|
||||
SHARDOK_MAPS_PATH: "/app/resources/maps"
|
||||
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
|
||||
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
@@ -136,8 +178,9 @@ services:
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- eagle
|
||||
- admin
|
||||
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
|
||||
# For blue-green deployments, update EAGLE_ADDR in .env before switching
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -150,18 +193,18 @@ services:
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "eagle:40032"
|
||||
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "jfr-sidecar:8081"
|
||||
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
# No external port - accessed via nginx at admin.eagle0.net
|
||||
depends_on:
|
||||
- eagle
|
||||
- auth
|
||||
- jfr-sidecar
|
||||
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
|
||||
# For blue-green deployments, set both in .env before switching
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -179,11 +222,12 @@ services:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar
|
||||
# Share PID namespace with Eagle to access its JVM via jcmd
|
||||
pid: "service:eagle"
|
||||
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
|
||||
pid: "service:eagle-blue"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle
|
||||
- eagle-blue
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -197,6 +241,29 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
jfr-sidecar-green:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
# Share PID namespace with Eagle green instance
|
||||
pid: "service:eagle-green"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle-green
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: certbot
|
||||
|
||||
+32
-1
@@ -231,6 +231,21 @@ All action files have been migrated to use Scala types:
|
||||
- ✅ `CommandChoiceHelpers` migrated to Scala types
|
||||
- ✅ `ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
|
||||
|
||||
### Enum Type Migrations
|
||||
|
||||
Proto enums are being converted to Scala sealed traits with converters at boundaries:
|
||||
|
||||
| Enum | Scala Type | Status | Notes |
|
||||
|------|------------|--------|-------|
|
||||
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
|
||||
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
|
||||
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
|
||||
|
||||
**DiplomacyOfferStatus Migration (PR #5093):**
|
||||
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
|
||||
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
|
||||
- This pattern should be applied to other proto enums
|
||||
|
||||
### CommandChoiceHelpers Migration Status
|
||||
|
||||
Several command selectors have already been converted to use Scala types:
|
||||
@@ -257,7 +272,23 @@ Several command selectors have already been converted to use Scala types:
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 52 / 52 (100%) ✅ |
|
||||
| Proto usages in remaining actions | 0 |
|
||||
| Next target | History APIs (InMemoryHistory, PersistedHistory) |
|
||||
| Next target | See "Next Candidates" section below |
|
||||
|
||||
### Next Candidates
|
||||
|
||||
Priority candidates for further deproto work:
|
||||
|
||||
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
|
||||
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
|
||||
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
|
||||
|
||||
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
|
||||
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
|
||||
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
|
||||
|
||||
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
|
||||
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
|
||||
- `PersistedHistory` converts to proto internally for disk persistence
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Tutorial Content Guide
|
||||
|
||||
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding Sequence
|
||||
|
||||
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
|
||||
|
||||
| Step | ID | Display | Trigger | Title | Description |
|
||||
|------|-----|---------|---------|-------|-------------|
|
||||
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
|
||||
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
|
||||
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
|
||||
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
|
||||
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
|
||||
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
|
||||
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
|
||||
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
|
||||
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
|
||||
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
|
||||
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
|
||||
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
|
||||
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
|
||||
|
||||
### Notes on Onboarding Flow
|
||||
|
||||
- Steps 1-5 cover strategic gameplay
|
||||
- Step 6 is invisible - just waits for a battle
|
||||
- Steps 7-12 cover tactical combat
|
||||
- Step 13 celebrates completion
|
||||
|
||||
**Questions to consider:**
|
||||
- Should we skip tactical tutorial if player skips to first battle themselves?
|
||||
- Should there be a "skip all" option visible from step 1?
|
||||
- Is the step order correct for typical first-game flow?
|
||||
|
||||
---
|
||||
|
||||
## Strategic Contextual Tutorials
|
||||
|
||||
Triggered when players encounter features for the first time.
|
||||
|
||||
### Diplomacy Introduction
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `diplomacy_intro` |
|
||||
| Trigger | `diplomacy_available` (diplomacy commands appear) |
|
||||
| Display | Modal |
|
||||
| Title | Diplomacy |
|
||||
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
|
||||
|
||||
### Hero Recruitment
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `hero_recruitment` |
|
||||
| Trigger | `hero_recruitment_available` (free heroes detected) |
|
||||
| Display | Modal |
|
||||
| Title | Heroes Available |
|
||||
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
|
||||
|
||||
### Weather Control
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `weather_control` |
|
||||
| Trigger | `weather_control_available` (weather command appears) |
|
||||
| Display | Overlay |
|
||||
| Title | Weather Magic |
|
||||
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
|
||||
|
||||
### Prisoner Management
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `prisoner_management` |
|
||||
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
|
||||
| Display | Modal |
|
||||
| Title | Prisoners Captured |
|
||||
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
|
||||
|
||||
---
|
||||
|
||||
## Tactical Contextual Tutorials
|
||||
|
||||
Triggered during battles when players encounter spells, terrain, or abilities.
|
||||
|
||||
### Lightning Bolt Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_lightning` |
|
||||
| Trigger | `spell_lightning_available` |
|
||||
| Display | Tooltip |
|
||||
| Title | Lightning Bolt |
|
||||
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
|
||||
|
||||
### Meteor Strike Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_meteor` |
|
||||
| Trigger | `spell_meteor_available` |
|
||||
| Display | Modal |
|
||||
| Title | Meteor Strike |
|
||||
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
|
||||
|
||||
### Holy Wave Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_holywave` |
|
||||
| Trigger | `spell_holywave_available` |
|
||||
| Display | Tooltip |
|
||||
| Title | Holy Wave |
|
||||
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
|
||||
|
||||
### Raise Dead Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_raisedead` |
|
||||
| Trigger | `spell_raisedead_available` |
|
||||
| Display | Modal |
|
||||
| Title | Raise Dead |
|
||||
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
|
||||
|
||||
### Fire Terrain
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `terrain_fire` |
|
||||
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
|
||||
| Display | Tooltip |
|
||||
| Title | Fire Hazard |
|
||||
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
|
||||
|
||||
### Water Crossing
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `terrain_water` |
|
||||
| Trigger | `terrain_water_encountered` (water crossing attempted) |
|
||||
| Display | Tooltip |
|
||||
| Title | Water Crossing |
|
||||
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
|
||||
|
||||
### Cavalry Charge
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `ability_charge` |
|
||||
| Trigger | `ability_charge_available` |
|
||||
| Display | Overlay |
|
||||
| Title | Cavalry Charge |
|
||||
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
|
||||
|
||||
---
|
||||
|
||||
## Display Modes
|
||||
|
||||
| Mode | Description | Use For |
|
||||
|------|-------------|---------|
|
||||
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
|
||||
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
|
||||
| **Tooltip** | Small popup near target element | Quick tips, less important info |
|
||||
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
|
||||
| **None** | Invisible, just waits for event | Transition steps |
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tutorials
|
||||
|
||||
1. Add entry to this document
|
||||
2. Update `TutorialContentDefinitions.cs`:
|
||||
- For onboarding: add to `CreateOnboardingSequence()`
|
||||
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
|
||||
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
|
||||
4. Test the flow
|
||||
|
||||
---
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
- Keep descriptions to 2-3 short paragraphs max
|
||||
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
|
||||
- Avoid jargon - explain game terms when first introduced
|
||||
- Be encouraging, not condescending
|
||||
- Focus on "what to do" not exhaustive "how it works"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_framework_import")
|
||||
|
||||
# Import pre-built Sparkle framework
|
||||
apple_dynamic_framework_import(
|
||||
name = "Sparkle",
|
||||
framework_imports = glob(["Sparkle.framework/**"]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
+22
-9
@@ -3,6 +3,9 @@ events {
|
||||
}
|
||||
|
||||
http {
|
||||
# Allow large request bodies for game uploads (default is 1MB)
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Logging
|
||||
log_format grpc_json escape=json '{'
|
||||
'"time":"$time_iso8601",'
|
||||
@@ -24,10 +27,12 @@ http {
|
||||
# This prevents stale IP caching when containers restart
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Upstream for Eagle gRPC server
|
||||
upstream eagle_grpc {
|
||||
server eagle:40032;
|
||||
keepalive 100;
|
||||
# Eagle backend - blue-green deployment with variable-based routing
|
||||
# Uses a variable so nginx only resolves the configured backend (not all backends).
|
||||
# This allows nginx to start/reload even when the inactive backend is stopped.
|
||||
# The deploy script updates this map, then recreates nginx.
|
||||
map $host $eagle_backend {
|
||||
default "eagle-blue:40032";
|
||||
}
|
||||
|
||||
# HTTP server for Let's Encrypt challenge and redirect
|
||||
@@ -71,8 +76,8 @@ http {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
# gRPC proxy - uses variable for blue-green deployment
|
||||
grpc_pass grpc://$eagle_backend;
|
||||
|
||||
# Timeouts for long-running streams
|
||||
grpc_read_timeout 1200s;
|
||||
@@ -83,13 +88,14 @@ http {
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
# Route to auth service directly (not through Eagle)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
@@ -106,6 +112,13 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Invitation landing page (proxied to Go auth service)
|
||||
location /invite/ {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
@@ -8,3 +8,6 @@ ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_g
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
/bin/echo "building sparkle plugin"
|
||||
./scripts/build_sparkle_plugin.sh
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the SparklePlugin native library for Unity using Bazel
|
||||
#
|
||||
# Usage: build_sparkle_plugin.sh [output_dir]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
|
||||
|
||||
echo "=== Building SparklePlugin with Bazel ==="
|
||||
|
||||
bazel build --config=mactools //src/main/objc/net/eagle0/sparkle:SparklePlugin
|
||||
|
||||
# Get the zip path from bazel
|
||||
ZIP_PATH=$(bazel cquery --config=mactools --output=files //src/main/objc/net/eagle0/sparkle:SparklePlugin 2>/dev/null)
|
||||
|
||||
echo "=== Extracting SparklePlugin.bundle ==="
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
rm -rf "$OUTPUT_DIR/SparklePlugin.bundle"
|
||||
unzip -o "$ZIP_PATH" -d "$OUTPUT_DIR/"
|
||||
|
||||
echo "=== SparklePlugin built successfully ==="
|
||||
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Code sign a macOS .app bundle for distribution
|
||||
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
|
||||
#
|
||||
# Environment variables:
|
||||
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
|
||||
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
ENTITLEMENTS_PATH="${2:-}"
|
||||
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unlock keychain if password provided
|
||||
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
|
||||
fi
|
||||
|
||||
echo "=== Signing nested components first ==="
|
||||
|
||||
# Sign all dylibs
|
||||
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing dylib: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all bundles (plugins)
|
||||
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing bundle: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign XPC services (inside Sparkle framework)
|
||||
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing XPC service: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign nested apps (like Sparkle's Updater.app)
|
||||
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing nested app: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign standalone executables inside frameworks (like Autoupdate)
|
||||
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing executable: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all frameworks (after their contents are signed)
|
||||
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing framework: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
echo "=== Signing main app bundle ==="
|
||||
|
||||
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
|
||||
echo "Using entitlements: $ENTITLEMENTS_PATH"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--entitlements "$ENTITLEMENTS_PATH" \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
else
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
fi
|
||||
|
||||
echo "=== Verifying signature ==="
|
||||
codesign --verify --verbose=4 "$APP_PATH"
|
||||
|
||||
echo "=== Checking Gatekeeper assessment ==="
|
||||
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
|
||||
|
||||
echo "Code signing complete: $APP_PATH"
|
||||
Executable
+383
@@ -0,0 +1,383 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Blue-Green Deployment Script for Eagle Server
|
||||
#
|
||||
# This script performs a zero-downtime deployment with state consistency:
|
||||
# 1. Create .deployment_in_progress marker (signals deployment started)
|
||||
# 2. Start the staging instance (green) with new image
|
||||
# 3. Run warmup/smoke tests against staging (warms JIT)
|
||||
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
|
||||
# 5. Stop the active instance (blue) - blocks until flush completes
|
||||
# 6. Create .flush_complete marker (signals disk state is fresh)
|
||||
#
|
||||
# The flush marker coordination ensures green never serves stale game data:
|
||||
# - When users reconnect to green and trigger lazy-load, the code checks for markers
|
||||
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
|
||||
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
|
||||
#
|
||||
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
|
||||
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
|
||||
#
|
||||
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
|
||||
#
|
||||
# Example:
|
||||
# ./deploy-blue-green.sh latest
|
||||
# ./deploy-blue-green.sh sha-abc123
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
|
||||
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
|
||||
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
|
||||
SAVES_DIR="${APP_DIR}/saves"
|
||||
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
|
||||
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Marker file operations use docker exec because saves directory is owned by root (Docker).
|
||||
# We run commands inside a container that has the saves directory mounted.
|
||||
create_deployment_marker() {
|
||||
local deploy_id=$1
|
||||
local container=$2 # Container to use for file operations
|
||||
docker exec "${container}" rm -f /app/saves/.flush_complete
|
||||
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
|
||||
}
|
||||
|
||||
create_flush_marker() {
|
||||
local deploy_id=$1
|
||||
local container=$2 # Container to use for file operations
|
||||
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
|
||||
}
|
||||
|
||||
cleanup_markers_on_failure() {
|
||||
local container=$1 # Container to use for file operations
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
|
||||
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_stale_deployment_marker() {
|
||||
# Try any running eagle container
|
||||
local container
|
||||
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
|
||||
if [ -n "${container}" ]; then
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Determine which instance is currently running (not from nginx config)
|
||||
get_running_instance() {
|
||||
local blue_running green_running
|
||||
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
|
||||
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
|
||||
|
||||
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
|
||||
# Both running - use nginx config to determine primary
|
||||
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
|
||||
echo "blue"
|
||||
else
|
||||
echo "green"
|
||||
fi
|
||||
elif [ "$blue_running" = "true" ]; then
|
||||
echo "blue"
|
||||
elif [ "$green_running" = "true" ]; then
|
||||
echo "green"
|
||||
else
|
||||
# Neither running - default to blue (first deploy or recovery)
|
||||
echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
|
||||
pull_with_retry() {
|
||||
local image=$1
|
||||
local max_attempts=${2:-3}
|
||||
local attempt=1
|
||||
|
||||
# Skip pull if image already exists locally (e.g., CI already pulled it)
|
||||
if docker image inspect "${image}" &>/dev/null; then
|
||||
log_info "Image ${image} already exists locally, skipping pull"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Use crane if available (handles OCI format correctly)
|
||||
if [ -x "${APP_DIR}/crane" ]; then
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
|
||||
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
|
||||
rm -f /tmp/image.tar
|
||||
log_info "Image pulled and loaded successfully"
|
||||
return 0
|
||||
fi
|
||||
rm -f /tmp/image.tar
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
else
|
||||
# Fallback to docker pull if crane not available
|
||||
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
|
||||
if docker pull "${image}"; then
|
||||
log_info "Image pulled successfully"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
fi
|
||||
log_error "Failed to pull image after ${max_attempts} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for a container to be healthy
|
||||
wait_for_healthy() {
|
||||
local container=$1
|
||||
local max_attempts=${2:-60}
|
||||
local attempt=1
|
||||
|
||||
log_info "Waiting for ${container} to become healthy..."
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
|
||||
if [ "$health" = "healthy" ]; then
|
||||
log_info "${container} is healthy"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo ""
|
||||
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main deployment logic
|
||||
main() {
|
||||
local new_tag="${1:-latest}"
|
||||
local registry="registry.digitalocean.com/eagle0/eagle-server"
|
||||
local new_image="${registry}:${new_tag}"
|
||||
|
||||
# Generate deployment ID for log correlation with server logs
|
||||
local deploy_id
|
||||
deploy_id=$(date +%s)
|
||||
local deploy_start_time=$deploy_id
|
||||
|
||||
log_info "========================================="
|
||||
log_info "Starting blue-green deployment"
|
||||
log_info "Deployment ID: ${deploy_id}"
|
||||
log_info "New image: ${new_image}"
|
||||
log_info "========================================="
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
# Determine current active instance (need this before creating marker)
|
||||
local active=$(get_running_instance)
|
||||
local staging
|
||||
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
|
||||
staging="green"
|
||||
active="blue" # Normalize "none" to "blue" for first deploy
|
||||
else
|
||||
staging="blue"
|
||||
fi
|
||||
|
||||
# Step 1: Signal deployment in progress
|
||||
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
|
||||
# Use active container for marker operations (it's the one currently running)
|
||||
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
|
||||
create_deployment_marker "${deploy_id}" "eagle-${active}"
|
||||
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
|
||||
else
|
||||
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
|
||||
fi
|
||||
|
||||
# Pull the new image (with retry for intermittent registry issues)
|
||||
if ! pull_with_retry "${new_image}" 3; then
|
||||
log_error "Failed to pull new image, aborting deployment"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Start staging instance with new image
|
||||
log_info "Step 2: Starting eagle-${staging} with new image..."
|
||||
if [ "$staging" = "green" ]; then
|
||||
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
|
||||
else
|
||||
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
|
||||
fi
|
||||
|
||||
# Wait for staging to be healthy
|
||||
if ! wait_for_healthy "eagle-${staging}" 90; then
|
||||
log_error "Staging instance failed health check, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: Run warmup/smoke test
|
||||
local staging_port
|
||||
if [ "$staging" = "green" ]; then
|
||||
staging_port=40034
|
||||
else
|
||||
staging_port=40032
|
||||
fi
|
||||
|
||||
log_info "Step 3: Running warmup against eagle-${staging}..."
|
||||
if [ -x "${WARMUP_SCRIPT}" ]; then
|
||||
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
|
||||
log_error "Warmup/smoke test failed, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
|
||||
log_warn "JIT will be cold on first requests"
|
||||
fi
|
||||
|
||||
# Step 4: Switch nginx to staging BEFORE stopping active
|
||||
# This achieves zero downtime - users immediately route to staging.
|
||||
# Any lazy-loads will wait for the flush marker (created in step 6).
|
||||
local nginx_switch_start
|
||||
nginx_switch_start=$(date +%s)
|
||||
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
|
||||
|
||||
# Update nginx config (variable-based routing)
|
||||
if [ "$staging" = "green" ]; then
|
||||
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
|
||||
else
|
||||
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
|
||||
fi
|
||||
|
||||
# Recreate nginx to pick up new config
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
|
||||
|
||||
# Verify nginx picked up the correct config
|
||||
local nginx_backend
|
||||
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
|
||||
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
|
||||
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
|
||||
else
|
||||
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
|
||||
exit 1
|
||||
fi
|
||||
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
|
||||
local nginx_switch_end
|
||||
nginx_switch_end=$(date +%s)
|
||||
|
||||
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
|
||||
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
|
||||
local flush_start
|
||||
flush_start=$(date +%s)
|
||||
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
|
||||
local flush_end
|
||||
flush_end=$(date +%s)
|
||||
local flush_duration=$((flush_end - flush_start))
|
||||
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
|
||||
|
||||
# Step 6: Create flush marker - signals that disk state is fresh
|
||||
# Any waiting lazy-loads on staging will now proceed with fresh data.
|
||||
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
|
||||
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
|
||||
create_flush_marker "${deploy_id}" "eagle-${staging}"
|
||||
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
|
||||
|
||||
# Update .env for admin service
|
||||
local env_file="${APP_DIR}/.env"
|
||||
if [ "$staging" = "green" ]; then
|
||||
log_info "Updating .env for green instance..."
|
||||
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
|
||||
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
|
||||
else
|
||||
log_info "Updating .env for blue instance..."
|
||||
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
|
||||
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
|
||||
fi
|
||||
|
||||
# Restart admin to pick up new .env
|
||||
log_info "Restarting admin service..."
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
|
||||
|
||||
# Clean up old instance
|
||||
log_info "Cleaning up old eagle-${active}..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
|
||||
|
||||
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
|
||||
if [ "$active" = "green" ]; then
|
||||
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
|
||||
else
|
||||
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local deploy_end_time
|
||||
deploy_end_time=$(date +%s)
|
||||
local total_duration=$((deploy_end_time - deploy_start_time))
|
||||
local user_wait_window=$((flush_end - nginx_switch_end))
|
||||
|
||||
log_info ""
|
||||
log_info "========================================="
|
||||
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
|
||||
log_info " Active instance: eagle-${staging}"
|
||||
log_info " Total duration: ${total_duration}s"
|
||||
log_info " Flush duration: ${flush_duration}s"
|
||||
log_info " Max user wait window: ${user_wait_window}s"
|
||||
log_info "========================================="
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
check_requirements() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "docker is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v sed &> /dev/null; then
|
||||
log_error "sed is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${NGINX_CONF}" ]; then
|
||||
log_error "nginx config not found at ${NGINX_CONF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${COMPOSE_FILE}" ]; then
|
||||
log_error "docker-compose file not found at ${COMPOSE_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure saves directory exists
|
||||
if [ ! -d "${SAVES_DIR}" ]; then
|
||||
log_info "Creating saves directory at ${SAVES_DIR}"
|
||||
mkdir -p "${SAVES_DIR}"
|
||||
fi
|
||||
|
||||
# Clean up any stale deployment-in-progress marker from a previous failed deploy
|
||||
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
|
||||
log_warn "Found stale deployment-in-progress marker, removing it"
|
||||
remove_stale_deployment_marker
|
||||
fi
|
||||
}
|
||||
|
||||
# Run
|
||||
check_requirements
|
||||
main "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
// +build ignore
|
||||
|
||||
// Script to generate Ed25519 key pair for manifest signing.
|
||||
// Run with: go run scripts/generate_manifest_keys.go
|
||||
//
|
||||
// This will output:
|
||||
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
|
||||
// - Public key (base64): Embed in EagleInstaller for verification
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate Ed25519 key pair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
// Encode keys as base64
|
||||
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
|
||||
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
||||
|
||||
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
|
||||
fmt.Println()
|
||||
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
|
||||
fmt.Println(privateKeyB64)
|
||||
fmt.Println()
|
||||
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
|
||||
fmt.Println(publicKeyB64)
|
||||
fmt.Println()
|
||||
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
|
||||
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
|
||||
}
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Inject Sparkle framework into a macOS .app bundle for auto-updates
|
||||
# Usage: inject_sparkle.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
|
||||
#
|
||||
# Optional environment variables:
|
||||
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
|
||||
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
|
||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
|
||||
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
|
||||
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Sparkle if not cached
|
||||
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
|
||||
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
|
||||
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
|
||||
mkdir -p "$SPARKLE_CACHE_DIR"
|
||||
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
|
||||
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
|
||||
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
|
||||
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
|
||||
if [ ! -d "$SPARKLE_DIR" ]; then
|
||||
mkdir -p "$SPARKLE_DIR"
|
||||
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
|
||||
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Injecting Sparkle framework ==="
|
||||
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
|
||||
# Copy Sparkle framework
|
||||
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
|
||||
|
||||
# Also copy the XPC services if present
|
||||
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
|
||||
echo "Sparkle XPC services present"
|
||||
fi
|
||||
|
||||
echo "=== Updating Info.plist ==="
|
||||
PLIST_PATH="$APP_PATH/Contents/Info.plist"
|
||||
|
||||
# Add Sparkle configuration to Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
|
||||
|
||||
# Set bundle version from git for Sparkle version comparison
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
|
||||
|
||||
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
|
||||
|
||||
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
|
||||
echo "=== Adding URL scheme for invitation codes ==="
|
||||
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
|
||||
|
||||
echo "=== Sparkle injection complete ==="
|
||||
echo "App: $APP_PATH"
|
||||
echo "Feed URL: $SPARKLE_FEED_URL"
|
||||
echo "Version: $VERSION (build $BUILD_NUMBER)"
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Notarize a macOS .app bundle with Apple
|
||||
# Usage: notarize_mac_app.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set"
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ==="
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" \
|
||||
--wait 2>&1) || true
|
||||
|
||||
echo "$SUBMIT_OUTPUT"
|
||||
|
||||
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
|
||||
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
|
||||
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
|
||||
echo "Submission ID: $SUBMISSION_ID"
|
||||
echo "Status: $STATUS"
|
||||
|
||||
# Clean up the zip (use -f to avoid failure if already deleted)
|
||||
rm -f "$ZIP_PATH"
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "=== Notarization failed! Fetching log for details ==="
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
|
||||
MAX_STAPLE_ATTEMPTS=5
|
||||
STAPLE_ATTEMPT=1
|
||||
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
|
||||
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
|
||||
if xcrun stapler staple "$APP_PATH"; then
|
||||
echo "Stapling successful"
|
||||
break
|
||||
fi
|
||||
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
|
||||
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling failed, waiting 10 seconds before retry..."
|
||||
sleep 10
|
||||
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Submit a macOS .app bundle to Apple for notarization (no waiting)
|
||||
# Usage: notarize_submit.sh <app_path>
|
||||
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set" >&2
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ===" >&2
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" 2>&1)
|
||||
|
||||
echo "$SUBMIT_OUTPUT" >&2
|
||||
|
||||
# Extract submission ID
|
||||
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
|
||||
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "ERROR: Failed to get submission ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up the zip
|
||||
rm "$ZIP_PATH"
|
||||
|
||||
echo "Submission ID: $SUBMISSION_ID" >&2
|
||||
|
||||
# Output for GitHub Actions
|
||||
echo "submission_id=$SUBMISSION_ID"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Wait for Apple notarization to complete and staple the ticket
|
||||
# Usage: notarize_wait.sh <submission_id> <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SUBMISSION_ID="$1"
|
||||
APP_PATH="$2"
|
||||
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "ERROR: submission_id is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
|
||||
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" 2>&1) || true
|
||||
|
||||
echo "$WAIT_OUTPUT"
|
||||
|
||||
# Extract status (look for " status:" to avoid matching "Current status:")
|
||||
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
|
||||
echo "Status: $STATUS"
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "=== Notarization failed! Fetching log for details ==="
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
|
||||
MAX_STAPLE_ATTEMPTS=5
|
||||
STAPLE_ATTEMPT=1
|
||||
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
|
||||
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
|
||||
if xcrun stapler staple "$APP_PATH"; then
|
||||
echo "Stapling successful"
|
||||
break
|
||||
fi
|
||||
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
|
||||
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling failed, waiting 10 seconds before retry..."
|
||||
sleep 10
|
||||
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Warmup Script for Eagle Server
|
||||
#
|
||||
# This script warms up the JIT compiler before switching traffic to a new instance.
|
||||
# It uses the Go warmup tool which:
|
||||
# 1. Creates a test game via bidirectional streaming
|
||||
# 2. Posts an Improve command
|
||||
# 3. Verifies action results and new commands
|
||||
# 4. Cleans up the test game
|
||||
#
|
||||
# Usage: ./warmup-eagle.sh HOST:PORT
|
||||
#
|
||||
# Example:
|
||||
# ./warmup-eagle.sh localhost:40032
|
||||
# ./warmup-eagle.sh localhost:40034
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
HOST="${1:-localhost:40032}"
|
||||
|
||||
log_info "Warming up Eagle server at ${HOST}..."
|
||||
|
||||
# Try to find the Go warmup tool
|
||||
WARMUP_TOOL=""
|
||||
|
||||
# Check if we're in the project directory with bazel
|
||||
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
|
||||
# Try to find the pre-built binary
|
||||
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
|
||||
if [ -x "${BAZEL_BIN}" ]; then
|
||||
WARMUP_TOOL="${BAZEL_BIN}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for the warmup tool in common locations (for deployed environments)
|
||||
if [ -z "${WARMUP_TOOL}" ]; then
|
||||
for path in \
|
||||
"${SCRIPT_DIR}/bin/warmup" \
|
||||
"/opt/eagle0/scripts/bin/warmup" \
|
||||
"/opt/eagle0/bin/warmup" \
|
||||
"/usr/local/bin/eagle-warmup" \
|
||||
"${SCRIPT_DIR}/warmup"; do
|
||||
if [ -x "${path}" ]; then
|
||||
WARMUP_TOOL="${path}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# If we found the Go tool, use it
|
||||
if [ -n "${WARMUP_TOOL}" ]; then
|
||||
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
|
||||
# Use 5 minute timeout to allow for slow operations on cold JVM
|
||||
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
|
||||
log_info "Warmup complete!"
|
||||
exit 0
|
||||
else
|
||||
log_error "Go warmup tool failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to grpcurl-based warmup
|
||||
log_warn "Go warmup tool not found, falling back to grpcurl"
|
||||
|
||||
# Check for grpcurl
|
||||
if ! command -v grpcurl &> /dev/null; then
|
||||
log_error "Neither Go warmup tool nor grpcurl is available"
|
||||
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
|
||||
log_error "Or install grpcurl: brew install grpcurl (macOS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Warmup iterations
|
||||
WARMUP_ITERATIONS=3
|
||||
|
||||
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
|
||||
log_info "Warming up GetRunningGames..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
|
||||
if echo "$RESULT" | grep -q "games\|{}"; then
|
||||
echo -n "."
|
||||
else
|
||||
log_error "GetRunningGames failed on iteration $i"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# 2. Call GetSettings - exercises settings loading
|
||||
log_info "Warming up GetSettings..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
|
||||
echo -n "."
|
||||
else
|
||||
log_warn "GetSettings failed on iteration $i (non-fatal)"
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# 3. Call AddSettings with empty list - exercises settings path
|
||||
log_info "Warming up AddSettings..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
|
||||
echo -n "."
|
||||
else
|
||||
log_warn "AddSettings failed on iteration $i (non-fatal)"
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# Final health check
|
||||
log_info "Verifying server health..."
|
||||
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
|
||||
log_info "Health check passed"
|
||||
else
|
||||
log_error "Health check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info ""
|
||||
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
|
||||
log_info "The JIT should be warmed for:"
|
||||
log_info " - gRPC layer and protobuf parsing"
|
||||
log_info " - Settings loading and management"
|
||||
log_info ""
|
||||
log_warn "Note: For full warmup including game creation and command processing,"
|
||||
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
|
||||
@@ -107,6 +107,23 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
alCache,
|
||||
battalionTypeGetter,
|
||||
braveWaterCost));
|
||||
} else if (!defenderPositions.empty()) {
|
||||
// Defenders exist but none are on castles - they're scattering/fleeing.
|
||||
// Chase them down rather than holding empty castles, since eliminating
|
||||
// all defenders also wins the battle via LAST_PLAYER_STANDING.
|
||||
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
|
||||
Occupants(
|
||||
*gameState->units(),
|
||||
gameState->hex_map()->row_count(),
|
||||
gameState->hex_map()->column_count()),
|
||||
gameState->hex_map(),
|
||||
defenderPositions,
|
||||
attackerPid,
|
||||
attackerUnits,
|
||||
apdCache,
|
||||
alCache,
|
||||
battalionTypeGetter,
|
||||
braveWaterCost));
|
||||
} else {
|
||||
chosenStrategy = HoldCastlesStrategy;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ auto UnitIdsRequiringWaterCrossing(
|
||||
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
|
||||
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
|
||||
if (IsWater(mapCopy->terrain()->Get(index)->type())) continue;
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
// const_cast is safe because we own the mutable buffer (mapCopy)
|
||||
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
|
||||
mapCopy->mutable_terrain()->GetMutableObject(index))
|
||||
->mutable_modifier()
|
||||
.mutable_fire()
|
||||
.mutate_present(false);
|
||||
@@ -164,16 +165,11 @@ auto WaterCrossingTiles(
|
||||
if (modifier.ice().present() && !modifier.fire().present()) continue;
|
||||
|
||||
// Now try adding a bridge to the tile to see if it helps
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
->mutable_modifier()
|
||||
.mutable_bridge()
|
||||
.mutate_present(true);
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
->mutable_modifier()
|
||||
.mutable_fire()
|
||||
.mutate_present(false);
|
||||
// const_cast is safe because we own the mutable buffer (mapCopy)
|
||||
auto *terr = const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
|
||||
mapCopy->mutable_terrain()->GetMutableObject(index));
|
||||
terr->mutable_modifier().mutable_bridge().mutate_present(true);
|
||||
terr->mutable_modifier().mutable_fire().mutate_present(false);
|
||||
|
||||
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
|
||||
|
||||
@@ -183,11 +179,7 @@ auto WaterCrossingTiles(
|
||||
}
|
||||
|
||||
// Undo the new bridge for the next iteration of the loop
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
->mutable_modifier()
|
||||
.mutable_bridge()
|
||||
.mutate_present(false);
|
||||
terr->mutable_modifier().mutable_bridge().mutate_present(false);
|
||||
}
|
||||
|
||||
return returnCoords;
|
||||
|
||||
+4
-2
@@ -76,11 +76,13 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
|
||||
|
||||
// Now modify the ice on the mutable copy
|
||||
auto* mutableMap = mapCopy.Get();
|
||||
const auto* terrainVec = mutableMap->mutable_terrain();
|
||||
auto* terrainVec = mutableMap->mutable_terrain();
|
||||
|
||||
for (size_t i = 0; i < terrainVec->size(); i++) {
|
||||
// Only process tiles with ice
|
||||
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
|
||||
// const_cast is safe here because we own the mutable buffer (mapCopy)
|
||||
if (auto* terrain = const_cast<Terrain*>(terrainVec->GetMutableObject(i));
|
||||
terrain->modifier().ice().present()) {
|
||||
terrain->mutable_modifier().mutable_ice().mutate_present(false);
|
||||
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
|
||||
}
|
||||
|
||||
+13
-6
@@ -22,6 +22,13 @@ using std::unique_ptr;
|
||||
using ResolvedUnitProto = net::eagle0::shardok::storage::ResolvedUnit;
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
using GameStateT = net::eagle0::shardok::storage::fb::GameStateT;
|
||||
using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
|
||||
// Helper to get a mutable unit from the units vector.
|
||||
// const_cast is safe because we're accessing through a mutable GameState pointer.
|
||||
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
|
||||
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
|
||||
}
|
||||
using net::eagle0::shardok::storage::fb::DrawType;
|
||||
using net::eagle0::shardok::storage::fb::VictoryCondition;
|
||||
using net::eagle0::shardok::storage::fb::VictoryType;
|
||||
@@ -37,7 +44,7 @@ void ApplyResolvedUnit(
|
||||
if (unit.has_attached_hero() &&
|
||||
unit.attached_hero().control_info().controlled_unit_id() != -1) {
|
||||
const UnitId controlledUnitId = unit.attached_hero().control_info().controlled_unit_id();
|
||||
auto *controlledUnit = inoutState->mutable_units()->GetMutableObject(controlledUnitId);
|
||||
auto *controlledUnit = GetMutableUnit(inoutState, controlledUnitId);
|
||||
internalAssert(controlledUnit->unit_id() == controlledUnitId);
|
||||
internalAssert(controlledUnit->commanding_unit_id() == unitId);
|
||||
controlledUnit->mutate_commanding_unit_id(-1);
|
||||
@@ -47,7 +54,7 @@ void ApplyResolvedUnit(
|
||||
if (unit.commanding_unit_id() != -1) {
|
||||
const UnitId commandingUnitId = unit.commanding_unit_id();
|
||||
|
||||
auto *commandingUnit = inoutState->mutable_units()->GetMutableObject(commandingUnitId);
|
||||
auto *commandingUnit = GetMutableUnit(inoutState, commandingUnitId);
|
||||
internalAssert(commandingUnit->unit_id() == commandingUnitId);
|
||||
internalAssert(
|
||||
commandingUnit->attached_hero().control_info().controlled_unit_id() == unitId);
|
||||
@@ -58,7 +65,7 @@ void ApplyResolvedUnit(
|
||||
if (status == net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT &&
|
||||
unit.has_attached_hero() && unit.attached_hero().is_vip()) {
|
||||
for (uint32_t i = 0; i < inoutState->units()->size(); i++) {
|
||||
auto *playerUnit = inoutState->mutable_units()->GetMutableObject(i);
|
||||
auto *playerUnit = GetMutableUnit(inoutState, i);
|
||||
if (playerUnit->player_id() != unit.player_id()) continue;
|
||||
if (playerUnit->unit_id() == unit.unit_id()) continue;
|
||||
|
||||
@@ -70,7 +77,7 @@ void ApplyResolvedUnit(
|
||||
}
|
||||
}
|
||||
|
||||
inoutState->mutable_units()->GetMutableObject(unitId)->mutate_status(status);
|
||||
GetMutableUnit(inoutState, unitId)->mutate_status(status);
|
||||
}
|
||||
|
||||
void ApplyResolvedUnit(
|
||||
@@ -189,7 +196,7 @@ void MutatingAddUnits(GameStateW &mutatingState, const ActionResultProto &result
|
||||
// We only need to process the units that are being changed
|
||||
for (const auto &unitBytes : result.changed_units_fb()) {
|
||||
const auto *unit = (Unit *)unitBytes.data();
|
||||
auto *mutableUnit = mutatingState->mutable_units()->GetMutableObject(unit->unit_id());
|
||||
auto *mutableUnit = GetMutableUnit(mutatingState.Get(), unit->unit_id());
|
||||
if (mutableUnit->status() ==
|
||||
net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT) {
|
||||
// Convert this reserved slot to a real unit
|
||||
@@ -340,7 +347,7 @@ void MutatingApplyResult(
|
||||
}
|
||||
|
||||
// Capture old position before applying changes
|
||||
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
|
||||
auto *mutableUnit = GetMutableUnit(mutatingGameState.Get(), changedUnit->unit_id());
|
||||
const auto oldLocation = mutableUnit->location();
|
||||
|
||||
fb::ApplyUnit(mutableUnit, changedUnit, status);
|
||||
|
||||
@@ -191,6 +191,7 @@ auto MoveCommand::GetCommandProto() const -> CommandProto {
|
||||
proto.mutable_action_points()->set_value(pointCost);
|
||||
proto.mutable_actor()->set_value(moverId);
|
||||
*proto.mutable_target() = ToCoordsProto(interimTargets.back());
|
||||
for (const auto& coords : interimTargets) { *proto.add_path() = ToCoordsProto(coords); }
|
||||
for (const auto& fup : followUpCommandTypes) { proto.add_follow_up_command_types(fup); }
|
||||
proto.set_will_unhide(willUnhide);
|
||||
|
||||
|
||||
@@ -64,8 +64,16 @@ auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain * {
|
||||
return map->terrain()->Get(coords.row() * map->column_count() + coords.column());
|
||||
}
|
||||
|
||||
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain * {
|
||||
// const_cast is safe because we're accessing through a mutable HexMap pointer
|
||||
return const_cast<Terrain *>(map->mutable_terrain()->GetMutableObject(
|
||||
coords.row() * map->column_count() + coords.column()));
|
||||
}
|
||||
|
||||
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain * {
|
||||
return map->terrain()->GetMutableObject(coords.row() * map->column_count() + coords.column());
|
||||
// const_cast is safe when the underlying buffer is known to be mutable
|
||||
return const_cast<Terrain *>(
|
||||
map->terrain()->Get(coords.row() * map->column_count() + coords.column()));
|
||||
}
|
||||
|
||||
auto HasForestAccess(
|
||||
@@ -597,7 +605,9 @@ void MutatingSetTileModifier(
|
||||
const int row,
|
||||
const int column,
|
||||
const TileModifierProto &TileModifierProto) {
|
||||
auto *terr = hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column);
|
||||
// const_cast is safe because we're accessing through a mutable HexMap pointer
|
||||
auto *terr = const_cast<Terrain *>(
|
||||
hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column));
|
||||
|
||||
if (TileModifierProto.has_bridge()) {
|
||||
terr->mutable_modifier().mutable_bridge().mutate_present(true);
|
||||
|
||||
@@ -82,6 +82,9 @@ auto HasForestAccess(
|
||||
PlayerId player) -> bool;
|
||||
|
||||
auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain *;
|
||||
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain *;
|
||||
// Overload for const HexMap - uses const_cast internally. Safe when the underlying buffer is
|
||||
// mutable.
|
||||
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain *;
|
||||
|
||||
auto CoordsAreValid(const HexMap *map, const Coords &coords) -> bool;
|
||||
|
||||
@@ -29,6 +29,13 @@ using UnitViewProto = net::eagle0::shardok::api::UnitView;
|
||||
|
||||
using Coords = net::eagle0::shardok::storage::fb::Coords;
|
||||
using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
|
||||
// Helper to get a mutable unit from the units vector.
|
||||
// const_cast is safe because we're accessing through a mutable GameState pointer.
|
||||
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
|
||||
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
|
||||
}
|
||||
|
||||
constexpr int8_t kGuessedHeroStat = 75;
|
||||
constexpr int8_t kGuessedBattalionStat = 0;
|
||||
@@ -412,16 +419,13 @@ auto GameStateGuesser::GuessedState(
|
||||
if (unit->has_attached_hero()) {
|
||||
UnitId controlledUnitId = unit->attached_hero().control_info().controlled_unit_id();
|
||||
if (controlledUnitId != -1) {
|
||||
gsw->mutable_units()
|
||||
->GetMutableObject(controlledUnitId)
|
||||
->mutate_commanding_unit_id(unitId);
|
||||
GetMutableUnit(gsw.Get(), controlledUnitId)->mutate_commanding_unit_id(unitId);
|
||||
}
|
||||
}
|
||||
|
||||
UnitId commandingUnitId = unit->commanding_unit_id();
|
||||
if (unit->commanding_unit_id() != -1) {
|
||||
gsw->mutable_units()
|
||||
->GetMutableObject(commandingUnitId)
|
||||
GetMutableUnit(gsw.Get(), commandingUnitId)
|
||||
->mutable_attached_hero()
|
||||
.mutable_control_info()
|
||||
.mutate_controlled_unit_id(unitId);
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/FreeForAllDecisionCommandSelector.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerButtonEditor.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandWarningPanelController.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialModalPanel.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuManager.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoopEditor.cs" />
|
||||
<Compile Include="Assets/Eagle/BattalionUtils.cs" />
|
||||
@@ -125,6 +126,7 @@
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Notification/NotificationManagerEditor.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicEditor.cs" />
|
||||
<Compile Include="Assets/ConnectionHandler/RunningGameItem.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialCanvasBuilder.cs" />
|
||||
<Compile Include="Assets/Shardok/Table Rows/ArmyRowController.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExchangeDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
|
||||
@@ -154,6 +156,7 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
|
||||
<Compile Include="Assets/common/ResourceFetcher.cs" />
|
||||
<Compile Include="Assets/Tutorial/Content/TutorialStep.cs" />
|
||||
<Compile Include="Assets/Auth/AuthClient.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
|
||||
@@ -164,6 +167,7 @@
|
||||
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
|
||||
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
|
||||
<Compile Include="Assets/Tutorial/Content/TutorialContentDefinitions.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/HeroDepartureDetailsNotificationGenerator.cs" />
|
||||
@@ -204,7 +208,9 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/RansomRejectedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialOverlayBuilder.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/TutorialState.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
|
||||
<Compile Include="Assets/Bluetooth/DiceVectors.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoop.cs" />
|
||||
@@ -222,8 +228,10 @@
|
||||
<Compile Include="Assets/GUI Pro Kit Fantasy RPG/Extensions/UIParticle/UIParticleSystem.cs" />
|
||||
<Compile Include="Assets/Shardok/SoundManager.cs" />
|
||||
<Compile Include="Assets/Shardok/HexMesh.cs" />
|
||||
<Compile Include="Assets/Tutorial/Content/TutorialSequence.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationRejectedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/DiplomacyCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ResolveBreakAllianceCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
|
||||
@@ -232,6 +240,7 @@
|
||||
<Compile Include="Assets/Eagle/CustomFileLogger.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationAcceptedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ProvinceEventsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Tutorial/TutorialManager.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/FeastCommandSelector.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Animated Icon/AnimatedIconHandler.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/GenericNotificationGenerator.cs" />
|
||||
@@ -315,6 +324,7 @@
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerContextMenu.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceAmbassadorImprisonedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIcon.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialHintIndicator.cs" />
|
||||
<Compile Include="Assets/Bluetooth/UnityDieColors.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/OrganizeTroopsCommandSelector.cs" />
|
||||
<Compile Include="Assets/Shardok/ExtinguishAnimator.cs" />
|
||||
@@ -363,7 +373,9 @@
|
||||
<Compile Include="Assets/Terrain Hexes/Example Scene/BasicHexArranger.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ManagePrisonersCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/SendSuppliesCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialUIManager.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Window/WindowManager.cs" />
|
||||
<Compile Include="Assets/Auth/InvitationCodeManager.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroExiledDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SuppressBeastsSucceededNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Shardok/ReservesTableController.cs" />
|
||||
@@ -376,6 +388,7 @@
|
||||
<Compile Include="Assets/Eagle/Table Rows/IncomingArmyTableRow.cs" />
|
||||
<Compile Include="Assets/ConnectionHandler/CreateGameItem.cs" />
|
||||
<Compile Include="Assets/Eagle/EagleGameController.cs" />
|
||||
<Compile Include="Assets/Tutorial/TutorialTestSetup.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/FailedSwearBrotherhoodNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Shardok/FireEffectAnimator.cs" />
|
||||
<Compile Include="Assets/common/GUIUtils/EventBasedTable.cs" />
|
||||
@@ -383,6 +396,7 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/FactionDestroyedNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerNotification.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/HandleCapturedHeroesCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialOverlayController.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ARNNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/AttackDecisionCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
|
||||
|
||||
@@ -58,10 +58,19 @@ namespace Auth {
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
/// <param name="invitationCode">Optional invitation code for new account
|
||||
/// registration</param> <returns>Tuple of (URL to open in browser, state token for
|
||||
/// polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(
|
||||
OAuthProvider provider,
|
||||
string invitationCode = null) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider };
|
||||
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
request.InvitationCode = invitationCode;
|
||||
Debug.Log("[AuthClient] Including invitation code in OAuth request");
|
||||
}
|
||||
|
||||
var response = await _authServiceClient.GetOAuthUrlAsync(request);
|
||||
|
||||
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
|
||||
@@ -94,6 +103,10 @@ namespace Auth {
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.InvitationRequired:
|
||||
Debug.Log("[AuthClient] Server requires invitation code for new account");
|
||||
return response; // Return so OAuthManager can handle this
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
@@ -170,18 +183,16 @@ namespace Auth {
|
||||
|
||||
/// <summary>
|
||||
/// Logout - invalidates refresh token on server.
|
||||
/// Does NOT clear local tokens (OAuthManager handles that decision).
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
try {
|
||||
await _eagleClient.LogoutAsync(new LogoutRequest());
|
||||
Debug.Log("[AuthClient] Server logout successful");
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
|
||||
}
|
||||
|
||||
// Clear local tokens regardless of server response
|
||||
TokenStorage.Clear();
|
||||
Debug.Log("[AuthClient] Logged out, tokens cleared");
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Manages invitation codes for new account registration.
|
||||
/// Reads codes from installer-provided file or allows manual entry.
|
||||
/// </summary>
|
||||
public static class InvitationCodeManager {
|
||||
private const string InvitationFileName = "invitation.json";
|
||||
private const string PlayerPrefsKey = "InvitationCode";
|
||||
|
||||
// Cache the code to avoid repeated file reads
|
||||
private static string _cachedCode;
|
||||
private static bool _cacheInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Get the invitation code, if available.
|
||||
/// Checks: 1) Cached value, 2) PlayerPrefs, 3) File from installer
|
||||
/// </summary>
|
||||
public static string GetInvitationCode() {
|
||||
if (_cacheInitialized) { return _cachedCode; }
|
||||
|
||||
// Check PlayerPrefs first (manual entry takes priority)
|
||||
string prefsCode = PlayerPrefs.GetString(PlayerPrefsKey, null);
|
||||
if (!string.IsNullOrEmpty(prefsCode)) {
|
||||
_cachedCode = prefsCode;
|
||||
_cacheInitialized = true;
|
||||
Debug.Log("[InvitationCodeManager] Using invitation code from PlayerPrefs");
|
||||
return _cachedCode;
|
||||
}
|
||||
|
||||
// Try to read from installer file
|
||||
string fileCode = ReadFromFile();
|
||||
if (!string.IsNullOrEmpty(fileCode)) {
|
||||
_cachedCode = fileCode;
|
||||
_cacheInitialized = true;
|
||||
Debug.Log("[InvitationCodeManager] Using invitation code from installer file");
|
||||
return _cachedCode;
|
||||
}
|
||||
|
||||
_cacheInitialized = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set invitation code manually (e.g., from UI input).
|
||||
/// </summary>
|
||||
public static void SetInvitationCode(string code) {
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
PlayerPrefs.DeleteKey(PlayerPrefsKey);
|
||||
_cachedCode = null;
|
||||
} else {
|
||||
PlayerPrefs.SetString(PlayerPrefsKey, code);
|
||||
_cachedCode = code;
|
||||
}
|
||||
_cacheInitialized = true;
|
||||
PlayerPrefs.Save();
|
||||
Debug.Log(
|
||||
$"[InvitationCodeManager] Invitation code {(string.IsNullOrEmpty(code) ? "cleared" : "set")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the invitation code after successful account creation.
|
||||
/// </summary>
|
||||
public static void ClearInvitationCode() {
|
||||
PlayerPrefs.DeleteKey(PlayerPrefsKey);
|
||||
_cachedCode = null;
|
||||
_cacheInitialized = true;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Also delete the installer file if it exists
|
||||
DeleteInstallerFile();
|
||||
|
||||
Debug.Log("[InvitationCodeManager] Invitation code cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an invitation code is available.
|
||||
/// </summary>
|
||||
public static bool HasInvitationCode => !string.IsNullOrEmpty(GetInvitationCode());
|
||||
|
||||
private static string ReadFromFile() {
|
||||
try {
|
||||
string installDir = GetInstallDirectory();
|
||||
if (string.IsNullOrEmpty(installDir)) { return null; }
|
||||
|
||||
string filePath = Path.Combine(installDir, InvitationFileName);
|
||||
if (!File.Exists(filePath)) { return null; }
|
||||
|
||||
string json = File.ReadAllText(filePath);
|
||||
// Simple JSON parsing for {"invitation_code":"XXXX"}
|
||||
var parsed = JsonUtility.FromJson<InvitationFile>(json);
|
||||
return parsed?.invitation_code;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning(
|
||||
$"[InvitationCodeManager] Failed to read invitation file: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteInstallerFile() {
|
||||
try {
|
||||
string installDir = GetInstallDirectory();
|
||||
if (string.IsNullOrEmpty(installDir)) { return; }
|
||||
|
||||
string filePath = Path.Combine(installDir, InvitationFileName);
|
||||
if (File.Exists(filePath)) {
|
||||
File.Delete(filePath);
|
||||
Debug.Log("[InvitationCodeManager] Deleted installer invitation file");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning(
|
||||
$"[InvitationCodeManager] Failed to delete invitation file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetInstallDirectory() {
|
||||
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||||
// Windows: %LOCALAPPDATA%\eagle0
|
||||
string localAppData =
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "eagle0");
|
||||
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
|
||||
// Mac: ~/Library/Application Support/eagle0
|
||||
string appSupport =
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
return Path.Combine(appSupport, "eagle0");
|
||||
#else
|
||||
// Other platforms not yet supported
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class InvitationFile {
|
||||
public string invitation_code;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2a19f11d1f82412e8e2811b366113ac
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
@@ -9,6 +10,7 @@ namespace Auth {
|
||||
/// - Opens system browser for OAuth consent
|
||||
/// - Polls server for OAuth completion (no deep links needed)
|
||||
/// - Token storage and refresh
|
||||
/// - Multi-account support
|
||||
/// </summary>
|
||||
public class OAuthManager : MonoBehaviour {
|
||||
public static OAuthManager Instance { get; private set; }
|
||||
@@ -16,12 +18,14 @@ namespace Auth {
|
||||
private AuthClient _authClient;
|
||||
private string _currentAuthServiceUrl;
|
||||
private string _currentEagleUrl;
|
||||
private OAuthProvider _currentLoginProvider; // Track provider during login
|
||||
|
||||
// Events for UI updates
|
||||
public event Action<UserInfo> OnLoginSuccess;
|
||||
public event Action<string> OnLoginFailed;
|
||||
public event Action OnLogout;
|
||||
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
|
||||
public event Action OnInvitationRequired; // new user needs invitation code
|
||||
|
||||
public bool IsAuthenticated => TokenStorage.HasValidToken;
|
||||
public string DisplayName => TokenStorage.DisplayName;
|
||||
@@ -80,9 +84,14 @@ namespace Auth {
|
||||
/// </summary>
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
_currentLoginProvider = provider; // Track for later storage
|
||||
|
||||
try {
|
||||
// Get invitation code if available (for new account registration)
|
||||
string invitationCode = InvitationCodeManager.GetInvitationCode();
|
||||
|
||||
// Get OAuth URL and state from server
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
|
||||
|
||||
// Open system browser
|
||||
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
|
||||
@@ -91,16 +100,26 @@ namespace Auth {
|
||||
// Poll for OAuth completion (server handles the callback)
|
||||
var response = await _authClient.PollForOAuthCompletionAsync(state);
|
||||
|
||||
// Store tokens
|
||||
// Handle invitation required status
|
||||
if (response.Status == OAuthStatus.InvitationRequired) {
|
||||
Debug.Log("[OAuthManager] Server requires invitation code for new account");
|
||||
OnInvitationRequired?.Invoke();
|
||||
throw new Exception("An invitation code is required to create a new account");
|
||||
}
|
||||
|
||||
// Store tokens with provider info
|
||||
var providerName = provider.ToString().ToLowerInvariant();
|
||||
TokenStorage.StoreTokens(
|
||||
response.AccessToken,
|
||||
response.RefreshToken,
|
||||
response.ExpiresAt,
|
||||
response.User.UserId,
|
||||
response.User.DisplayName ?? "");
|
||||
response.User.DisplayName ?? "",
|
||||
providerName);
|
||||
|
||||
// Notify listeners
|
||||
// Clear invitation code after successful new account creation
|
||||
if (response.IsNewUser) {
|
||||
InvitationCodeManager.ClearInvitationCode();
|
||||
OnNewUserNeedsDisplayName?.Invoke(true);
|
||||
} else {
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
@@ -156,6 +175,15 @@ namespace Auth {
|
||||
// Validate session with server
|
||||
try {
|
||||
var response = await _authClient.GetCurrentUserAsync();
|
||||
|
||||
// Check if user still needs to set display name (e.g., closed app before setting
|
||||
// it)
|
||||
if (string.IsNullOrEmpty(response.User.DisplayName)) {
|
||||
Debug.Log("[OAuthManager] Session restored but user needs display name");
|
||||
OnNewUserNeedsDisplayName?.Invoke(false); // false = not a brand new user
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
@@ -178,16 +206,89 @@ namespace Auth {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout and clear stored tokens.
|
||||
/// Logout - clears current session but preserves stored tokens for quick re-login.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
// LogoutAsync can work even without server connection - just clear local tokens
|
||||
// Notify server of logout if connected
|
||||
if (_authClient != null) {
|
||||
await _authClient.LogoutAsync();
|
||||
} else {
|
||||
TokenStorage.Clear();
|
||||
try {
|
||||
await _authClient.LogoutAsync();
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Server logout failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clear current account selection but keep tokens stored
|
||||
TokenStorage.ClearCurrentAccount();
|
||||
OnLogout?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all stored accounts for display in UI.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredAccount> GetStoredAccounts() {
|
||||
return TokenStorage.GetAllAccounts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select and connect with a stored account.
|
||||
/// If token is expired or about to expire, refreshes it first.
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectWithStoredAccountAsync(StoredAccount account) {
|
||||
EnsureConfigured();
|
||||
|
||||
// Select this account as current
|
||||
TokenStorage.SelectAccount(account.AccountKey);
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!account.HasValidToken || account.NeedsRefresh) {
|
||||
if (account.HasRefreshToken) {
|
||||
try {
|
||||
await _authClient.RefreshTokenAsync();
|
||||
Debug.Log($"[OAuthManager] Refreshed token for {account.DisplayName}");
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
|
||||
// Token refresh failed - need to re-authenticate
|
||||
// Parse provider from account key
|
||||
var provider = account.Provider.ToLowerInvariant() == "google"
|
||||
? OAuthProvider.Google
|
||||
: OAuthProvider.Discord;
|
||||
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session with server
|
||||
try {
|
||||
var response = await _authClient.GetCurrentUserAsync();
|
||||
|
||||
// Check if user still needs to set display name
|
||||
if (string.IsNullOrEmpty(response.User.DisplayName)) {
|
||||
Debug.Log("[OAuthManager] Stored account needs display name");
|
||||
OnNewUserNeedsDisplayName?.Invoke(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"[OAuthManager] Connected with stored account: {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke("Session invalid. Please sign in again.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a stored account permanently.
|
||||
/// </summary>
|
||||
public void RemoveStoredAccount(StoredAccount account) {
|
||||
TokenStorage.RemoveAccount(account.AccountKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Represents a stored OAuth account with tokens.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class StoredAccount {
|
||||
public string Provider; // "discord" or "google"
|
||||
public string UserId; // OAuth provider user ID
|
||||
public string DisplayName; // User's display name
|
||||
public string AccessToken;
|
||||
public string RefreshToken;
|
||||
public long ExpiresAt; // Unix timestamp
|
||||
|
||||
public string AccountKey => $"{Provider}:{UserId}";
|
||||
|
||||
public bool HasValidToken => !string.IsNullOrEmpty(AccessToken) &&
|
||||
ExpiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
public bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
|
||||
|
||||
public bool NeedsRefresh {
|
||||
get {
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return ExpiresAt > 0 && ExpiresAt - now < 300; // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container for all stored accounts, serialized to JSON.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class StoredAccountsData {
|
||||
public List<StoredAccount> Accounts = new();
|
||||
public string CurrentAccountKey; // Provider:UserId of active account
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
|
||||
/// Supports multiple accounts - tokens are preserved on logout.
|
||||
/// Values are cached in memory to allow access from background threads.
|
||||
/// In production, consider using platform-specific secure storage
|
||||
/// (Keychain on iOS, Keystore on Android).
|
||||
/// </summary>
|
||||
public static class TokenStorage {
|
||||
private const string AccessTokenKey = "eagle0_access_token";
|
||||
private const string RefreshTokenKey = "eagle0_refresh_token";
|
||||
private const string ExpiresAtKey = "eagle0_expires_at";
|
||||
private const string UserIdKey = "eagle0_user_id";
|
||||
private const string DisplayNameKey = "eagle0_display_name";
|
||||
private const string AccountsDataKey = "eagle0_accounts_data";
|
||||
|
||||
// In-memory cache for thread-safe access from background threads
|
||||
// PlayerPrefs can only be accessed from the main thread
|
||||
private static string _cachedAccessToken;
|
||||
private static string _cachedRefreshToken;
|
||||
private static long _cachedExpiresAt;
|
||||
private static string _cachedUserId;
|
||||
private static string _cachedDisplayName;
|
||||
// Legacy keys for migration
|
||||
private const string LegacyAccessTokenKey = "eagle0_access_token";
|
||||
private const string LegacyRefreshTokenKey = "eagle0_refresh_token";
|
||||
private const string LegacyExpiresAtKey = "eagle0_expires_at";
|
||||
private const string LegacyUserIdKey = "eagle0_user_id";
|
||||
private const string LegacyDisplayNameKey = "eagle0_display_name";
|
||||
|
||||
// In-memory cache
|
||||
private static StoredAccountsData _accountsData;
|
||||
private static bool _cacheInitialized = false;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,147 +65,245 @@ namespace Auth {
|
||||
/// Call this early in app startup (e.g., in Awake or Start).
|
||||
/// </summary>
|
||||
public static void InitializeCache() {
|
||||
_cachedAccessToken = PlayerPrefs.GetString(AccessTokenKey, null);
|
||||
_cachedRefreshToken = PlayerPrefs.GetString(RefreshTokenKey, null);
|
||||
_cachedExpiresAt =
|
||||
long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
|
||||
_cachedUserId = PlayerPrefs.GetString(UserIdKey, null);
|
||||
_cachedDisplayName = PlayerPrefs.GetString(DisplayNameKey, null);
|
||||
_cacheInitialized = true;
|
||||
var json = PlayerPrefs.GetString(AccountsDataKey, "");
|
||||
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Cache initialized, hasToken={!string.IsNullOrEmpty(_cachedAccessToken)}");
|
||||
}
|
||||
|
||||
public static bool HasValidToken {
|
||||
get {
|
||||
var token = AccessToken;
|
||||
var expiresAt = ExpiresAt;
|
||||
return !string.IsNullOrEmpty(token) &&
|
||||
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
|
||||
|
||||
public static string AccessToken {
|
||||
get {
|
||||
if (!_cacheInitialized) {
|
||||
Debug.LogWarning(
|
||||
"[TokenStorage] Cache not initialized, returning cached value anyway");
|
||||
if (!string.IsNullOrEmpty(json)) {
|
||||
try {
|
||||
_accountsData = JsonUtility.FromJson<StoredAccountsData>(json);
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[TokenStorage] Failed to parse accounts data: {ex.Message}");
|
||||
_accountsData = new StoredAccountsData();
|
||||
}
|
||||
return _cachedAccessToken;
|
||||
} else {
|
||||
_accountsData = new StoredAccountsData();
|
||||
// Try to migrate legacy single-account data
|
||||
MigrateLegacyData();
|
||||
}
|
||||
private
|
||||
set {
|
||||
_cachedAccessToken = value;
|
||||
PlayerPrefs.SetString(AccessTokenKey, value ?? "");
|
||||
|
||||
_cacheInitialized = true;
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Cache initialized, {_accountsData.Accounts.Count} accounts stored");
|
||||
}
|
||||
|
||||
private static void MigrateLegacyData() {
|
||||
var legacyAccessToken = PlayerPrefs.GetString(LegacyAccessTokenKey, "");
|
||||
if (string.IsNullOrEmpty(legacyAccessToken)) return;
|
||||
|
||||
var legacyRefreshToken = PlayerPrefs.GetString(LegacyRefreshTokenKey, "");
|
||||
var legacyExpiresAt =
|
||||
long.TryParse(PlayerPrefs.GetString(LegacyExpiresAtKey, "0"), out var val) ? val
|
||||
: 0;
|
||||
var legacyUserId = PlayerPrefs.GetString(LegacyUserIdKey, "");
|
||||
var legacyDisplayName = PlayerPrefs.GetString(LegacyDisplayNameKey, "");
|
||||
|
||||
if (!string.IsNullOrEmpty(legacyUserId)) {
|
||||
// Assume discord for legacy data (most common)
|
||||
var account = new StoredAccount {
|
||||
Provider = "discord",
|
||||
UserId = legacyUserId,
|
||||
DisplayName = legacyDisplayName,
|
||||
AccessToken = legacyAccessToken,
|
||||
RefreshToken = legacyRefreshToken,
|
||||
ExpiresAt = legacyExpiresAt
|
||||
};
|
||||
_accountsData.Accounts.Add(account);
|
||||
_accountsData.CurrentAccountKey = account.AccountKey;
|
||||
SaveAccountsData();
|
||||
|
||||
// Clear legacy keys
|
||||
PlayerPrefs.DeleteKey(LegacyAccessTokenKey);
|
||||
PlayerPrefs.DeleteKey(LegacyRefreshTokenKey);
|
||||
PlayerPrefs.DeleteKey(LegacyExpiresAtKey);
|
||||
PlayerPrefs.DeleteKey(LegacyUserIdKey);
|
||||
PlayerPrefs.DeleteKey(LegacyDisplayNameKey);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"[TokenStorage] Migrated legacy account: {account.DisplayName}");
|
||||
}
|
||||
}
|
||||
|
||||
public static string RefreshToken {
|
||||
get => _cachedRefreshToken;
|
||||
private
|
||||
set {
|
||||
_cachedRefreshToken = value;
|
||||
PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
|
||||
}
|
||||
private static void SaveAccountsData() {
|
||||
var json = JsonUtility.ToJson(_accountsData);
|
||||
PlayerPrefs.SetString(AccountsDataKey, json);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
public static long ExpiresAt {
|
||||
get => _cachedExpiresAt;
|
||||
private
|
||||
set {
|
||||
_cachedExpiresAt = value;
|
||||
PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
|
||||
}
|
||||
/// <summary>
|
||||
/// Get all stored accounts.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<StoredAccount> GetAllAccounts() {
|
||||
EnsureInitialized();
|
||||
return _accountsData.Accounts.AsReadOnly();
|
||||
}
|
||||
|
||||
public static string UserId {
|
||||
get => _cachedUserId;
|
||||
private
|
||||
set {
|
||||
_cachedUserId = value;
|
||||
PlayerPrefs.SetString(UserIdKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static string DisplayName {
|
||||
get => _cachedDisplayName;
|
||||
private
|
||||
set {
|
||||
_cachedDisplayName = value;
|
||||
PlayerPrefs.SetString(DisplayNameKey, value ?? "");
|
||||
/// <summary>
|
||||
/// Get the currently active account, or null if none selected.
|
||||
/// </summary>
|
||||
public static StoredAccount CurrentAccount {
|
||||
get {
|
||||
EnsureInitialized();
|
||||
if (string.IsNullOrEmpty(_accountsData.CurrentAccountKey)) return null;
|
||||
return _accountsData.Accounts.Find(
|
||||
a => a.AccountKey == _accountsData.CurrentAccountKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store tokens received from OAuth exchange.
|
||||
/// Select an account as the current active account.
|
||||
/// </summary>
|
||||
public static void SelectAccount(string accountKey) {
|
||||
EnsureInitialized();
|
||||
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
|
||||
if (account != null) {
|
||||
_accountsData.CurrentAccountKey = accountKey;
|
||||
SaveAccountsData();
|
||||
Debug.Log($"[TokenStorage] Selected account: {account.DisplayName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the current account selection (logout without deleting tokens).
|
||||
/// </summary>
|
||||
public static void ClearCurrentAccount() {
|
||||
EnsureInitialized();
|
||||
_accountsData.CurrentAccountKey = null;
|
||||
SaveAccountsData();
|
||||
Debug.Log("[TokenStorage] Cleared current account selection");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a specific account and its tokens.
|
||||
/// </summary>
|
||||
public static void RemoveAccount(string accountKey) {
|
||||
EnsureInitialized();
|
||||
var removed = _accountsData.Accounts.RemoveAll(a => a.AccountKey == accountKey);
|
||||
if (_accountsData.CurrentAccountKey == accountKey) {
|
||||
_accountsData.CurrentAccountKey = null;
|
||||
}
|
||||
if (removed > 0) {
|
||||
SaveAccountsData();
|
||||
Debug.Log($"[TokenStorage] Removed account: {accountKey}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility properties that reference the current account
|
||||
|
||||
public static bool HasValidToken => CurrentAccount?.HasValidToken ?? false;
|
||||
|
||||
public static bool HasRefreshToken => CurrentAccount?.HasRefreshToken ?? false;
|
||||
|
||||
public static string AccessToken => CurrentAccount?.AccessToken;
|
||||
|
||||
public static string RefreshToken => CurrentAccount?.RefreshToken;
|
||||
|
||||
public static long ExpiresAt => CurrentAccount?.ExpiresAt ?? 0;
|
||||
|
||||
public static string UserId => CurrentAccount?.UserId;
|
||||
|
||||
public static string DisplayName => CurrentAccount?.DisplayName;
|
||||
|
||||
public static bool NeedsRefresh => CurrentAccount?.NeedsRefresh ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Store tokens for an account. Creates new or updates existing.
|
||||
/// </summary>
|
||||
public static void StoreTokens(
|
||||
string accessToken,
|
||||
string refreshToken,
|
||||
long expiresAt,
|
||||
string userId,
|
||||
string displayName) {
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
ExpiresAt = expiresAt;
|
||||
UserId = userId;
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
string displayName,
|
||||
string provider = "discord") {
|
||||
EnsureInitialized();
|
||||
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
|
||||
var accountKey = $"{provider}:{userId}";
|
||||
var existingAccount = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
|
||||
|
||||
if (existingAccount != null) {
|
||||
existingAccount.AccessToken = accessToken;
|
||||
existingAccount.RefreshToken = refreshToken;
|
||||
existingAccount.ExpiresAt = expiresAt;
|
||||
existingAccount.DisplayName = displayName;
|
||||
} else {
|
||||
var newAccount = new StoredAccount {
|
||||
Provider = provider,
|
||||
UserId = userId,
|
||||
DisplayName = displayName,
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
_accountsData.Accounts.Add(newAccount);
|
||||
}
|
||||
|
||||
// Set as current account
|
||||
_accountsData.CurrentAccountKey = accountKey;
|
||||
SaveAccountsData();
|
||||
|
||||
Debug.Log($"[TokenStorage] Stored tokens for {displayName} ({provider})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update access token after refresh.
|
||||
/// Update access token after refresh for the current account.
|
||||
/// </summary>
|
||||
public static void UpdateAccessToken(string accessToken, long expiresAt) {
|
||||
AccessToken = accessToken;
|
||||
ExpiresAt = expiresAt;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
|
||||
EnsureInitialized();
|
||||
var account = CurrentAccount;
|
||||
if (account != null) {
|
||||
account.AccessToken = accessToken;
|
||||
account.ExpiresAt = expiresAt;
|
||||
SaveAccountsData();
|
||||
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update display name after user sets it.
|
||||
/// Update access token for a specific account (by key).
|
||||
/// </summary>
|
||||
public static void
|
||||
UpdateAccessTokenForAccount(string accountKey, string accessToken, long expiresAt) {
|
||||
EnsureInitialized();
|
||||
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
|
||||
if (account != null) {
|
||||
account.AccessToken = accessToken;
|
||||
account.ExpiresAt = expiresAt;
|
||||
SaveAccountsData();
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Updated access token for {account.DisplayName} (expires at {expiresAt})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update display name for the current account.
|
||||
/// </summary>
|
||||
public static void UpdateDisplayName(string displayName) {
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
EnsureInitialized();
|
||||
var account = CurrentAccount;
|
||||
if (account != null) {
|
||||
account.DisplayName = displayName;
|
||||
SaveAccountsData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored tokens (logout).
|
||||
/// Clear all stored accounts (full reset).
|
||||
/// </summary>
|
||||
public static void Clear() {
|
||||
_cachedAccessToken = null;
|
||||
_cachedRefreshToken = null;
|
||||
_cachedExpiresAt = 0;
|
||||
_cachedUserId = null;
|
||||
_cachedDisplayName = null;
|
||||
|
||||
PlayerPrefs.DeleteKey(AccessTokenKey);
|
||||
PlayerPrefs.DeleteKey(RefreshTokenKey);
|
||||
PlayerPrefs.DeleteKey(ExpiresAtKey);
|
||||
PlayerPrefs.DeleteKey(UserIdKey);
|
||||
PlayerPrefs.DeleteKey(DisplayNameKey);
|
||||
public static void ClearAll() {
|
||||
_accountsData = new StoredAccountsData();
|
||||
PlayerPrefs.DeleteKey(AccountsDataKey);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log("[TokenStorage] Cleared all tokens");
|
||||
Debug.Log("[TokenStorage] Cleared all accounts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if token needs refresh (expires within 5 minutes).
|
||||
/// Legacy Clear method - now just clears current selection, not tokens.
|
||||
/// </summary>
|
||||
public static bool NeedsRefresh {
|
||||
get {
|
||||
var expiresAt = ExpiresAt;
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
|
||||
public static void Clear() { ClearCurrentAccount(); }
|
||||
|
||||
private static void EnsureInitialized() {
|
||||
if (!_cacheInitialized) {
|
||||
Debug.LogWarning("[TokenStorage] Cache not initialized, initializing now");
|
||||
InitializeCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+220
-86
@@ -80,9 +80,6 @@ public class AuthInterceptor : Interceptor {
|
||||
};
|
||||
|
||||
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public TMP_Dropdown environmentDropdown;
|
||||
public TMP_InputField nameField;
|
||||
public TMP_InputField passwordField;
|
||||
public TMP_Dropdown resolutionDropdown;
|
||||
|
||||
[Header("OAuth UI")]
|
||||
@@ -91,20 +88,31 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public Button googleLoginButton;
|
||||
public TextMeshProUGUI oauthStatusText;
|
||||
|
||||
[Header("Stored Accounts")]
|
||||
public GameObject storedAccountsContainer;
|
||||
public GameObject storedAccountButtonPrefab;
|
||||
public Sprite discordProviderIcon;
|
||||
public Sprite googleProviderIcon;
|
||||
|
||||
[Header("Display Name Setup")]
|
||||
public GameObject displayNamePanel;
|
||||
public TMP_InputField displayNameField;
|
||||
public Button setDisplayNameButton;
|
||||
public TextMeshProUGUI displayNameErrorText;
|
||||
|
||||
[Header("Legacy Auth (for testing)")]
|
||||
public GameObject legacyAuthPanel;
|
||||
public Button useLegacyAuthButton;
|
||||
public TextMeshProUGUI useLegacyAuthButtonText;
|
||||
[Header("Invitation Code Entry")]
|
||||
public GameObject invitationCodePanel;
|
||||
public TMP_InputField invitationCodeField;
|
||||
public Button submitInvitationCodeButton;
|
||||
public TextMeshProUGUI invitationCodeErrorText;
|
||||
|
||||
[Header("Status Display")]
|
||||
public TextMeshProUGUI connectionStatusText;
|
||||
|
||||
[Header("Connection Panel Environment")]
|
||||
[Tooltip("Environment dropdown in connection panel (fallback if lobby unreachable)")]
|
||||
public TMP_Dropdown connectionEnvironmentDropdown;
|
||||
|
||||
public GameObject connectionPanel;
|
||||
public GameObject gameSelectionPanel;
|
||||
public GameObject customBattlePanel;
|
||||
@@ -114,7 +122,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public Button logoutButton;
|
||||
public Button customBattleButton;
|
||||
public Button cancelCustomBattleButton;
|
||||
public TextMeshProUGUI lobbyEnvironmentText;
|
||||
public TMP_Dropdown lobbyEnvironmentDropdown;
|
||||
public TextMeshProUGUI lobbyUserText;
|
||||
|
||||
public GameObject runningGamesListArea;
|
||||
@@ -142,7 +150,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly Object pendingReplyLock = new Object();
|
||||
private int _connectedEnvironmentIndex = -1; // -1 means not connected
|
||||
private bool _useOAuth = false; // Default to legacy until server-side OAuth is ready
|
||||
private List<GameObject> _storedAccountButtons = new List<GameObject>();
|
||||
|
||||
public ClientPregeneratedText clientPregeneratedText;
|
||||
|
||||
@@ -166,7 +174,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
: null;
|
||||
|
||||
private string GetUrlFromEnvironment() {
|
||||
int envIndex = environmentDropdown.value;
|
||||
int envIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
string prefix = EnvironmentOptions[envIndex];
|
||||
return prefix + BaseDomain;
|
||||
}
|
||||
@@ -199,14 +207,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
|
||||
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
|
||||
|
||||
// Set up environment dropdown
|
||||
environmentDropdown.ClearOptions();
|
||||
environmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
|
||||
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
|
||||
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
|
||||
|
||||
connectionCanvas.gameObject.SetActive(true);
|
||||
eagleCanvas.gameObject.SetActive(false);
|
||||
shardokCanvas.gameObject.SetActive(false);
|
||||
@@ -221,6 +221,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
// Initialize OAuth UI
|
||||
SetupOAuthUI();
|
||||
|
||||
// Initialize connection panel environment dropdown
|
||||
SetupConnectionEnvironmentDropdown();
|
||||
|
||||
// Initialize Lobby UI (logout button, etc.)
|
||||
SetupLobbyUI();
|
||||
|
||||
@@ -231,8 +234,24 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
TryRestoreSession();
|
||||
}
|
||||
|
||||
private void SetupConnectionEnvironmentDropdown() {
|
||||
if (connectionEnvironmentDropdown == null) return;
|
||||
|
||||
connectionEnvironmentDropdown.ClearOptions();
|
||||
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
|
||||
// Just save the preference - it will be used on next connection attempt
|
||||
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
|
||||
Debug.Log(
|
||||
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
|
||||
}
|
||||
|
||||
private void SetupOAuthUI() {
|
||||
// Set up OAuth button click handlers
|
||||
// Set up OAuth button click handlers for new sign-ins
|
||||
if (discordLoginButton != null) {
|
||||
discordLoginButton.onClick.AddListener(
|
||||
() => OnOAuthLoginClicked(OAuthProvider.Discord));
|
||||
@@ -243,8 +262,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
if (setDisplayNameButton != null) {
|
||||
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
|
||||
}
|
||||
if (useLegacyAuthButton != null) {
|
||||
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
|
||||
if (submitInvitationCodeButton != null) {
|
||||
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
|
||||
}
|
||||
|
||||
// Subscribe to OAuthManager events
|
||||
@@ -253,26 +272,92 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
|
||||
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
|
||||
OAuthManager.Instance.OnLogout += OnOAuthLogout;
|
||||
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
|
||||
}
|
||||
|
||||
// Show appropriate panel based on auth state
|
||||
// Show auth panel with stored accounts
|
||||
ShowAuthPanel();
|
||||
}
|
||||
|
||||
private void ShowAuthPanel() {
|
||||
// Hide all auth panels first
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
// Hide other panels
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
|
||||
|
||||
// Show appropriate panel based on _useOAuth
|
||||
if (_useOAuth) {
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
|
||||
// Sync connection environment dropdown with saved preference
|
||||
if (connectionEnvironmentDropdown != null) {
|
||||
connectionEnvironmentDropdown.onValueChanged.RemoveListener(
|
||||
OnConnectionEnvironmentChanged);
|
||||
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
connectionEnvironmentDropdown.onValueChanged.AddListener(
|
||||
OnConnectionEnvironmentChanged);
|
||||
}
|
||||
|
||||
// Refresh stored account buttons
|
||||
RefreshStoredAccountButtons();
|
||||
}
|
||||
|
||||
private void RefreshStoredAccountButtons() {
|
||||
// Clear existing buttons
|
||||
foreach (var btn in _storedAccountButtons) {
|
||||
if (btn != null) Destroy(btn);
|
||||
}
|
||||
_storedAccountButtons.Clear();
|
||||
|
||||
if (storedAccountsContainer == null || storedAccountButtonPrefab == null) return;
|
||||
|
||||
// Get stored accounts
|
||||
var accounts = OAuthManager.Instance?.GetStoredAccounts();
|
||||
if (accounts == null || accounts.Count == 0) return;
|
||||
|
||||
// Create button for each stored account
|
||||
foreach (var account in accounts) {
|
||||
var buttonObj =
|
||||
Instantiate(storedAccountButtonPrefab, storedAccountsContainer.transform);
|
||||
buttonObj.transform.localScale = Vector3.one;
|
||||
|
||||
// Set button text (just display name, icon shows provider)
|
||||
var buttonText = buttonObj.GetComponentInChildren<TextMeshProUGUI>();
|
||||
if (buttonText != null) { buttonText.text = account.DisplayName; }
|
||||
|
||||
// Set provider icon (look for child named "ProviderIcon")
|
||||
var providerIconTransform = buttonObj.transform.Find("ProviderIcon");
|
||||
if (providerIconTransform != null) {
|
||||
var providerImage = providerIconTransform.GetComponent<Image>();
|
||||
if (providerImage != null) {
|
||||
var isGoogle = account.Provider.ToLowerInvariant() == "google";
|
||||
providerImage.sprite = isGoogle ? googleProviderIcon : discordProviderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
// Set click handler
|
||||
var button = buttonObj.GetComponent<Button>();
|
||||
if (button != null) {
|
||||
var accountCopy = account; // Capture for lambda
|
||||
button.onClick.AddListener(() => OnStoredAccountClicked(accountCopy));
|
||||
}
|
||||
|
||||
_storedAccountButtons.Add(buttonObj);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStoredAccountClicked(StoredAccount account) {
|
||||
// Configure OAuthManager with URLs
|
||||
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
|
||||
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = $"Connecting as {account.DisplayName}...";
|
||||
}
|
||||
|
||||
var success = await OAuthManager.Instance.ConnectWithStoredAccountAsync(account);
|
||||
if (success) {
|
||||
// OnLoginSuccess will handle connection to lobby
|
||||
} else {
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
// Failed - might need re-auth, refresh the buttons
|
||||
RefreshStoredAccountButtons();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,22 +367,56 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
if (cancelCustomBattleButton != null) {
|
||||
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
|
||||
}
|
||||
|
||||
// Set up environment dropdown
|
||||
if (lobbyEnvironmentDropdown != null) {
|
||||
lobbyEnvironmentDropdown.ClearOptions();
|
||||
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLobbyStatusDisplays() {
|
||||
// Show environment (prod/qa)
|
||||
if (lobbyEnvironmentText != null) {
|
||||
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
|
||||
// Update environment dropdown selection
|
||||
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
|
||||
// Temporarily remove listener to avoid triggering reconnect
|
||||
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
|
||||
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
|
||||
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
|
||||
}
|
||||
|
||||
// Show current user - prefer OAuth display name, fall back to classic login name
|
||||
if (lobbyUserText != null) {
|
||||
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
|
||||
lobbyUserText.text = TokenStorage.DisplayName;
|
||||
} else {
|
||||
lobbyUserText.text = nameField.text;
|
||||
}
|
||||
}
|
||||
// Show current user from OAuth
|
||||
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
|
||||
}
|
||||
|
||||
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
|
||||
if (newEnvironmentIndex == _connectedEnvironmentIndex) return;
|
||||
|
||||
Debug.Log(
|
||||
$"[ConnectionHandler] Switching environment from {ConnectedEnvironmentName} to {EnvironmentDisplayNames[newEnvironmentIndex]}");
|
||||
|
||||
// Disconnect from current environment
|
||||
_persistentClientConnection?.Dispose();
|
||||
eagleConnection?.Dispose();
|
||||
_httpClient?.Dispose();
|
||||
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection = null;
|
||||
_httpClient = null;
|
||||
|
||||
// Update environment index and reconnect
|
||||
_connectedEnvironmentIndex = newEnvironmentIndex;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
|
||||
|
||||
// Clear current game lists while reconnecting
|
||||
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
|
||||
// Reconnect to new environment
|
||||
_createConnection();
|
||||
RequestMaps();
|
||||
StartListeningForLobbyUpdates();
|
||||
}
|
||||
|
||||
private async void OnLogoutClicked() {
|
||||
@@ -378,11 +497,53 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show display name panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(true);
|
||||
if (displayNameErrorText != null) displayNameErrorText.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnInvitationRequired() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show invitation code entry panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text =
|
||||
"An invitation code is required to create a new account.";
|
||||
}
|
||||
if (invitationCodeField != null) invitationCodeField.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSubmitInvitationCodeClicked() {
|
||||
if (invitationCodeField == null) return;
|
||||
|
||||
var code = invitationCodeField.text.Trim();
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text = "Please enter an invitation code";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the code and return to login screen to retry
|
||||
InvitationCodeManager.SetInvitationCode(code);
|
||||
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text = "Code saved. Please sign in again.";
|
||||
}
|
||||
|
||||
// Return to auth panel after a short delay
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
|
||||
}
|
||||
ShowAuthPanel();
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnSetDisplayNameClicked() {
|
||||
if (displayNameField == null || OAuthManager.Instance == null) return;
|
||||
|
||||
@@ -410,27 +571,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
});
|
||||
}
|
||||
|
||||
private void OnUseLegacyAuthClicked() {
|
||||
_useOAuth = !_useOAuth; // Toggle instead of just setting false
|
||||
|
||||
if (_useOAuth) {
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
// Show legacy panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
private void ConnectWithOAuth() {
|
||||
_useOAuth = true;
|
||||
_internalConnectEagle();
|
||||
}
|
||||
private void ConnectWithOAuth() { _internalConnectEagle(); }
|
||||
|
||||
private float _statusUpdateTimer = 0f;
|
||||
private const float StatusUpdateInterval = 0.5f;
|
||||
@@ -573,7 +714,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
var runningGameItem = listItem.GetComponent<RunningGameItem>();
|
||||
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
|
||||
runningGameItem.SetRunningGame(
|
||||
runningGame.GameId,
|
||||
runningGame.Leader,
|
||||
runningGame.LastPlayedTimestampMillis);
|
||||
runningGameItem.GoCallback = this.SelectEagleGame;
|
||||
runningGameItem.DropCallback = this.DropGame;
|
||||
}
|
||||
@@ -634,10 +778,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void _createConnection() {
|
||||
_connectedEnvironmentIndex = environmentDropdown.value;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
_connectedEnvironmentIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
|
||||
// Dispose existing connections before creating new ones
|
||||
_httpClient?.Dispose();
|
||||
@@ -650,25 +791,18 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
string url = GetUrlFromEnvironment();
|
||||
|
||||
if (_useOAuth && TokenStorage.HasValidToken) {
|
||||
// Use JWT-based connection
|
||||
eagleConnection = EagleConnection.CreateWithJwt(url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
|
||||
} else {
|
||||
// Legacy Basic Auth connection
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
|
||||
// OAuth JWT-based connection
|
||||
if (!TokenStorage.HasValidToken) {
|
||||
Debug.LogError("[ConnectionHandler] No valid token available for connection");
|
||||
return;
|
||||
}
|
||||
|
||||
eagleConnection = EagleConnection.CreateWithJwt(url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
|
||||
|
||||
_persistentClientConnection = new PersistentClientConnection(
|
||||
eagleConnection.EagleGrpcClient,
|
||||
eagleConnection.credentials,
|
||||
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af9f637b3a1f6433f819a0bcdb326453
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+179
-18
@@ -1,5 +1,164 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &246340277068347999
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 5589171621630283209}
|
||||
- component: {fileID: 9020655488278447847}
|
||||
- component: {fileID: 5482253986454480250}
|
||||
- component: {fileID: 6109349435209553009}
|
||||
m_Layer: 5
|
||||
m_Name: Last Played
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &5589171621630283209
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 246340277068347999}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 909271087739711220}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &9020655488278447847
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 246340277068347999}
|
||||
m_CullTransparentMesh: 0
|
||||
--- !u!114 &5482253986454480250
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 246340277068347999}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_text: Tars Tarkas
|
||||
m_isRightToLeft: 0
|
||||
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
|
||||
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
|
||||
type: 2}
|
||||
m_fontSharedMaterials: []
|
||||
m_fontMaterial: {fileID: 0}
|
||||
m_fontMaterials: []
|
||||
m_fontColor32:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_enableVertexGradient: 0
|
||||
m_colorMode: 3
|
||||
m_fontColorGradient:
|
||||
topLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
topRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_fontColorGradientPreset: {fileID: 0}
|
||||
m_spriteAsset: {fileID: 0}
|
||||
m_tintAllSprites: 0
|
||||
m_StyleSheet: {fileID: 0}
|
||||
m_TextStyleHashCode: -1183493901
|
||||
m_overrideHtmlColors: 0
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 1
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
m_horizontalMapping: 0
|
||||
m_verticalMapping: 0
|
||||
m_uvLineOffset: 0
|
||||
m_geometrySortingOrder: 0
|
||||
m_IsTextObjectScaleStatic: 0
|
||||
m_VertexBufferAutoSizeReduction: 1
|
||||
m_useMaxVisibleDescender: 1
|
||||
m_pageToDisplay: 1
|
||||
m_margin: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_isUsingLegacyAnimationComponent: 0
|
||||
m_isVolumetricText: 0
|
||||
m_hasFontAssetChanged: 0
|
||||
m_baseMaterial: {fileID: 0}
|
||||
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
|
||||
--- !u!114 &6109349435209553009
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 246340277068347999}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 100
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &649403511955802327
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -94,8 +253,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 30
|
||||
m_fontSizeBase: 30
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -153,9 +312,9 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 450
|
||||
m_MinHeight: 60
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 450
|
||||
m_PreferredHeight: 60
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -253,8 +412,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -314,7 +473,7 @@ MonoBehaviour:
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: 35
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 35
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -521,10 +680,10 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 0
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 50
|
||||
m_PreferredHeight: -1
|
||||
m_MinWidth: 40
|
||||
m_MinHeight: 40
|
||||
m_PreferredWidth: 40
|
||||
m_PreferredHeight: 40
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -621,8 +780,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -885,9 +1044,9 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: -1
|
||||
m_MinHeight: 40
|
||||
m_PreferredWidth: 200
|
||||
m_PreferredHeight: -1
|
||||
m_PreferredHeight: 40
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -926,6 +1085,7 @@ RectTransform:
|
||||
m_Children:
|
||||
- {fileID: 2008335870574158510}
|
||||
- {fileID: 6674238761151365000}
|
||||
- {fileID: 5589171621630283209}
|
||||
- {fileID: 1348356180262758599}
|
||||
- {fileID: 7336928442537586311}
|
||||
- {fileID: 7341333336313446738}
|
||||
@@ -994,7 +1154,7 @@ MonoBehaviour:
|
||||
m_ChildAlignment: 0
|
||||
m_Spacing: 0
|
||||
m_ChildForceExpandWidth: 0
|
||||
m_ChildForceExpandHeight: 1
|
||||
m_ChildForceExpandHeight: 0
|
||||
m_ChildControlWidth: 1
|
||||
m_ChildControlHeight: 1
|
||||
m_ChildScaleWidth: 0
|
||||
@@ -1015,6 +1175,7 @@ MonoBehaviour:
|
||||
item: {fileID: 5643565463360785033}
|
||||
gameIdField: {fileID: 8058295588038032232}
|
||||
leaderField: {fileID: 3311450659768657245}
|
||||
lastPlayedField: {fileID: 5482253986454480250}
|
||||
goButton: {fileID: 145214844678457394}
|
||||
dropButton: {fileID: 4081637582368108930}
|
||||
--- !u!114 &2208043217657811503
|
||||
@@ -1033,9 +1194,9 @@ MonoBehaviour:
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_PreferredHeight: 45
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &7814378479006347708
|
||||
GameObject:
|
||||
|
||||
+29
-1
@@ -9,6 +9,7 @@ public class RunningGameItem : MonoBehaviour {
|
||||
public GameObject item;
|
||||
public TextMeshProUGUI gameIdField;
|
||||
public TextMeshProUGUI leaderField;
|
||||
public TextMeshProUGUI lastPlayedField;
|
||||
public Button goButton;
|
||||
public Button dropButton;
|
||||
|
||||
@@ -24,7 +25,8 @@ public class RunningGameItem : MonoBehaviour {
|
||||
|
||||
public void DropClicked() { DropCallback?.Invoke(gameId); }
|
||||
|
||||
public void SetRunningGame(long gameId, AvailableLeader leader) {
|
||||
public void
|
||||
SetRunningGame(long gameId, AvailableLeader leader, long lastPlayedTimestampMillis) {
|
||||
this.gameId = gameId;
|
||||
|
||||
gameIdField.text = string.Format("{0:X}", gameId);
|
||||
@@ -34,5 +36,31 @@ public class RunningGameItem : MonoBehaviour {
|
||||
"{0} ({1})",
|
||||
leaderName,
|
||||
DisplayNames.ProfessionNames[leader.Profession]);
|
||||
|
||||
// Display last played time in user's local timezone
|
||||
if (lastPlayedField != null) {
|
||||
if (lastPlayedTimestampMillis > 0) {
|
||||
var lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(lastPlayedTimestampMillis)
|
||||
.LocalDateTime;
|
||||
var now = DateTime.Now;
|
||||
var diff = now - lastPlayed;
|
||||
|
||||
string timeText;
|
||||
if (diff.TotalMinutes < 1) {
|
||||
timeText = "Just now";
|
||||
} else if (diff.TotalHours < 1) {
|
||||
timeText = $"{(int)diff.TotalMinutes}m ago";
|
||||
} else if (diff.TotalDays < 1) {
|
||||
timeText = $"{(int)diff.TotalHours}h ago";
|
||||
} else if (diff.TotalDays < 7) {
|
||||
timeText = $"{(int)diff.TotalDays}d ago";
|
||||
} else {
|
||||
timeText = lastPlayed.ToString("MMM d");
|
||||
}
|
||||
lastPlayedField.text = timeText;
|
||||
} else {
|
||||
lastPlayedField.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &1262787222422545696
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 6242087783640990751}
|
||||
- component: {fileID: 5444015614922696687}
|
||||
- component: {fileID: 8871991993173924918}
|
||||
- component: {fileID: 6965305925514130928}
|
||||
m_Layer: 5
|
||||
m_Name: ProviderIcon
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &6242087783640990751
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 157686408557551303}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &5444015614922696687
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &8871991993173924918
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 80
|
||||
m_PreferredHeight: 50
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &6965305925514130928
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 21300000, guid: b6b68c8c37d5cf1419267a80c426ac83, type: 3}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 1
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &5166012670666351313
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 157686408557551303}
|
||||
- component: {fileID: 3634401029247236684}
|
||||
- component: {fileID: 7790188191322585408}
|
||||
- component: {fileID: 646268409010340088}
|
||||
- component: {fileID: 3097176747511135857}
|
||||
- component: {fileID: 7691398338434978342}
|
||||
m_Layer: 5
|
||||
m_Name: StoredAccountButton
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &157686408557551303
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 7974514965529143181}
|
||||
- {fileID: 6242087783640990751}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &3634401029247236684
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &7790188191322585408
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_Type: 1
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!114 &646268409010340088
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
|
||||
m_Navigation:
|
||||
m_Mode: 3
|
||||
m_WrapAround: 0
|
||||
m_SelectOnUp: {fileID: 0}
|
||||
m_SelectOnDown: {fileID: 0}
|
||||
m_SelectOnLeft: {fileID: 0}
|
||||
m_SelectOnRight: {fileID: 0}
|
||||
m_Transition: 1
|
||||
m_Colors:
|
||||
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
|
||||
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
|
||||
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
|
||||
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
|
||||
m_ColorMultiplier: 1
|
||||
m_FadeDuration: 0.1
|
||||
m_SpriteState:
|
||||
m_HighlightedSprite: {fileID: 0}
|
||||
m_PressedSprite: {fileID: 0}
|
||||
m_SelectedSprite: {fileID: 0}
|
||||
m_DisabledSprite: {fileID: 0}
|
||||
m_AnimationTriggers:
|
||||
m_NormalTrigger: Normal
|
||||
m_HighlightedTrigger: Highlighted
|
||||
m_PressedTrigger: Pressed
|
||||
m_SelectedTrigger: Selected
|
||||
m_DisabledTrigger: Disabled
|
||||
m_Interactable: 1
|
||||
m_TargetGraphic: {fileID: 7790188191322585408}
|
||||
m_OnClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
--- !u!114 &3097176747511135857
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 400
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &7691398338434978342
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 30649d3a9faa99c48a7b1166b86bf2a0, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.HorizontalLayoutGroup
|
||||
m_Padding:
|
||||
m_Left: 0
|
||||
m_Right: 0
|
||||
m_Top: 0
|
||||
m_Bottom: 0
|
||||
m_ChildAlignment: 5
|
||||
m_Spacing: 0
|
||||
m_ChildForceExpandWidth: 0
|
||||
m_ChildForceExpandHeight: 0
|
||||
m_ChildControlWidth: 1
|
||||
m_ChildControlHeight: 1
|
||||
m_ChildScaleWidth: 0
|
||||
m_ChildScaleHeight: 0
|
||||
m_ReverseArrangement: 0
|
||||
--- !u!1 &5617793748733146591
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 7974514965529143181}
|
||||
- component: {fileID: 4513536456760004536}
|
||||
- component: {fileID: 6210669843299902677}
|
||||
- component: {fileID: 572895541873054588}
|
||||
m_Layer: 5
|
||||
m_Name: Label
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &7974514965529143181
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 157686408557551303}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &4513536456760004536
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &6210669843299902677
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_text: nolen (dan@danielcrosby.net)
|
||||
m_isRightToLeft: 0
|
||||
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
|
||||
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
|
||||
type: 2}
|
||||
m_fontSharedMaterials: []
|
||||
m_fontMaterial: {fileID: 0}
|
||||
m_fontMaterials: []
|
||||
m_fontColor32:
|
||||
serializedVersion: 2
|
||||
rgba: 4281479730
|
||||
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
|
||||
m_enableVertexGradient: 0
|
||||
m_colorMode: 3
|
||||
m_fontColorGradient:
|
||||
topLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
topRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_fontColorGradientPreset: {fileID: 0}
|
||||
m_spriteAsset: {fileID: 0}
|
||||
m_tintAllSprites: 0
|
||||
m_StyleSheet: {fileID: 0}
|
||||
m_TextStyleHashCode: -1183493901
|
||||
m_overrideHtmlColors: 0
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 0
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
m_horizontalMapping: 0
|
||||
m_verticalMapping: 0
|
||||
m_uvLineOffset: 0
|
||||
m_geometrySortingOrder: 0
|
||||
m_IsTextObjectScaleStatic: 0
|
||||
m_VertexBufferAutoSizeReduction: 0
|
||||
m_useMaxVisibleDescender: 1
|
||||
m_pageToDisplay: 1
|
||||
m_margin: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_isUsingLegacyAnimationComponent: 0
|
||||
m_isVolumetricText: 0
|
||||
m_hasFontAssetChanged: 0
|
||||
m_baseMaterial: {fileID: 0}
|
||||
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
|
||||
--- !u!114 &572895541873054588
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6440904d10a244010b3a91e0aa247749
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+25
-3
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using common;
|
||||
using common.GUIUtils;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
@@ -158,9 +159,11 @@ namespace eagle {
|
||||
|
||||
public void StopAll() {
|
||||
_newModel = null;
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
ModelUpdater.UpdateAction = null;
|
||||
ModelUpdater = null;
|
||||
if (ModelUpdater != null) {
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
ModelUpdater.UpdateAction = null;
|
||||
ModelUpdater = null;
|
||||
}
|
||||
SwapModel();
|
||||
Model = null;
|
||||
chronicleCanvasController.Entries = new List<ChronicleEntry>();
|
||||
@@ -281,6 +284,13 @@ namespace eagle {
|
||||
MainQueue.Q.EnqueueForNextUpdate(
|
||||
() => { _ = ModelUpdater.StartListeningForUpdates(); });
|
||||
|
||||
// Initialize tutorial system
|
||||
TutorialManager.Instance?.Initialize(this, null);
|
||||
if (TutorialManager.Instance != null &&
|
||||
!TutorialManager.Instance.State.OnboardingCompleted) {
|
||||
TutorialManager.Instance.StartOnboarding();
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
|
||||
#endif
|
||||
@@ -362,6 +372,12 @@ namespace eagle {
|
||||
SetNextActiveProvinceButton();
|
||||
_dominionPanelController.ForceUpdate();
|
||||
SetMusic();
|
||||
|
||||
// Notify tutorial system of province selection
|
||||
if (pid.HasValue) {
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnProvinceSelected(
|
||||
Model.Provinces[pid.Value]);
|
||||
}
|
||||
}
|
||||
|
||||
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
|
||||
@@ -477,6 +493,9 @@ namespace eagle {
|
||||
var oldModel = Model;
|
||||
Model = _newModel;
|
||||
|
||||
// Notify tutorial system of model change
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnModelUpdated(Model, oldModel);
|
||||
|
||||
provinceInfoPanelController.Model = _newModel;
|
||||
freeHeroesTableController.Model = _newModel;
|
||||
movingArmiesTableController.Model = _newModel;
|
||||
@@ -625,6 +644,9 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void PostCommittedCommand(ProvinceId provinceId, SelectedCommand selectedCommand) {
|
||||
// Notify tutorial system of command
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnCommandIssued(selectedCommand);
|
||||
|
||||
ModelUpdater.PostCommand(provinceId: provinceId, command: selectedCommand)
|
||||
.ContinueWith(response => {
|
||||
if (response.IsFaulted) { errorHandler.Add(response.Exception); }
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ TextureImporter:
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
@@ -37,13 +37,13 @@ TextureImporter:
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
@@ -52,9 +52,9 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
@@ -100,7 +100,7 @@ TextureImporter:
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ TextureImporter:
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
@@ -37,13 +37,13 @@ TextureImporter:
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
@@ -52,9 +52,9 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
@@ -100,7 +100,7 @@ TextureImporter:
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
|
||||
+86
-8
@@ -299,6 +299,36 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove all pending commands for a specific game.
|
||||
/// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN).
|
||||
/// </summary>
|
||||
private void RemovePendingCommandsForGame(long gameId) {
|
||||
lock (this) {
|
||||
var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList();
|
||||
foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
|
||||
if (toRemove.Count > 0) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Removed {toRemove.Count} pending command(s) for game {gameId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh the subscription for a specific game to get fresh state from the server.
|
||||
/// Used when a command is rejected (e.g., BAD_TOKEN) and we need current state.
|
||||
/// </summary>
|
||||
private void RefreshGameSubscription(long gameId) {
|
||||
IClientConnectionSubscriber subscriber;
|
||||
lock (this) { _subscribers.TryGetValue(gameId, out subscriber); }
|
||||
if (subscriber != null) {
|
||||
_ = StreamOneGameAsync(subscriber);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[REFRESH] No subscriber found for game {gameId}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Connect() {
|
||||
// Prevent concurrent connection attempts
|
||||
if (_isConnecting) {
|
||||
@@ -468,7 +498,13 @@ namespace eagle {
|
||||
await PostRequest(nextCommand);
|
||||
} else if (eagleToken > providedToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"{providedToken} seems to be stale, dropping");
|
||||
$"{providedToken} seems to be stale (current token " +
|
||||
$"{eagleToken}), dropping and refreshing state");
|
||||
// Server already processed this command and advanced
|
||||
// the token. Re-subscribe to ensure we have current
|
||||
// state, especially if turn passed and we need new
|
||||
// commands.
|
||||
_ = StreamOneGameAsync(subscriber);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"{providedToken} seems to be from the future, adding back to the queue");
|
||||
@@ -503,7 +539,10 @@ namespace eagle {
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
$"Shardok token mismatch: pending={providedShardokToken} " +
|
||||
$"current={currentShardokToken}, dropping and refreshing state");
|
||||
// Server processed command, token advanced. Refresh state.
|
||||
_ = StreamOneGameAsync(subscriber);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -528,7 +567,10 @@ namespace eagle {
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
$"Shardok placement token mismatch: pending={providedShardokToken} " +
|
||||
$"current={currentShardokToken}, dropping and refreshing state");
|
||||
// Server processed command, token advanced. Refresh state.
|
||||
_ = StreamOneGameAsync(subscriber);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -658,11 +700,12 @@ namespace eagle {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Only remove from pending if successfully sent.
|
||||
// If connection was dead, leave in queue for retry after reconnect.
|
||||
if (success) {
|
||||
lock (this) { _pendingCommands.Remove(request); }
|
||||
} else {
|
||||
// IMPORTANT: Do NOT remove from pending here even if write succeeded.
|
||||
// WriteAsync completing only means data was written to local buffers,
|
||||
// not that the server received and processed it. The command stays in
|
||||
// _pendingCommands until we receive PostCommandResponse SUCCESS or
|
||||
// TryPendingCommands sees the token has advanced (command was processed).
|
||||
if (!success) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
|
||||
}
|
||||
@@ -1073,6 +1116,41 @@ namespace eagle {
|
||||
}
|
||||
ackTcs?.TrySetResult(ack);
|
||||
|
||||
break;
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.PostCommandResponse:
|
||||
var postResponse = current.PostCommandResponse;
|
||||
if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Server returned ERROR: {postResponse.ErrorMessage}");
|
||||
LogConnectionEvent(
|
||||
"post_command_error",
|
||||
postResponse.ErrorMessage);
|
||||
|
||||
// Disconnect and let normal reconnect flow handle recovery
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
} else if (
|
||||
postResponse.Status ==
|
||||
PostCommandResponse.Types.Status.BadToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Server returned BAD_TOKEN for game {postResponse.GameId} " +
|
||||
"- command rejected, refreshing state");
|
||||
// Server rejected command due to stale token. Remove from
|
||||
// pending (already processed) and re-subscribe to ensure we
|
||||
// have current state.
|
||||
RemovePendingCommandsForGame(postResponse.GameId);
|
||||
RefreshGameSubscription(postResponse.GameId);
|
||||
} else if (
|
||||
postResponse.Status ==
|
||||
PostCommandResponse.Types.Status.Success) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Server confirmed command for game {postResponse.GameId}");
|
||||
// Command was successfully processed. Remove from pending.
|
||||
RemovePendingCommandsForGame(postResponse.GameId);
|
||||
}
|
||||
// UNKNOWN is benign (old servers that don't set status)
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes Sparkle auto-updater at app startup.
|
||||
/// Uses RuntimeInitializeOnLoadMethod to ensure early initialization.
|
||||
/// </summary>
|
||||
public static class SparkleInitializer {
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void Initialize() {
|
||||
Debug.Log("SparkleInitializer: Initializing Sparkle at startup");
|
||||
SparkleUpdater.Initialize();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -100
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// C# wrapper for the native SparklePlugin on macOS.
|
||||
/// Provides auto-update functionality via the Sparkle framework.
|
||||
/// On non-macOS platforms, all methods are no-ops.
|
||||
/// </summary>
|
||||
public static class SparkleUpdater {
|
||||
#if UNITY_STANDALONE_OSX && !UNITY_EDITOR
|
||||
private const string PluginName = "SparklePlugin";
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_Initialize();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_CheckForUpdates();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_CheckForUpdatesInBackground();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern int SparklePlugin_IsCheckingForUpdates();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern int SparklePlugin_GetAutomaticallyChecksForUpdates();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled);
|
||||
|
||||
private static bool _initialized = false;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the Sparkle updater. Should be called once at app startup.
|
||||
/// This starts automatic background update checks based on Info.plist settings.
|
||||
/// </summary>
|
||||
public static void Initialize() {
|
||||
if (_initialized) {
|
||||
Debug.Log("SparkleUpdater: Already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log("SparkleUpdater: Initializing Sparkle");
|
||||
try {
|
||||
SparklePlugin_Initialize();
|
||||
_initialized = true;
|
||||
Debug.Log("SparkleUpdater: Initialization complete");
|
||||
} catch (System.Exception e) {
|
||||
Debug.LogError($"SparkleUpdater: Failed to initialize: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually check for updates. Shows the update UI to the user.
|
||||
/// </summary>
|
||||
public static void CheckForUpdates() {
|
||||
if (!_initialized) {
|
||||
Debug.LogWarning("SparkleUpdater: Not initialized");
|
||||
return;
|
||||
}
|
||||
SparklePlugin_CheckForUpdates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check for updates silently in the background.
|
||||
/// Only shows UI if an update is found.
|
||||
/// </summary>
|
||||
public static void CheckForUpdatesInBackground() {
|
||||
if (!_initialized) {
|
||||
Debug.LogWarning("SparkleUpdater: Not initialized");
|
||||
return;
|
||||
}
|
||||
SparklePlugin_CheckForUpdatesInBackground();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an update check is currently in progress.
|
||||
/// </summary>
|
||||
public static bool IsCheckingForUpdates {
|
||||
get {
|
||||
if (!_initialized) return false;
|
||||
return SparklePlugin_IsCheckingForUpdates() != 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether automatic update checks are enabled.
|
||||
/// </summary>
|
||||
public static bool AutomaticallyChecksForUpdates {
|
||||
get {
|
||||
if (!_initialized) return false;
|
||||
return SparklePlugin_GetAutomaticallyChecksForUpdates() != 0;
|
||||
}
|
||||
set {
|
||||
if (!_initialized) return;
|
||||
SparklePlugin_SetAutomaticallyChecksForUpdates(value? 1: 0);
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
// Stub implementations for non-macOS platforms and Editor
|
||||
|
||||
public static void Initialize() { Debug.Log("SparkleUpdater: Not available on this platform"); }
|
||||
|
||||
public static void CheckForUpdates() {
|
||||
Debug.Log("SparkleUpdater: Not available on this platform");
|
||||
}
|
||||
|
||||
public static void CheckForUpdatesInBackground() {
|
||||
Debug.Log("SparkleUpdater: Not available on this platform");
|
||||
}
|
||||
|
||||
public static bool IsCheckingForUpdates => false;
|
||||
|
||||
public static bool AutomaticallyChecksForUpdates {
|
||||
get => false;
|
||||
set {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
System.Runtime.CompilerServices.Unsafe/**
|
||||
!**/*.psd
|
||||
**/*.dll
|
||||
# Native plugins built at CI time
|
||||
DarwinGodiceBundle.bundle/
|
||||
macOS/SparklePlugin.bundle/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d6e8f0a2b3c5d7e9f1a3b5c7d9e1f3a
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 0
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
: Any
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
Exclude Editor: 1
|
||||
Exclude Linux64: 1
|
||||
Exclude OSXUniversal: 0
|
||||
Exclude Win: 1
|
||||
Exclude Win64: 1
|
||||
- first:
|
||||
Any:
|
||||
second:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
- first:
|
||||
Editor: Editor
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
DefaultValueInitialized: true
|
||||
OS: AnyOS
|
||||
- first:
|
||||
Standalone: OSXUniversal
|
||||
second:
|
||||
enabled: 1
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -3,7 +3,9 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using common;
|
||||
using eagle;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Shardok;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
@@ -28,6 +30,9 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
public Toggle tooltipHugsCursorToggle;
|
||||
public HoveringTooltip hoveringTooltip;
|
||||
|
||||
public Toggle showAnimationTestsToggle;
|
||||
public AnimationTestController animationTestController;
|
||||
|
||||
public Slider autoScrollSpeedSlider;
|
||||
public TMP_Text autoScrollSpeedLabel;
|
||||
|
||||
@@ -39,11 +44,14 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
private const string MusicVolumeKey = "musicVolume";
|
||||
public const string UseGoDiceKey = "useGoDice";
|
||||
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
|
||||
private const string AnimationToggleKey = "animationToggle";
|
||||
|
||||
public EagleGameController eagleGameController;
|
||||
public GameObject ShardokCanvas;
|
||||
public ConnectionHandler connectionHandler;
|
||||
|
||||
public Button resetTutorialsButton;
|
||||
|
||||
void Start() {
|
||||
_active = false;
|
||||
panel.SetActive(false);
|
||||
@@ -59,6 +67,9 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
|
||||
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
|
||||
|
||||
showAnimationTestsToggle.isOn = PlayerPrefs.GetInt(AnimationToggleKey, 0) == 1;
|
||||
animationTestController.gameObject.SetActive(showAnimationTestsToggle.isOn);
|
||||
|
||||
if (autoScrollSpeedSlider != null) {
|
||||
autoScrollSpeedSlider.value = AutoScrollingText.GlobalScrollSpeedMultiplier;
|
||||
UpdateAutoScrollSpeedLabel(autoScrollSpeedSlider.value);
|
||||
@@ -116,6 +127,11 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
hoveringTooltip.HugsCursor = val;
|
||||
}
|
||||
|
||||
public void OnAnimationTestToggleChange(bool val) {
|
||||
PlayerPrefs.SetInt(AnimationToggleKey, val ? 1 : 0);
|
||||
animationTestController.gameObject.SetActive(val);
|
||||
}
|
||||
|
||||
public void OnAutoScrollSpeedSliderChange(float val) {
|
||||
AutoScrollingText.GlobalScrollSpeedMultiplier = val;
|
||||
UpdateAutoScrollSpeedLabel(val);
|
||||
@@ -135,6 +151,11 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
|
||||
}
|
||||
|
||||
public void OnResetTutorialsClick() {
|
||||
TutorialManager.Instance?.ResetAllProgress();
|
||||
UnityEngine.Debug.Log("Tutorial progress reset");
|
||||
}
|
||||
|
||||
private void HandleLobby() {
|
||||
eagleGameController.StopAll();
|
||||
eagleGameController.gameObject.SetActive(false);
|
||||
|
||||
BIN
Binary file not shown.
+169
@@ -0,0 +1,169 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32f7f3d380ec1413590676a13c22324c
|
||||
TextureImporter:
|
||||
internalIDToNameTable:
|
||||
- first:
|
||||
213: 7482667652216324306
|
||||
second: Square
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMasterTextureLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 2
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 256
|
||||
spriteBorder: {x: 4, y: 4, z: 4, w: 4}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: 0
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites:
|
||||
- serializedVersion: 2
|
||||
name: Triangle
|
||||
rect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 256
|
||||
height: 256
|
||||
alignment: 9
|
||||
pivot: {x: 0.5, y: 0.28866667}
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
outline:
|
||||
- - {x: 0, y: 93.7025033688}
|
||||
- {x: -128, y: -128}
|
||||
- {x: 128, y: -128}
|
||||
physicsShape:
|
||||
- - {x: 0, y: 93.7025033688}
|
||||
- {x: -128, y: -128}
|
||||
- {x: 128, y: -128}
|
||||
tessellationDetail: 0
|
||||
bones: []
|
||||
spriteID: 2d009a6b596c7d760800000000000000
|
||||
internalID: 7482667652216324306
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
outline: []
|
||||
physicsShape:
|
||||
- - {x: -128, y: 128}
|
||||
- {x: -128, y: -128}
|
||||
- {x: 128, y: -128}
|
||||
- {x: 128, y: 128}
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
nameFileIdTable:
|
||||
Triangle: 7482667652216324306
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7fee21f21c4f146f69dd2eae14a94b4b
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bfa8cdc5e73d54ed1876c17a47ff7346
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd23b09c6f27b4754a769858a2020668
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fe839b58f5fd46d695ece8ccf2412a5
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+21
-3
@@ -1,3 +1,4 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Shardok {
|
||||
@@ -125,13 +126,30 @@ namespace Shardok {
|
||||
Debug.Log("Melee sequence complete!");
|
||||
}
|
||||
|
||||
public void TestMove() {
|
||||
Debug.Log($"Testing Move: {sourceCell} -> {targetCell}");
|
||||
public void TestMove() { StartCoroutine(TestMoveSequence()); }
|
||||
|
||||
private IEnumerator TestMoveSequence() {
|
||||
Debug.Log($"Testing Move (Infantry): {sourceCell} -> {targetCell}");
|
||||
if (_moveAnimator != null) {
|
||||
_moveAnimator.AnimateMove(sourceCell, targetCell);
|
||||
_moveAnimator.AnimateMove(
|
||||
sourceCell,
|
||||
targetCell,
|
||||
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightInfantry);
|
||||
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
|
||||
} else {
|
||||
Debug.LogWarning("MoveAnimator not available");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
|
||||
Debug.Log($"Testing Move (Cavalry): {sourceCell} -> {targetCell}");
|
||||
if (_moveAnimator != null) {
|
||||
_moveAnimator.AnimateMove(
|
||||
sourceCell,
|
||||
targetCell,
|
||||
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightCavalry);
|
||||
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -59,8 +59,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -68,14 +68,10 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"ArrowVolleyAnimator: Starting volley from cell {sourceCellIndex} to {targetCellIndex}");
|
||||
StartCoroutine(SpawnArrowVolley(sourcePos.Value, targetPos.Value));
|
||||
}
|
||||
|
||||
private IEnumerator SpawnArrowVolley(Vector2 sourcePosition, Vector2 targetPosition) {
|
||||
Debug.Log(
|
||||
$"ArrowVolleyAnimator: Spawning {arrowCount} arrows from {sourcePosition} to {targetPosition}");
|
||||
var arrows = new List<GameObject>();
|
||||
|
||||
for (int i = 0; i < arrowCount; i++) {
|
||||
|
||||
@@ -59,8 +59,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
|
||||
+137
-217
@@ -7,7 +7,8 @@ using static Net.Eagle0.Shardok.Api.BattalionView.Types;
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Animates charge attacks between two hexes.
|
||||
/// Similar to MeleeAnimator but with more dramatic motion for charging units.
|
||||
/// Shows attacker's weapon thrusting toward defender with accelerating motion
|
||||
/// and a violent flash on impact.
|
||||
/// </summary>
|
||||
public class ChargeAnimator : MonoBehaviour {
|
||||
[Header("Weapon Sprites")]
|
||||
@@ -29,28 +30,41 @@ namespace Shardok {
|
||||
[Tooltip("Bone/claw sprite for undead charge")]
|
||||
public Sprite boneSprite;
|
||||
|
||||
[Header("Impact Flash Settings")]
|
||||
[Tooltip("Sprite for impact flash")]
|
||||
public Sprite flashSprite;
|
||||
|
||||
[Tooltip("Color of impact flash")]
|
||||
public Color flashColor = new Color(1f, 1f, 0.8f, 0.9f);
|
||||
|
||||
[Tooltip("Scale of impact flash")]
|
||||
public float flashScale = 35f;
|
||||
|
||||
[Tooltip("Duration of flash")]
|
||||
public float flashDuration = 0.1f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Base scale of weapon sprites")]
|
||||
public float weaponScale = 25.0f;
|
||||
|
||||
[Tooltip("Duration of the charge thrust in seconds")]
|
||||
public float chargeDuration = 0.15f;
|
||||
[Tooltip("Number of thrusts")]
|
||||
public int thrustCount = 2;
|
||||
|
||||
[Tooltip("Number of impact cycles")]
|
||||
public int impactCount = 2;
|
||||
[Tooltip("Duration of each thrust (slow to fast)")]
|
||||
public float thrustDuration = 0.25f;
|
||||
|
||||
[Tooltip("Distance weapons travel toward each other (in local units)")]
|
||||
public float thrustDistance = 35f;
|
||||
[Tooltip("Duration of pullback between thrusts")]
|
||||
public float pullbackDuration = 0.15f;
|
||||
|
||||
[Tooltip("Offset from center point along attack axis (in local units)")]
|
||||
public float weaponOffset = 50f;
|
||||
[Tooltip("Starting position offset from midpoint (in local units)")]
|
||||
public float startOffset = 60f;
|
||||
|
||||
[Tooltip("End position offset from midpoint (in local units)")]
|
||||
public float endOffset = 15f;
|
||||
|
||||
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
|
||||
public float baseRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Amount of random shake (in local units)")]
|
||||
public float shakeIntensity = 12f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
private Coroutine _activeAnimation;
|
||||
|
||||
@@ -59,10 +73,6 @@ namespace Shardok {
|
||||
/// <summary>
|
||||
/// Starts a charge animation between two hex cells.
|
||||
/// </summary>
|
||||
/// <param name="attackerCellIndex">Grid index of the charging hex</param>
|
||||
/// <param name="defenderCellIndex">Grid index of the defending hex</param>
|
||||
/// <param name="attackerType">Battalion type of the attacker</param>
|
||||
/// <param name="defenderType">Battalion type of the defender</param>
|
||||
public void AnimateCharge(
|
||||
int attackerCellIndex,
|
||||
int defenderCellIndex,
|
||||
@@ -79,8 +89,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
|
||||
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
|
||||
|
||||
if (attackerPos == null || defenderPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -90,238 +100,148 @@ namespace Shardok {
|
||||
|
||||
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
|
||||
|
||||
Debug.Log(
|
||||
$"ChargeAnimator: Starting charge from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
|
||||
_activeAnimation = StartCoroutine(AnimateChargeClash(
|
||||
attackerPos.Value,
|
||||
defenderPos.Value,
|
||||
attackerType,
|
||||
defenderType));
|
||||
_activeAnimation = StartCoroutine(
|
||||
AnimateChargeThrusts(attackerPos.Value, defenderPos.Value, attackerType));
|
||||
}
|
||||
|
||||
private struct WeaponConfig {
|
||||
public Sprite sprite;
|
||||
public float scaleMultiplier;
|
||||
public float violenceMultiplier;
|
||||
}
|
||||
|
||||
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
|
||||
private Sprite GetWeaponSprite(BattalionTypeId type) {
|
||||
switch (type) {
|
||||
case BattalionTypeId.LightInfantry:
|
||||
return new WeaponConfig {
|
||||
sprite = swordSprite,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 1.2f
|
||||
};
|
||||
case BattalionTypeId.HeavyInfantry:
|
||||
return new WeaponConfig {
|
||||
sprite = maceSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.2f,
|
||||
violenceMultiplier = 1.4f
|
||||
};
|
||||
case BattalionTypeId.LightInfantry: return swordSprite;
|
||||
case BattalionTypeId.HeavyInfantry: return maceSprite ?? swordSprite;
|
||||
case BattalionTypeId.LightCavalry:
|
||||
return new WeaponConfig {
|
||||
sprite = smallSpearSprite ?? largeLanceSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.1f,
|
||||
violenceMultiplier = 1.6f
|
||||
};
|
||||
return smallSpearSprite ?? largeLanceSprite ?? swordSprite;
|
||||
case BattalionTypeId.HeavyCavalry:
|
||||
return new WeaponConfig {
|
||||
sprite = largeLanceSprite ?? smallSpearSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.4f,
|
||||
violenceMultiplier = 2.0f
|
||||
};
|
||||
case BattalionTypeId.Longbowmen:
|
||||
return new WeaponConfig {
|
||||
sprite = daggerSprite ?? swordSprite,
|
||||
scaleMultiplier = 0.8f,
|
||||
violenceMultiplier = 1.0f
|
||||
};
|
||||
case BattalionTypeId.Undead:
|
||||
return new WeaponConfig {
|
||||
sprite = boneSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 1.3f
|
||||
};
|
||||
default:
|
||||
return new WeaponConfig {
|
||||
sprite = swordSprite,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 1.2f
|
||||
};
|
||||
return largeLanceSprite ?? smallSpearSprite ?? swordSprite;
|
||||
case BattalionTypeId.Longbowmen: return daggerSprite ?? swordSprite;
|
||||
case BattalionTypeId.Undead: return boneSprite ?? swordSprite;
|
||||
default: return swordSprite;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateChargeClash(
|
||||
private float GetScaleMultiplier(BattalionTypeId type) {
|
||||
switch (type) {
|
||||
case BattalionTypeId.HeavyInfantry: return 1.2f;
|
||||
case BattalionTypeId.LightCavalry: return 1.1f;
|
||||
case BattalionTypeId.HeavyCavalry: return 1.4f;
|
||||
case BattalionTypeId.Longbowmen: return 0.8f;
|
||||
default: return 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateChargeThrusts(
|
||||
Vector2 attackerPos,
|
||||
Vector2 defenderPos,
|
||||
BattalionTypeId attackerType,
|
||||
BattalionTypeId defenderType) {
|
||||
// Calculate midpoint and direction
|
||||
BattalionTypeId attackerType) {
|
||||
// Calculate direction toward defender
|
||||
Vector2 midpoint = (attackerPos + defenderPos) / 2f;
|
||||
Vector2 direction = (defenderPos - attackerPos).normalized;
|
||||
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
|
||||
|
||||
// Get weapon configs
|
||||
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
|
||||
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
|
||||
// Create attacker weapon
|
||||
Sprite weaponSprite = GetWeaponSprite(attackerType);
|
||||
float scale = weaponScale * GetScaleMultiplier(attackerType);
|
||||
|
||||
// Create weapons
|
||||
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
|
||||
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
|
||||
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
|
||||
|
||||
// Starting positions offset from midpoint (further back for charge buildup)
|
||||
Vector3 attackerStart = new Vector3(
|
||||
midpoint.x - direction.x * weaponOffset,
|
||||
midpoint.y - direction.y * weaponOffset,
|
||||
10f);
|
||||
Vector3 defenderStart = new Vector3(
|
||||
midpoint.x + direction.x * weaponOffset,
|
||||
midpoint.y + direction.y * weaponOffset,
|
||||
10f);
|
||||
|
||||
// Clash point (closer to midpoint)
|
||||
Vector3 attackerClash = new Vector3(
|
||||
midpoint.x - direction.x * (weaponOffset - thrustDistance),
|
||||
midpoint.y - direction.y * (weaponOffset - thrustDistance),
|
||||
10f);
|
||||
Vector3 defenderClash = new Vector3(
|
||||
midpoint.x + direction.x * (weaponOffset - thrustDistance),
|
||||
midpoint.y + direction.y * (weaponOffset - thrustDistance),
|
||||
10f);
|
||||
|
||||
attackerWeapon.transform.localPosition = attackerStart;
|
||||
defenderWeapon.transform.localPosition = defenderStart;
|
||||
|
||||
// Animate impact cycles
|
||||
for (int i = 0; i < impactCount; i++) {
|
||||
// Charge thrust toward clash point
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
attackerWeapon,
|
||||
attackerStart,
|
||||
attackerClash,
|
||||
chargeDuration / 2f,
|
||||
true,
|
||||
attackerViolence));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
defenderWeapon,
|
||||
defenderStart,
|
||||
defenderClash,
|
||||
chargeDuration / 2f,
|
||||
true,
|
||||
defenderViolence));
|
||||
|
||||
// Violent impact shake
|
||||
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
|
||||
yield return StartCoroutine(ImpactShake(
|
||||
attackerWeapon,
|
||||
defenderWeapon,
|
||||
attackerClash,
|
||||
defenderClash,
|
||||
0.08f,
|
||||
impactViolence));
|
||||
|
||||
// Pull back
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
attackerWeapon,
|
||||
attackerClash,
|
||||
attackerStart,
|
||||
chargeDuration / 2f,
|
||||
false,
|
||||
attackerViolence * 0.5f));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
defenderWeapon,
|
||||
defenderClash,
|
||||
defenderStart,
|
||||
chargeDuration / 2f,
|
||||
false,
|
||||
defenderViolence * 0.5f));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (attackerWeapon != null) { Destroy(attackerWeapon); }
|
||||
if (defenderWeapon != null) { Destroy(defenderWeapon); }
|
||||
_activeAnimation = null;
|
||||
}
|
||||
|
||||
private GameObject CreateWeapon(WeaponConfig config, float angle) {
|
||||
GameObject weapon = new GameObject("ChargeWeapon");
|
||||
weapon.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer renderer = weapon.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = config.sprite;
|
||||
renderer.sprite = weaponSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
|
||||
float scale = weaponScale * config.scaleMultiplier;
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, baseAngle + baseRotationOffset);
|
||||
weapon.transform.localScale = new Vector3(scale, scale, scale);
|
||||
|
||||
return weapon;
|
||||
// Calculate positions
|
||||
Vector3 startPos = new Vector3(
|
||||
midpoint.x - direction.x * startOffset,
|
||||
midpoint.y - direction.y * startOffset,
|
||||
10f);
|
||||
Vector3 endPos = new Vector3(
|
||||
midpoint.x + direction.x * endOffset,
|
||||
midpoint.y + direction.y * endOffset,
|
||||
10f);
|
||||
|
||||
weapon.transform.localPosition = startPos;
|
||||
|
||||
// Animate thrusts
|
||||
for (int i = 0; i < thrustCount; i++) {
|
||||
// Thrust forward - starts slow, accelerates
|
||||
float elapsed = 0f;
|
||||
while (elapsed < thrustDuration) {
|
||||
if (weapon == null) yield break;
|
||||
|
||||
float t = elapsed / thrustDuration;
|
||||
// Cubic ease-in for accelerating thrust
|
||||
float easeT = t * t * t;
|
||||
|
||||
weapon.transform.localPosition = Vector3.Lerp(startPos, endPos, easeT);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
weapon.transform.localPosition = endPos;
|
||||
|
||||
// Impact flash
|
||||
if (flashSprite != null) {
|
||||
yield return StartCoroutine(ShowImpactFlash(endPos, direction));
|
||||
}
|
||||
|
||||
// Pullback - smooth ease out
|
||||
elapsed = 0f;
|
||||
while (elapsed < pullbackDuration) {
|
||||
if (weapon == null) yield break;
|
||||
|
||||
float t = elapsed / pullbackDuration;
|
||||
// Ease out for smooth pullback
|
||||
float easeT = t * (2f - t);
|
||||
|
||||
weapon.transform.localPosition = Vector3.Lerp(endPos, startPos, easeT);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
weapon.transform.localPosition = startPos;
|
||||
|
||||
// Brief pause between thrusts
|
||||
if (i < thrustCount - 1) { yield return new WaitForSeconds(0.05f); }
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (weapon != null) { Destroy(weapon); }
|
||||
_activeAnimation = null;
|
||||
}
|
||||
|
||||
private IEnumerator AnimateWeaponThrust(
|
||||
GameObject weapon,
|
||||
Vector3 fromPos,
|
||||
Vector3 toPos,
|
||||
float duration,
|
||||
bool isCharging,
|
||||
float violence) {
|
||||
private IEnumerator ShowImpactFlash(Vector3 position, Vector2 direction) {
|
||||
GameObject flash = new GameObject("ChargeFlash");
|
||||
flash.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
flash.transform.localPosition = position;
|
||||
|
||||
SpriteRenderer renderer = flash.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = flashSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = flashColor;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < flashDuration) {
|
||||
float t = elapsed / flashDuration;
|
||||
|
||||
while (elapsed < duration) {
|
||||
if (weapon == null) { yield break; }
|
||||
// Quick expand then fade
|
||||
float expandT = t < 0.3f ? t / 0.3f : 1f;
|
||||
float scale = flashScale * (0.5f + 0.5f * expandT);
|
||||
flash.transform.localScale = Vector3.one * scale;
|
||||
|
||||
float t = elapsed / duration;
|
||||
// More aggressive ease for charge, smoother for retreat
|
||||
float smoothT = isCharging ? t * t * t : t * (2f - t);
|
||||
|
||||
// Position with shake
|
||||
Vector3 shake = new Vector3(
|
||||
Random.Range(-violence, violence),
|
||||
Random.Range(-violence, violence),
|
||||
0f);
|
||||
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
|
||||
// Fade out
|
||||
Color c = flashColor;
|
||||
c.a = flashColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (weapon != null) { weapon.transform.localPosition = toPos; }
|
||||
}
|
||||
|
||||
private IEnumerator ImpactShake(
|
||||
GameObject weapon1,
|
||||
GameObject weapon2,
|
||||
Vector3 pos1,
|
||||
Vector3 pos2,
|
||||
float duration,
|
||||
float violence) {
|
||||
float elapsed = 0f;
|
||||
float intensity = violence * 2.5f; // Extra intense for charge impact
|
||||
|
||||
while (elapsed < duration) {
|
||||
if (weapon1 == null || weapon2 == null) { yield break; }
|
||||
|
||||
Vector3 shake1 = new Vector3(
|
||||
Random.Range(-intensity, intensity),
|
||||
Random.Range(-intensity, intensity),
|
||||
0f);
|
||||
Vector3 shake2 = new Vector3(
|
||||
Random.Range(-intensity, intensity),
|
||||
Random.Range(-intensity, intensity),
|
||||
0f);
|
||||
|
||||
weapon1.transform.localPosition = pos1 + shake1;
|
||||
weapon2.transform.localPosition = pos2 + shake2;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
Destroy(flash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+40
-17
@@ -12,7 +12,13 @@ namespace Shardok {
|
||||
public Sprite dropletSprite;
|
||||
|
||||
[Tooltip("Number of droplets")]
|
||||
public int dropletCount = 12;
|
||||
public int dropletCount = 25;
|
||||
|
||||
[Tooltip("Minimum droplet scale")]
|
||||
public float dropletScaleMin = 5f;
|
||||
|
||||
[Tooltip("Maximum droplet scale")]
|
||||
public float dropletScaleMax = 15f;
|
||||
|
||||
[Tooltip("Color of water")]
|
||||
public Color waterColor = new Color(0.3f, 0.6f, 0.9f, 0.8f);
|
||||
@@ -22,23 +28,29 @@ namespace Shardok {
|
||||
public Sprite steamSprite;
|
||||
|
||||
[Tooltip("Number of steam puffs")]
|
||||
public int steamCount = 5;
|
||||
public int steamCount = 8;
|
||||
|
||||
[Tooltip("Color of steam")]
|
||||
public Color steamColor = new Color(0.9f, 0.9f, 0.9f, 0.6f);
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Height water falls from")]
|
||||
public float fallHeight = 60f;
|
||||
public float fallHeight = 100f;
|
||||
|
||||
[Tooltip("Spread radius of the water")]
|
||||
public float waterSpread = 25f;
|
||||
public float waterSpread = 40f;
|
||||
|
||||
[Tooltip("Duration of the water fall")]
|
||||
public float fallDuration = 0.3f;
|
||||
public float fallDuration = 0.5f;
|
||||
|
||||
[Tooltip("Time spread for staggered droplet launch")]
|
||||
public float launchSpread = 0.25f;
|
||||
|
||||
[Tooltip("Horizontal chaos during fall")]
|
||||
public float fallChaos = 15f;
|
||||
|
||||
[Tooltip("Duration of the steam effect")]
|
||||
public float steamDuration = 0.4f;
|
||||
public float steamDuration = 0.5f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
@@ -72,6 +84,7 @@ namespace Shardok {
|
||||
// Create droplets
|
||||
GameObject[] droplets = new GameObject[dropletCount];
|
||||
Vector2[] dropletOffsets = new Vector2[dropletCount];
|
||||
Vector2[] dropletChaos = new Vector2[dropletCount];
|
||||
float[] dropletDelays = new float[dropletCount];
|
||||
|
||||
for (int i = 0; i < dropletCount; i++) {
|
||||
@@ -84,17 +97,23 @@ namespace Shardok {
|
||||
renderer.sprite = dropletSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = waterColor;
|
||||
droplet.transform.localScale = Vector3.one * Random.Range(4f, 8f);
|
||||
droplet.transform.localScale =
|
||||
Vector3.one * Random.Range(dropletScaleMin, dropletScaleMax);
|
||||
|
||||
float angle = Random.Range(0f, Mathf.PI * 2f);
|
||||
float radius = waterSpread * Random.Range(0f, 1f);
|
||||
dropletOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
|
||||
dropletDelays[i] = Random.Range(0f, fallDuration * 0.3f);
|
||||
dropletDelays[i] = Random.Range(0f, launchSpread);
|
||||
|
||||
// Start above
|
||||
// Random horizontal chaos direction for each droplet
|
||||
float chaosAngle = Random.Range(0f, Mathf.PI * 2f);
|
||||
dropletChaos[i] =
|
||||
new Vector2(Mathf.Cos(chaosAngle), Mathf.Sin(chaosAngle)) * fallChaos;
|
||||
|
||||
// Start above with some horizontal spread
|
||||
droplet.transform.localPosition = new Vector3(
|
||||
target.x + dropletOffsets[i].x,
|
||||
target.y + fallHeight + dropletOffsets[i].y * 0.5f,
|
||||
target.x + dropletOffsets[i].x * 0.3f,
|
||||
target.y + fallHeight + Random.Range(-10f, 10f),
|
||||
5f);
|
||||
|
||||
droplets[i] = droplet;
|
||||
@@ -102,23 +121,27 @@ namespace Shardok {
|
||||
|
||||
// Animate falling water
|
||||
float elapsed = 0f;
|
||||
float totalDuration = fallDuration + launchSpread;
|
||||
|
||||
while (elapsed < fallDuration) {
|
||||
float t = elapsed / fallDuration;
|
||||
|
||||
while (elapsed < totalDuration) {
|
||||
for (int i = 0; i < droplets.Length; i++) {
|
||||
if (droplets[i] == null) continue;
|
||||
|
||||
float dropletT = Mathf.Clamp01(
|
||||
(elapsed - dropletDelays[i]) / (fallDuration - dropletDelays[i]));
|
||||
(elapsed - dropletDelays[i]) /
|
||||
(fallDuration - dropletDelays[i] * 0.5f));
|
||||
if (dropletT <= 0) continue;
|
||||
|
||||
// Accelerating fall
|
||||
float fallT = dropletT * dropletT;
|
||||
|
||||
// Add chaotic horizontal movement (sine wave pattern)
|
||||
float chaosT = Mathf.Sin(dropletT * Mathf.PI * 2f) * (1f - dropletT);
|
||||
Vector2 chaos = dropletChaos[i] * chaosT;
|
||||
|
||||
float x = target.x + dropletOffsets[i].x + chaos.x;
|
||||
float y = target.y + fallHeight * (1f - fallT);
|
||||
droplets[i].transform.localPosition =
|
||||
new Vector3(target.x + dropletOffsets[i].x, y, 5f);
|
||||
droplets[i].transform.localPosition = new Vector3(x, y, 5f);
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
|
||||
@@ -462,11 +462,14 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetProfessionImage(int cellIndex, Texture image) {
|
||||
var professionImage = cells[cellIndex].ProfessionImage;
|
||||
if (professionImage == null) return;
|
||||
|
||||
if (image == null) {
|
||||
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: false);
|
||||
professionImage.gameObject.SetActive(value: false);
|
||||
} else {
|
||||
cells[cellIndex].ProfessionImage.texture = image;
|
||||
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: true);
|
||||
professionImage.texture = image;
|
||||
professionImage.gameObject.SetActive(value: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,11 +478,14 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetUnitTypeImage(int cellIndex, Texture image) {
|
||||
var unitTypeImage = cells[cellIndex].UnitTypeImage;
|
||||
if (unitTypeImage == null) return;
|
||||
|
||||
if (image == null) {
|
||||
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: false);
|
||||
unitTypeImage.gameObject.SetActive(value: false);
|
||||
} else {
|
||||
cells[cellIndex].UnitTypeImage.texture = image;
|
||||
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: true);
|
||||
unitTypeImage.texture = image;
|
||||
unitTypeImage.gameObject.SetActive(value: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ namespace Shardok {
|
||||
[Tooltip("Mace/hammer sprite for heavy infantry")]
|
||||
public Sprite maceSprite;
|
||||
|
||||
[Tooltip("Small spear sprite for light cavalry")]
|
||||
public Sprite smallSpearSprite;
|
||||
[Tooltip("Falchion sprite for light cavalry")]
|
||||
public Sprite falchionSprite;
|
||||
|
||||
[Tooltip("Large spear/lance sprite for heavy cavalry")]
|
||||
public Sprite largeSpearSprite;
|
||||
[Tooltip("Two-handed sword sprite for heavy cavalry")]
|
||||
public Sprite twoHandedSwordSprite;
|
||||
|
||||
[Tooltip("Dagger sprite for longbowmen")]
|
||||
public Sprite daggerSprite;
|
||||
@@ -30,15 +30,57 @@ namespace Shardok {
|
||||
[Tooltip("Bone/fist sprite for undead")]
|
||||
public Sprite boneSprite;
|
||||
|
||||
[Header("Weapon Orientation - Sword")]
|
||||
[Tooltip("Base rotation offset for sword (degrees)")]
|
||||
public float swordRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip sword horizontally")]
|
||||
public bool swordFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Mace")]
|
||||
[Tooltip("Base rotation offset for mace (degrees)")]
|
||||
public float maceRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip mace horizontally")]
|
||||
public bool maceFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Falchion")]
|
||||
[Tooltip("Base rotation offset for falchion (degrees)")]
|
||||
public float falchionRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip falchion horizontally")]
|
||||
public bool falchionFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Two-Handed Sword")]
|
||||
[Tooltip("Base rotation offset for two-handed sword (degrees)")]
|
||||
public float twoHandedSwordRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip two-handed sword horizontally")]
|
||||
public bool twoHandedSwordFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Dagger")]
|
||||
[Tooltip("Base rotation offset for dagger (degrees)")]
|
||||
public float daggerRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip dagger horizontally")]
|
||||
public bool daggerFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Bone")]
|
||||
[Tooltip("Base rotation offset for bone (degrees)")]
|
||||
public float boneRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip bone horizontally")]
|
||||
public bool boneFlipHorizontal = false;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Base scale of weapon sprites")]
|
||||
public float weaponScale = 20.0f;
|
||||
|
||||
[Tooltip("Duration of a single clash cycle in seconds")]
|
||||
public float clashDuration = 0.12f;
|
||||
[Tooltip("Duration of a single swing cycle in seconds")]
|
||||
public float clashDuration = 0.3f;
|
||||
|
||||
[Tooltip("Number of clash cycles")]
|
||||
public int clashCount = 3;
|
||||
[Tooltip("Number of swing cycles")]
|
||||
public int clashCount = 2;
|
||||
|
||||
[Tooltip("Distance weapons travel toward each other (in local units)")]
|
||||
public float thrustDistance = 25f;
|
||||
@@ -46,20 +88,30 @@ namespace Shardok {
|
||||
[Tooltip("Offset from center point along attack axis (in local units)")]
|
||||
public float weaponOffset = 40f;
|
||||
|
||||
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
|
||||
public float baseRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Swing arc angle for swinging weapons (degrees)")]
|
||||
public float swingArc = 60f;
|
||||
public float swingArc = 90f;
|
||||
|
||||
[Tooltip("Amount of random shake (in local units)")]
|
||||
public float shakeIntensity = 8f;
|
||||
[Tooltip("Amount of shake at impact only (in local units)")]
|
||||
public float shakeIntensity = 3f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
private Coroutine _activeAnimation;
|
||||
private GameObject _attackerWeapon;
|
||||
private GameObject _defenderWeapon;
|
||||
|
||||
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
|
||||
private void CleanupWeapons() {
|
||||
if (_attackerWeapon != null) {
|
||||
Destroy(_attackerWeapon);
|
||||
_attackerWeapon = null;
|
||||
}
|
||||
if (_defenderWeapon != null) {
|
||||
Destroy(_defenderWeapon);
|
||||
_defenderWeapon = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a melee animation between two adjacent hex cells.
|
||||
/// </summary>
|
||||
@@ -72,8 +124,8 @@ namespace Shardok {
|
||||
int defenderCellIndex,
|
||||
BattalionTypeId attackerType,
|
||||
BattalionTypeId defenderType) {
|
||||
if (swordSprite == null && maceSprite == null && smallSpearSprite == null &&
|
||||
largeSpearSprite == null && daggerSprite == null && boneSprite == null) {
|
||||
if (swordSprite == null && maceSprite == null && falchionSprite == null &&
|
||||
twoHandedSwordSprite == null && daggerSprite == null && boneSprite == null) {
|
||||
Debug.LogWarning("MeleeAnimator: No weapon sprites assigned");
|
||||
return;
|
||||
}
|
||||
@@ -83,8 +135,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
|
||||
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
|
||||
|
||||
if (attackerPos == null || defenderPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -92,10 +144,11 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
|
||||
if (_activeAnimation != null) {
|
||||
StopCoroutine(_activeAnimation);
|
||||
CleanupWeapons();
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"MeleeAnimator: Starting melee from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
|
||||
_activeAnimation = StartCoroutine(
|
||||
AnimateClash(attackerPos.Value, defenderPos.Value, attackerType, defenderType));
|
||||
}
|
||||
@@ -104,7 +157,8 @@ namespace Shardok {
|
||||
public Sprite sprite;
|
||||
public bool swings; // true = swing arc, false = thrust
|
||||
public float scaleMultiplier;
|
||||
public float violenceMultiplier; // shake intensity multiplier
|
||||
public float rotationOffset;
|
||||
public bool flipHorizontal;
|
||||
}
|
||||
|
||||
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
|
||||
@@ -114,49 +168,66 @@ namespace Shardok {
|
||||
sprite = swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 0.3f
|
||||
rotationOffset = swordRotationOffset,
|
||||
flipHorizontal = swordFlipHorizontal
|
||||
};
|
||||
case BattalionTypeId.HeavyInfantry:
|
||||
return new WeaponConfig {
|
||||
sprite = maceSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.2f,
|
||||
violenceMultiplier = 0.4f
|
||||
rotationOffset =
|
||||
maceSprite != null ? maceRotationOffset : swordRotationOffset,
|
||||
flipHorizontal =
|
||||
maceSprite != null ? maceFlipHorizontal : swordFlipHorizontal
|
||||
};
|
||||
case BattalionTypeId.LightCavalry:
|
||||
return new WeaponConfig {
|
||||
sprite = smallSpearSprite ?? largeSpearSprite ?? swordSprite,
|
||||
swings = false,
|
||||
scaleMultiplier = 0.9f,
|
||||
violenceMultiplier = 1.0f
|
||||
sprite = falchionSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
rotationOffset = falchionSprite != null ? falchionRotationOffset
|
||||
: swordRotationOffset,
|
||||
flipHorizontal = falchionSprite != null ? falchionFlipHorizontal
|
||||
: swordFlipHorizontal
|
||||
};
|
||||
case BattalionTypeId.HeavyCavalry:
|
||||
return new WeaponConfig {
|
||||
sprite = largeSpearSprite ?? smallSpearSprite ?? swordSprite,
|
||||
swings = false,
|
||||
sprite = twoHandedSwordSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.4f,
|
||||
violenceMultiplier = 1.8f
|
||||
rotationOffset = twoHandedSwordSprite != null ? twoHandedSwordRotationOffset
|
||||
: swordRotationOffset,
|
||||
flipHorizontal = twoHandedSwordSprite != null ? twoHandedSwordFlipHorizontal
|
||||
: swordFlipHorizontal
|
||||
};
|
||||
case BattalionTypeId.Longbowmen:
|
||||
return new WeaponConfig {
|
||||
sprite = daggerSprite ?? swordSprite,
|
||||
swings = false,
|
||||
scaleMultiplier = 0.7f,
|
||||
violenceMultiplier = 0.8f
|
||||
rotationOffset =
|
||||
daggerSprite != null ? daggerRotationOffset : swordRotationOffset,
|
||||
flipHorizontal =
|
||||
daggerSprite != null ? daggerFlipHorizontal : swordFlipHorizontal
|
||||
};
|
||||
case BattalionTypeId.Undead:
|
||||
return new WeaponConfig {
|
||||
sprite = boneSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 0.5f
|
||||
rotationOffset =
|
||||
boneSprite != null ? boneRotationOffset : swordRotationOffset,
|
||||
flipHorizontal =
|
||||
boneSprite != null ? boneFlipHorizontal : swordFlipHorizontal
|
||||
};
|
||||
default:
|
||||
return new WeaponConfig {
|
||||
sprite = swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 0.3f
|
||||
rotationOffset = swordRotationOffset,
|
||||
flipHorizontal = swordFlipHorizontal
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -175,14 +246,12 @@ namespace Shardok {
|
||||
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
|
||||
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
|
||||
|
||||
// Create weapons with type-specific scaling
|
||||
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
// Create weapons with type-specific scaling (stored in class fields for cleanup)
|
||||
_attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
_defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
|
||||
bool attackerSwings = attackerConfig.swings;
|
||||
bool defenderSwings = defenderConfig.swings;
|
||||
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
|
||||
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
|
||||
|
||||
// Starting positions offset from midpoint
|
||||
Vector3 attackerStart = new Vector3(
|
||||
@@ -204,77 +273,71 @@ namespace Shardok {
|
||||
midpoint.y + direction.y * (weaponOffset - thrustDistance),
|
||||
10f);
|
||||
|
||||
attackerWeapon.transform.localPosition = attackerStart;
|
||||
defenderWeapon.transform.localPosition = defenderStart;
|
||||
_attackerWeapon.transform.localPosition = attackerStart;
|
||||
_defenderWeapon.transform.localPosition = defenderStart;
|
||||
|
||||
float attackerBaseAngle = baseAngle + baseRotationOffset;
|
||||
float defenderBaseAngle = baseAngle + 180f + baseRotationOffset;
|
||||
float attackerBaseAngle = baseAngle + attackerConfig.rotationOffset;
|
||||
float defenderBaseAngle = baseAngle + 180f + defenderConfig.rotationOffset;
|
||||
|
||||
// Animate clash cycles with alternating swing directions
|
||||
// Animate swing cycles with alternating swing directions
|
||||
for (int i = 0; i < clashCount; i++) {
|
||||
// Alternate swing direction each cycle
|
||||
float swingDirection = (i % 2 == 0) ? 1f : -1f;
|
||||
|
||||
// Swing/thrust toward clash point
|
||||
// Swing toward clash point
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
attackerWeapon,
|
||||
_attackerWeapon,
|
||||
attackerStart,
|
||||
attackerClash,
|
||||
attackerBaseAngle,
|
||||
attackerSwings ? -swingArc * swingDirection : 0f,
|
||||
attackerSwings ? 0f : 0f,
|
||||
clashDuration / 2f,
|
||||
true,
|
||||
attackerViolence));
|
||||
true));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
defenderWeapon,
|
||||
_defenderWeapon,
|
||||
defenderStart,
|
||||
defenderClash,
|
||||
defenderBaseAngle,
|
||||
defenderSwings ? swingArc * swingDirection : 0f,
|
||||
defenderSwings ? 0f : 0f,
|
||||
clashDuration / 2f,
|
||||
true,
|
||||
defenderViolence));
|
||||
true));
|
||||
|
||||
// Brief violent shake at impact (use max violence of both)
|
||||
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
|
||||
// Brief shake at impact
|
||||
yield return StartCoroutine(ImpactShake(
|
||||
attackerWeapon,
|
||||
defenderWeapon,
|
||||
_attackerWeapon,
|
||||
_defenderWeapon,
|
||||
attackerClash,
|
||||
defenderClash,
|
||||
0.05f,
|
||||
impactViolence));
|
||||
shakeIntensity));
|
||||
|
||||
// Swing/pull back
|
||||
// Swing back
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
attackerWeapon,
|
||||
_attackerWeapon,
|
||||
attackerClash,
|
||||
attackerStart,
|
||||
attackerBaseAngle,
|
||||
0f,
|
||||
attackerSwings ? -swingArc * swingDirection : 0f,
|
||||
clashDuration / 2f,
|
||||
false,
|
||||
attackerViolence));
|
||||
false));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
defenderWeapon,
|
||||
_defenderWeapon,
|
||||
defenderClash,
|
||||
defenderStart,
|
||||
defenderBaseAngle,
|
||||
0f,
|
||||
defenderSwings ? swingArc * swingDirection : 0f,
|
||||
clashDuration / 2f,
|
||||
false,
|
||||
defenderViolence));
|
||||
false));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (attackerWeapon != null) { Destroy(attackerWeapon); }
|
||||
if (defenderWeapon != null) { Destroy(defenderWeapon); }
|
||||
CleanupWeapons();
|
||||
_activeAnimation = null;
|
||||
}
|
||||
|
||||
@@ -286,9 +349,10 @@ namespace Shardok {
|
||||
renderer.sprite = config.sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + config.rotationOffset);
|
||||
float scale = weaponScale * config.scaleMultiplier;
|
||||
weapon.transform.localScale = new Vector3(scale, scale, scale);
|
||||
float xScale = config.flipHorizontal ? -scale : scale;
|
||||
weapon.transform.localScale = new Vector3(xScale, scale, scale);
|
||||
|
||||
return weapon;
|
||||
}
|
||||
@@ -301,25 +365,20 @@ namespace Shardok {
|
||||
float fromAngleOffset,
|
||||
float toAngleOffset,
|
||||
float duration,
|
||||
bool isAttacking,
|
||||
float violence) {
|
||||
bool isAttacking) {
|
||||
float elapsed = 0f;
|
||||
|
||||
while (elapsed < duration) {
|
||||
if (weapon == null) { yield break; }
|
||||
|
||||
float t = elapsed / duration;
|
||||
// Aggressive ease for attack, smoother for retreat
|
||||
// Smooth ease for deliberate swing motion
|
||||
float smoothT = isAttacking ? t * t * (3f - 2f * t) : t * (2f - t);
|
||||
|
||||
// Position with shake (scaled by violence)
|
||||
Vector3 shake = new Vector3(
|
||||
Random.Range(-violence, violence),
|
||||
Random.Range(-violence, violence),
|
||||
0f);
|
||||
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
|
||||
// Clean position interpolation - no shake during swing
|
||||
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT);
|
||||
|
||||
// Rotation (for swinging weapons)
|
||||
// Rotation arc for swinging weapons
|
||||
float currentAngle =
|
||||
Mathf.Lerp(baseAngle + fromAngleOffset, baseAngle + toAngleOffset, smoothT);
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
|
||||
@@ -372,6 +431,7 @@ namespace Shardok {
|
||||
StopCoroutine(_activeAnimation);
|
||||
_activeAnimation = null;
|
||||
}
|
||||
CleanupWeapons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,40 +28,102 @@ namespace Shardok {
|
||||
[Tooltip("Sprite for trail particles")]
|
||||
public Sprite trailSprite;
|
||||
|
||||
[Tooltip("Number of trail particles")]
|
||||
public int trailParticleCount = 8;
|
||||
[Tooltip("Number of trail particles in continuous trail")]
|
||||
public int trailParticleCount = 20;
|
||||
|
||||
[Tooltip("Scale of trail particles")]
|
||||
public float trailScale = 8f;
|
||||
public float trailScale = 50f;
|
||||
|
||||
[Tooltip("Color of trail")]
|
||||
public Color trailColor = new Color(1f, 0.7f, 0.3f, 0.8f);
|
||||
[Tooltip("Color of trail at front (hottest)")]
|
||||
public Color trailColorHot = new Color(1f, 0.9f, 0.6f, 0.9f);
|
||||
|
||||
[Tooltip("Color of trail at back (cooler)")]
|
||||
public Color trailColorCool = new Color(1f, 0.4f, 0.1f, 0.6f);
|
||||
|
||||
[Tooltip("Trail spread width")]
|
||||
public float trailSpread = 12f;
|
||||
|
||||
[Header("Explosion Settings")]
|
||||
[Tooltip("Sprite for explosion")]
|
||||
public Sprite explosionSprite;
|
||||
|
||||
[Tooltip("Initial scale of explosion")]
|
||||
public float explosionStartScale = 15f;
|
||||
public float explosionStartScale = 20f;
|
||||
|
||||
[Tooltip("Final scale of explosion")]
|
||||
public float explosionEndScale = 50f;
|
||||
public float explosionEndScale = 250f;
|
||||
|
||||
[Tooltip("Color of explosion")]
|
||||
public Color explosionColor = new Color(1f, 0.4f, 0.1f, 1f);
|
||||
[Tooltip("Color of explosion core")]
|
||||
public Color explosionColor = new Color(1f, 0.6f, 0.2f, 1f);
|
||||
|
||||
[Tooltip("Number of sparks")]
|
||||
public int sparkCount = 6;
|
||||
[Tooltip("Number of fire sparks")]
|
||||
public int sparkCount = 8;
|
||||
|
||||
[Tooltip("Spark spread distance")]
|
||||
public float sparkSpread = 40f;
|
||||
public float sparkSpread = 50f;
|
||||
|
||||
[Header("Debris Settings")]
|
||||
[Tooltip("Sprite for rock debris")]
|
||||
public Sprite debrisSprite;
|
||||
|
||||
[Tooltip("Number of debris rocks")]
|
||||
public int debrisCount = 6;
|
||||
|
||||
[Tooltip("Scale of debris rocks")]
|
||||
public float debrisScale = 6f;
|
||||
|
||||
[Tooltip("Debris spread distance")]
|
||||
public float debrisSpread = 60f;
|
||||
|
||||
[Tooltip("Color of debris")]
|
||||
public Color debrisColor = new Color(0.4f, 0.3f, 0.2f, 1f);
|
||||
|
||||
[Header("Meteor Rotation")]
|
||||
[Tooltip("Rotation speed in degrees per second")]
|
||||
public float rotationSpeed = 360f;
|
||||
|
||||
[Header("Charging Settings (MeteorStart)")]
|
||||
[Tooltip("Sprite for charging glow")]
|
||||
public Sprite chargeSprite;
|
||||
|
||||
[Tooltip("Color of charging effect")]
|
||||
public Color chargeColor = new Color(1f, 0.6f, 0.2f, 0.7f);
|
||||
|
||||
[Tooltip("Starting scale of charge glow")]
|
||||
public float chargeStartScale = 5f;
|
||||
|
||||
[Tooltip("Ending scale of charge glow")]
|
||||
public float chargeEndScale = 25f;
|
||||
|
||||
[Tooltip("Duration of charge animation")]
|
||||
public float chargeDuration = 0.5f;
|
||||
|
||||
[Header("Targeting Settings (MeteorTarget)")]
|
||||
[Tooltip("Sprite for target indicator")]
|
||||
public Sprite targetSprite;
|
||||
|
||||
[Tooltip("Color of target indicator")]
|
||||
public Color targetColor = new Color(1f, 0.3f, 0.1f, 0.6f);
|
||||
|
||||
[Tooltip("Scale of target indicator")]
|
||||
public float targetScale = 30f;
|
||||
|
||||
[Tooltip("Duration of target animation")]
|
||||
public float targetDuration = 0.4f;
|
||||
|
||||
[Header("Cancel Settings (MeteorCancel)")]
|
||||
[Tooltip("Color of fizzle effect")]
|
||||
public Color cancelColor = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||||
|
||||
[Tooltip("Duration of cancel animation")]
|
||||
public float cancelDuration = 0.3f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Duration of meteor fall")]
|
||||
public float fallDuration = 0.4f;
|
||||
public float fallDuration = 0.8f;
|
||||
|
||||
[Tooltip("Duration of explosion")]
|
||||
public float explosionDuration = 0.5f;
|
||||
public float explosionDuration = 0.6f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
@@ -87,6 +149,315 @@ namespace Shardok {
|
||||
StartCoroutine(AnimateMeteor(targetPos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates meteor charging effect at the caster's position.
|
||||
/// Shows a growing fiery glow gathering energy.
|
||||
/// </summary>
|
||||
public void AnimateMeteorStart(int sourceCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
|
||||
if (sourcePos == null) {
|
||||
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateCharge(sourcePos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates meteor targeting indicator at destination.
|
||||
/// Shows a pulsing circle/crosshairs marking the target.
|
||||
/// </summary>
|
||||
public void AnimateMeteorTarget(int targetCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (targetPos == null) {
|
||||
Debug.LogWarning($"MeteorAnimator: Invalid cell index {targetCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateTarget(targetPos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates meteor cancel/fizzle effect at the caster's position.
|
||||
/// Shows the charging energy dissipating.
|
||||
/// </summary>
|
||||
public void AnimateMeteorCancel(int sourceCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
|
||||
if (sourcePos == null) {
|
||||
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateCancel(sourcePos.Value));
|
||||
}
|
||||
|
||||
private IEnumerator AnimateCharge(Vector2 source) {
|
||||
Sprite sprite = chargeSprite ?? explosionSprite;
|
||||
if (sprite == null) yield break;
|
||||
|
||||
// Create charging glow
|
||||
GameObject glow = new GameObject("MeteorCharge");
|
||||
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
|
||||
|
||||
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = chargeColor;
|
||||
|
||||
// Create rising particles
|
||||
int particleCount = 5;
|
||||
GameObject[] particles = new GameObject[particleCount];
|
||||
Vector2[] particleOffsets = new Vector2[particleCount];
|
||||
|
||||
for (int i = 0; i < particleCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
|
||||
GameObject particle = new GameObject($"ChargeParticle_{i}");
|
||||
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
|
||||
pRenderer.sprite = trailSprite;
|
||||
pRenderer.sortingLayerName = "Effects";
|
||||
pRenderer.color = chargeColor;
|
||||
particle.transform.localScale = Vector3.one * trailScale;
|
||||
|
||||
float angle = (float)i / particleCount * Mathf.PI * 2f;
|
||||
particleOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 20f;
|
||||
particles[i] = particle;
|
||||
}
|
||||
|
||||
// Animation
|
||||
float elapsed = 0f;
|
||||
while (elapsed < chargeDuration) {
|
||||
float t = elapsed / chargeDuration;
|
||||
float easeT = t * (2f - t); // Ease out
|
||||
|
||||
// Grow glow
|
||||
float scale = Mathf.Lerp(chargeStartScale, chargeEndScale, easeT);
|
||||
glow.transform.localScale = Vector3.one * scale;
|
||||
|
||||
// Pulse alpha
|
||||
Color c = chargeColor;
|
||||
c.a = chargeColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
|
||||
renderer.color = c;
|
||||
|
||||
// Particles spiral inward and upward
|
||||
for (int i = 0; i < particles.Length; i++) {
|
||||
if (particles[i] == null) continue;
|
||||
|
||||
float particleT = (t + (float)i / particleCount) % 1f;
|
||||
float radius = 20f * (1f - particleT);
|
||||
float angle =
|
||||
particleT * Mathf.PI * 4f + (float)i / particleCount * Mathf.PI * 2f;
|
||||
Vector2 offset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
|
||||
|
||||
particles[i].transform.localPosition = new Vector3(
|
||||
source.x + offset.x,
|
||||
source.y + offset.y + particleT * 15f,
|
||||
4f);
|
||||
|
||||
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
|
||||
if (pRenderer != null) {
|
||||
Color pc = chargeColor;
|
||||
pc.a = chargeColor.a * (1f - particleT * 0.5f);
|
||||
pRenderer.color = pc;
|
||||
}
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Brief flash at end
|
||||
glow.transform.localScale = Vector3.one * chargeEndScale * 1.2f;
|
||||
Color flashColor = chargeColor;
|
||||
flashColor.a = 1f;
|
||||
renderer.color = flashColor;
|
||||
yield return new WaitForSeconds(0.05f);
|
||||
|
||||
// Cleanup
|
||||
Destroy(glow);
|
||||
foreach (var p in particles) {
|
||||
if (p != null) Destroy(p);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateTarget(Vector2 target) {
|
||||
Sprite sprite = targetSprite ?? explosionSprite;
|
||||
if (sprite == null) yield break;
|
||||
|
||||
// Create target indicator
|
||||
GameObject indicator = new GameObject("MeteorTarget");
|
||||
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
indicator.transform.localPosition = new Vector3(target.x, target.y, 5f);
|
||||
|
||||
SpriteRenderer renderer = indicator.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = targetColor;
|
||||
|
||||
// Create outer ring that contracts
|
||||
GameObject ring = null;
|
||||
SpriteRenderer ringRenderer = null;
|
||||
if (sprite != null) {
|
||||
ring = new GameObject("TargetRing");
|
||||
ring.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
ring.transform.localPosition = new Vector3(target.x, target.y, 6f);
|
||||
|
||||
ringRenderer = ring.AddComponent<SpriteRenderer>();
|
||||
ringRenderer.sprite = sprite;
|
||||
ringRenderer.sortingLayerName = "Effects";
|
||||
Color ringColor = targetColor;
|
||||
ringColor.a = targetColor.a * 0.5f;
|
||||
ringRenderer.color = ringColor;
|
||||
}
|
||||
|
||||
// Animation - indicator appears, ring contracts to it
|
||||
float elapsed = 0f;
|
||||
while (elapsed < targetDuration) {
|
||||
float t = elapsed / targetDuration;
|
||||
|
||||
// Main indicator pulses
|
||||
float pulse = 0.8f + 0.2f * Mathf.Sin(elapsed * 15f);
|
||||
indicator.transform.localScale = Vector3.one * targetScale * pulse;
|
||||
|
||||
Color c = targetColor;
|
||||
c.a = targetColor.a * pulse;
|
||||
renderer.color = c;
|
||||
|
||||
// Ring contracts inward
|
||||
if (ring != null) {
|
||||
float ringScale = targetScale * (2f - t);
|
||||
ring.transform.localScale = Vector3.one * ringScale;
|
||||
|
||||
Color rc = targetColor;
|
||||
rc.a = targetColor.a * 0.5f * (1f - t);
|
||||
ringRenderer.color = rc;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Final pulse and fade
|
||||
float fadeDuration = 0.15f;
|
||||
elapsed = 0f;
|
||||
while (elapsed < fadeDuration) {
|
||||
float t = elapsed / fadeDuration;
|
||||
|
||||
indicator.transform.localScale = Vector3.one * targetScale * (1f + t * 0.3f);
|
||||
|
||||
Color c = targetColor;
|
||||
c.a = targetColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Destroy(indicator);
|
||||
if (ring != null) Destroy(ring);
|
||||
}
|
||||
|
||||
private IEnumerator AnimateCancel(Vector2 source) {
|
||||
Sprite sprite = chargeSprite ?? explosionSprite;
|
||||
if (sprite == null) yield break;
|
||||
|
||||
// Create fizzling glow (starts at charge end size)
|
||||
GameObject glow = new GameObject("MeteorCancel");
|
||||
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
|
||||
glow.transform.localScale = Vector3.one * chargeEndScale;
|
||||
|
||||
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = cancelColor;
|
||||
|
||||
// Create dispersing particles
|
||||
int particleCount = 6;
|
||||
GameObject[] particles = new GameObject[particleCount];
|
||||
Vector2[] directions = new Vector2[particleCount];
|
||||
|
||||
for (int i = 0; i < particleCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
|
||||
GameObject particle = new GameObject($"CancelParticle_{i}");
|
||||
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
particle.transform.localPosition = new Vector3(source.x, source.y, 4f);
|
||||
|
||||
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
|
||||
pRenderer.sprite = trailSprite;
|
||||
pRenderer.sortingLayerName = "Effects";
|
||||
pRenderer.color = cancelColor;
|
||||
particle.transform.localScale = Vector3.one * trailScale;
|
||||
|
||||
float angle = (float)i / particleCount * Mathf.PI * 2f + Random.Range(-0.2f, 0.2f);
|
||||
directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||||
particles[i] = particle;
|
||||
}
|
||||
|
||||
// Animation - shrink and fade while particles disperse
|
||||
float elapsed = 0f;
|
||||
while (elapsed < cancelDuration) {
|
||||
float t = elapsed / cancelDuration;
|
||||
|
||||
// Shrink and fade glow
|
||||
float scale = chargeEndScale * (1f - t * 0.7f);
|
||||
glow.transform.localScale = Vector3.one * scale;
|
||||
|
||||
Color c = cancelColor;
|
||||
c.a = cancelColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
|
||||
// Particles drift outward and fade
|
||||
for (int i = 0; i < particles.Length; i++) {
|
||||
if (particles[i] == null) continue;
|
||||
|
||||
Vector2 pos = source + directions[i] * 30f * t;
|
||||
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
|
||||
|
||||
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
|
||||
if (pRenderer != null) {
|
||||
Color pc = cancelColor;
|
||||
pc.a = cancelColor.a * (1f - t);
|
||||
pRenderer.color = pc;
|
||||
particles[i].transform.localScale =
|
||||
Vector3.one * trailScale * (1f - t * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Destroy(glow);
|
||||
foreach (var p in particles) {
|
||||
if (p != null) Destroy(p);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateMeteor(Vector2 target) {
|
||||
List<GameObject> objects = new List<GameObject>();
|
||||
|
||||
@@ -95,25 +466,24 @@ namespace Shardok {
|
||||
Vector2 fallDirection = new Vector2(Mathf.Sin(angleRad), Mathf.Cos(angleRad));
|
||||
Vector2 startPos = target + fallDirection * fallDistance;
|
||||
|
||||
// Create meteor
|
||||
// Create meteor rock
|
||||
GameObject meteor = null;
|
||||
SpriteRenderer meteorRenderer = null;
|
||||
float currentRotation = 0f;
|
||||
if (meteorSprite != null) {
|
||||
meteor = new GameObject("Meteor");
|
||||
meteor.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer meteorRenderer = meteor.AddComponent<SpriteRenderer>();
|
||||
meteorRenderer = meteor.AddComponent<SpriteRenderer>();
|
||||
meteorRenderer.sprite = meteorSprite;
|
||||
meteorRenderer.sortingLayerName = "Effects";
|
||||
meteorRenderer.color = meteorColor;
|
||||
meteor.transform.localScale = Vector3.one * meteorScale;
|
||||
|
||||
// Rotate meteor to face direction of travel
|
||||
float rotAngle = Mathf.Atan2(-fallDirection.x, -fallDirection.y) * Mathf.Rad2Deg;
|
||||
meteor.transform.localRotation = Quaternion.Euler(0, 0, rotAngle);
|
||||
meteor.transform.localPosition = new Vector3(startPos.x, startPos.y, 3f);
|
||||
objects.Add(meteor);
|
||||
}
|
||||
|
||||
// Trail particles
|
||||
// Pre-create trail particles (continuous trail behind meteor)
|
||||
List<TrailParticle> trailParticles = new List<TrailParticle>();
|
||||
for (int i = 0; i < trailParticleCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
@@ -124,58 +494,75 @@ namespace Shardok {
|
||||
SpriteRenderer renderer = particle.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = trailSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = trailColor;
|
||||
particle.transform.localScale = Vector3.one * trailScale;
|
||||
particle.SetActive(false);
|
||||
particle.transform.localPosition = new Vector3(startPos.x, startPos.y, 4f);
|
||||
|
||||
// Particles further back are smaller and cooler colored
|
||||
float trailT = (float)i / trailParticleCount;
|
||||
float particleScale = trailScale * (1f - trailT * 0.6f);
|
||||
particle.transform.localScale = Vector3.one * particleScale;
|
||||
|
||||
Color particleColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
|
||||
renderer.color = particleColor;
|
||||
|
||||
// Random offset perpendicular to fall direction
|
||||
float perpOffset = Random.Range(-trailSpread, trailSpread) * trailT;
|
||||
|
||||
trailParticles.Add(new TrailParticle {
|
||||
gameObject = particle,
|
||||
delay = (float)i / trailParticleCount * 0.15f,
|
||||
offset = Random.Range(-5f, 5f)
|
||||
delay = trailT * 0.3f, // How far behind the meteor this particle trails
|
||||
offset = perpOffset
|
||||
});
|
||||
objects.Add(particle);
|
||||
}
|
||||
|
||||
// Fall phase
|
||||
// Fall phase with rotation and continuous trail
|
||||
float elapsed = 0f;
|
||||
int nextTrailIndex = 0;
|
||||
|
||||
while (elapsed < fallDuration) {
|
||||
float t = elapsed / fallDuration;
|
||||
float easeT = t * t; // Accelerating fall
|
||||
// Ease-in for accelerating fall (starts slow, speeds up)
|
||||
float easeT = t * t;
|
||||
|
||||
Vector2 currentPos = Vector2.Lerp(startPos, target, easeT);
|
||||
|
||||
// Update meteor position and rotation
|
||||
if (meteor != null) {
|
||||
meteor.transform.localPosition = new Vector3(currentPos.x, currentPos.y, 3f);
|
||||
|
||||
// Continuous rotation
|
||||
currentRotation += rotationSpeed * Time.deltaTime;
|
||||
meteor.transform.localRotation = Quaternion.Euler(0, 0, currentRotation);
|
||||
|
||||
// Slight glow intensification as it approaches
|
||||
Color c = meteorColor;
|
||||
c.r = Mathf.Min(1f, meteorColor.r + t * 0.2f);
|
||||
c.g = Mathf.Min(1f, meteorColor.g + t * 0.1f);
|
||||
meteorRenderer.color = c;
|
||||
}
|
||||
|
||||
// Spawn trail particles
|
||||
while (nextTrailIndex < trailParticles.Count &&
|
||||
elapsed >= trailParticles[nextTrailIndex].delay) {
|
||||
var tp = trailParticles[nextTrailIndex];
|
||||
tp.gameObject.SetActive(true);
|
||||
tp.spawnPosition =
|
||||
currentPos + new Vector2(tp.offset, tp.offset * 0.5f) * fallDirection.x;
|
||||
tp.gameObject.transform.localPosition =
|
||||
new Vector3(tp.spawnPosition.x, tp.spawnPosition.y, 4f);
|
||||
nextTrailIndex++;
|
||||
}
|
||||
// Update trail particles to follow behind meteor
|
||||
Vector2 perpDir = new Vector2(-fallDirection.y, fallDirection.x);
|
||||
for (int i = 0; i < trailParticles.Count; i++) {
|
||||
var tp = trailParticles[i];
|
||||
if (tp.gameObject == null) continue;
|
||||
|
||||
// Fade trail particles
|
||||
foreach (var tp in trailParticles) {
|
||||
if (!tp.gameObject.activeSelf) continue;
|
||||
float age = elapsed - tp.delay;
|
||||
if (age > 0) {
|
||||
float fadeT = Mathf.Clamp01(age / 0.2f);
|
||||
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = trailColor;
|
||||
c.a = trailColor.a * (1f - fadeT);
|
||||
renderer.color = c;
|
||||
tp.gameObject.transform.localScale =
|
||||
Vector3.one * trailScale * (1f - fadeT * 0.5f);
|
||||
}
|
||||
// Calculate position along the trail (behind the meteor)
|
||||
float trailT = (float)i / trailParticleCount;
|
||||
float trailDistance = trailT * 50f; // How far behind
|
||||
|
||||
// Position is behind meteor along fall direction
|
||||
Vector2 trailPos = currentPos + fallDirection * trailDistance;
|
||||
// Add perpendicular wobble
|
||||
trailPos += perpDir * tp.offset * Mathf.Sin(elapsed * 8f + i);
|
||||
|
||||
tp.gameObject.transform.localPosition = new Vector3(trailPos.x, trailPos.y, 4f);
|
||||
|
||||
// Flicker effect
|
||||
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color baseColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
|
||||
baseColor.a *= 0.8f + 0.2f * Mathf.Sin(elapsed * 15f + i * 0.5f);
|
||||
renderer.color = baseColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +577,7 @@ namespace Shardok {
|
||||
}
|
||||
objects.Clear();
|
||||
|
||||
// Explosion phase
|
||||
// Explosion phase - bigger and with debris
|
||||
GameObject explosion = null;
|
||||
SpriteRenderer explosionRenderer = null;
|
||||
|
||||
@@ -203,10 +590,11 @@ namespace Shardok {
|
||||
explosionRenderer.sprite = explosionSprite;
|
||||
explosionRenderer.sortingLayerName = "Effects";
|
||||
explosionRenderer.color = explosionColor;
|
||||
explosion.transform.localScale = Vector3.one * explosionStartScale;
|
||||
objects.Add(explosion);
|
||||
}
|
||||
|
||||
// Sparks
|
||||
// Fire sparks (fast moving, fiery)
|
||||
List<SparkParticle> sparks = new List<SparkParticle>();
|
||||
for (int i = 0; i < sparkCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
@@ -225,27 +613,61 @@ namespace Shardok {
|
||||
sparks.Add(new SparkParticle {
|
||||
gameObject = spark,
|
||||
direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)),
|
||||
speed = sparkSpread* Random.Range(0.7f, 1.3f)
|
||||
speed = sparkSpread* Random.Range(0.8f, 1.4f)
|
||||
});
|
||||
objects.Add(spark);
|
||||
}
|
||||
|
||||
// Rock debris (slower, with gravity arc, rotating)
|
||||
List<DebrisParticle> debris = new List<DebrisParticle>();
|
||||
Sprite actualDebrisSprite = debrisSprite ?? meteorSprite;
|
||||
if (actualDebrisSprite != null) {
|
||||
for (int i = 0; i < debrisCount; i++) {
|
||||
GameObject rock = new GameObject($"Debris_{i}");
|
||||
rock.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
rock.transform.localPosition = new Vector3(target.x, target.y, 3f);
|
||||
|
||||
SpriteRenderer renderer = rock.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = actualDebrisSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = debrisColor;
|
||||
|
||||
float randomScale = debrisScale * Random.Range(0.6f, 1.4f);
|
||||
rock.transform.localScale = Vector3.one * randomScale;
|
||||
|
||||
// Debris flies outward in all directions
|
||||
float angle =
|
||||
(float)i / debrisCount * Mathf.PI * 2f + Random.Range(-0.4f, 0.4f);
|
||||
Vector2 dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||||
|
||||
debris.Add(new DebrisParticle {
|
||||
gameObject = rock,
|
||||
direction = dir,
|
||||
speed = debrisSpread * Random.Range(0.6f, 1.2f),
|
||||
rotationSpeed = Random.Range(-400f, 400f),
|
||||
currentRotation = Random.Range(0f, 360f)
|
||||
});
|
||||
objects.Add(rock);
|
||||
}
|
||||
}
|
||||
|
||||
// Explosion animation
|
||||
elapsed = 0f;
|
||||
while (elapsed < explosionDuration) {
|
||||
float t = elapsed / explosionDuration;
|
||||
|
||||
// Expand and fade explosion
|
||||
// Expand and fade explosion with initial flash
|
||||
if (explosion != null) {
|
||||
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t);
|
||||
float flashT = t < 0.1f ? 1f + (0.1f - t) * 5f : 1f;
|
||||
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t) * flashT;
|
||||
explosion.transform.localScale = Vector3.one * scale;
|
||||
|
||||
Color c = explosionColor;
|
||||
c.a = 1f - t;
|
||||
c.a = (1f - t * t); // Slower fade at start
|
||||
explosionRenderer.color = c;
|
||||
}
|
||||
|
||||
// Animate sparks
|
||||
// Animate fire sparks (fast, straight lines)
|
||||
foreach (var spark in sparks) {
|
||||
if (spark.gameObject == null) continue;
|
||||
|
||||
@@ -258,7 +680,31 @@ namespace Shardok {
|
||||
c.a = 1f - t;
|
||||
renderer.color = c;
|
||||
spark.gameObject.transform.localScale =
|
||||
Vector3.one * trailScale * 1.5f * (1f - t * 0.5f);
|
||||
Vector3.one * trailScale * 1.5f * (1f - t * 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
// Animate debris (flies outward in all directions, rotating)
|
||||
for (int di = 0; di < debris.Count; di++) {
|
||||
var d = debris[di];
|
||||
if (d.gameObject == null) continue;
|
||||
|
||||
// Debris flies outward, slowing down over time
|
||||
float easeT = t * (2f - t); // Ease out - fast start, slow end
|
||||
Vector2 pos = target + d.direction * d.speed * easeT;
|
||||
d.gameObject.transform.localPosition = new Vector3(pos.x, pos.y, 3f);
|
||||
|
||||
// Spin the debris
|
||||
d.currentRotation += d.rotationSpeed * Time.deltaTime;
|
||||
d.gameObject.transform.localRotation =
|
||||
Quaternion.Euler(0, 0, d.currentRotation);
|
||||
debris[di] = d; // Write back modified struct
|
||||
|
||||
var renderer = d.gameObject.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = debrisColor;
|
||||
c.a = 1f - t * 0.8f; // Debris fades slower
|
||||
renderer.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,5 +730,13 @@ namespace Shardok {
|
||||
public Vector2 direction;
|
||||
public float speed;
|
||||
}
|
||||
|
||||
private struct DebrisParticle {
|
||||
public GameObject gameObject;
|
||||
public Vector2 direction;
|
||||
public float speed;
|
||||
public float rotationSpeed;
|
||||
public float currentRotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +1,225 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using static Net.Eagle0.Shardok.Api.BattalionView.Types;
|
||||
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Animates unit movement from source to target hex with smooth interpolation.
|
||||
/// Creates a temporary indicator that slides across the grid.
|
||||
/// Animates unit movement between hex cells using a trail of footprints/hoofprints.
|
||||
/// Boot prints for infantry, horseshoe prints for cavalry.
|
||||
/// Prints appear sequentially then fade out FIFO.
|
||||
/// </summary>
|
||||
public class MoveAnimator : MonoBehaviour {
|
||||
[Header("Print Sprites")]
|
||||
[Tooltip("Boot print sprite (will be flipped for left/right)")]
|
||||
public Sprite bootPrintSprite;
|
||||
|
||||
[Tooltip("Paired horseshoe prints sprite")]
|
||||
public Sprite hoofPrintSprite;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Duration of the movement in seconds")]
|
||||
public float moveDuration = 0.3f;
|
||||
[Tooltip("Number of prints between hex centers")]
|
||||
public int printsPerHex = 5;
|
||||
|
||||
[Tooltip("Optional sprite to show during movement")]
|
||||
public Sprite moveIndicatorSprite;
|
||||
[Tooltip("Time between each print appearing (seconds)")]
|
||||
public float printInterval = 0.06f;
|
||||
|
||||
[Tooltip("Scale of the movement indicator")]
|
||||
public float indicatorScale = 15f;
|
||||
[Tooltip("How long each print stays fully visible before fading (seconds)")]
|
||||
public float printLingerTime = 0.3f;
|
||||
|
||||
[Tooltip("Height offset for arc movement (0 = straight line)")]
|
||||
public float arcHeight = 0.05f;
|
||||
[Tooltip("Duration of each print's fade out (seconds)")]
|
||||
public float fadeDuration = 0.4f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
[Tooltip("Scale of print sprites")]
|
||||
public float printScale = 1.5f;
|
||||
|
||||
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
[Tooltip("Lateral offset for alternating prints")]
|
||||
public float lateralOffset = 6f;
|
||||
|
||||
[Tooltip("Color tint for boot prints")]
|
||||
public Color bootPrintColor = new Color(0.3f, 0.2f, 0.1f, 0.8f);
|
||||
|
||||
[Tooltip("Color tint for hoof prints")]
|
||||
public Color hoofPrintColor = new Color(0.25f, 0.15f, 0.05f, 0.8f);
|
||||
|
||||
private HexGrid _hexGrid;
|
||||
private readonly List<Coroutine> _activeCoroutines = new List<Coroutine>();
|
||||
|
||||
private void Start() { _hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
|
||||
/// <summary>
|
||||
/// Starts a move animation between two hex cells.
|
||||
/// Multiple animations can run simultaneously.
|
||||
/// Animate movement from source to target hex.
|
||||
/// </summary>
|
||||
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MoveAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
$"MoveAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateMovement(sourcePos.Value, targetPos.Value));
|
||||
/// <param name="sourceCellIndex">Starting hex cell index</param>
|
||||
/// <param name="targetCellIndex">Destination hex cell index</param>
|
||||
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
|
||||
public void
|
||||
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
|
||||
AnimateMove(new List<int> { sourceCellIndex, targetCellIndex }, unitType);
|
||||
}
|
||||
|
||||
private IEnumerator AnimateMovement(Vector2 source, Vector2 target) {
|
||||
// Create movement indicator if sprite assigned
|
||||
GameObject indicator = null;
|
||||
if (moveIndicatorSprite != null) {
|
||||
indicator = new GameObject("MoveIndicator");
|
||||
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
var renderer = indicator.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = moveIndicatorSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
indicator.transform.localScale = Vector3.one * indicatorScale;
|
||||
/// <summary>
|
||||
/// Animate movement along a path of hex cells.
|
||||
/// </summary>
|
||||
/// <param name="pathCellIndices">List of cell indices forming the path (including
|
||||
/// start)</param> <param name="unitType">Type of battalion (determines boot vs hoof
|
||||
/// prints)</param>
|
||||
public void AnimateMove(List<int> pathCellIndices, BattalionTypeId unitType) {
|
||||
if (pathCellIndices == null || pathCellIndices.Count < 2) {
|
||||
Debug.LogWarning("MoveAnimator: Path must have at least 2 points");
|
||||
return;
|
||||
}
|
||||
|
||||
if (bootPrintSprite == null && hoofPrintSprite == null) {
|
||||
Debug.LogWarning("MoveAnimator: No print sprites assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hexGrid == null || _hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MoveAnimator: No HexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert cell indices to positions
|
||||
var pathPositions = new List<Vector2>();
|
||||
foreach (int cellIndex in pathCellIndices) {
|
||||
Vector2? pos = _hexGrid.GetCellCenterPosition(cellIndex);
|
||||
if (pos == null) {
|
||||
Debug.LogWarning($"MoveAnimator: Invalid cell index {cellIndex}");
|
||||
return;
|
||||
}
|
||||
pathPositions.Add(pos.Value);
|
||||
}
|
||||
|
||||
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
|
||||
unitType == BattalionTypeId.HeavyCavalry;
|
||||
|
||||
StartCoroutine(SpawnPrintTrailAlongPath(pathPositions, isMounted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload for when unit type is not available - defaults to boot prints.
|
||||
/// </summary>
|
||||
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
|
||||
AnimateMove(sourceCellIndex, targetCellIndex, BattalionTypeId.LightInfantry);
|
||||
}
|
||||
|
||||
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
|
||||
yield return SpawnPrintTrailAlongPath(new List<Vector2> { source, target }, isMounted);
|
||||
}
|
||||
|
||||
private IEnumerator SpawnPrintTrailAlongPath(List<Vector2> pathPositions, bool isMounted) {
|
||||
var prints = new List<GameObject>();
|
||||
|
||||
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
|
||||
: (bootPrintSprite ?? hoofPrintSprite);
|
||||
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
|
||||
|
||||
int totalPrintIndex = 0;
|
||||
|
||||
// Spawn prints along each segment of the path
|
||||
for (int segmentIdx = 0; segmentIdx < pathPositions.Count - 1; segmentIdx++) {
|
||||
Vector2 source = pathPositions[segmentIdx];
|
||||
Vector2 target = pathPositions[segmentIdx + 1];
|
||||
Vector2 direction = (target - source).normalized;
|
||||
|
||||
// Calculate rotation angle for prints to face direction of travel
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
|
||||
|
||||
// Spawn prints along this segment
|
||||
for (int i = 0; i < printsPerHex; i++) {
|
||||
float t = (i + 1f) / (printsPerHex + 1f);
|
||||
Vector2 position = Vector2.Lerp(source, target, t);
|
||||
|
||||
// Alternate left/right for boot prints (flip horizontally)
|
||||
bool flipX = !isMounted && (totalPrintIndex % 2 == 1);
|
||||
|
||||
// Lateral offset for alternating prints (feet or front/rear hooves)
|
||||
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
|
||||
float offset = (totalPrintIndex % 2 == 0) ? -lateralOffset : lateralOffset;
|
||||
position += perpendicular * offset;
|
||||
|
||||
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
|
||||
prints.Add(print);
|
||||
|
||||
// Start FIFO fade coroutine for this print
|
||||
var fadeCoroutine = StartCoroutine(FadePrintFIFO(
|
||||
print,
|
||||
printLingerTime + totalPrintIndex * printInterval));
|
||||
_activeCoroutines.Add(fadeCoroutine);
|
||||
|
||||
totalPrintIndex++;
|
||||
yield return new WaitForSeconds(printInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all fades to complete
|
||||
float totalWaitTime = printLingerTime + fadeDuration + 0.1f;
|
||||
yield return new WaitForSeconds(totalWaitTime);
|
||||
|
||||
// Cleanup any remaining prints
|
||||
foreach (var print in prints) {
|
||||
if (print != null) { Destroy(print); }
|
||||
}
|
||||
|
||||
_activeCoroutines.Clear();
|
||||
}
|
||||
|
||||
private GameObject
|
||||
CreatePrint(Vector2 position, float angle, Sprite sprite, Color color, bool flipX) {
|
||||
GameObject print = new GameObject("Footprint");
|
||||
print.transform.SetParent(_hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer renderer = print.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.sortingOrder = -1; // Below other effects
|
||||
renderer.color = color;
|
||||
|
||||
print.transform.localPosition = new Vector3(position.x, position.y, 0);
|
||||
print.transform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
|
||||
Vector3 scale = new Vector3(flipX ? -printScale : printScale, printScale, printScale);
|
||||
print.transform.localScale = scale;
|
||||
|
||||
return print;
|
||||
}
|
||||
|
||||
private IEnumerator FadePrintFIFO(GameObject print, float delay) {
|
||||
// Wait before starting fade (FIFO - earlier prints fade first)
|
||||
yield return new WaitForSeconds(delay);
|
||||
|
||||
if (print == null) yield break;
|
||||
|
||||
SpriteRenderer renderer = print.GetComponent<SpriteRenderer>();
|
||||
if (renderer == null) yield break;
|
||||
|
||||
Color startColor = renderer.color;
|
||||
float elapsed = 0f;
|
||||
float distance = Vector2.Distance(source, target);
|
||||
|
||||
while (elapsed < moveDuration) {
|
||||
float t = elapsed / moveDuration;
|
||||
float smoothT = t * t * (3f - 2f * t); // Smooth step
|
||||
while (elapsed < fadeDuration) {
|
||||
if (print == null || renderer == null) yield break;
|
||||
|
||||
Vector3 pos = Vector3.Lerp(
|
||||
new Vector3(source.x, source.y, 5f),
|
||||
new Vector3(target.x, target.y, 5f),
|
||||
smoothT);
|
||||
|
||||
// Add slight arc on Y axis for visible height in top-down view
|
||||
float arc = 4f * arcHeight * distance * t * (1f - t);
|
||||
pos.y += arc;
|
||||
|
||||
if (indicator != null) { indicator.transform.localPosition = pos; }
|
||||
float t = elapsed / fadeDuration;
|
||||
Color newColor = startColor;
|
||||
newColor.a = Mathf.Lerp(startColor.a, 0f, t);
|
||||
renderer.color = newColor;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (indicator != null) { Destroy(indicator); }
|
||||
if (print != null) { Destroy(print); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all active print animations.
|
||||
/// </summary>
|
||||
public void CancelAllAnimations() {
|
||||
foreach (var coroutine in _activeCoroutines) {
|
||||
if (coroutine != null) { StopCoroutine(coroutine); }
|
||||
}
|
||||
_activeCoroutines.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ using UnityEngine;
|
||||
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Animates scouting action with an eye/binoculars effect and scanning beam.
|
||||
/// Creates a sweeping vision cone from source toward target.
|
||||
/// Animates scouting action with an eye at source, a vision cone extending
|
||||
/// toward target, and a glow that travels with the cone tip and grows to
|
||||
/// cover the target hex and its neighbors.
|
||||
/// </summary>
|
||||
public class ScoutAnimator : MonoBehaviour {
|
||||
[Header("Eye Settings")]
|
||||
@@ -18,34 +19,40 @@ namespace Shardok {
|
||||
public float eyeScale = 15f;
|
||||
|
||||
[Header("Vision Cone Settings")]
|
||||
[Tooltip("Sprite for vision cone/beam")]
|
||||
[Tooltip("Sprite for vision cone/beam (triangle pointing right)")]
|
||||
public Sprite coneSprite;
|
||||
|
||||
[Tooltip("Color of the vision cone")]
|
||||
public Color coneColor = new Color(1f, 1f, 0.5f, 0.3f);
|
||||
|
||||
[Tooltip("Length of the vision cone")]
|
||||
public float coneLength = 60f;
|
||||
[Tooltip("Width of the vision cone at base")]
|
||||
public float coneWidth = 30f;
|
||||
|
||||
[Tooltip("Width of the vision cone")]
|
||||
public float coneWidth = 40f;
|
||||
[Tooltip("Rotation offset for cone sprite (degrees). +90 for upward-pointing triangle")]
|
||||
public float coneRotationOffset = 90f;
|
||||
|
||||
[Header("Scan Lines Settings")]
|
||||
[Tooltip("Sprite for scan line particles")]
|
||||
public Sprite scanSprite;
|
||||
[Header("Glow Settings")]
|
||||
[Tooltip("Sprite for the traveling glow (circle)")]
|
||||
public Sprite glowSprite;
|
||||
|
||||
[Tooltip("Number of scan lines")]
|
||||
public int scanLineCount = 3;
|
||||
[Tooltip("Color of the glow")]
|
||||
public Color glowColor = new Color(1f, 1f, 0.7f, 0.5f);
|
||||
|
||||
[Tooltip("Color of scan lines")]
|
||||
public Color scanColor = new Color(1f, 1f, 0.8f, 0.6f);
|
||||
[Tooltip("Starting scale of glow (at source)")]
|
||||
public float glowStartScale = 10f;
|
||||
|
||||
[Tooltip("Final scale of glow in hex radii (2.5 = covers 7-hex area)")]
|
||||
public float glowEndRadii = 2.5f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Duration of the scan sweep")]
|
||||
public float scanDuration = 0.6f;
|
||||
[Tooltip("Duration of cone extension")]
|
||||
public float extendDuration = 0.5f;
|
||||
|
||||
[Tooltip("Sweep angle in degrees")]
|
||||
public float sweepAngle = 30f;
|
||||
[Tooltip("Duration the glow holds at target")]
|
||||
public float holdDuration = 0.4f;
|
||||
|
||||
[Tooltip("Duration of fade out")]
|
||||
public float fadeDuration = 0.3f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
@@ -55,11 +62,6 @@ namespace Shardok {
|
||||
/// Starts a scout animation from source toward target hex.
|
||||
/// </summary>
|
||||
public void AnimateScout(int sourceCellIndex, int targetCellIndex) {
|
||||
if (eyeSprite == null && coneSprite == null) {
|
||||
Debug.LogWarning("ScoutAnimator: No sprites assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("ScoutAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
@@ -74,59 +76,62 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value));
|
||||
float hexRadius = __hexGrid.GetHexInnerRadius();
|
||||
float glowEndScale = hexRadius * glowEndRadii * 2f;
|
||||
|
||||
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value, glowEndScale));
|
||||
}
|
||||
|
||||
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target) {
|
||||
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target, float glowEndScale) {
|
||||
Vector2 direction = (target - source).normalized;
|
||||
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
|
||||
float distance = Vector2.Distance(source, target);
|
||||
|
||||
// Create eye at source
|
||||
GameObject eye = null;
|
||||
SpriteRenderer eyeRenderer = null;
|
||||
if (eyeSprite != null) {
|
||||
eye = new GameObject("ScoutEye");
|
||||
eye.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
eye.transform.localPosition = new Vector3(source.x, source.y, 5f);
|
||||
eye.transform.localScale = Vector3.zero;
|
||||
|
||||
SpriteRenderer renderer = eye.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = eyeSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = eyeColor;
|
||||
eyeRenderer = eye.AddComponent<SpriteRenderer>();
|
||||
eyeRenderer.sprite = eyeSprite;
|
||||
eyeRenderer.sortingLayerName = "Effects";
|
||||
eyeRenderer.color = eyeColor;
|
||||
}
|
||||
|
||||
// Create vision cone
|
||||
GameObject cone = null;
|
||||
SpriteRenderer coneRenderer = null;
|
||||
if (coneSprite != null) {
|
||||
cone = new GameObject("VisionCone");
|
||||
cone.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
cone.transform.localPosition = new Vector3(source.x, source.y, 6f);
|
||||
cone.transform.localRotation = Quaternion.Euler(0, 0, baseAngle - sweepAngle);
|
||||
cone.transform.localScale = new Vector3(0f, coneWidth, 1f);
|
||||
cone.transform.localRotation =
|
||||
Quaternion.Euler(0, 0, baseAngle + coneRotationOffset);
|
||||
cone.transform.localScale = new Vector3(coneWidth, 0f, 1f);
|
||||
|
||||
SpriteRenderer renderer = cone.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = coneSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = coneColor;
|
||||
coneRenderer = cone.AddComponent<SpriteRenderer>();
|
||||
coneRenderer.sprite = coneSprite;
|
||||
coneRenderer.sortingLayerName = "Effects";
|
||||
coneRenderer.color = coneColor;
|
||||
}
|
||||
|
||||
// Create scan lines
|
||||
GameObject[] scanLines = new GameObject[scanLineCount];
|
||||
// Create traveling glow
|
||||
GameObject glow = null;
|
||||
SpriteRenderer glowRenderer = null;
|
||||
if (glowSprite != null) {
|
||||
glow = new GameObject("ScoutGlow");
|
||||
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
glow.transform.localPosition = new Vector3(source.x, source.y, 7f);
|
||||
glow.transform.localScale = Vector3.one * glowStartScale;
|
||||
|
||||
for (int i = 0; i < scanLineCount; i++) {
|
||||
if (scanSprite == null) break;
|
||||
|
||||
GameObject scanLine = new GameObject($"ScanLine_{i}");
|
||||
scanLine.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
scanLine.transform.localPosition = new Vector3(source.x, source.y, 4f);
|
||||
scanLine.transform.localScale = Vector3.one * 5f;
|
||||
|
||||
SpriteRenderer renderer = scanLine.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = scanSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = scanColor;
|
||||
|
||||
scanLines[i] = scanLine;
|
||||
glowRenderer = glow.AddComponent<SpriteRenderer>();
|
||||
glowRenderer.sprite = glowSprite;
|
||||
glowRenderer.sortingLayerName = "Effects";
|
||||
glowRenderer.color = glowColor;
|
||||
}
|
||||
|
||||
// Eye appearance phase
|
||||
@@ -137,7 +142,7 @@ namespace Shardok {
|
||||
float t = elapsed / appearDuration;
|
||||
|
||||
if (eye != null) {
|
||||
float scale = eyeScale * t * (2f - t); // Overshoot slightly
|
||||
float scale = eyeScale * t * (2f - t);
|
||||
eye.transform.localScale = Vector3.one * scale;
|
||||
}
|
||||
|
||||
@@ -147,89 +152,90 @@ namespace Shardok {
|
||||
|
||||
if (eye != null) { eye.transform.localScale = Vector3.one * eyeScale; }
|
||||
|
||||
// Scan sweep phase
|
||||
// Cone extension phase - cone extends, glow travels and grows
|
||||
elapsed = 0f;
|
||||
|
||||
while (elapsed < scanDuration) {
|
||||
float t = elapsed / scanDuration;
|
||||
float sweepT = Mathf.Sin(t * Mathf.PI); // Smooth ease in-out
|
||||
while (elapsed < extendDuration) {
|
||||
float t = elapsed / extendDuration;
|
||||
float easeT = t * (2f - t); // Ease out
|
||||
|
||||
// Rotate cone through sweep
|
||||
// Extend cone - Y axis is length after rotation, X is width
|
||||
if (cone != null) {
|
||||
float currentAngle = baseAngle - sweepAngle + sweepAngle * 2f * t;
|
||||
cone.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
|
||||
float length = distance * easeT;
|
||||
Vector2 conePos = source + direction * (length * 0.5f);
|
||||
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
|
||||
cone.transform.localScale =
|
||||
new Vector3(coneWidth * (1f - easeT * 0.3f), length, 1f);
|
||||
|
||||
// Extend cone
|
||||
float length = coneLength * Mathf.Min(t * 3f, 1f);
|
||||
cone.transform.localScale = new Vector3(length, coneWidth, 1f);
|
||||
|
||||
// Pulse alpha
|
||||
var coneRenderer = cone.GetComponent<SpriteRenderer>();
|
||||
if (coneRenderer != null) {
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (0.7f + sweepT * 0.3f);
|
||||
coneRenderer.color = c;
|
||||
}
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 15f));
|
||||
coneRenderer.color = c;
|
||||
}
|
||||
|
||||
// Animate scan lines along the cone
|
||||
for (int i = 0; i < scanLines.Length; i++) {
|
||||
if (scanLines[i] == null) continue;
|
||||
// Move and grow glow at cone tip
|
||||
if (glow != null) {
|
||||
Vector2 glowPos = source + direction * distance * easeT;
|
||||
glow.transform.localPosition = new Vector3(glowPos.x, glowPos.y, 7f);
|
||||
|
||||
float lineT = (t + (float)i / scanLineCount * 0.3f) % 1f;
|
||||
float lineAngle =
|
||||
(baseAngle - sweepAngle + sweepAngle * 2f * t) * Mathf.Deg2Rad;
|
||||
Vector2 lineDir = new Vector2(Mathf.Cos(lineAngle), Mathf.Sin(lineAngle));
|
||||
float glowScale = Mathf.Lerp(glowStartScale, glowEndScale, easeT);
|
||||
glow.transform.localScale = Vector3.one * glowScale;
|
||||
|
||||
Vector2 pos = source + lineDir * coneLength * lineT;
|
||||
scanLines[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
|
||||
|
||||
var renderer = scanLines[i].GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = scanColor;
|
||||
c.a = scanColor.a * (1f - lineT) * sweepT;
|
||||
renderer.color = c;
|
||||
}
|
||||
Color c = glowColor;
|
||||
c.a = glowColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
|
||||
glowRenderer.color = c;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Fade out
|
||||
float fadeDuration = 0.2f;
|
||||
elapsed = 0f;
|
||||
// Ensure final positions
|
||||
if (cone != null) {
|
||||
Vector2 conePos = source + direction * (distance * 0.5f);
|
||||
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
|
||||
cone.transform.localScale = new Vector3(coneWidth * 0.7f, distance, 1f);
|
||||
}
|
||||
if (glow != null) {
|
||||
glow.transform.localPosition = new Vector3(target.x, target.y, 7f);
|
||||
glow.transform.localScale = Vector3.one * glowEndScale;
|
||||
}
|
||||
|
||||
// Hold phase - glow pulses at target
|
||||
elapsed = 0f;
|
||||
while (elapsed < holdDuration) {
|
||||
float t = elapsed / holdDuration;
|
||||
|
||||
if (glowRenderer != null) {
|
||||
Color c = glowColor;
|
||||
c.a = glowColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 10f));
|
||||
glowRenderer.color = c;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Fade out phase
|
||||
elapsed = 0f;
|
||||
while (elapsed < fadeDuration) {
|
||||
float t = elapsed / fadeDuration;
|
||||
|
||||
if (eye != null) {
|
||||
var renderer = eye.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = eyeColor;
|
||||
c.a = eyeColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
}
|
||||
eye.transform.localScale = Vector3.one * eyeScale * (1f - t * 0.3f);
|
||||
if (eyeRenderer != null) {
|
||||
Color c = eyeColor;
|
||||
c.a = eyeColor.a * (1f - t);
|
||||
eyeRenderer.color = c;
|
||||
}
|
||||
|
||||
if (cone != null) {
|
||||
var renderer = cone.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
}
|
||||
if (coneRenderer != null) {
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (1f - t);
|
||||
coneRenderer.color = c;
|
||||
}
|
||||
|
||||
foreach (var scanLine in scanLines) {
|
||||
if (scanLine == null) continue;
|
||||
var renderer = scanLine.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = scanColor;
|
||||
c.a = scanColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
}
|
||||
if (glowRenderer != null) {
|
||||
Color c = glowColor;
|
||||
c.a = glowColor.a * (1f - t);
|
||||
glowRenderer.color = c;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
@@ -239,9 +245,7 @@ namespace Shardok {
|
||||
// Cleanup
|
||||
if (eye != null) { Destroy(eye); }
|
||||
if (cone != null) { Destroy(cone); }
|
||||
foreach (var scanLine in scanLines) {
|
||||
if (scanLine != null) { Destroy(scanLine); }
|
||||
}
|
||||
if (glow != null) { Destroy(glow); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-15
@@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using common;
|
||||
using eagle0;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using TMPro;
|
||||
@@ -35,7 +36,10 @@ namespace Shardok {
|
||||
Fear,
|
||||
// Magic
|
||||
LightningBolt,
|
||||
MeteorStart,
|
||||
MeteorTarget,
|
||||
MeteorCast,
|
||||
MeteorCancel,
|
||||
HolyWave,
|
||||
RaiseDead,
|
||||
Control,
|
||||
@@ -241,6 +245,8 @@ namespace Shardok {
|
||||
turnHistoryPanel.Clear();
|
||||
this.gameObject.SetActive(false);
|
||||
} else {
|
||||
// Notify tutorial system of turn end
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnTurnEnded();
|
||||
Model.EndTurn();
|
||||
}
|
||||
}
|
||||
@@ -391,8 +397,6 @@ namespace Shardok {
|
||||
|
||||
Model = shardokGameModel;
|
||||
|
||||
Model.UpdateAction = ModelUpdated;
|
||||
|
||||
HexMap map = Model.Map;
|
||||
Texture[] textures = new Texture[map.RowCount * map.ColumnCount];
|
||||
Coords coords = new Coords();
|
||||
@@ -418,10 +422,17 @@ namespace Shardok {
|
||||
hexGrid.SetUp();
|
||||
hexGrid.gameObject.SetActive(true);
|
||||
|
||||
// Set UpdateAction after hexGrid is ready to avoid race condition
|
||||
Model.UpdateAction = ModelUpdated;
|
||||
|
||||
SetHeroLabels();
|
||||
|
||||
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdated(); });
|
||||
|
||||
// Initialize tutorial system for tactical combat
|
||||
TutorialManager.Instance?.Initialize(null, this);
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnBattleEntered(Model);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
|
||||
#endif
|
||||
@@ -469,6 +480,9 @@ namespace Shardok {
|
||||
SetModifiers();
|
||||
UpdateReserves();
|
||||
|
||||
// Notify tutorial system of available commands (for spell/ability tutorials)
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnTacticalCommandsAvailable(Model);
|
||||
|
||||
HandleEnemyStartingPositionOverlays();
|
||||
|
||||
endTurnButton.interactable = false;
|
||||
@@ -541,6 +555,9 @@ namespace Shardok {
|
||||
var historyEntry = Model.History[i];
|
||||
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
|
||||
|
||||
// Notify tutorial system of battle action
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnBattleAction(historyEntry);
|
||||
|
||||
ActionType type = historyEntry.Type;
|
||||
AnimationType animationType = AnimationTypeForAction(type);
|
||||
|
||||
@@ -565,7 +582,12 @@ namespace Shardok {
|
||||
if (Model.UnitsById.TryGetValue(
|
||||
historyEntry.Actor.Value,
|
||||
out var actorUnit)) {
|
||||
int sourceIndex = MapCoordsToGridIndex(actorUnit.Location);
|
||||
// For Move actions, use stored source coords (unit's pre-move location)
|
||||
// For other actions, use current unit location
|
||||
int sourceIndex =
|
||||
Model.MoveSourceCoords.TryGetValue(i, out var sourceCoords)
|
||||
? MapCoordsToGridIndex(sourceCoords)
|
||||
: MapCoordsToGridIndex(actorUnit.Location);
|
||||
|
||||
// Get target - either coords (archery) or unit location (melee)
|
||||
int targetIndex = -1;
|
||||
@@ -1178,6 +1200,9 @@ namespace Shardok {
|
||||
}
|
||||
_selectedGridIndex = selectedIndex;
|
||||
RedrawCommandOverlays(selectedIndex, selectedIndex);
|
||||
|
||||
// Notify tutorial system of unit selection
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnUnitSelected(selectedIndex);
|
||||
}
|
||||
|
||||
public void HandleActionClick(int clickedIndex) {
|
||||
@@ -1343,8 +1368,8 @@ namespace Shardok {
|
||||
case CommandType.MoveCommand: return AnimationType.Move;
|
||||
// Magic
|
||||
case CommandType.LightningBoltCommand: return AnimationType.LightningBolt;
|
||||
case CommandType.MeteorStartCommand:
|
||||
case CommandType.MeteorTargetCommand: return AnimationType.MeteorCast;
|
||||
case CommandType.MeteorStartCommand: return AnimationType.MeteorStart;
|
||||
case CommandType.MeteorTargetCommand: return AnimationType.MeteorTarget;
|
||||
case CommandType.HolyWaveCommand: return AnimationType.HolyWave;
|
||||
case CommandType.RaiseDeadCommand: return AnimationType.RaiseDead;
|
||||
case CommandType.ControlCommand: return AnimationType.Control;
|
||||
@@ -1383,9 +1408,10 @@ namespace Shardok {
|
||||
case ActionType.FearAttack: return AnimationType.Fear;
|
||||
// Magic
|
||||
case ActionType.LightningBolt: return AnimationType.LightningBolt;
|
||||
case ActionType.MeteorStart:
|
||||
case ActionType.MeteorTarget:
|
||||
case ActionType.MeteorStart: return AnimationType.MeteorStart;
|
||||
case ActionType.MeteorTarget: return AnimationType.MeteorTarget;
|
||||
case ActionType.MeteorCast: return AnimationType.MeteorCast;
|
||||
case ActionType.MeteorCancel: return AnimationType.MeteorCancel;
|
||||
case ActionType.HolyWave:
|
||||
case ActionType.HolyWaveDamage: return AnimationType.HolyWave;
|
||||
case ActionType.RaisedUndead: return AnimationType.RaiseDead;
|
||||
@@ -1425,7 +1451,10 @@ namespace Shardok {
|
||||
case AnimationType.Fear: return ActionType.FearAttack;
|
||||
// Magic
|
||||
case AnimationType.LightningBolt: return ActionType.LightningBolt;
|
||||
case AnimationType.MeteorStart: return ActionType.MeteorStart;
|
||||
case AnimationType.MeteorTarget: return ActionType.MeteorTarget;
|
||||
case AnimationType.MeteorCast: return ActionType.MeteorCast;
|
||||
case AnimationType.MeteorCancel: return ActionType.MeteorCancel;
|
||||
case AnimationType.HolyWave: return ActionType.HolyWave;
|
||||
case AnimationType.RaiseDead: return ActionType.RaisedUndead;
|
||||
case AnimationType.Control: return ActionType.Controlled;
|
||||
@@ -1505,13 +1534,15 @@ namespace Shardok {
|
||||
/// For two-stage sounds (when isOwnCommand=true), plays attempt sound and tracks pending.
|
||||
/// For single-stage or other player actions, plays full sound.
|
||||
/// </summary>
|
||||
/// <param name="movePath">Optional path for move animations (list of grid indices)</param>
|
||||
void PlayAnimationAndSound(
|
||||
AnimationType animationType,
|
||||
int sourceGridIndex,
|
||||
int targetGridIndex,
|
||||
UnitViewWithName attackerUnit,
|
||||
UnitViewWithName defenderUnit,
|
||||
bool isOwnCommand = false) {
|
||||
bool isOwnCommand = false,
|
||||
List<int> movePath = null) {
|
||||
if (animationType == AnimationType.None) return;
|
||||
|
||||
// Handle sound based on whether this is own command with two-stage sounds
|
||||
@@ -1549,8 +1580,15 @@ namespace Shardok {
|
||||
}
|
||||
break;
|
||||
case AnimationType.Move:
|
||||
if (moveAnimator != null) {
|
||||
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
|
||||
if (moveAnimator != null && attackerUnit != null) {
|
||||
if (movePath != null && movePath.Count >= 2) {
|
||||
moveAnimator.AnimateMove(movePath, attackerUnit.Battalion.Type);
|
||||
} else {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case AnimationType.LightningBolt:
|
||||
@@ -1568,11 +1606,26 @@ namespace Shardok {
|
||||
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorStart:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorTarget:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCast:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCast(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCancel:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.HolyWave:
|
||||
if (holyWaveAnimator != null) {
|
||||
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
|
||||
@@ -1694,8 +1747,11 @@ namespace Shardok {
|
||||
}
|
||||
break;
|
||||
case AnimationType.Move:
|
||||
if (moveAnimator != null) {
|
||||
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
|
||||
if (moveAnimator != null && attackerUnit != null) {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
}
|
||||
break;
|
||||
case AnimationType.LightningBolt:
|
||||
@@ -1713,11 +1769,26 @@ namespace Shardok {
|
||||
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorStart:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorTarget:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCast:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCast(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCancel:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.HolyWave:
|
||||
if (holyWaveAnimator != null) {
|
||||
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
|
||||
@@ -1835,13 +1906,22 @@ namespace Shardok {
|
||||
Coords finishCoords = GridIndexToMapCoords(finishGridIndex);
|
||||
|
||||
var commandTypes = commandTypeUIManager.CommandTypesForGroup(_displayedCommandGroup);
|
||||
CommandType? executedCommand =
|
||||
var executedCommand =
|
||||
Model.PerformTargetedCommand(startCoords, finishCoords, commandTypes);
|
||||
|
||||
if (executedCommand != null) {
|
||||
var attackerUnit = Model.UnitAtCoords(startCoords);
|
||||
var defenderUnit = Model.UnitAtCoords(finishCoords);
|
||||
var animationType = AnimationTypeForCommand(executedCommand.Value);
|
||||
var animationType = AnimationTypeForCommand(executedCommand.Type);
|
||||
|
||||
// Extract path for move animations (prepend start position)
|
||||
List<int> movePath = null;
|
||||
if (executedCommand.Path.Count > 0) {
|
||||
movePath = new List<int> { startGridIndex };
|
||||
foreach (var coords in executedCommand.Path) {
|
||||
movePath.Add(MapCoordsToGridIndex(coords));
|
||||
}
|
||||
}
|
||||
|
||||
if (animationType != AnimationType.None) {
|
||||
PlayAnimationAndSound(
|
||||
@@ -1850,7 +1930,8 @@ namespace Shardok {
|
||||
finishGridIndex,
|
||||
attackerUnit,
|
||||
defenderUnit,
|
||||
isOwnCommand: true);
|
||||
isOwnCommand: true,
|
||||
movePath: movePath);
|
||||
} else if (Model.InSetUp) {
|
||||
// For commands without specific animations, play generic click in setup
|
||||
audioClipSource.PlayOneShot(soundManager.GenericClickSound());
|
||||
|
||||
@@ -75,6 +75,13 @@ public class ShardokGameModel {
|
||||
ReserveUnitsById.Values.FirstOrDefault(u => u.Location.Equals(location));
|
||||
|
||||
public List<ActionResultView> History { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the source coordinates for Move actions, keyed by history index.
|
||||
/// Used for animations since the model is fully updated before animations play.
|
||||
/// </summary>
|
||||
public Dictionary<int, Coords> MoveSourceCoords { get; } = new();
|
||||
|
||||
public PlayerId PlayerId { get; private set; }
|
||||
public GameStatus GameStatus { get; private set; }
|
||||
public RoundId CurrentRound { get; private set; }
|
||||
@@ -267,16 +274,14 @@ public class ShardokGameModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
public CommandType? PerformTargetedCommand(
|
||||
Coords start,
|
||||
Coords target,
|
||||
List<CommandType> possibleTypes) {
|
||||
public CommandDescriptor
|
||||
PerformTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
|
||||
List<CommandDescriptor> heroActions = GetCommandsForCoords(start);
|
||||
|
||||
foreach (CommandDescriptor action in heroActions) {
|
||||
if (target.Equals(action.Target) && possibleTypes.Contains(action.Type)) {
|
||||
PostAction(action);
|
||||
return action.Type;
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -402,6 +407,14 @@ public class ShardokGameModel {
|
||||
}
|
||||
|
||||
private void HandleNewHistoryEntry(ActionResultView entry) {
|
||||
// For Move actions, record the source coordinates BEFORE applying the diff
|
||||
// This is needed for animations since all diffs are applied before animations play
|
||||
if (entry.Type == ActionType.Move && entry.Actor != null) {
|
||||
if (UnitsById.TryGetValue(entry.Actor.Value, out var actorUnit)) {
|
||||
MoveSourceCoords[History.Count] = actorUnit.Location;
|
||||
}
|
||||
}
|
||||
|
||||
History.Add(entry);
|
||||
var gsvDiff = entry.GameStateViewDiff;
|
||||
if (gsvDiff == null) {
|
||||
|
||||
@@ -43,6 +43,18 @@ namespace Shardok {
|
||||
[Tooltip("Rotation arc of the swing (degrees)")]
|
||||
public float swingArc = 45f;
|
||||
|
||||
[Tooltip("Base rotation offset for hammer (degrees)")]
|
||||
public float hammerBaseRotation = 180f;
|
||||
|
||||
[Tooltip("Flip hammer horizontally")]
|
||||
public bool hammerFlipHorizontal = true;
|
||||
|
||||
[Tooltip("Base rotation offset for alternate tool (degrees)")]
|
||||
public float alternateBaseRotation = 0f;
|
||||
|
||||
[Tooltip("Flip alternate tool horizontally")]
|
||||
public bool alternateFlipHorizontal = false;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
@@ -79,9 +91,11 @@ namespace Shardok {
|
||||
}
|
||||
|
||||
private IEnumerator AnimateStrikes(Vector2 target, bool useBridgeStyle) {
|
||||
Sprite toolSprite = useBridgeStyle && alternateToolSprite != null
|
||||
? alternateToolSprite
|
||||
: hammerSprite ?? alternateToolSprite;
|
||||
bool useAlternate = useBridgeStyle && alternateToolSprite != null;
|
||||
Sprite toolSprite =
|
||||
useAlternate ? alternateToolSprite : hammerSprite ?? alternateToolSprite;
|
||||
float baseRotation = useAlternate ? alternateBaseRotation : hammerBaseRotation;
|
||||
bool flipHorizontal = useAlternate ? alternateFlipHorizontal : hammerFlipHorizontal;
|
||||
|
||||
// Create tool
|
||||
GameObject tool = new GameObject("Tool");
|
||||
@@ -90,7 +104,8 @@ namespace Shardok {
|
||||
SpriteRenderer toolRenderer = tool.AddComponent<SpriteRenderer>();
|
||||
toolRenderer.sprite = toolSprite;
|
||||
toolRenderer.sortingLayerName = "Effects";
|
||||
tool.transform.localScale = Vector3.one * toolScale;
|
||||
float xScale = flipHorizontal ? -toolScale : toolScale;
|
||||
tool.transform.localScale = new Vector3(xScale, toolScale, toolScale);
|
||||
|
||||
// Starting position (raised above target)
|
||||
Vector3 strikePoint = new Vector3(target.x, target.y, 5f);
|
||||
@@ -100,7 +115,7 @@ namespace Shardok {
|
||||
for (int i = 0; i < strikeCount; i++) {
|
||||
// Raise tool
|
||||
tool.transform.localPosition = raisePoint;
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, -swingArc);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation - swingArc);
|
||||
|
||||
float elapsed = 0f;
|
||||
float downDuration = strikeDuration * 0.4f;
|
||||
@@ -112,14 +127,14 @@ namespace Shardok {
|
||||
|
||||
tool.transform.localPosition = Vector3.Lerp(raisePoint, strikePoint, easeT);
|
||||
float angle = Mathf.Lerp(-swingArc, 0f, easeT);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
tool.transform.localPosition = strikePoint;
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, 0);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation);
|
||||
|
||||
// Spawn sparks on impact
|
||||
yield return StartCoroutine(SpawnSparks(target));
|
||||
@@ -137,7 +152,7 @@ namespace Shardok {
|
||||
|
||||
tool.transform.localPosition = Vector3.Lerp(strikePoint, raisePoint, easeT);
|
||||
float angle = Mathf.Lerp(0f, -swingArc, easeT);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0deea4d267414bcd9421d74e9c1d0d7e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f80e1b28dcb84478b8a97821d82dbde7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Defines all tutorial content programmatically.
|
||||
/// This keeps content in code for easy version control and review.
|
||||
/// Call RegisterAll() during TutorialManager initialization.
|
||||
/// </summary>
|
||||
public static class TutorialContentDefinitions {
|
||||
/// <summary>
|
||||
/// Creates and registers all tutorial sequences with the registry.
|
||||
/// </summary>
|
||||
public static void RegisterAll(TutorialManager manager, TutorialTriggerRegistry registry) {
|
||||
// Create and assign onboarding sequence
|
||||
var onboarding = CreateOnboardingSequence();
|
||||
manager.OnboardingSequence = onboarding;
|
||||
|
||||
// Register strategic contextual tutorials
|
||||
RegisterStrategicTutorials(registry);
|
||||
|
||||
// Register tactical contextual tutorials
|
||||
RegisterTacticalTutorials(registry);
|
||||
}
|
||||
|
||||
// ========== ONBOARDING SEQUENCE (13 steps) ==========
|
||||
|
||||
private static TutorialSequence CreateOnboardingSequence() {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = "onboarding";
|
||||
sequence.DisplayName = "Welcome to Eagle0";
|
||||
sequence.IsOnboarding = true;
|
||||
|
||||
sequence.Steps = new List<TutorialStep> {
|
||||
// Step 1: Welcome
|
||||
new TutorialStep {
|
||||
StepId = "welcome",
|
||||
Title = "Welcome to Eagle0",
|
||||
Description =
|
||||
"Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.\n\nLet's walk through the basics!",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 2: Map overview - select a province
|
||||
new TutorialStep {
|
||||
StepId = "select_province",
|
||||
Title = "The Strategic Map",
|
||||
Description =
|
||||
"This is your kingdom. Each colored region is a province.\n\nTap a province you control (shown in your color) to see what you can do there.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "province_selected",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 3: Province info panel
|
||||
new TutorialStep {
|
||||
StepId = "province_panel",
|
||||
Title = "Province Information",
|
||||
Description =
|
||||
"This panel shows province details: its name, terrain, any armies present, and the commands available to you.\n\nCommands let you move troops, recruit heroes, and more.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 4: Try March command
|
||||
new TutorialStep {
|
||||
StepId = "try_march",
|
||||
Title = "Issue a Command",
|
||||
Description =
|
||||
"Try issuing a March command to move your army to an adjacent province.\n\nSelect a destination and confirm the order.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "command_issued",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 5: Turn cycle explanation
|
||||
new TutorialStep {
|
||||
StepId = "turn_cycle",
|
||||
Title = "The Turn Cycle",
|
||||
Description =
|
||||
"Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.\n\nWhen all players are ready, the server processes everyone's commands and shows the results.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 6: Hidden wait for battle
|
||||
new TutorialStep {
|
||||
StepId = "wait_for_battle",
|
||||
Title = "",
|
||||
Description = "",
|
||||
DisplayMode = TutorialDisplayMode.None,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "first_battle_available",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 7: Battle introduction
|
||||
new TutorialStep {
|
||||
StepId = "battle_intro",
|
||||
Title = "Battle Time!",
|
||||
Description =
|
||||
"When armies collide, you'll fight tactical battles on a hex grid.\n\nYou command individual units - infantry, cavalry, archers, and heroes with special abilities.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 8: Battle button highlight
|
||||
new TutorialStep {
|
||||
StepId = "enter_battle",
|
||||
Title = "Enter the Battle",
|
||||
Description = "Tap the Battle button to enter tactical combat.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "battle_entered",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 9: Tactical overview
|
||||
new TutorialStep {
|
||||
StepId = "tactical_overview",
|
||||
Title = "Tactical Combat",
|
||||
Description =
|
||||
"Each unit has movement points and attack power. Position your troops wisely!\n\nUnits attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 10: Move a unit
|
||||
new TutorialStep {
|
||||
StepId = "move_unit",
|
||||
Title = "Move Your Units",
|
||||
Description =
|
||||
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\nBlue hexes show where you can move.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "battle_action",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 11: Attack an enemy
|
||||
new TutorialStep {
|
||||
StepId = "attack_enemy",
|
||||
Title = "Attack!",
|
||||
Description =
|
||||
"Move next to an enemy unit, then tap the enemy to attack.\n\nRed highlights show valid attack targets.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "battle_action",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 12: End turn
|
||||
new TutorialStep {
|
||||
StepId = "end_turn",
|
||||
Title = "End Your Turn",
|
||||
Description =
|
||||
"When you've moved all units or want to pass, tap End Turn.\n\nThe enemy will then take their turn.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "turn_ended",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 13: Completion
|
||||
new TutorialStep {
|
||||
StepId = "complete",
|
||||
Title = "You're Ready!",
|
||||
Description =
|
||||
"You now know the basics of Eagle0!\n\nExplore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander!",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = false // Don't allow skipping the final step
|
||||
}
|
||||
};
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
// ========== STRATEGIC CONTEXTUAL TUTORIALS ==========
|
||||
|
||||
private static void RegisterStrategicTutorials(TutorialTriggerRegistry registry) {
|
||||
// Diplomacy introduction
|
||||
var diplomacy = CreateSingleStepTutorial(
|
||||
"diplomacy_intro",
|
||||
"Diplomacy",
|
||||
"You can negotiate with other factions!\n\nOffer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm.",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(diplomacy, "diplomacy_available");
|
||||
|
||||
// Hero recruitment
|
||||
var heroRecruitment = CreateSingleStepTutorial(
|
||||
"hero_recruitment",
|
||||
"Heroes Available",
|
||||
"Free heroes wander the realm seeking a lord to serve.\n\nRecruit them to lead your armies! Heroes have unique abilities and grow stronger with experience.",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(heroRecruitment, "hero_recruitment_available");
|
||||
|
||||
// Weather control
|
||||
var weatherControl = CreateSingleStepTutorial(
|
||||
"weather_control",
|
||||
"Weather Magic",
|
||||
"Your mages can influence the weather!\n\nRain slows movement, storms disrupt enemies, and clear skies speed your march.",
|
||||
TutorialDisplayMode.Overlay);
|
||||
registry.RegisterTutorial(weatherControl, "weather_control_available");
|
||||
|
||||
// Prisoner management
|
||||
var prisoners = CreateSingleStepTutorial(
|
||||
"prisoner_management",
|
||||
"Prisoners Captured",
|
||||
"You've captured enemy soldiers!\n\nYou can ransom them for gold, recruit them into your army, or execute them as a warning.",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(prisoners, "prisoner_command_issued");
|
||||
}
|
||||
|
||||
// ========== TACTICAL CONTEXTUAL TUTORIALS ==========
|
||||
|
||||
private static void RegisterTacticalTutorials(TutorialTriggerRegistry registry) {
|
||||
// Lightning spell
|
||||
var lightning = CreateSingleStepTutorial(
|
||||
"spell_lightning",
|
||||
"Lightning Bolt",
|
||||
"Your mage can cast Lightning Bolt!\n\nThis spell strikes a single target for heavy damage. Great for eliminating key enemy units.",
|
||||
TutorialDisplayMode.Tooltip);
|
||||
registry.RegisterTutorial(lightning, "spell_lightning_available");
|
||||
|
||||
// Meteor spell
|
||||
var meteor = CreateSingleStepTutorial(
|
||||
"spell_meteor",
|
||||
"Meteor Strike",
|
||||
"Meteor is a devastating area spell!\n\nIt takes a turn to cast: first select target, then it lands next turn. Plan ahead!",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(meteor, "spell_meteor_available");
|
||||
|
||||
// Holy Wave spell
|
||||
var holyWave = CreateSingleStepTutorial(
|
||||
"spell_holywave",
|
||||
"Holy Wave",
|
||||
"Holy Wave heals your units and damages undead!\n\nPosition your troops carefully to maximize its effect.",
|
||||
TutorialDisplayMode.Tooltip);
|
||||
registry.RegisterTutorial(holyWave, "spell_holywave_available");
|
||||
|
||||
// Raise Dead spell
|
||||
var raiseDead = CreateSingleStepTutorial(
|
||||
"spell_raisedead",
|
||||
"Raise Dead",
|
||||
"Dark magic can raise fallen soldiers as undead!\n\nThey fight for you, but beware - they may crumble if your necromancer falls.",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(raiseDead, "spell_raisedead_available");
|
||||
|
||||
// Fire terrain
|
||||
var fireTerrain = CreateSingleStepTutorial(
|
||||
"terrain_fire",
|
||||
"Fire Hazard",
|
||||
"Fire spreads across the battlefield!\n\nUnits in burning hexes take damage. Use fire to block enemy routes or avoid it yourself.",
|
||||
TutorialDisplayMode.Tooltip);
|
||||
registry.RegisterTutorial(fireTerrain, "terrain_fire_encountered");
|
||||
|
||||
// Water terrain
|
||||
var waterTerrain = CreateSingleStepTutorial(
|
||||
"terrain_water",
|
||||
"Water Crossing",
|
||||
"Units can cross shallow water, but it's risky.\n\nCrossing takes extra movement and may fail. Some units swim better than others.",
|
||||
TutorialDisplayMode.Tooltip);
|
||||
registry.RegisterTutorial(waterTerrain, "terrain_water_encountered");
|
||||
|
||||
// Charge ability
|
||||
var charge = CreateSingleStepTutorial(
|
||||
"ability_charge",
|
||||
"Cavalry Charge",
|
||||
"Your cavalry can Charge!\n\nCharging deals bonus damage based on distance traveled. Use open terrain for maximum impact.",
|
||||
TutorialDisplayMode.Overlay);
|
||||
registry.RegisterTutorial(charge, "ability_charge_available");
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
private static TutorialSequence CreateSingleStepTutorial(
|
||||
string id,
|
||||
string title,
|
||||
string description,
|
||||
TutorialDisplayMode displayMode) {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = id;
|
||||
sequence.DisplayName = title;
|
||||
sequence.IsOnboarding = false;
|
||||
|
||||
sequence.Steps = new List<TutorialStep> { new TutorialStep {
|
||||
StepId = id + "_step",
|
||||
Title = title,
|
||||
Description = description,
|
||||
DisplayMode = displayMode,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
} };
|
||||
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a768fdf067694657a5d24b935064f88
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Ordered collection of tutorial steps forming a complete tutorial.
|
||||
/// Can be used for onboarding or contextual tutorials.
|
||||
/// Create instances via Assets > Create > Eagle0 > Tutorial Sequence.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "TutorialSequence", menuName = "Eagle0/Tutorial Sequence")]
|
||||
public class TutorialSequence : ScriptableObject {
|
||||
[Header("Identity")]
|
||||
[Tooltip("Unique identifier for this tutorial sequence")]
|
||||
public string SequenceId;
|
||||
|
||||
[Tooltip("Display name shown to user")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Whether this is the main onboarding sequence")]
|
||||
public bool IsOnboarding;
|
||||
|
||||
[Header("Steps")]
|
||||
[Tooltip("Ordered list of tutorial steps")]
|
||||
public List<TutorialStep> Steps = new List<TutorialStep>();
|
||||
|
||||
[Header("Prerequisites")]
|
||||
[Tooltip("Tutorial IDs that must be completed before this one can trigger")]
|
||||
public List<string> RequiredCompletedTutorials = new List<string>();
|
||||
|
||||
[Header("Events")]
|
||||
[Tooltip("Called when this sequence starts")]
|
||||
public UnityEvent OnSequenceStart;
|
||||
|
||||
[Tooltip("Called when this sequence completes normally")]
|
||||
public UnityEvent OnSequenceComplete;
|
||||
|
||||
[Tooltip("Called when user skips this sequence")]
|
||||
public UnityEvent OnSequenceSkipped;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a step by index, or null if out of range.
|
||||
/// </summary>
|
||||
public TutorialStep GetStep(int index) {
|
||||
if (index < 0 || index >= Steps.Count) return null;
|
||||
return Steps[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the index of a step by its ID, or -1 if not found.
|
||||
/// </summary>
|
||||
public int IndexOfStep(string stepId) {
|
||||
if (string.IsNullOrEmpty(stepId)) return -1;
|
||||
|
||||
for (int i = 0; i < Steps.Count; i++) {
|
||||
if (Steps[i].StepId == stepId) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total number of steps in this sequence.
|
||||
/// </summary>
|
||||
public int StepCount => Steps?.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all prerequisites are met.
|
||||
/// </summary>
|
||||
public bool ArePrerequisitesMet(TutorialState state) {
|
||||
if (RequiredCompletedTutorials == null || RequiredCompletedTutorials.Count == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var reqId in RequiredCompletedTutorials) {
|
||||
if (!state.HasCompletedTutorial(reqId)) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>
|
||||
/// Editor validation to catch common issues.
|
||||
/// </summary>
|
||||
private void OnValidate() {
|
||||
// Ensure SequenceId is set
|
||||
if (string.IsNullOrEmpty(SequenceId)) { SequenceId = name; }
|
||||
|
||||
// Check for duplicate step IDs
|
||||
var seenIds = new HashSet<string>();
|
||||
foreach (var step in Steps) {
|
||||
if (!string.IsNullOrEmpty(step.StepId)) {
|
||||
if (!seenIds.Add(step.StepId)) {
|
||||
Debug.LogWarning(
|
||||
$"TutorialSequence '{SequenceId}': Duplicate step ID '{step.StepId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user