mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 01:35:42 +00:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12b87dabc4 | ||
|
|
cb0111a00f | ||
|
|
508bfabece | ||
|
|
de532a3448 | ||
|
|
29c3d5f2c1 | ||
|
|
7b118f24b6 | ||
|
|
2ddb627c58 | ||
|
|
154fac85a5 | ||
|
|
5e61f43183 | ||
|
|
a79209891d | ||
|
|
cbcad13d23 | ||
|
|
9e7eaa6367 | ||
|
|
3fc8589847 | ||
|
|
abec33585d | ||
|
|
e82c174814 | ||
|
|
38732c9255 | ||
|
|
7cd56e5980 | ||
|
|
454d4e2fc7 | ||
|
|
7ad5ebdb56 | ||
|
|
1e3ae0d82c | ||
|
|
92efdbae8b | ||
|
|
378d3f6828 | ||
|
|
ca0b3930dc | ||
|
|
99fef8312b | ||
|
|
cdb8631e91 | ||
|
|
7c0bbd8b9a | ||
|
|
7c9e434430 | ||
|
|
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 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/**'
|
||||
@@ -33,11 +33,11 @@ permissions:
|
||||
jobs:
|
||||
# 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
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
|
||||
shardok_image_tag: ${{ steps.push-images.outputs.shardok_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:
|
||||
@@ -52,13 +52,12 @@ jobs:
|
||||
set -ex
|
||||
|
||||
# Build ALL images in a single bazel command - Bazel parallelizes internally
|
||||
# This is much more efficient than 4 separate GitHub Actions jobs
|
||||
echo "=== Building all Docker images ==="
|
||||
# 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:shardok_server_image \
|
||||
//ci:admin_server_image \
|
||||
//ci:jfr_sidecar_image \
|
||||
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
@@ -69,35 +68,18 @@ jobs:
|
||||
|
||||
# Save all image paths before any other bazel command changes bazel-bin symlink
|
||||
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
SHARDOK_PATH=$(readlink -f bazel-bin/ci/shardok_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 "shardok_path=$SHARDOK_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 "Shardok: $SHARDOK_PATH"
|
||||
echo "Admin: $ADMIN_PATH"
|
||||
echo "JFR Sidecar: $JFR_PATH"
|
||||
|
||||
# Verify Shardok binary is ELF (Linux) format
|
||||
echo "=== Verifying Shardok binary format ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
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 is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary is NOT ELF format!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
@@ -143,14 +125,6 @@ jobs:
|
||||
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
|
||||
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Push Shardok image
|
||||
SHARDOK_IMAGE="${{ steps.build-all.outputs.shardok_path }}"
|
||||
SHARDOK_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
|
||||
echo "Pushing Shardok: $SHARDOK_TAG"
|
||||
$CRANE push "$SHARDOK_IMAGE" "$SHARDOK_TAG"
|
||||
$CRANE copy "$SHARDOK_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
|
||||
echo "shardok_image_tag=$SHARDOK_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Push Admin image
|
||||
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
|
||||
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
||||
@@ -170,13 +144,12 @@ jobs:
|
||||
echo "=== All images pushed successfully ==="
|
||||
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
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-all.outputs.eagle_image_tag }}
|
||||
SHARDOK_IMAGE: ${{ needs.build-all.outputs.shardok_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 }}
|
||||
@@ -238,7 +211,6 @@ jobs:
|
||||
|
||||
# Environment variables passed via heredoc
|
||||
EAGLE_IMAGE="${EAGLE_IMAGE}"
|
||||
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
|
||||
ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
@@ -268,7 +240,6 @@ jobs:
|
||||
chmod +x deploy/update-env.sh
|
||||
cd deploy && ./update-env.sh \
|
||||
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
|
||||
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
|
||||
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
|
||||
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
|
||||
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
|
||||
@@ -292,7 +263,7 @@ jobs:
|
||||
# Login to registry
|
||||
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
||||
|
||||
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Install crane for pulling OCI images
|
||||
echo "Installing crane..."
|
||||
@@ -304,9 +275,6 @@ jobs:
|
||||
echo "Pulling Eagle image..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
|
||||
|
||||
echo "Pulling Shardok image..."
|
||||
./crane pull "\${SHARDOK_IMAGE}" shardok.tar && 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
|
||||
|
||||
@@ -319,10 +287,12 @@ jobs:
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Recreate shardok (stateless, safe to restart anytime)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok
|
||||
# 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
|
||||
|
||||
# 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/.*://')
|
||||
|
||||
@@ -17,7 +17,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+144
-25
@@ -6,13 +6,18 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/mac_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/**"
|
||||
- "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_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/**"
|
||||
@@ -20,11 +25,17 @@ on:
|
||||
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_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/**"
|
||||
@@ -40,26 +51,34 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-unity:
|
||||
runs-on: self-hosted
|
||||
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
|
||||
clean: true # Remove untracked files like old SparklePlugin.bundle
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Restore Library/
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: mac
|
||||
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/
|
||||
if: success()
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: mac
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
|
||||
- name: Inject Sparkle Framework
|
||||
@@ -70,8 +89,21 @@ jobs:
|
||||
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() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
@@ -101,25 +133,124 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Code Sign App
|
||||
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
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: Notarize App
|
||||
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
- 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_mac_app.sh
|
||||
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
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
|
||||
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
@@ -140,15 +271,3 @@ jobs:
|
||||
"$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: /tmp/eagle0/editor_mac.log
|
||||
|
||||
@@ -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,7 +159,7 @@ jobs:
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
deploy-hetzner:
|
||||
runs-on: self-hosted
|
||||
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
|
||||
|
||||
@@ -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,13 +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"
|
||||
@@ -30,27 +37,31 @@ 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
|
||||
with:
|
||||
lfs: true
|
||||
clean: false
|
||||
clean: true # Remove untracked files from previous builds
|
||||
- name: Pull lfs files
|
||||
run: git lfs pull
|
||||
- name: Restore Library/
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: windows
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
- name: Build Windows unity
|
||||
run: ./ci/github_actions/build_unity.sh "/tmp/eagle0/eagle0WIN"
|
||||
- name: Persist Library/
|
||||
if: success()
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: windows
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
- name: Deploy Windows unity
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
|
||||
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
|
||||
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
|
||||
|
||||
## Recent Completed Work
|
||||
|
||||
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
|
||||
|
||||
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
|
||||
|
||||
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
|
||||
|
||||
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
|
||||
|
||||
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
|
||||
|
||||
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
|
||||
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
|
||||
|
||||
When migrating a file:
|
||||
1. Create a `Legacy*` version containing the proto-dependent methods
|
||||
2. Keep the original file name for protoless methods
|
||||
3. Update callers to use the appropriate version based on their context
|
||||
|
||||
## Migration Status
|
||||
|
||||
### Fully Protoless (no proto imports)
|
||||
|
||||
**Utilities:**
|
||||
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
|
||||
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
|
||||
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
|
||||
|
||||
**Command Selectors (all use native GameState):**
|
||||
- [x] `AllianceOfferCommandSelector`
|
||||
- [x] `AlmsCommandSelector`
|
||||
- [x] `AttackCommandChooser`
|
||||
- [x] `ExpandCommandSelector`
|
||||
- [x] `HeroGiftCommandSelector`
|
||||
- [x] `ImproveCommandSelector`
|
||||
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
|
||||
- [x] `OrganizeCommandSelector`
|
||||
- [x] `RansomOfferHelpers`
|
||||
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
|
||||
- [x] `TruceOfferCommandSelector`
|
||||
- [x] `TrustForDiplomacy`
|
||||
|
||||
**Quest Command Selectors (all protoless):**
|
||||
- [x] `AllianceQuestCommandChooser`
|
||||
- [x] `AlmsAcrossRealmQuestCommandChooser`
|
||||
- [x] `AlmsToProvinceQuestCommandChooser`
|
||||
- [x] `DismissSpecificVassalCommandChooser`
|
||||
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
|
||||
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
|
||||
- [x] `ImproveQuestCommandChooser`
|
||||
- [x] `QuestCommandChooser`
|
||||
- [x] `TruceCountQuestCommandChooser`
|
||||
- [x] `TruceWithFactionQuestCommandChooser`
|
||||
|
||||
### Fully Protoless
|
||||
|
||||
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
|
||||
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
|
||||
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
|
||||
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
|
||||
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
|
||||
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
|
||||
|
||||
### AI Layer ✅ COMPLETE
|
||||
|
||||
All AI and command chooser code is now fully protoless:
|
||||
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
|
||||
- [x] `CommandChooser` - trait uses Scala GameState
|
||||
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
|
||||
- [x] `MidGameAIClient` - uses Scala GameState internally
|
||||
|
||||
### Still Using Proto GameState (Boundary Code)
|
||||
|
||||
These files use proto GameState because they're at system boundaries:
|
||||
|
||||
**View Filters (client projection):**
|
||||
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
|
||||
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
|
||||
- `view_filters/FactionViewFilter` - has Scala overload
|
||||
- `view_filters/HeroViewFilter` - has Scala overload
|
||||
- `view_filters/BattalionNameFilter` - has Scala overload
|
||||
- `view_filters/BattleFilter` - has Scala overload
|
||||
|
||||
**Legacy Utilities (to be deprecated):**
|
||||
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
|
||||
- Used by code that still needs proto GameState
|
||||
|
||||
**Persistence/Action System:**
|
||||
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
|
||||
- `ActionWithResultingState` - caches both proto and Scala state
|
||||
|
||||
**Shardok Interface (gRPC boundary):**
|
||||
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1-3: AI Layer ✅ COMPLETE
|
||||
|
||||
The entire AI decision-making layer is now protoless.
|
||||
|
||||
### Phase 4: View Filters ✅ COMPLETE
|
||||
|
||||
The view_filters package migration is complete:
|
||||
|
||||
**Completed:**
|
||||
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
|
||||
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
|
||||
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
|
||||
- [x] `HeroViewFilter` - added Scala overload
|
||||
- [x] `FactionViewFilter` - added Scala overload
|
||||
- [x] `Visibility` - added Scala overloads
|
||||
|
||||
**Still Using Proto:**
|
||||
- [x] `BattalionNameFilter` - has Scala overload
|
||||
- [x] `BattleFilter` - has Scala overload
|
||||
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
|
||||
|
||||
**Strategy:**
|
||||
1. Add Scala GameState overloads to view filter methods
|
||||
2. Update callers to pass Scala GameState where available
|
||||
3. Eventually deprecate proto versions
|
||||
|
||||
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
|
||||
|
||||
Remove Legacy* utilities by migrating remaining callers:
|
||||
1. Identify callers of each Legacy* util
|
||||
2. Update callers to use protoless versions
|
||||
3. Delete Legacy* files when no longer needed
|
||||
|
||||
**Deleted (no production callers):**
|
||||
- [x] `LegacyProvinceDistances` - deleted (no callers)
|
||||
- [x] `LegacyBattalionSuitability` - deleted (no callers)
|
||||
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
|
||||
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
|
||||
|
||||
**Refactored to Thin Wrappers (delegating to protoless versions):**
|
||||
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
|
||||
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
|
||||
|
||||
**Parallel Implementations (proto mirrors protoless):**
|
||||
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
|
||||
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
|
||||
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
|
||||
|
||||
**Parallel Implementations (awaiting migration of callers):**
|
||||
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
|
||||
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
|
||||
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
|
||||
|
||||
### Recent Caller Migration
|
||||
|
||||
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
|
||||
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
|
||||
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
|
||||
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
|
||||
|
||||
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
|
||||
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
|
||||
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
|
||||
- Proto overload retained for backward compatibility
|
||||
|
||||
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
|
||||
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
|
||||
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
|
||||
- Factory is now fully protoless internally (still returns proto types for API boundary)
|
||||
|
||||
**ProvinceViewFilter** - added Scala overload with faction filtering:
|
||||
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
|
||||
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
|
||||
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
|
||||
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
|
||||
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
|
||||
|
||||
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
|
||||
- Scala overload now fully protoless internally
|
||||
- Uses the new ProvinceViewFilter Scala overload with faction filtering
|
||||
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
|
||||
|
||||
## Key Files
|
||||
|
||||
### Protoless Model Types
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
|
||||
|
||||
### Proto Converters
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
|
||||
|
||||
## Notes
|
||||
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
|
||||
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
|
||||
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
|
||||
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
|
||||
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
|
||||
@@ -130,6 +130,13 @@ bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_a
|
||||
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
|
||||
#
|
||||
@@ -293,6 +300,17 @@ 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.)
|
||||
# Primary: DigitalOcean Spaces (public, reliable)
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
|
||||
Generated
+1
-1
@@ -396,7 +396,7 @@
|
||||
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
|
||||
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
|
||||
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
|
||||
@@ -9,9 +9,6 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
/bin/echo "build protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
/bin/echo "build plugins"
|
||||
./scripts/build_windows_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Windows"
|
||||
|
||||
@@ -7,8 +7,8 @@ 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
|
||||
/bin/echo "build Sparkle plugin"
|
||||
./scripts/build_sparkle_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Persist Unity Library/ cache to persistent storage
|
||||
#
|
||||
# Environment variables:
|
||||
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
|
||||
# Defaults to "mac" if not set
|
||||
#
|
||||
# Note: Library/Bee/ is excluded because it contains DAG files with hardcoded
|
||||
# file paths that become stale when project files change. This prevents
|
||||
# "Data at the root level is invalid" XML errors from stale references.
|
||||
|
||||
set -uxo pipefail
|
||||
|
||||
/bin/echo "persist Library/"
|
||||
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
|
||||
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
|
||||
|
||||
/bin/echo "persist Library/ to $CACHE_DIR (excluding Bee/)"
|
||||
|
||||
# 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/
|
||||
/usr/bin/rsync -rtlDvq --exclude='Bee/' src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ "$CACHE_DIR/"
|
||||
rsync_exit=$?
|
||||
|
||||
if [ $rsync_exit -eq 0 ]; then
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Restore Unity Library/ cache from persistent storage
|
||||
#
|
||||
# Environment variables:
|
||||
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
|
||||
# Defaults to "mac" if not set
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "restore Library/"
|
||||
/bin/mkdir -p /tmp/eagle0/Library
|
||||
/usr/bin/rsync -rtlDvq /tmp/eagle0/Library/ src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
|
||||
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
|
||||
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
|
||||
|
||||
/bin/echo "restore Library/ from $CACHE_DIR"
|
||||
/bin/mkdir -p "$CACHE_DIR"
|
||||
/usr/bin/rsync -rtlDvq "$CACHE_DIR/" src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
|
||||
|
||||
+3
-3
@@ -3,8 +3,8 @@
|
||||
# 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
|
||||
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-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
|
||||
@@ -27,8 +27,8 @@ DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Shardok connection
|
||||
SHARDOK_ADDRESS=shardok:40042
|
||||
# Shardok connection (Hetzner ARM64 server)
|
||||
SHARDOK_ADDRESS=
|
||||
SHARDOK_AUTH_TOKEN=
|
||||
|
||||
# Monitoring
|
||||
|
||||
+6
-28
@@ -1,11 +1,13 @@
|
||||
# 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:
|
||||
# Blue-green deployment: eagle-blue is the primary (production) instance
|
||||
@@ -33,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"
|
||||
@@ -47,7 +49,6 @@ services:
|
||||
- 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:
|
||||
@@ -96,7 +97,6 @@ services:
|
||||
- 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:
|
||||
- shardok
|
||||
- auth
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
@@ -162,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
|
||||
|
||||
+198
-300
@@ -33,7 +33,23 @@
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
## Current State (January 2026)
|
||||
|
||||
### Summary
|
||||
|
||||
| Area | Proto Imports | Status |
|
||||
|------|---------------|--------|
|
||||
| AI (`/ai/`) | **0** | ✅ **Complete** |
|
||||
| Actions (`/library/actions/impl/action/`) | **0** | ✅ **Complete** |
|
||||
| Commands (`/library/actions/impl/command/`) | **0** | ✅ **Complete** |
|
||||
| Availability (`/library/actions/availability/`) | **0** | ✅ **Complete** |
|
||||
| Command Choice Helpers (`/library/util/command_choice_helpers/`) | **3** | ✅ Near Complete (1 file) |
|
||||
| View Filters (`/library/util/view_filters/`) | **4** | ⏳ In progress (3 files) |
|
||||
| LLM Prompt Generators (`/library/actions/llm_prompt_generators/`) | **56** | ⏳ Remaining work (34 files) |
|
||||
| Other Utilities (`/library/util/`) | **13** | ⏳ Remaining work (5 files) |
|
||||
| Root Library (`/library/`) | **9** | Boundary code (3 files) |
|
||||
|
||||
**Total: ~85 proto imports remaining** (down from 149)
|
||||
|
||||
### Completed Phases
|
||||
|
||||
@@ -43,346 +59,228 @@
|
||||
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
|
||||
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
|
||||
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
|
||||
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
|
||||
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
|
||||
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
|
||||
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
|
||||
| Phase 5: Action Base Classes | **Complete** | All actions converted to T-type base classes |
|
||||
| Phase 5b-d: RoundPhaseAdvancer | **Complete** | Uses Scala GameState throughout |
|
||||
| Phase 6: Core Engine | **Complete** | ActionResultApplier, RandomStateSequencer protoless |
|
||||
| Phase 6b: CommandFactory | **Complete** | Uses Scala SelectedCommand and AvailableCommand |
|
||||
| Phase 6c: AI Clients | **Complete** | AIClient, command choosers all protoless |
|
||||
| Phase 6d: CommandSelection | **Complete** | Renamed ScalaCommandSelection → CommandSelection |
|
||||
|
||||
### Phase 5c/5d Progress (Complete)
|
||||
### Recent Completions (PRs #5326, #5330, #5332, #5333, #5334, #5336)
|
||||
|
||||
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
|
||||
|
||||
| Action | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
|
||||
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
|
||||
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
|
||||
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
|
||||
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
|
||||
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
|
||||
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
|
||||
|
||||
### EngineImpl Progress
|
||||
|
||||
| Change | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `recursiveTransform` deleted | #4677 | ✅ Merged |
|
||||
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
|
||||
|
||||
### Current Architecture
|
||||
|
||||
**ActionResultT Production (100% Complete):**
|
||||
- All actions produce `ActionResultT`
|
||||
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
|
||||
- No direct `ActionResultProto` construction outside the converter
|
||||
|
||||
**ActionResultProto Consumption (Next Target):**
|
||||
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
|
||||
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
|
||||
- `InMemoryHistory` / `PersistedHistory` - stores proto results
|
||||
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
|
||||
1. **CommandFactory protoless** - Now accepts/returns Scala `SelectedCommand` types
|
||||
2. **AI clients protoless** - `AIClient`, `MidGameAIClient`, all command choosers use Scala types
|
||||
3. **CommandSelection cleanup** - Deleted legacy proto-based `CommandSelection`, renamed `ScalaCommandSelection` → `CommandSelection`
|
||||
4. **AvailableCommandsFactory protoless** - Returns Scala `OneProvinceAvailableCommands`
|
||||
5. **All CommandChoiceHelpers protoless** - ~25 selector/chooser files converted (only RansomOfferHelpers remains)
|
||||
6. **ChronicleEventGenerator protoless** (PR #5332) - Removed last proto enum import from Actions layer
|
||||
7. **ProvinceUtils protoless** (PR #5333) - Converted to use Scala `BattalionType`
|
||||
8. **FactionViewFilter protoless** (PR #5334) - Returns Scala `FactionView`, converts to proto at edge
|
||||
9. **HeroViewFilter protoless** (PR #5336) - Returns Scala `HeroView`, converts to proto at edge
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Migrate to ActionResultT Consumers
|
||||
## Remaining Work
|
||||
|
||||
### Objective
|
||||
### Phase 7: View Filters (~4 proto imports remaining in 3 files)
|
||||
|
||||
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
|
||||
| File | Status | Migration Path |
|
||||
|------|--------|----------------|
|
||||
| `FactionViewFilter.scala` | ✅ **Complete** | Returns Scala `FactionView` (PR #5334) |
|
||||
| `HeroViewFilter.scala` | ✅ **Complete** | Returns Scala `HeroView` (PR #5336) |
|
||||
| `GameStateViewFilter.scala` | Partial | Converts views at edge; uses `FactionViewProto`, `GameStateView` |
|
||||
| `ProvinceViewFilter.scala` | Partial | Uses `ProvinceEvent` proto for `knownEvents` field |
|
||||
| `BattleFilter.scala` | Keep | Uses `ShardokBattleView` (boundary code for battles) |
|
||||
|
||||
### Current Flow (Proto-Heavy)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultProtoConverter.toProto()
|
||||
→ ActionResultProto
|
||||
→ ActionResultProtoApplierImpl.applyActionResults()
|
||||
→ GameStateProto
|
||||
→ GameStateConverter.fromProto()
|
||||
→ GameStateC
|
||||
```
|
||||
**Pattern established**: Create Scala view type → Create converter → Update filter to return Scala type → Convert at edge in `GameStateViewFilter`.
|
||||
|
||||
### Target Flow (T-Types Throughout)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultApplier.applyActionResults()
|
||||
→ GameStateC
|
||||
### Phase 8: LLM Prompt Generators (~56 proto imports in 34 files)
|
||||
|
||||
(Proto conversion only at boundaries)
|
||||
```
|
||||
The LLM prompt generators still use proto types for hero/faction/province data:
|
||||
|
||||
### Key Files to Convert
|
||||
| File Category | Files | Proto Usage |
|
||||
|---------------|-------|-------------|
|
||||
| Diplomacy prompts | 12 | `Hero`, `Faction`, `Province` protos |
|
||||
| Quest prompts | 4 | `Hero`, `Faction` protos |
|
||||
| Chronicle prompts | 3 | `Hero`, `Faction`, `Province` protos |
|
||||
| Other prompts | 15 | Various proto types |
|
||||
|
||||
**Tier 1 - Core Applier:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
|
||||
```
|
||||
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
|
||||
**Migration strategy**: These files generate text for LLM prompts. They can be migrated to accept Scala types (`HeroT`, `FactionT`, `ProvinceT`) with converters at the call sites if needed.
|
||||
|
||||
**Tier 2 - RoundPhaseAdvancer:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
|
||||
```
|
||||
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
|
||||
**Priority**: Medium - These don't block other migrations and are isolated.
|
||||
|
||||
**Tier 3 - Sequencers:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
|
||||
```
|
||||
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
|
||||
### Phase 9: Remaining Utilities (~13 proto imports in 5 files)
|
||||
|
||||
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
|
||||
| File | Proto Usage | Migration Path |
|
||||
|------|-------------|----------------|
|
||||
| `ProvinceEventUtils.scala` | 2 imports | Convert to Scala types |
|
||||
| `MapGenerator.scala` | 1 import | Convert to Scala types |
|
||||
| `IncomingArmyUtils.scala` | 1 import | Convert to Scala types |
|
||||
| `GameStateViewDiffer.scala` | 7 imports | Keep - works with client view protos (boundary) |
|
||||
| `StatWithConditionUtils.scala` | 2 imports | Convert to Scala types |
|
||||
|
||||
**Target State**: Create a fully protoless sequencer where:
|
||||
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
|
||||
2. All callback methods pass Scala `GameState` to callers
|
||||
3. Actions using the sequencer can be fully protoless
|
||||
### Phase 10: History APIs
|
||||
|
||||
**Migration Path**:
|
||||
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
|
||||
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
|
||||
3. Migrate actions one by one to use the new Scala-based callbacks
|
||||
4. Once all actions migrated, deprecate/remove proto-based callbacks
|
||||
5. Remove `lastStateProto` once no longer used
|
||||
|
||||
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
|
||||
|
||||
| Action | Status |
|
||||
|--------|--------|
|
||||
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformReconResolutionAction` | ✅ Migrated |
|
||||
| `NewRoundAction` | ✅ Migrated (PR #4698) |
|
||||
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
|
||||
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
|
||||
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
|
||||
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
|
||||
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
|
||||
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
|
||||
|
||||
**TCommandFactory Extraction** (PR #4684):
|
||||
|
||||
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
|
||||
|
||||
- `TCommandFactory` - lightweight trait with just `makeTCommand`
|
||||
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
|
||||
- Actions accepting command factories now use `TCommandFactory` type for better testability
|
||||
|
||||
**Tier 4 - History APIs:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
|
||||
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
|
||||
```
|
||||
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
|
||||
|
||||
### ActionResultProto Consumer Inventory
|
||||
|
||||
| File | Usage | Status |
|
||||
|------|-------|--------|
|
||||
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
|
||||
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
|
||||
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
|
||||
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
|
||||
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
|
||||
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
|
||||
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
|
||||
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
|
||||
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
|
||||
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
**Progress: 52 of 52 action files (100%) are fully protoless.** ✅
|
||||
|
||||
All action files have been migrated to use Scala types:
|
||||
|
||||
| Action | Status | Notes |
|
||||
|--------|--------|-------|
|
||||
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
|
||||
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
|
||||
|
||||
**Deleted Dead Code:**
|
||||
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
|
||||
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
|
||||
|
||||
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
|
||||
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
|
||||
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
|
||||
|
||||
### Estimated Effort (Remaining)
|
||||
|
||||
| Component | Lines | Complexity | Blocks |
|
||||
|-----------|-------|------------|--------|
|
||||
| History API updates | ~100 | Low | - |
|
||||
| **Total Remaining** | **~100** | | |
|
||||
|
||||
**Completed:**
|
||||
- ✅ `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
|
||||
- ✅ `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:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
|
||||
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
|
||||
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
|
||||
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
|
||||
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
|
||||
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
|
||||
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
|
||||
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
|
||||
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
|
||||
|
||||
**All CommandChoiceHelpers selectors have been migrated to Scala types.** ✅
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 52 / 52 (100%) ✅ |
|
||||
| Proto usages in remaining actions | 0 |
|
||||
| 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
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
|
||||
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
|
||||
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
|
||||
- [x] `CommandChoiceHelpers` uses Scala types ✅
|
||||
- [x] All action files (52/52) are fully protoless ✅
|
||||
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
|
||||
- [ ] History APIs vend Scala types
|
||||
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
|
||||
- [ ] All tests pass
|
||||
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Clean Up Legacy Utilities
|
||||
## Architecture Summary
|
||||
|
||||
### Objective
|
||||
Remove remaining direct proto imports from utility classes.
|
||||
### Fully Protoless Areas ✅
|
||||
|
||||
### Files to Modify
|
||||
- **AI layer** (`/ai/`) - All AI clients and command choosers
|
||||
- **Actions** (`/library/actions/impl/action/`) - All 52 action files
|
||||
- **Commands** (`/library/actions/impl/command/`) - CommandFactory and all commands
|
||||
- **Availability** (`/library/actions/availability/`) - AvailableCommandsFactory and all factories
|
||||
- **Command helpers** (`/library/util/command_choice_helpers/`) - All selectors and choosers (except RansomOfferHelpers)
|
||||
- **Core types** - `CommandSelection`, `SelectedCommand`, `AvailableCommand`, `OneProvinceAvailableCommands`
|
||||
- **View types** - `FactionView`, `HeroView` (return Scala, convert at edge)
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
|
||||
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
|
||||
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
|
||||
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
|
||||
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
|
||||
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
|
||||
### Proto at Boundaries (Expected) ✅
|
||||
|
||||
### View Filters (Partially Complete)
|
||||
|
||||
The view filter utilities now have Scala overloads for server-side use:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
|
||||
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
|
||||
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
|
||||
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
|
||||
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
|
||||
|
||||
**Unblocked Actions** (PR #4752):
|
||||
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
|
||||
- `PerformReconResolutionAction` - can now use Scala overload
|
||||
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
|
||||
|
||||
**Remaining Work**:
|
||||
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
|
||||
- `withdrawnFromProvinceView` still uses proto types
|
||||
- These are needed for client-facing views with visibility restrictions
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Verify Boundaries
|
||||
|
||||
### Objective
|
||||
Confirm protos are used correctly at boundaries — and ONLY there.
|
||||
|
||||
### Expected Proto Usage (Keep)
|
||||
- `EagleServiceImpl.scala` - gRPC boundary
|
||||
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
|
||||
- `GameController.scala` - Client communication
|
||||
- `GameStateViewFilter.scala` - Converts Scala views to proto for client
|
||||
- `*Converter.scala` - Explicit conversion utilities
|
||||
- `*Loader.scala` - File loading utilities
|
||||
- `PersistedHistory.scala` - Disk persistence
|
||||
|
||||
### Expected No Proto Usage (Verify)
|
||||
- `/library/actions/impl/` - Pure Scala models
|
||||
- `/library/util/` - Pure Scala models (except loaders)
|
||||
- `/model/state/` - Pure Scala models
|
||||
### Remaining Proto Usage ⏳
|
||||
|
||||
- View filters (4 imports in 3 files) - In progress
|
||||
- LLM prompt generators (56 imports in 34 files) - Medium priority
|
||||
- Some utility files (13 imports in 5 files) - Low priority
|
||||
- History API internals - Low priority
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
## Estimated Remaining Effort
|
||||
|
||||
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
|
||||
|
||||
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
|
||||
|
||||
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
|
||||
| Component | Files | Imports | Priority |
|
||||
|-----------|-------|---------|----------|
|
||||
| View Filters | 3 | 4 | High |
|
||||
| LLM Prompt Generators | 34 | 56 | Medium |
|
||||
| Utility files | 5 | 13 | Low |
|
||||
| Root Library (boundary) | 3 | 9 | Keep |
|
||||
| **Total** | **45** | **~82** | |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Code Quality
|
||||
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
|
||||
- [ ] Zero proto imports in `/library/` utilities (except loaders)
|
||||
- [ ] `GameStateT` used throughout engine internals
|
||||
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
|
||||
- [x] Zero proto imports in `/ai/`
|
||||
- [x] Zero proto imports in `/library/actions/impl/command/`
|
||||
- [x] Zero proto imports in `/library/actions/availability/`
|
||||
- [x] Zero proto imports in `/library/util/command_choice_helpers/` (except RansomOfferHelpers)
|
||||
- [x] Zero proto imports in `/library/actions/impl/action/`
|
||||
- [ ] Zero proto imports in `/library/util/view_filters/` (except BattleFilter, GameStateViewDiffer)
|
||||
- [ ] Zero proto imports in `/library/actions/llm_prompt_generators/`
|
||||
- [ ] Zero proto imports in `/library/util/` (except view filters)
|
||||
|
||||
### Architecture
|
||||
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
|
||||
- [ ] Converters as the only bridge between domains
|
||||
- [x] Clear separation: Scala models (internal) vs Proto (boundaries)
|
||||
- [x] Converters as the only bridge between domains
|
||||
- [x] CommandFactory accepts/returns Scala types
|
||||
- [x] AI layer fully protoless
|
||||
- [x] FactionViewFilter returns Scala types
|
||||
- [x] HeroViewFilter returns Scala types
|
||||
- [ ] All view filters return Scala types
|
||||
- [ ] LLM layer uses Scala types
|
||||
- [ ] No "proto creep" into business logic
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **LLM Prompt Generators**: Should these accept Scala types directly, or is it acceptable to have proto usage here since they're generating text (not core game logic)?
|
||||
|
||||
2. **GameStateViewDiffer**: This works with view protos for client updates. Should it remain proto-based since it's generating client-facing data?
|
||||
|
||||
3. **History Serialization**: Keep proto for persistence (good for schema evolution) or consider alternatives?
|
||||
|
||||
---
|
||||
|
||||
## Proto Import Inventory (Detailed)
|
||||
|
||||
**Current: ~85 proto imports across 45 files** (as of 2026-01-14)
|
||||
|
||||
### Summary by Directory
|
||||
|
||||
| Directory | Files | Imports | Status |
|
||||
|-----------|-------|---------|--------|
|
||||
| `ai/` | 0 | 0 | ✅ Clean |
|
||||
| `actions/availability/` | 0 | 0 | ✅ Clean |
|
||||
| `actions/impl/action/` | 0 | 0 | ✅ Clean |
|
||||
| `actions/impl/command/` | 0 | 0 | ✅ Clean |
|
||||
| `actions/llm_prompt_generators/` | 34 | 56 | LLM request types |
|
||||
| `util/command_choice_helpers/` | 1 | 3 | Near complete |
|
||||
| `util/view_filters/` | 3 | 4 | View boundary |
|
||||
| `util/` (other) | 5 | 13 | Mixed |
|
||||
| Root (`library/`) | 3 | 9 | Boundary code |
|
||||
|
||||
### util/command_choice_helpers/ (3 imports, 1 file)
|
||||
|
||||
| File | Import | Notes |
|
||||
|------|--------|-------|
|
||||
| `RansomOfferHelpers.scala` | `common.diplomacy_offer.RansomOfferDetails` | Diplomacy details |
|
||||
| `RansomOfferHelpers.scala` | `common.diplomacy_offer_status.DiplomacyOfferStatus` | Status enum |
|
||||
| `RansomOfferHelpers.scala` | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | Status variants |
|
||||
|
||||
### util/view_filters/ (4 imports, 3 files)
|
||||
|
||||
| File | Import | Notes |
|
||||
|------|--------|-------|
|
||||
| `ProvinceViewFilter.scala` | `common.province_event.ProvinceEvent` | For knownEvents field |
|
||||
| `GameStateViewFilter.scala` | `views.faction_view.FactionView` | Output type |
|
||||
| `GameStateViewFilter.scala` | `views.game_state_view.GameStateView` | Output type |
|
||||
| `BattleFilter.scala` | `views.shardok_battle_view.{...}` | Battle view boundary |
|
||||
|
||||
### util/ other files (13 imports, 5 files)
|
||||
|
||||
| File | Imports | Notes |
|
||||
|------|---------|-------|
|
||||
| `GameStateViewDiffer.scala` | 7 | View diff for client (boundary code) |
|
||||
| `ProvinceEventUtils.scala` | 2 | Province event handling |
|
||||
| `StatWithConditionUtils.scala` | 2 | Stat condition handling |
|
||||
| `MapGenerator.scala` | 1 | Map generation |
|
||||
| `IncomingArmyUtils.scala` | 1 | Army utilities |
|
||||
|
||||
### Root library/ (9 imports, 3 files)
|
||||
|
||||
| File | Imports | Notes |
|
||||
|------|---------|-------|
|
||||
| `ActionResultFilter.scala` | 3 | Action result filtering (boundary) |
|
||||
| `Engine.scala` | 1 | Trait interface |
|
||||
| `EngineImpl.scala` | 5 | Returns proto for persistence (boundary) |
|
||||
|
||||
### actions/llm_prompt_generators/ (56 imports, 34 files)
|
||||
|
||||
These files generate LLM prompts and primarily use `internal.generated_text_request.*` types plus some common enums like `DiplomacyOfferStatus` and `BattalionTypeId`.
|
||||
|
||||
**Migration strategy**: Can be migrated to Scala types when convenient, but low priority as they're isolated from core game logic.
|
||||
|
||||
---
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
|
||||
|
||||
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
|
||||
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, etc.
|
||||
|
||||
When migrating a file:
|
||||
1. Create a `Legacy*` version containing the proto-dependent methods
|
||||
2. Keep the original file name for protoless methods
|
||||
3. Update callers to use the appropriate version based on their context
|
||||
4. Delete `Legacy*` files when no longer needed
|
||||
|
||||
### Deleted Legacy Files (no production callers)
|
||||
- `LegacyProvinceDistances`
|
||||
- `LegacyBattalionSuitability`
|
||||
- `LegacyFoodConsumptionUtils`
|
||||
- `LegacyHandleRiotUtils`
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
+5
-6
@@ -27,11 +27,10 @@ http {
|
||||
# This prevents stale IP caching when containers restart
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Eagle backend address - configured via variable for blue-green deployments
|
||||
# Using a variable makes nginx resolve the hostname at request time, not config load time
|
||||
# This allows nginx to reload even when the backend is temporarily down
|
||||
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
|
||||
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
|
||||
# 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";
|
||||
}
|
||||
@@ -77,7 +76,7 @@ http {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy - uses variable for blue-green deployment support
|
||||
# gRPC proxy - uses variable for blue-green deployment
|
||||
grpc_pass grpc://$eagle_backend;
|
||||
|
||||
# Timeouts for long-running streams
|
||||
|
||||
@@ -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
+30
@@ -0,0 +1,30 @@
|
||||
#!/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/"
|
||||
|
||||
# Convert Info.plist from binary to XML format (Unity requires XML)
|
||||
/usr/bin/plutil -convert xml1 "$OUTPUT_DIR/SparklePlugin.bundle/Contents/Info.plist"
|
||||
|
||||
echo "=== SparklePlugin built successfully ==="
|
||||
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
|
||||
+168
-124
@@ -2,14 +2,21 @@
|
||||
#
|
||||
# Blue-Green Deployment Script for Eagle Server
|
||||
#
|
||||
# This script performs a zero-downtime deployment by:
|
||||
# 1. Starting the new version on a staging port (green)
|
||||
# 2. Waiting for it to become healthy
|
||||
# 3. Running warmup traffic to pre-heat the JIT
|
||||
# 4. Stopping the old version (blue) - which flushes state to storage
|
||||
# 5. Switching nginx to route traffic to green
|
||||
# 6. Users reconnect, triggering lazy game loading from fresh storage
|
||||
# 7. Cleaning up
|
||||
# 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]
|
||||
#
|
||||
@@ -25,6 +32,9 @@ 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'
|
||||
@@ -36,15 +46,57 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Determine which instance is currently active
|
||||
get_active_instance() {
|
||||
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
|
||||
# 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 grep -q "eagle-green:40032" "${NGINX_CONF}"; then
|
||||
elif [ "$green_running" = "true" ]; then
|
||||
echo "green"
|
||||
else
|
||||
log_error "Cannot determine active instance from nginx config"
|
||||
exit 1
|
||||
# Neither running - default to blue (first deploy or recovery)
|
||||
echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -120,31 +172,48 @@ main() {
|
||||
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
|
||||
local active=$(get_active_instance)
|
||||
# Determine current active instance (need this before creating marker)
|
||||
local active=$(get_running_instance)
|
||||
local staging
|
||||
if [ "$active" = "blue" ]; then
|
||||
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
|
||||
staging="green"
|
||||
active="blue" # Normalize "none" to "blue" for first deploy
|
||||
else
|
||||
staging="blue"
|
||||
fi
|
||||
|
||||
log_info "Active instance: eagle-${active}"
|
||||
log_info "Staging instance: eagle-${staging}"
|
||||
# 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
|
||||
|
||||
# Start staging instance with new image (and its jfr-sidecar)
|
||||
log_info "Starting eagle-${staging} with new image..."
|
||||
# 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
|
||||
@@ -155,11 +224,11 @@ main() {
|
||||
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}"
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run warmup/smoke test
|
||||
# Step 3: Run warmup/smoke test
|
||||
local staging_port
|
||||
if [ "$staging" = "green" ]; then
|
||||
staging_port=40034
|
||||
@@ -167,12 +236,12 @@ main() {
|
||||
staging_port=40032
|
||||
fi
|
||||
|
||||
log_info "Running warmup against eagle-${staging}..."
|
||||
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}"
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
@@ -180,34 +249,55 @@ main() {
|
||||
log_warn "JIT will be cold on first requests"
|
||||
fi
|
||||
|
||||
# Backup games.e0es BEFORE stopping the active instance
|
||||
# Critical: The old server may wipe games.e0es on shutdown if it doesn't have the merge fix
|
||||
local backup_timestamp
|
||||
backup_timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
log_info "Creating S3 backup of games.e0es (backup_${backup_timestamp})..."
|
||||
if command -v s3cmd &> /dev/null; then
|
||||
if s3cmd put "${APP_DIR}/saves/games.e0es" "s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}" 2>/dev/null; then
|
||||
log_info "Backup created: s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}"
|
||||
else
|
||||
log_warn "S3 backup failed (s3cmd put), continuing deployment"
|
||||
fi
|
||||
else
|
||||
log_warn "s3cmd not found, skipping S3 backup"
|
||||
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}..."
|
||||
|
||||
# Stop the active instance (flushes state to storage)
|
||||
# We must do this BEFORE switching so staging loads fresh data
|
||||
log_info "Stopping eagle-${active} (flushing state to storage)..."
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
|
||||
|
||||
# Prepare nginx config and .env BEFORE restarting anything
|
||||
log_info "Switching nginx upstream 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..."
|
||||
@@ -221,91 +311,36 @@ main() {
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
|
||||
fi
|
||||
|
||||
# Validate nginx config before restart
|
||||
log_info "Validating nginx configuration..."
|
||||
if ! docker exec nginx nginx -t 2>&1; then
|
||||
log_error "nginx config validation failed, aborting!"
|
||||
log_error "Rolling back nginx.conf..."
|
||||
mv "${NGINX_CONF}.bak" "${NGINX_CONF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Restart nginx to pick up new upstream
|
||||
# Note: We use restart instead of reload because reload doesn't always
|
||||
# force DNS re-resolution in Docker, causing nginx to keep trying to
|
||||
# connect to the old (removed) container
|
||||
log_info "Restarting nginx..."
|
||||
docker compose -f "${COMPOSE_FILE}" restart nginx
|
||||
|
||||
# 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 and its jfr-sidecar (in background, not critical path)
|
||||
log_info "Cleaning up old eagle-${active} container..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" &
|
||||
# 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}" rm -f "jfr-sidecar-green" 2>/dev/null &
|
||||
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}" rm -f "jfr-sidecar" 2>/dev/null &
|
||||
fi
|
||||
wait
|
||||
|
||||
log_info "Deployment complete!"
|
||||
log_info "Active instance: eagle-${staging}"
|
||||
}
|
||||
|
||||
# Ensure s3cmd is installed and configured for S3 backups
|
||||
ensure_s3cmd() {
|
||||
# Install s3cmd if not present
|
||||
if ! command -v s3cmd &> /dev/null; then
|
||||
log_info "Installing s3cmd for S3 backups..."
|
||||
if command -v pip3 &> /dev/null; then
|
||||
pip3 install --quiet s3cmd
|
||||
elif command -v pip &> /dev/null; then
|
||||
pip install --quiet s3cmd
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq s3cmd
|
||||
else
|
||||
log_warn "Cannot install s3cmd (no pip or apt-get), S3 backups will be skipped"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v s3cmd &> /dev/null; then
|
||||
log_warn "s3cmd installation failed, S3 backups will be skipped"
|
||||
return 1
|
||||
fi
|
||||
log_info "s3cmd installed successfully"
|
||||
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
|
||||
|
||||
# Configure s3cmd for DigitalOcean Spaces if not already configured
|
||||
local s3cfg="${HOME}/.s3cfg"
|
||||
if [ ! -f "${s3cfg}" ]; then
|
||||
# Load credentials from .env
|
||||
local env_file="${APP_DIR}/.env"
|
||||
if [ -f "${env_file}" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "${env_file}"
|
||||
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))
|
||||
|
||||
if [ -z "${DO_SPACES_ACCESS_KEY:-}" ] || [ -z "${DO_SPACES_SECRET_KEY:-}" ]; then
|
||||
log_warn "DO_SPACES_ACCESS_KEY or DO_SPACES_SECRET_KEY not set, S3 backups will be skipped"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Configuring s3cmd for DigitalOcean Spaces..."
|
||||
cat > "${s3cfg}" << EOF
|
||||
[default]
|
||||
access_key = ${DO_SPACES_ACCESS_KEY}
|
||||
secret_key = ${DO_SPACES_SECRET_KEY}
|
||||
host_base = sfo3.digitaloceanspaces.com
|
||||
host_bucket = %(bucket)s.sfo3.digitaloceanspaces.com
|
||||
use_https = True
|
||||
EOF
|
||||
log_info "s3cmd configured successfully"
|
||||
fi
|
||||
|
||||
return 0
|
||||
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
|
||||
@@ -330,8 +365,17 @@ check_requirements() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure s3cmd is available for backups (non-fatal if it fails)
|
||||
ensure_s3cmd || true
|
||||
# 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
|
||||
|
||||
@@ -46,8 +46,8 @@ STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
echo "Submission ID: $SUBMISSION_ID"
|
||||
echo "Status: $STATUS"
|
||||
|
||||
# Clean up the zip
|
||||
rm "$ZIP_PATH"
|
||||
# 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 ==="
|
||||
|
||||
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"
|
||||
@@ -167,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" />
|
||||
@@ -207,6 +208,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ddb90d3140384d50a98a19e72539b62
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using common;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityGoDiceInterface;
|
||||
|
||||
public class DiceConfigurationPanelController : MonoBehaviour {
|
||||
private DiceInterface _diceInterface;
|
||||
|
||||
public EventBasedTable diceRollGroup;
|
||||
public PickerRowController pickerRowPrefab;
|
||||
public TMP_Text instructionsLabel;
|
||||
|
||||
public delegate void ConfigurationCallback(DieInfo? tensDieInfo, DieInfo? onesDieInfo);
|
||||
|
||||
private ConfigurationCallback _configurationCallback;
|
||||
|
||||
private DieInfo? _tensDieInfo = null;
|
||||
private DieInfo? _onesDieInfo = null;
|
||||
|
||||
private List<DieInfo> _knownDice = new List<DieInfo>();
|
||||
private HashSet<string> _connectingIdentifiers = new HashSet<string>();
|
||||
|
||||
void Awake() {}
|
||||
|
||||
public void GetConfiguration(ConfigurationCallback cb) {
|
||||
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
|
||||
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
|
||||
_diceInterface.connectionCallback = ConnectionCallback;
|
||||
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
|
||||
_diceInterface.disconnectionCallback = DisconnectionCallback;
|
||||
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
|
||||
_diceInterface.colorCallback = ReceiveColorCallback;
|
||||
|
||||
string currentPath = NewLogLocation();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(currentPath));
|
||||
_diceInterface.logger = log => {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
Debug.Log(log);
|
||||
File.AppendAllText(currentPath, log);
|
||||
});
|
||||
};
|
||||
|
||||
_diceInterface.StartListening();
|
||||
|
||||
_configurationCallback = cb;
|
||||
_tensDieInfo = null;
|
||||
_onesDieInfo = null;
|
||||
instructionsLabel.text = "Looking for dice...";
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
string NewLogLocation() {
|
||||
return Path.Combine(
|
||||
Application.persistentDataPath,
|
||||
"eagle0",
|
||||
"Resources",
|
||||
"DiceConfigurationLogs",
|
||||
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
|
||||
}
|
||||
|
||||
public void RowClicked(int rowIndex) {
|
||||
if (_tensDieInfo == null) {
|
||||
_tensDieInfo = _knownDice[rowIndex];
|
||||
_knownDice.RemoveAt(rowIndex);
|
||||
SetUpTable();
|
||||
} else {
|
||||
_diceInterface.StopListening();
|
||||
|
||||
_onesDieInfo = _knownDice[rowIndex];
|
||||
_knownDice.RemoveAt(rowIndex);
|
||||
_knownDice.ForEach(dieInfo => _diceInterface.Disconnect(dieInfo.identifier));
|
||||
|
||||
// _diceInterface.deviceFoundCallback = null;
|
||||
// _diceInterface.connectionCallback = null;
|
||||
// _diceInterface.disconnectionCallback = null;
|
||||
// _diceInterface.listenerStoppedCallback = null;
|
||||
// _diceInterface.rollCallback = null;
|
||||
// _diceInterface.colorCallback = null;
|
||||
|
||||
_configurationCallback(_tensDieInfo, _onesDieInfo);
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable() {}
|
||||
|
||||
private void OnDisable() { diceRollGroup.RowCount = 0; }
|
||||
|
||||
void ListenerStoppedCallback() { Debug.Log("Listener stopped!"); }
|
||||
|
||||
void SetUpTable() {
|
||||
diceRollGroup.RowCount = _knownDice.Count;
|
||||
|
||||
if (_knownDice.Count == 0) {
|
||||
instructionsLabel.text = "Looking for dice...";
|
||||
} else if (_tensDieInfo == null) {
|
||||
instructionsLabel.text = "Select a TENS die:";
|
||||
} else {
|
||||
instructionsLabel.text = "Select an ONES die:";
|
||||
}
|
||||
|
||||
for (int i = 0; i < _knownDice.Count; i++) {
|
||||
var row = diceRollGroup.ComponentAt<PickerRowController>(i);
|
||||
|
||||
row.Text = _knownDice[i].deviceName;
|
||||
row.TextColor = UnityDieColors.ColorFromDieColor(_knownDice[i].color);
|
||||
}
|
||||
}
|
||||
|
||||
void ReceiveColorCallback(string identifier, DieColor dieColor) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
var existingIndex =
|
||||
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
|
||||
if (existingIndex >= 0) {
|
||||
var colorName = dieColor.ToString();
|
||||
_knownDice[existingIndex] = new DieInfo(
|
||||
identifier: _knownDice[existingIndex].identifier,
|
||||
deviceName: colorName,
|
||||
color: dieColor,
|
||||
connected: true);
|
||||
} else {
|
||||
Debug.Log("This shouldn't happen");
|
||||
}
|
||||
|
||||
SetUpTable();
|
||||
});
|
||||
}
|
||||
|
||||
void DeviceFoundCallback(string identifier) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (_connectingIdentifiers.Contains(identifier)) { return; }
|
||||
|
||||
_connectingIdentifiers.Add(identifier);
|
||||
|
||||
var existingIndex =
|
||||
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
|
||||
if (existingIndex >= 0) {
|
||||
_knownDice[existingIndex] =
|
||||
new DieInfo(identifier, "Unknown", DieColor.DieColorBlack);
|
||||
} else {
|
||||
_knownDice.Add(new DieInfo(identifier, "Unknown", DieColor.DieColorBlack));
|
||||
}
|
||||
|
||||
SetUpTable();
|
||||
|
||||
_diceInterface.Connect(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
void ConnectionCallback(string identifier, string deviceName) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_connectingIdentifiers.Remove(identifier);
|
||||
|
||||
if (_tensDieInfo != null && _tensDieInfo.Value.identifier.Equals(identifier)) {
|
||||
return;
|
||||
}
|
||||
if (_onesDieInfo != null && _onesDieInfo.Value.identifier.Equals(identifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var shortenedName = deviceName.Split('_')[1];
|
||||
var existingIndex =
|
||||
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
|
||||
if (existingIndex >= 0) {
|
||||
_knownDice[existingIndex] =
|
||||
new DieInfo(identifier, shortenedName, DieColor.DieColorBlack);
|
||||
} else {
|
||||
_knownDice.Add(new DieInfo(identifier, shortenedName, DieColor.DieColorBlack));
|
||||
}
|
||||
|
||||
SetUpTable();
|
||||
|
||||
_diceInterface.RequestColor(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
void ConnectionFailedCallback(string identifier) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_connectingIdentifiers.Remove(identifier);
|
||||
|
||||
var existingIndex =
|
||||
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
|
||||
if (existingIndex >= 0) {
|
||||
_knownDice.RemoveAt(existingIndex);
|
||||
|
||||
SetUpTable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DisconnectionCallback(string identifier) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_connectingIdentifiers.Remove(identifier);
|
||||
|
||||
if (_tensDieInfo is {} tensInfo && tensInfo.identifier.Equals(identifier)) {
|
||||
_tensDieInfo = null;
|
||||
_onesDieInfo = null;
|
||||
} else if (_onesDieInfo is {} onesInfo && onesInfo.identifier.Equals(identifier)) {
|
||||
_onesDieInfo = null;
|
||||
}
|
||||
|
||||
var existingIndex =
|
||||
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
|
||||
if (existingIndex >= 0) { _knownDice.RemoveAt(existingIndex); }
|
||||
|
||||
SetUpTable();
|
||||
_diceInterface.Connect(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
public void DismissButtonClicked() {
|
||||
gameObject.SetActive(false);
|
||||
_configurationCallback(null, null);
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update() {}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
#if UNITY_IOS || UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
|
||||
#define USE_SWIFT_INTERFACE
|
||||
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || ENABLE_WINMD_SUPPORT
|
||||
#define USE_WINDOWS_INTERFACE
|
||||
#else
|
||||
#endif
|
||||
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityGoDiceInterface;
|
||||
|
||||
public class DiceInterface : MonoBehaviour {
|
||||
public struct Commands {
|
||||
public static readonly byte PulseLed = 16;
|
||||
public static readonly byte GetColor = 23;
|
||||
}
|
||||
|
||||
readonly NativeDiceInterfaceImports _diceInterfaceImports = new();
|
||||
private static DiceInterface _singleton = null;
|
||||
|
||||
private static Dictionary<string, string> _deviceNames = new Dictionary<string, string>();
|
||||
|
||||
public delegate void DeviceFoundCallback(string identifier);
|
||||
public delegate void ConnectionCallback(string identifier, string deviceName);
|
||||
public delegate void ConnectionFailedCallback(string identifier);
|
||||
public delegate void DisconnectionCallback(string identifier);
|
||||
public delegate void ListenerStoppedCallback();
|
||||
public delegate void RollCallback(string identifier, sbyte x, sbyte y, sbyte z);
|
||||
public delegate void ColorCallback(string identifier, DieColor color);
|
||||
public delegate void LoggerCallback(string log);
|
||||
|
||||
public DeviceFoundCallback deviceFoundCallback = null;
|
||||
public ConnectionCallback connectionCallback = null;
|
||||
public ConnectionFailedCallback connectionFailedCallback = null;
|
||||
public DisconnectionCallback disconnectionCallback = null;
|
||||
public ListenerStoppedCallback listenerStoppedCallback = null;
|
||||
public RollCallback rollCallback = null;
|
||||
public ColorCallback colorCallback = null;
|
||||
public LoggerCallback logger = null;
|
||||
|
||||
public void StartListening() { _diceInterfaceImports.StartListening(); }
|
||||
|
||||
public void StopListening() { _diceInterfaceImports.StopListening(); }
|
||||
|
||||
public void Connect(string identifier) { _diceInterfaceImports.Connect(identifier); }
|
||||
public void Disconnect(string identifier) { _diceInterfaceImports.Disconnect(identifier); }
|
||||
|
||||
public void Reset() {
|
||||
_diceInterfaceImports.Reset();
|
||||
_deviceNames.Clear();
|
||||
}
|
||||
|
||||
public void RequestColor(string identifier) {
|
||||
_diceInterfaceImports.Send(identifier, new List<byte> { Commands.GetColor });
|
||||
}
|
||||
|
||||
public void FlashColor(
|
||||
string identifier,
|
||||
byte pulseCount,
|
||||
byte onTime10ms,
|
||||
byte offTime10ms,
|
||||
byte red,
|
||||
byte green,
|
||||
byte blue) {
|
||||
_diceInterfaceImports.Send(
|
||||
identifier,
|
||||
new List<byte> {
|
||||
Commands.PulseLed,
|
||||
pulseCount,
|
||||
onTime10ms,
|
||||
offTime10ms,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
0x01,
|
||||
0x00
|
||||
});
|
||||
}
|
||||
|
||||
// Start is called before the first frame update
|
||||
void Start() {
|
||||
_singleton = this;
|
||||
|
||||
_diceInterfaceImports.SetDeviceConnectedCallback(DeviceConnectedDelegateMessageReceived);
|
||||
_diceInterfaceImports.SetDeviceConnectionFailedCallback(
|
||||
DeviceConnectionFailedDelegateMessageReceived);
|
||||
_diceInterfaceImports.SetDeviceDisconnectedCallback(
|
||||
DeviceDisconnectedDelegateMessageReceived);
|
||||
_diceInterfaceImports.SetDeviceFoundCallback(DeviceFoundDelegateMessageReceived);
|
||||
_diceInterfaceImports.SetDataCallback(DataDelegateMessageReceived);
|
||||
_diceInterfaceImports.SetListenerStoppedCallback(ListenerStoppedDelegateMessageReceived);
|
||||
_diceInterfaceImports.SetLoggerCallback(LoggerDelegateMessageReceived);
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update() {}
|
||||
|
||||
private static sbyte[] GetRollVector(List<byte> rawData) {
|
||||
if (rawData.Count < 1) {
|
||||
Debug.Log("rollVector: no data");
|
||||
return null;
|
||||
}
|
||||
byte firstByte = rawData[0];
|
||||
if (firstByte != 83) {
|
||||
Debug.Log("rollVector: first byte is not 83");
|
||||
return null;
|
||||
}
|
||||
if (rawData.Count != 4) {
|
||||
Debug.Log("rollVector: data length is not 4");
|
||||
return null;
|
||||
}
|
||||
return new[] { (sbyte)rawData[1], (sbyte)rawData[2], (sbyte)rawData[3] };
|
||||
}
|
||||
|
||||
private static void DeviceFoundDelegateMessageReceived(string identifier, string deviceName) {
|
||||
if (_singleton != null) {
|
||||
_deviceNames[identifier] = deviceName;
|
||||
if (_singleton.deviceFoundCallback != null) {
|
||||
_singleton.deviceFoundCallback(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeviceConnectedDelegateMessageReceived(string identifier) {
|
||||
if (_singleton != null && _singleton.connectionCallback != null) {
|
||||
_singleton.connectionCallback(identifier, _deviceNames[identifier]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeviceConnectionFailedDelegateMessageReceived(string identifier) {
|
||||
if (_singleton != null && _singleton.connectionFailedCallback != null) {
|
||||
_singleton.connectionFailedCallback(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeviceDisconnectedDelegateMessageReceived(string identifier) {
|
||||
if (_singleton != null && _singleton.disconnectionCallback != null) {
|
||||
_singleton.disconnectionCallback(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DataDelegateMessageReceived(string identifier, List<byte> byteList) {
|
||||
if (_singleton != null) {
|
||||
if (byteList.Count == 0) {
|
||||
if (_singleton.connectionCallback != null) {
|
||||
// I don't think this one should still happen
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
byte firstByte = byteList[0];
|
||||
|
||||
switch (firstByte) {
|
||||
case 82:
|
||||
// Roll started
|
||||
break;
|
||||
case 66:
|
||||
// Battery level
|
||||
break;
|
||||
case (byte)'C':
|
||||
if (byteList[1] == (byte)'o' && byteList[2] == (byte)'l') {
|
||||
byte colorRawValue = byteList[3];
|
||||
|
||||
_singleton.colorCallback(identifier, (DieColor)colorRawValue);
|
||||
}
|
||||
// Color (fetched)
|
||||
break;
|
||||
case 83: {
|
||||
var roll = GetRollVector(byteList);
|
||||
if (roll != null && _singleton.rollCallback != null) {
|
||||
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 70:
|
||||
case 84:
|
||||
case 77: {
|
||||
byteList.RemoveAt(0);
|
||||
var roll = GetRollVector(byteList);
|
||||
if (roll != null && _singleton.rollCallback != null) {
|
||||
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: Debug.Log("Not yet handled"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ListenerStoppedDelegateMessageReceived() {
|
||||
if (_singleton != null && _singleton.listenerStoppedCallback != null) {
|
||||
_singleton.listenerStoppedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoggerDelegateMessageReceived(string log) {
|
||||
if (_singleton != null && _singleton.logger != null) { _singleton.logger(log); }
|
||||
}
|
||||
|
||||
public void OnDestroy() {
|
||||
_diceInterfaceImports.SetDeviceConnectedCallback(null);
|
||||
_diceInterfaceImports.SetDeviceConnectionFailedCallback(null);
|
||||
_diceInterfaceImports.SetDeviceDisconnectedCallback(null);
|
||||
_diceInterfaceImports.SetDeviceFoundCallback(null);
|
||||
_diceInterfaceImports.SetDataCallback(null);
|
||||
_diceInterfaceImports.SetListenerStoppedCallback(null);
|
||||
_diceInterfaceImports.SetLoggerCallback(null);
|
||||
#if UNITY_EDITOR
|
||||
StopListening();
|
||||
Reset();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace UnityGoDiceInterface {
|
||||
public class DiceVectors {
|
||||
public struct Vector3 {
|
||||
public sbyte x;
|
||||
public sbyte y;
|
||||
public sbyte z;
|
||||
|
||||
public Vector3(sbyte x, sbyte y, sbyte z) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
}
|
||||
|
||||
public static int D6Value(sbyte x, sbyte y, sbyte z) {
|
||||
return DieValueForVector(d6, new Vector3(x, y, z));
|
||||
}
|
||||
|
||||
public static int D10Value(sbyte x, sbyte y, sbyte z) {
|
||||
return d10Transform[DieValueForVector(d20, new Vector3(x, y, z))];
|
||||
}
|
||||
|
||||
private static int sq(int x) { return x * x; }
|
||||
|
||||
private static int DieValueForVector(List<Vector3> table, Vector3 vector) {
|
||||
var closestIndex = -1;
|
||||
var closestDistance = int.MaxValue;
|
||||
|
||||
for (int i = 0; i < table.Count; i++) {
|
||||
var entry = table[i];
|
||||
var distance =
|
||||
sq(entry.x - vector.x) + sq(entry.y - vector.y) + sq(entry.z - vector.z);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return closestIndex + 1;
|
||||
}
|
||||
|
||||
// The private vectors are 0-based, so we need to add 1 to the index
|
||||
private static List<Vector3> d6 = new List<Vector3> {
|
||||
new Vector3(-64, 0, 0),
|
||||
new Vector3(-0, 0, 64),
|
||||
new Vector3(0, 64, 0),
|
||||
new Vector3(0, -64, 0),
|
||||
new Vector3(0, 0, -64),
|
||||
new Vector3(64, 0, 0)
|
||||
};
|
||||
|
||||
private static List<Vector3> d20 = new List<Vector3> {
|
||||
new Vector3(-64, 0, -22), new Vector3(42, -42, 40), new Vector3(0, 22, -64),
|
||||
new Vector3(0, 22, 64), new Vector3(-42, -42, 42), new Vector3(22, 64, 0),
|
||||
new Vector3(-42, -42, -42), new Vector3(64, 0, -22), new Vector3(-22, 64, 0),
|
||||
new Vector3(42, -42, -42), new Vector3(-42, 42, 42), new Vector3(22, -64, 0),
|
||||
new Vector3(-64, 0, 22), new Vector3(42, 42, 42), new Vector3(-22, -64, 0),
|
||||
new Vector3(42, 42, -42), new Vector3(0, -22, -64), new Vector3(0, -22, 64),
|
||||
new Vector3(-42, 42, -42), new Vector3(64, 0, 22),
|
||||
};
|
||||
|
||||
// static d24Vectors = {
|
||||
// new Vector3(20, -60, -20),
|
||||
// new Vector3(20, 0, 60),
|
||||
// new Vector3(-40, -40, 40),
|
||||
// new Vector3(-60, 0, 20),
|
||||
// new Vector3(40, 20, 40),
|
||||
// new Vector3(-20, -60, -20),
|
||||
// new Vector3(20, 60, 20),
|
||||
// new Vector3(-40, 20, -40),
|
||||
// new Vector3(-40, 40, 40),
|
||||
// new Vector3(-20, 0, 60),
|
||||
// new Vector3(-20, -60, 20),
|
||||
// new Vector3(60, 0, 20),
|
||||
// new Vector3(-60, 0, -20),
|
||||
// new Vector3(20, 60, -20),
|
||||
// new Vector3(20, 0, -60),
|
||||
// new Vector3(40, -20, -40),
|
||||
// new Vector3(-20, 60, -20),
|
||||
// new Vector3(-40, -40, -40),
|
||||
// new Vector3(40, -20, 40),
|
||||
// new Vector3(20, -60, 20),
|
||||
// new Vector3(60, 0, -20),
|
||||
// new Vector3(40, 20, -40),
|
||||
// new Vector3(-20, 0, -60),
|
||||
// new Vector3(-20, 60, 20),
|
||||
// }
|
||||
//
|
||||
// Transforms from each shell type to according number on shell
|
||||
// D20 Transforms
|
||||
private static Dictionary<int, int> d10Transform = new Dictionary<int, int> {
|
||||
{ 1, 8 }, { 2, 2 }, { 3, 6 }, { 4, 1 }, { 5, 4 }, { 6, 3 }, { 7, 9 },
|
||||
{ 8, 0 }, { 9, 7 }, { 10, 5 }, { 11, 5 }, { 12, 7 }, { 13, 0 }, { 14, 9 },
|
||||
{ 15, 3 }, { 16, 4 }, { 17, 1 }, { 18, 6 }, { 19, 2 }, { 20, 8 },
|
||||
};
|
||||
//
|
||||
// static d10XTransform = {
|
||||
// 1: 80,
|
||||
// 2: 20,
|
||||
// 3: 60,
|
||||
// 4: 10,
|
||||
// 5: 40,
|
||||
// 6: 30,
|
||||
// 7: 90,
|
||||
// 8: 0,
|
||||
// 9: 70,
|
||||
// 10: 50,
|
||||
// 11: 50,
|
||||
// 12: 70,
|
||||
// 13: 0,
|
||||
// 14: 90,
|
||||
// 15: 30,
|
||||
// 16: 40,
|
||||
// 17: 10,
|
||||
// 18: 60,
|
||||
// 19: 20,
|
||||
// 20: 80,
|
||||
// }
|
||||
//
|
||||
// // D24 Transforms
|
||||
// static d4Transform = {
|
||||
// 1: 3,
|
||||
// 2: 1,
|
||||
// 3: 4,
|
||||
// 4: 1,
|
||||
// 5: 4,
|
||||
// 6: 4,
|
||||
// 7: 1,
|
||||
// 8: 4,
|
||||
// 9: 2,
|
||||
// 10: 3,
|
||||
// 11: 1,
|
||||
// 12: 1,
|
||||
// 13: 1,
|
||||
// 14: 4,
|
||||
// 15: 2,
|
||||
// 16: 3,
|
||||
// 17: 3,
|
||||
// 18: 2,
|
||||
// 19: 2,
|
||||
// 20: 2,
|
||||
// 21: 4,
|
||||
// 22: 1,
|
||||
// 23: 3,
|
||||
// 24: 2,
|
||||
// }
|
||||
//
|
||||
// static d8Transform = {
|
||||
// 1: 3,
|
||||
// 2: 3,
|
||||
// 3: 6,
|
||||
// 4: 1,
|
||||
// 5: 2,
|
||||
// 6: 8,
|
||||
// 7: 1,
|
||||
// 8: 1,
|
||||
// 9: 4,
|
||||
// 10: 7,
|
||||
// 11: 5,
|
||||
// 12: 5,
|
||||
// 13: 4,
|
||||
// 14: 4,
|
||||
// 15: 2,
|
||||
// 16: 5,
|
||||
// 17: 7,
|
||||
// 18: 7,
|
||||
// 19: 8,
|
||||
// 20: 2,
|
||||
// 21: 8,
|
||||
// 22: 3,
|
||||
// 23: 6,
|
||||
// 24: 6,
|
||||
// }
|
||||
//
|
||||
// static d12Transform = {
|
||||
// 1: 1,
|
||||
// 2: 2,
|
||||
// 3: 3,
|
||||
// 4: 4,
|
||||
// 5: 5,
|
||||
// 6: 6,
|
||||
// 7: 7,
|
||||
// 8: 8,
|
||||
// 9: 9,
|
||||
// 10: 10,
|
||||
// 11: 11,
|
||||
// 12: 12,
|
||||
// 13: 1,
|
||||
// 14: 2,
|
||||
// 15: 3,
|
||||
// 16: 4,
|
||||
// 17: 5,
|
||||
// 18: 6,
|
||||
// 19: 7,
|
||||
// 20: 8,
|
||||
// 21: 9,
|
||||
// 22: 10,
|
||||
// 23: 11,
|
||||
// 24: 12,
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9d67d9a2f8145a999d04de91c27073d
|
||||
timeCreated: 1702612480
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace UnityGoDiceInterface {
|
||||
public enum DieColor : byte {
|
||||
DieColorBlack = 0,
|
||||
DieColorRed = 1,
|
||||
DieColorGreen = 2,
|
||||
DieColorBlue = 3,
|
||||
DieColorYellow = 4,
|
||||
DieColorOrange = 5,
|
||||
}
|
||||
|
||||
public struct DieInfo {
|
||||
public string identifier;
|
||||
public string deviceName;
|
||||
public DieColor color;
|
||||
public bool connected;
|
||||
|
||||
public DieInfo(
|
||||
string identifier,
|
||||
string deviceName,
|
||||
DieColor color,
|
||||
bool connected = false) {
|
||||
this.identifier = identifier;
|
||||
this.deviceName = deviceName;
|
||||
this.color = color;
|
||||
this.connected = connected;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e1000782bb845399869ca7910eebcd6
|
||||
timeCreated: 1703172326
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using AOT;
|
||||
|
||||
namespace UnityGoDiceInterface {
|
||||
public class NativeDiceInterfaceImports {
|
||||
public delegate void DeviceFoundDelegateMessage(string identifier, string name);
|
||||
public delegate void DataDelegateMessage(string identifier, List<byte> bytes);
|
||||
public delegate void DeviceConnectedDelegateMessage(string identifier);
|
||||
public delegate void DeviceConnectionFailedDelegateMessage(string identifier);
|
||||
public delegate void DeviceDisconnectedDelegateMessage(string identifier);
|
||||
public delegate void ListenerStoppedDelegateMessage();
|
||||
public delegate void LoggerDelegateMessage(string log);
|
||||
|
||||
private static DeviceFoundDelegateMessage deviceFoundDelegate;
|
||||
private static DataDelegateMessage dataDelegate;
|
||||
private static DeviceConnectedDelegateMessage deviceConnectedDelegate;
|
||||
private static DeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegate;
|
||||
private static DeviceDisconnectedDelegateMessage deviceDisconnectedDelegate;
|
||||
private static ListenerStoppedDelegateMessage listenerStoppedDelegate;
|
||||
private static LoggerDelegateMessage loggerDelegateMessage;
|
||||
|
||||
#if UNITY_IOS
|
||||
private const string BundleName = "__Internal";
|
||||
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
|
||||
private const string BundleName = "DarwinGodiceBundle";
|
||||
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||||
private const string BundleName = "GoDiceDll.dll";
|
||||
#endif
|
||||
|
||||
private delegate void MonoDeviceFoundDelegateMessage(string identifier, string name);
|
||||
private delegate void
|
||||
MonoDataDelegateMessage(string identifier, UInt32 byteCount, IntPtr bytePtr);
|
||||
private delegate void MonoDeviceConnectedDelegateMessage(string identifier);
|
||||
private delegate void MonoDeviceConnectionFailedDelegateMessage(string identifier);
|
||||
private delegate void MonoDeviceDisconnectedDelegateMessage(string identifier);
|
||||
private delegate void MonoListenerStoppedDelegateMessage();
|
||||
private delegate void MonoLoggerDelegateMessage(string log);
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_start_listening")]
|
||||
private static extern void _NativeBridgeStartListening();
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_stop_listening")]
|
||||
private static extern void _NativeBridgeStopListening();
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_set_callbacks")]
|
||||
private static extern void _NativeBridgeSetCallbacks(
|
||||
MonoDeviceFoundDelegateMessage deviceFoundDelegate,
|
||||
MonoDataDelegateMessage dataDelegate,
|
||||
MonoDeviceConnectedDelegateMessage deviceConnectedDelegate,
|
||||
MonoDeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegateMessage,
|
||||
MonoDeviceDisconnectedDelegateMessage deviceDisconnectedDelegate,
|
||||
MonoListenerStoppedDelegateMessage listenerStoppedDelegate);
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_connect")]
|
||||
private static extern void _NativeBridgeConnect(string identifier);
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_disconnect")]
|
||||
private static extern void _NativeBridgeDisconnect(string identifier);
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_send")]
|
||||
private static extern void
|
||||
_NativeBridgeSend(string identifier, UInt32 byteCount, IntPtr bytePtr);
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_set_logger")]
|
||||
private static extern void _NativeBridgeSetLogger(MonoLoggerDelegateMessage loggerDelegate);
|
||||
|
||||
[DllImport(dllName: BundleName, EntryPoint = "godice_reset")]
|
||||
private static extern void _NativeBridgeReset();
|
||||
|
||||
private static List<byte> BytesFromRawPointer(UInt32 byteCount, IntPtr bytes) {
|
||||
byte[] array = new byte[byteCount];
|
||||
if (byteCount > 0) { Marshal.Copy(bytes, array, 0, (int)byteCount); }
|
||||
return new List<byte>(array);
|
||||
}
|
||||
|
||||
/*
|
||||
* Callback methods
|
||||
*/
|
||||
[MonoPInvokeCallback(typeof(MonoDeviceFoundDelegateMessage))]
|
||||
private static void MonoDeviceFoundMessageReceived(string identifier, string name) {
|
||||
if (deviceFoundDelegate != null) { deviceFoundDelegate(identifier, name); }
|
||||
}
|
||||
|
||||
[MonoPInvokeCallback(typeof(MonoDataDelegateMessage))]
|
||||
private static void
|
||||
MonoDataMessageReceived(string identifier, UInt32 byteCount, IntPtr bytePtr) {
|
||||
if (dataDelegate != null) {
|
||||
dataDelegate(identifier, BytesFromRawPointer(byteCount, bytePtr));
|
||||
}
|
||||
}
|
||||
|
||||
[MonoPInvokeCallback(typeof(MonoDeviceConnectedDelegateMessage))]
|
||||
private static void MonoDeviceConnected(string identifier) {
|
||||
if (deviceConnectedDelegate != null) { deviceConnectedDelegate(identifier); }
|
||||
}
|
||||
|
||||
[MonoPInvokeCallback(typeof(MonoDeviceConnectionFailedDelegateMessage))]
|
||||
private static void MonoDeviceConnectionFailed(string identifier) {
|
||||
if (deviceConnectionFailedDelegate != null) {
|
||||
deviceConnectionFailedDelegate(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
[MonoPInvokeCallback(typeof(MonoDeviceDisconnectedDelegateMessage))]
|
||||
private static void MonoDeviceDisconnected(string identifier) {
|
||||
if (deviceDisconnectedDelegate != null) { deviceDisconnectedDelegate(identifier); }
|
||||
}
|
||||
|
||||
[MonoPInvokeCallback(typeof(MonoListenerStoppedDelegateMessage))]
|
||||
private static void MonoListenerStopped() {
|
||||
if (listenerStoppedDelegate != null) { listenerStoppedDelegate(); }
|
||||
}
|
||||
|
||||
[MonoPInvokeCallback(typeof(MonoLoggerDelegateMessage))]
|
||||
private static void MonoLogger(string log) {
|
||||
if (loggerDelegateMessage != null) { loggerDelegateMessage(log); }
|
||||
}
|
||||
|
||||
public void StartListening() {
|
||||
_NativeBridgeSetCallbacks(
|
||||
MonoDeviceFoundMessageReceived,
|
||||
MonoDataMessageReceived,
|
||||
MonoDeviceConnected,
|
||||
MonoDeviceConnectionFailed,
|
||||
MonoDeviceDisconnected,
|
||||
MonoListenerStopped);
|
||||
_NativeBridgeSetLogger(MonoLogger);
|
||||
_NativeBridgeStartListening();
|
||||
}
|
||||
|
||||
public void StopListening() { _NativeBridgeStopListening(); }
|
||||
|
||||
public void Connect(string identifier) { _NativeBridgeConnect(identifier); }
|
||||
|
||||
public void Disconnect(string identifier) { _NativeBridgeDisconnect(identifier); }
|
||||
|
||||
public void Send(string identifier, List<byte> data) {
|
||||
byte[] byteArray = data.ToArray();
|
||||
GCHandle pinnedArray = GCHandle.Alloc(byteArray, GCHandleType.Pinned);
|
||||
IntPtr pointer = pinnedArray.AddrOfPinnedObject();
|
||||
_NativeBridgeSend(identifier, (uint)data.Count, pointer);
|
||||
pinnedArray.Free();
|
||||
}
|
||||
|
||||
public void SetDeviceFoundCallback(DeviceFoundDelegateMessage deviceFound) {
|
||||
deviceFoundDelegate = deviceFound;
|
||||
}
|
||||
|
||||
public void SetDataCallback(DataDelegateMessage data) { dataDelegate = data; }
|
||||
|
||||
public void SetDeviceConnectedCallback(DeviceConnectedDelegateMessage deviceConnected) {
|
||||
deviceConnectedDelegate = deviceConnected;
|
||||
}
|
||||
|
||||
public void SetDeviceConnectionFailedCallback(
|
||||
DeviceConnectionFailedDelegateMessage deviceConnectionFailed) {
|
||||
deviceConnectionFailedDelegate = deviceConnectionFailed;
|
||||
}
|
||||
|
||||
public void SetDeviceDisconnectedCallback(
|
||||
DeviceDisconnectedDelegateMessage deviceDisconnected) {
|
||||
deviceDisconnectedDelegate = deviceDisconnected;
|
||||
}
|
||||
|
||||
public void SetListenerStoppedCallback(ListenerStoppedDelegateMessage listenerStopped) {
|
||||
listenerStoppedDelegate = listenerStopped;
|
||||
}
|
||||
|
||||
public void SetLoggerCallback(LoggerDelegateMessage logger) {
|
||||
loggerDelegateMessage = logger;
|
||||
}
|
||||
|
||||
public void Reset() { _NativeBridgeReset(); }
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 424476bb5b18c47039d649c28a58a8fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-447
@@ -1,447 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &2154262805260441194
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 7181142147825592926}
|
||||
- component: {fileID: 4917217417751722011}
|
||||
- component: {fileID: 8876282893591316834}
|
||||
- component: {fileID: 1837103631940551462}
|
||||
- component: {fileID: 7123394496182663843}
|
||||
m_Layer: 0
|
||||
m_Name: OneDiceResult
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &7181142147825592926
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2154262805260441194}
|
||||
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: 145111913694346175}
|
||||
- {fileID: 6185631331142294830}
|
||||
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: 100, y: 100}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &4917217417751722011
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2154262805260441194}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Padding:
|
||||
m_Left: 0
|
||||
m_Right: 0
|
||||
m_Top: 0
|
||||
m_Bottom: 0
|
||||
m_ChildAlignment: 4
|
||||
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!114 &8876282893591316834
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2154262805260441194}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &1837103631940551462
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2154262805260441194}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 680b7d1179eb34a0fa338286236db2e9, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
nameLabel: {fileID: 9015301821484574050}
|
||||
d6Label: {fileID: 0}
|
||||
d10Label: {fileID: 4483020407950697354}
|
||||
--- !u!222 &7123394496182663843
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2154262805260441194}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &6943825177879701675
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 6185631331142294830}
|
||||
- component: {fileID: 4032026062387785650}
|
||||
- component: {fileID: 4483020407950697354}
|
||||
- component: {fileID: 742703408719749652}
|
||||
- component: {fileID: 4292989854150447437}
|
||||
m_Layer: 0
|
||||
m_Name: result
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &6185631331142294830
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 6943825177879701675}
|
||||
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: 7181142147825592926}
|
||||
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 &4032026062387785650
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 6943825177879701675}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &4483020407950697354
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 6943825177879701675}
|
||||
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: 9
|
||||
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: 4278190080
|
||||
m_fontColor: {r: 0, g: 0, b: 0, 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: 48
|
||||
m_fontSizeBase: 48
|
||||
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_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_enableWordWrapping: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 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 &742703408719749652
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 6943825177879701675}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &4292989854150447437
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 6943825177879701675}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &8198927819804470251
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 145111913694346175}
|
||||
- component: {fileID: 8510898822381381753}
|
||||
- component: {fileID: 9015301821484574050}
|
||||
- component: {fileID: 4918688670369599320}
|
||||
m_Layer: 0
|
||||
m_Name: Name
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &145111913694346175
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8198927819804470251}
|
||||
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: 7181142147825592926}
|
||||
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 &8510898822381381753
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8198927819804470251}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &9015301821484574050
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8198927819804470251}
|
||||
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: 'Tens
|
||||
|
||||
'
|
||||
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: 4278190080
|
||||
m_fontColor: {r: 0, g: 0, b: 0, 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: 16
|
||||
m_fontSizeBase: 16
|
||||
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_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_enableWordWrapping: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 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 &4918688670369599320
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8198927819804470251}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: 1
|
||||
m_LayoutPriority: 1
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9acf3ba64a8c04e6083aae699248b0c4
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
using TMPro;
|
||||
|
||||
public class OneDiceRollController : TableRowController {
|
||||
private string _identifier;
|
||||
|
||||
public TMP_Text nameLabel;
|
||||
public TMP_Text d6Label;
|
||||
public TMP_Text d10Label;
|
||||
|
||||
// Start is called before the first frame update
|
||||
void Start() {}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update() {}
|
||||
|
||||
public void SetUp(string identifier, string deviceName) {
|
||||
_identifier = identifier;
|
||||
|
||||
nameLabel.text = deviceName;
|
||||
d6Label.text = "?";
|
||||
d10Label.text = "?";
|
||||
}
|
||||
|
||||
public void SetRoll(string identifier, string d6, string d10) {
|
||||
_identifier = identifier;
|
||||
|
||||
d6Label.text = d6;
|
||||
d10Label.text = d10;
|
||||
}
|
||||
|
||||
public string GetIdentifier() { return _identifier; }
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 680b7d1179eb34a0fa338286236db2e9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,281 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &3756143511112798868
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 5260824885446700081}
|
||||
- component: {fileID: 757087991837136050}
|
||||
- component: {fileID: 8474069203316727232}
|
||||
m_Layer: 0
|
||||
m_Name: Text (TMP)
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &5260824885446700081
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3756143511112798868}
|
||||
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: 3793282440965013679}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 200, y: 50}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &757087991837136050
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3756143511112798868}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &8474069203316727232
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3756143511112798868}
|
||||
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: Orange
|
||||
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: 4278229493
|
||||
m_fontColor: {r: 0.9607843, g: 0.6, b: 0, 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_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_enableWordWrapping: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 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!1 &5800426311821892848
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 3793282440965013679}
|
||||
- component: {fileID: 6926687573176944590}
|
||||
- component: {fileID: -2050598564750628712}
|
||||
- component: {fileID: 2781819584472077043}
|
||||
- component: {fileID: -2772541110306127347}
|
||||
- component: {fileID: 5171470299130298381}
|
||||
m_Layer: 0
|
||||
m_Name: PickerRow
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &3793282440965013679
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5800426311821892848}
|
||||
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: 5260824885446700081}
|
||||
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: 200, y: 50}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &6926687573176944590
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5800426311821892848}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &-2050598564750628712
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5800426311821892848}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: 30
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 30
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &2781819584472077043
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5800426311821892848}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, 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_Sprite: {fileID: 0}
|
||||
m_Type: 0
|
||||
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 &-2772541110306127347
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5800426311821892848}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d7b0b988957134dbc9c5e270bd5e9e70, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
nameLabel: {fileID: 8474069203316727232}
|
||||
--- !u!114 &5171470299130298381
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5800426311821892848}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 876f18cbcbaea4cba9510a977f343c42, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
onClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
onRightClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
onLeftDown:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
onLeftUp:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
onRightDown:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
onRightUp:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
selectable: 1
|
||||
rightSelectable: 0
|
||||
@@ -1,7 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a5c83874e75141728c54f347958d4ae
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
using TMPro;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class PickerRowController : TableRowController {
|
||||
public TMP_Text nameLabel;
|
||||
|
||||
public string Text {
|
||||
get => nameLabel.text;
|
||||
set => nameLabel.text = value;
|
||||
}
|
||||
|
||||
override public void ColorRow() { gameObject.GetComponent<Image>().color = BackgroundColor; }
|
||||
|
||||
// Start is called before the first frame update
|
||||
void Start() {}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update() {}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7b0b988957134dbc9c5e270bd5e9e70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,9 +0,0 @@
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
|
||||
namespace UnityGoDiceInterface {
|
||||
public interface RollFetcher {
|
||||
public delegate void RollResultCallback(int? result);
|
||||
|
||||
public void GetRoll(RollRequest rollType, RollResultCallback cb);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aade83941cf241b398049ff3fe0475cb
|
||||
timeCreated: 1703812608
|
||||
-375
@@ -1,375 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityGoDiceInterface;
|
||||
|
||||
public class RollPanelController : MonoBehaviour, RollFetcher {
|
||||
public DiceConfigurationPanelController diceConfigurationPanelController;
|
||||
public Button skipContinueButton;
|
||||
public TMP_Text skipContinueButtonText;
|
||||
private DiceInterface _diceInterface;
|
||||
|
||||
public TMP_Text tensResultLabel;
|
||||
public TMP_Text onesResultLabel;
|
||||
|
||||
public TMP_Text header;
|
||||
public TMP_Text reconnectionLabel;
|
||||
public TMP_Text runningTotalLabel;
|
||||
|
||||
private RollFetcher.RollResultCallback _rollResultCallback;
|
||||
private RollRequest _rollRequest;
|
||||
|
||||
private string logLocation;
|
||||
|
||||
enum OpenEndedState { None, High, Low, RollComplete }
|
||||
private OpenEndedState _openEndedState = OpenEndedState.None;
|
||||
|
||||
private readonly int _d100OpenEndedHighThreshold = 96;
|
||||
private readonly int _d100OpenEndedLowThreshold = 5;
|
||||
|
||||
private DateTime _lastRollTime = DateTime.MinValue;
|
||||
private readonly TimeSpan _disconnectionDelay = TimeSpan.FromMinutes(5);
|
||||
private readonly TimeSpan _displayTime = TimeSpan.FromSeconds(3);
|
||||
private readonly TimeSpan _openEndedResetTime = TimeSpan.FromSeconds(2);
|
||||
|
||||
public void GetRoll(RollRequest rollRequest, RollFetcher.RollResultCallback cb) {
|
||||
// Check for dice enabled
|
||||
if (PlayerPrefs.GetInt(SettingsPanelController.UseGoDiceKey, 0) == 0) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
|
||||
skipContinueButton.gameObject.SetActive(true);
|
||||
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
|
||||
|
||||
_onesResult = null;
|
||||
_tensResult = null;
|
||||
_runningTotal = 0;
|
||||
_openEndedState = OpenEndedState.None;
|
||||
|
||||
skipContinueButtonText.text = "Skip";
|
||||
|
||||
_rollResultCallback = cb;
|
||||
_rollRequest = rollRequest;
|
||||
|
||||
SetResultLabels();
|
||||
|
||||
logLocation = NewLogLocation();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(logLocation));
|
||||
|
||||
if (string.IsNullOrEmpty(_tensDieInfo.identifier) ||
|
||||
string.IsNullOrEmpty(_onesDieInfo.identifier)) {
|
||||
GetConfiguration();
|
||||
} else {
|
||||
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
|
||||
_diceInterface.connectionCallback = ConnectionCallback;
|
||||
_diceInterface.rollCallback = RollCallback;
|
||||
_diceInterface.logger = LogFromNative;
|
||||
|
||||
// _diceInterface.StartListening();
|
||||
SetAlphas();
|
||||
|
||||
if (!_tensDieInfo.connected) { _diceInterface.Connect(_tensDieInfo.identifier); }
|
||||
if (!_onesDieInfo.connected) { _diceInterface.Connect(_onesDieInfo.identifier); }
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
private DieInfo _tensDieInfo;
|
||||
private DieInfo _onesDieInfo;
|
||||
|
||||
private int? _tensResult = null;
|
||||
private int? _onesResult = null;
|
||||
private int _runningTotal = 0;
|
||||
|
||||
private void SetAlphas() {
|
||||
tensResultLabel.alpha = _tensDieInfo.connected ? 1.0f : 0.25f;
|
||||
onesResultLabel.alpha = _onesDieInfo.connected ? 1.0f : 0.25f;
|
||||
}
|
||||
|
||||
void OnEnable() {}
|
||||
|
||||
private void GetConfiguration() {
|
||||
diceConfigurationPanelController.GetConfiguration(ConfigurationCallback);
|
||||
}
|
||||
|
||||
public void ConfigureClicked() { GetConfiguration(); }
|
||||
|
||||
public void SkipClicked() {
|
||||
gameObject.SetActive(false);
|
||||
_rollResultCallback(_runningTotal);
|
||||
_diceInterface.StopListening();
|
||||
}
|
||||
|
||||
void ConfigurationCallback(DieInfo? tensOpt, DieInfo? onesOpt) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (tensOpt is DieInfo tens && onesOpt is DieInfo ones) {
|
||||
gameObject.SetActive(true);
|
||||
_diceInterface.connectionCallback = ConnectionCallback;
|
||||
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
|
||||
_diceInterface.disconnectionCallback = DisconnectionCallback;
|
||||
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
|
||||
_diceInterface.rollCallback = RollCallback;
|
||||
|
||||
_tensDieInfo = tens;
|
||||
_onesDieInfo = ones;
|
||||
|
||||
reconnectionLabel.gameObject.SetActive(false);
|
||||
|
||||
if (!_tensDieInfo.connected) _diceInterface.Connect(_tensDieInfo.identifier);
|
||||
if (!_onesDieInfo.connected) _diceInterface.Connect(_onesDieInfo.identifier);
|
||||
|
||||
SetAlphas();
|
||||
SetResultLabels();
|
||||
|
||||
tensResultLabel.color = UnityDieColors.ColorFromDieColor(_tensDieInfo.color);
|
||||
onesResultLabel.color = UnityDieColors.ColorFromDieColor(_onesDieInfo.color);
|
||||
} else {
|
||||
gameObject.SetActive(false);
|
||||
_rollResultCallback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DeviceFoundCallback(string identifier) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if ((_tensDieInfo.identifier == identifier && !_tensDieInfo.connected) ||
|
||||
(_onesDieInfo.identifier == identifier && !_onesDieInfo.connected)) {
|
||||
_diceInterface.Connect(identifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ConnectionCallback(string identifier, string deviceName) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (identifier == _tensDieInfo.identifier) {
|
||||
_tensDieInfo.connected = true;
|
||||
} else if (identifier == _onesDieInfo.identifier) {
|
||||
_onesDieInfo.connected = true;
|
||||
}
|
||||
|
||||
SetAlphas();
|
||||
|
||||
if (_tensDieInfo.connected && _onesDieInfo.connected) {
|
||||
reconnectionLabel.gameObject.SetActive(false);
|
||||
_diceInterface.StopListening();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ConnectionFailedCallback(string identifier) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (identifier == _onesDieInfo.identifier || identifier == _tensDieInfo.identifier) {
|
||||
reconnectionLabel.gameObject.SetActive(true);
|
||||
_diceInterface.StartListening();
|
||||
} else {
|
||||
Debug.Log("Got connection failed for unknown identifier");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DisconnectionCallback(string identifier) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (identifier == _tensDieInfo.identifier) {
|
||||
_tensDieInfo.connected = false;
|
||||
SetAlphas();
|
||||
_diceInterface.Connect(identifier);
|
||||
} else if (identifier == _onesDieInfo.identifier) {
|
||||
_onesDieInfo.connected = false;
|
||||
SetAlphas();
|
||||
_diceInterface.Connect(identifier);
|
||||
} else {
|
||||
Debug.Log("Got disconnection callback for unknown identifier");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ListenerStoppedCallback() {
|
||||
MainQueue.Q.Enqueue(() => { reconnectionLabel.text = "Unable to connect."; });
|
||||
}
|
||||
|
||||
private readonly Dictionary<CommandType, string> _baseHeaderText = new() {
|
||||
{ CommandType.ArcheryCommand, "Archery Attack" },
|
||||
{ CommandType.BuildBridgeCommand, "Build Bridge" },
|
||||
{ CommandType.ChargeCommand, "Charge" },
|
||||
{ CommandType.BraveWaterCommand, "Brave Water" },
|
||||
{ CommandType.MeleeCommand, "Melee Attack" }
|
||||
};
|
||||
|
||||
private string BaseHeaderText(CommandType commandType) {
|
||||
if (_baseHeaderText.ContainsKey(commandType)) { return _baseHeaderText[commandType]; }
|
||||
|
||||
return "Action";
|
||||
}
|
||||
|
||||
void SetResultLabels() {
|
||||
var baseText = BaseHeaderText(_rollRequest.CommandType);
|
||||
string fullText = "";
|
||||
|
||||
if (_openEndedState == OpenEndedState.High) {
|
||||
fullText = $"You rolled OPEN ENDED HIGH! Keep rolling for {baseText}!";
|
||||
} else if (_openEndedState == OpenEndedState.Low) {
|
||||
fullText = $"You rolled OPEN ENDED LOW! Keep rolling for {baseText}!";
|
||||
} else {
|
||||
switch (_rollRequest.RollType) {
|
||||
case RollType.D100: fullText = $"Roll D100 for {baseText}"; break;
|
||||
case RollType.D100OpenEnded:
|
||||
fullText = $"Roll Open Ended D100 for {baseText}";
|
||||
break;
|
||||
case RollType.D100OpenEndedHigh:
|
||||
fullText = $"Roll Open Ended High D100 for {baseText}";
|
||||
break;
|
||||
case RollType.D100OpenEndedLow:
|
||||
fullText = $"Roll Open Ended Low D100 for {baseText}";
|
||||
break;
|
||||
case RollType.Unspecified:
|
||||
default: throw new ArgumentException("Invalid roll type");
|
||||
}
|
||||
}
|
||||
|
||||
if (_rollRequest.Odds is OddsView odds) {
|
||||
int minRoll = 101 - odds.SuccessChance;
|
||||
fullText += $" ({minRoll}+ to succeed)";
|
||||
}
|
||||
|
||||
header.text = fullText;
|
||||
|
||||
if (_tensResult is int tr) {
|
||||
tensResultLabel.text = tr.ToString();
|
||||
} else {
|
||||
tensResultLabel.text = "?";
|
||||
}
|
||||
|
||||
if (_onesResult is int or) {
|
||||
onesResultLabel.text = or.ToString();
|
||||
} else {
|
||||
onesResultLabel.text = "?";
|
||||
}
|
||||
|
||||
runningTotalLabel.text = $"Running total: {_runningTotal}";
|
||||
runningTotalLabel.gameObject.SetActive(_runningTotal != 0);
|
||||
}
|
||||
|
||||
void RollCallback(string identifier, sbyte x, sbyte y, sbyte z) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (gameObject.activeSelf) {
|
||||
var d10Roll = DiceVectors.D10Value(x, y, z);
|
||||
|
||||
if (identifier == _tensDieInfo.identifier) {
|
||||
if (_tensResult == null) { _tensResult = d10Roll; }
|
||||
} else if (identifier == _onesDieInfo.identifier) {
|
||||
if (_onesResult == null) { _onesResult = d10Roll; }
|
||||
}
|
||||
|
||||
SetResultLabels();
|
||||
CheckCompletion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void HandleOneRoll(int newD100Result) {
|
||||
if (_openEndedState == OpenEndedState.Low) {
|
||||
_runningTotal -= newD100Result;
|
||||
} else {
|
||||
_runningTotal += newD100Result;
|
||||
}
|
||||
|
||||
// Rolls in the middle always end the open ended state.
|
||||
if (newD100Result > _d100OpenEndedLowThreshold &&
|
||||
newD100Result < _d100OpenEndedHighThreshold) {
|
||||
_openEndedState = OpenEndedState.RollComplete;
|
||||
return;
|
||||
}
|
||||
|
||||
// For a LOW roll, if we are currently in a None state, move to OpenEnded
|
||||
// Low so we can roll again, but if we were already there, this ends the roll.
|
||||
if (newD100Result <= _d100OpenEndedLowThreshold) {
|
||||
if ((_rollRequest.RollType == RollType.D100OpenEnded ||
|
||||
_rollRequest.RollType == RollType.D100OpenEndedLow) &&
|
||||
_openEndedState == OpenEndedState.None) {
|
||||
_openEndedState = OpenEndedState.Low;
|
||||
} else {
|
||||
_openEndedState = OpenEndedState.RollComplete;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point we know the current roll is high. If we were already in OpenEndedLow,
|
||||
// stay there and keep subtracting, otherwise move into High.
|
||||
if (_openEndedState == OpenEndedState.Low) {
|
||||
_openEndedState = OpenEndedState.Low;
|
||||
} else {
|
||||
_openEndedState = OpenEndedState.High;
|
||||
}
|
||||
}
|
||||
|
||||
void CheckCompletion() {
|
||||
if (_onesResult is int ones && _tensResult is int tens) {
|
||||
// FIXME: open ended rolls
|
||||
var currentResult = tens * 10 + ones;
|
||||
|
||||
HandleOneRoll(currentResult);
|
||||
// TODO: flash the dice here for high rolls
|
||||
|
||||
if (_openEndedState == OpenEndedState.RollComplete) {
|
||||
skipContinueButtonText.text = "Dismiss";
|
||||
SetResultLabels();
|
||||
StartCoroutine(AfterDelay(_displayTime, () => {
|
||||
if (gameObject.activeSelf) {
|
||||
gameObject.SetActive(false);
|
||||
_rollResultCallback(_runningTotal);
|
||||
}
|
||||
}));
|
||||
|
||||
// Disconnect only after _disconnectionDelay time
|
||||
_lastRollTime = DateTime.Now;
|
||||
StartCoroutine(AfterDelay(_disconnectionDelay, () => {
|
||||
if (DateTime.Now - _lastRollTime > _disconnectionDelay) {
|
||||
_diceInterface.StopListening();
|
||||
|
||||
_tensDieInfo.connected = false;
|
||||
_onesDieInfo.connected = false;
|
||||
_diceInterface.Disconnect(_tensDieInfo.identifier);
|
||||
_diceInterface.Disconnect(_onesDieInfo.identifier);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// We keep rolling, so reset the dice and keep going.
|
||||
StartCoroutine(AfterDelay(_openEndedResetTime, () => {
|
||||
_tensResult = null;
|
||||
_onesResult = null;
|
||||
SetResultLabels();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator AfterDelay(TimeSpan timeSpan, Action action) {
|
||||
yield return new WaitForSeconds(timeSpan.Seconds);
|
||||
action();
|
||||
}
|
||||
|
||||
void LogFromNative(string log) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
Debug.Log(log);
|
||||
File.AppendAllText(logLocation, log);
|
||||
});
|
||||
}
|
||||
|
||||
string NewLogLocation() {
|
||||
return Path.Combine(
|
||||
Application.persistentDataPath,
|
||||
"eagle0",
|
||||
"Resources",
|
||||
"RollPanelLogs",
|
||||
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db0669ab8b7684b2bb7eb30a0e66f5e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,18 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UnityGoDiceInterface {
|
||||
public class UnityDieColors {
|
||||
public static Color ColorFromDieColor(DieColor dieColor) {
|
||||
switch (dieColor) {
|
||||
case DieColor.DieColorBlack: return Color.black;
|
||||
case DieColor.DieColorRed: return Color.red;
|
||||
case DieColor.DieColorGreen: return Color.green;
|
||||
case DieColor.DieColorBlue: return Color.blue;
|
||||
case DieColor.DieColorYellow: return Color.yellow;
|
||||
case DieColor.DieColorOrange: return new Color(1.0f, 0.5f, 0.0f);
|
||||
default: throw new ArgumentOutOfRangeException(nameof(dieColor), dieColor, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d26c35650c67416e86ab5a26906cfb5a
|
||||
timeCreated: 1703173073
|
||||
+4
-1
@@ -714,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;
|
||||
}
|
||||
|
||||
+1
-4
@@ -20,8 +20,6 @@ using Random = System.Random;
|
||||
using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
|
||||
|
||||
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
|
||||
public RollPanelController rollPanelController;
|
||||
|
||||
public GameObject yourArmiesArea;
|
||||
public GameObject aiArmiesArea;
|
||||
public Canvas shardokCanvas;
|
||||
@@ -103,8 +101,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
|
||||
players: players,
|
||||
heroNameTextIds: _heroNames,
|
||||
heroImages: _heroImages,
|
||||
battalionNames: new Dictionary<int, string> { { 0, "no name" } },
|
||||
rollFetcher: rollPanelController);
|
||||
battalionNames: new Dictionary<int, string> { { 0, "no name" } });
|
||||
|
||||
shardokCanvas.gameObject.SetActive(true);
|
||||
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
|
||||
|
||||
+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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
using UnityEngine;
|
||||
@@ -88,6 +89,9 @@ namespace eagle {
|
||||
_model = model;
|
||||
_availableCommand = ac;
|
||||
SetUpUI();
|
||||
|
||||
// Trigger tutorial for this command type (fires every time command is selected)
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnCommandPanelShown(CommandType);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -22,8 +22,6 @@ namespace eagle {
|
||||
public class EagleGameController : MonoBehaviour {
|
||||
public SoundManager soundManager;
|
||||
|
||||
public RollPanelController rollPanelController;
|
||||
|
||||
public TextMeshProUGUI roundStatusLabel;
|
||||
public TextMeshProUGUI connectionStatusLabel;
|
||||
private bool _connectionStatusUIInitialized = false;
|
||||
@@ -248,11 +246,7 @@ namespace eagle {
|
||||
int? playerId,
|
||||
PersistentClientConnection persistentClientConnection,
|
||||
string environmentName = null) {
|
||||
ModelUpdater = new GameModelUpdater(
|
||||
gameId,
|
||||
playerId,
|
||||
persistentClientConnection,
|
||||
rollPanelController) {
|
||||
ModelUpdater = new GameModelUpdater(gameId, playerId, persistentClientConnection) {
|
||||
UpdateAction = ModelUpdated,
|
||||
NoteRecipient = (title, text, llmId, pids, displayedHeroes) =>
|
||||
notificationPanel.AddNote(title, text, llmId, pids, displayedHeroes)
|
||||
@@ -284,11 +278,13 @@ namespace eagle {
|
||||
MainQueue.Q.EnqueueForNextUpdate(
|
||||
() => { _ = ModelUpdater.StartListeningForUpdates(); });
|
||||
|
||||
// Initialize tutorial system
|
||||
// Initialize tutorial system (onboarding starts after first model update in SwapModel)
|
||||
TutorialManager.Instance?.Initialize(this, null);
|
||||
if (TutorialManager.Instance != null &&
|
||||
!TutorialManager.Instance.State.OnboardingCompleted) {
|
||||
TutorialManager.Instance.StartOnboarding();
|
||||
// Register command panel for tutorial positioning
|
||||
if (TutorialManager.Instance?.TargetRegistry != null && commandPanel != null) {
|
||||
TutorialManager.Instance.TargetRegistry.RegisterTarget(
|
||||
"CommandPanel",
|
||||
commandPanel.GetComponent<RectTransform>());
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
@@ -507,6 +503,12 @@ namespace eagle {
|
||||
|
||||
if (Model == null) { return; }
|
||||
|
||||
// Start onboarding after first model update (UI panels are now populated)
|
||||
if (oldModel == null && TutorialManager.Instance != null &&
|
||||
!TutorialManager.Instance.State.OnboardingCompleted) {
|
||||
TutorialManager.Instance.StartOnboarding();
|
||||
}
|
||||
|
||||
PrefetchHeadshots(Model);
|
||||
|
||||
SetMusic();
|
||||
|
||||
@@ -13,7 +13,6 @@ using Net.Eagle0.Eagle.Views;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityGoDiceInterface;
|
||||
using Logger = common.Logger;
|
||||
using Notification = eagle.Notifications.Notification;
|
||||
|
||||
@@ -58,8 +57,6 @@ namespace eagle {
|
||||
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
|
||||
|
||||
public List<ChronicleEntry> ChronicleEntries { get; }
|
||||
|
||||
RollFetcher RollFetcher { get; }
|
||||
}
|
||||
|
||||
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
|
||||
@@ -150,8 +147,6 @@ namespace eagle {
|
||||
private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
|
||||
new ConcurrentDictionary<string, int>();
|
||||
|
||||
private readonly RollFetcher _rollFetcher;
|
||||
|
||||
// State synced with server
|
||||
private struct ConcreteGameModel : IGameModel {
|
||||
public PlayerId? PlayerId { get; set; }
|
||||
@@ -220,8 +215,6 @@ namespace eagle {
|
||||
var faction = MaybeDestroyedFaction(factionId);
|
||||
textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
|
||||
}
|
||||
|
||||
public RollFetcher RollFetcher { get; set; }
|
||||
}
|
||||
|
||||
private ConcreteGameModel _currentModel;
|
||||
@@ -229,11 +222,9 @@ namespace eagle {
|
||||
public GameModelUpdater(
|
||||
GameId eagleGameId,
|
||||
FactionId? factionId,
|
||||
PersistentClientConnection pers,
|
||||
RollFetcher rollFetcher) {
|
||||
PersistentClientConnection pers) {
|
||||
GameId = eagleGameId;
|
||||
PersistentConnection = pers;
|
||||
this._rollFetcher = rollFetcher;
|
||||
|
||||
_currentModel.LastPostedToken = -1;
|
||||
_currentModel.PlayerId = factionId;
|
||||
@@ -245,8 +236,6 @@ namespace eagle {
|
||||
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
|
||||
|
||||
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
|
||||
|
||||
_currentModel.RollFetcher = rollFetcher;
|
||||
}
|
||||
|
||||
private ShardokGameModel MakeGameModel(ShardokGameId shardokGameId) {
|
||||
@@ -294,8 +283,7 @@ namespace eagle {
|
||||
kv => kv.Value.ImagePath),
|
||||
battalionNames: _currentModel.BattalionNames.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value),
|
||||
rollFetcher: _rollFetcher);
|
||||
kv => kv.Value));
|
||||
|
||||
return shardokModel;
|
||||
}
|
||||
|
||||
+20
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using common;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -136,6 +137,25 @@ namespace eagle {
|
||||
row.Hero = hero;
|
||||
SetHeroRowSelections(row, hero);
|
||||
});
|
||||
|
||||
// Register hero rows with tutorial system for highlighting
|
||||
RegisterHeroRowsForTutorial();
|
||||
}
|
||||
|
||||
private void RegisterHeroRowsForTutorial() {
|
||||
var registry = TutorialManager.Instance?.TargetRegistry;
|
||||
if (registry == null) return;
|
||||
|
||||
// Register first row as WarlordRow (index 0)
|
||||
var warlordRow = heroesTable.GetRowRectTransform(0);
|
||||
if (warlordRow != null) { registry.RegisterTarget("WarlordRow", warlordRow); }
|
||||
|
||||
// Register rows 1 and 2 as VassalRows
|
||||
var vassalRow1 = heroesTable.GetRowRectTransform(1);
|
||||
if (vassalRow1 != null) { registry.RegisterTarget("VassalRow1", vassalRow1); }
|
||||
|
||||
var vassalRow2 = heroesTable.GetRowRectTransform(2);
|
||||
if (vassalRow2 != null) { registry.RegisterTarget("VassalRow2", vassalRow2); }
|
||||
}
|
||||
|
||||
private void SetUpBattalionsTable() {
|
||||
|
||||
+130
-15
@@ -196,6 +196,20 @@ namespace eagle {
|
||||
/// </summary>
|
||||
private async Task<bool> StreamOneGameAsync(IClientConnectionSubscriber subscriber) {
|
||||
var gameId = subscriber.GameId;
|
||||
|
||||
// Send JoinGameRequest first to ensure game is loaded on server.
|
||||
// For already-started games, this returns GAME_FULL but still triggers
|
||||
// game loading, which prevents "key not found" errors on subsequent commands.
|
||||
var joinRequest = new UpdateStreamRequest {
|
||||
JoinGameRequest = new JoinGameRequest { GameId = gameId }
|
||||
};
|
||||
await DoWithStreamingCall(async (sc) => {
|
||||
await sc.RequestStream.WriteAsync(joinRequest);
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Sent JoinGameRequest for game {gameId} to ensure game is loaded");
|
||||
return true;
|
||||
});
|
||||
|
||||
var streamGameRequest = new StreamGameRequest {
|
||||
GameId = gameId,
|
||||
UnfilteredResultCount = subscriber.LastUnfilteredResultCount
|
||||
@@ -299,6 +313,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 +512,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 +553,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 +581,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 +714,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");
|
||||
}
|
||||
@@ -767,12 +824,19 @@ namespace eagle {
|
||||
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// WriteAsync timed out - connection is likely dead
|
||||
// WriteAsync timed out - connection is dead
|
||||
LogConnectionEvent(
|
||||
"write_timeout",
|
||||
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
|
||||
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, triggering reconnect");
|
||||
|
||||
// Dispose the dead connection and schedule reconnect
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
}
|
||||
ScheduleReconnect("WriteAsyncTimeout");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -780,15 +844,31 @@ namespace eagle {
|
||||
timeoutCts.Cancel();
|
||||
return await actionTask.ConfigureAwait(false);
|
||||
} catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
// This is expected when the connection is closed.
|
||||
return false;
|
||||
} else {
|
||||
throw;
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_ERROR] RpcException: {e.StatusCode} - {e.Message}");
|
||||
// Connection is broken - dispose and reconnect
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
}
|
||||
if (e.StatusCode != StatusCode.Cancelled) {
|
||||
// Cancelled is expected during normal shutdown, don't reconnect
|
||||
ScheduleReconnect($"RpcException-{e.StatusCode}");
|
||||
}
|
||||
return false;
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout was triggered
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_ERROR] Exception: {e.GetType().Name} - {e.Message}");
|
||||
// Unknown error - dispose and reconnect
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
}
|
||||
ScheduleReconnect($"WriteException-{e.GetType().Name}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,6 +1153,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();
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 75ea5d911cfab4851831d1de4b61f559
|
||||
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
executionOrder: -100
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e694fb18ea1df4034b392352bf3aa04a
|
||||
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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/
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1fc634da07f5047c99dba5ce558b5886
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 0
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
Any:
|
||||
second:
|
||||
enabled: 1
|
||||
settings: {}
|
||||
- first:
|
||||
Editor: Editor
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
DefaultValueInitialized: true
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+18
-6
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5b6db3c9b9e34b82ae9163a61276c8a
|
||||
guid: 4d6e8f0a2b3c5d7e9f1a3b5c7d9e1f3a
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -12,22 +12,34 @@ PluginImporter:
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
Any:
|
||||
: 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: 1
|
||||
enabled: 0
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
DefaultValueInitialized: true
|
||||
OS: AnyOS
|
||||
- first:
|
||||
Standalone: OSXUniversal
|
||||
second:
|
||||
enabled: 1
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -23,10 +23,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
public Slider tileBorderWidthSlider;
|
||||
public TMP_Text tileBorderWidthLabel;
|
||||
|
||||
public Toggle useGoDiceToggle;
|
||||
public Button testGoDice;
|
||||
public RollPanelController rollPanelController;
|
||||
|
||||
public Toggle tooltipHugsCursorToggle;
|
||||
public HoveringTooltip hoveringTooltip;
|
||||
|
||||
@@ -42,7 +38,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
|
||||
private const string SoundEffectsVolumeKey = "soundEffectsVolume";
|
||||
private const string MusicVolumeKey = "musicVolume";
|
||||
public const string UseGoDiceKey = "useGoDice";
|
||||
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
|
||||
private const string AnimationToggleKey = "animationToggle";
|
||||
|
||||
@@ -62,7 +57,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
|
||||
soundEffectsSlider.value = soundEffectsSource.volume;
|
||||
musicSlider.value = musicSource.volume;
|
||||
useGoDiceToggle.isOn = PlayerPrefs.GetInt(UseGoDiceKey, 0) == 1;
|
||||
|
||||
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
|
||||
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
|
||||
@@ -111,17 +105,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
PlayerPrefs.SetFloat(MusicVolumeKey, val);
|
||||
}
|
||||
|
||||
public void OnUseGoDiceToggleChange(bool val) {
|
||||
PlayerPrefs.SetInt(UseGoDiceKey, val ? 1 : 0);
|
||||
testGoDice.interactable = val;
|
||||
}
|
||||
|
||||
public void OnTestGoDiceClick() {
|
||||
rollPanelController.GetRoll(
|
||||
new RollRequest { RollType = RollType.D100OpenEnded },
|
||||
(result) => { Console.Out.WriteLine($"Got ${result}"); });
|
||||
}
|
||||
|
||||
public void OnCursorHugToggleChange(bool val) {
|
||||
PlayerPrefs.SetInt(TooltipHugsCursorKey, val ? 1 : 0);
|
||||
hoveringTooltip.HugsCursor = val;
|
||||
|
||||
@@ -55,6 +55,21 @@ namespace Shardok {
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
@@ -65,19 +80,21 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
$"MoveAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
|
||||
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(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
|
||||
StartCoroutine(SpawnPrintTrailAlongPath(pathPositions, isMounted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,39 +105,52 @@ namespace Shardok {
|
||||
}
|
||||
|
||||
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
|
||||
var prints = new List<GameObject>();
|
||||
Vector2 direction = (target - source).normalized;
|
||||
float totalDistance = Vector2.Distance(source, target);
|
||||
yield return SpawnPrintTrailAlongPath(new List<Vector2> { source, target }, isMounted);
|
||||
}
|
||||
|
||||
// Calculate rotation angle for prints to face direction of travel
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
|
||||
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;
|
||||
|
||||
// Spawn prints along the path
|
||||
for (int i = 0; i < printsPerHex; i++) {
|
||||
float t = (i + 1f) / (printsPerHex + 1f);
|
||||
Vector2 position = Vector2.Lerp(source, target, t);
|
||||
int totalPrintIndex = 0;
|
||||
|
||||
// Alternate left/right for boot prints (flip horizontally)
|
||||
bool flipX = !isMounted && (i % 2 == 1);
|
||||
// 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;
|
||||
|
||||
// Lateral offset for alternating prints (feet or front/rear hooves)
|
||||
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
|
||||
float offset = (i % 2 == 0) ? -lateralOffset : lateralOffset;
|
||||
position += perpendicular * offset;
|
||||
// Calculate rotation angle for prints to face direction of travel
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
|
||||
|
||||
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
|
||||
prints.Add(print);
|
||||
// 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);
|
||||
|
||||
// Start FIFO fade coroutine for this print
|
||||
var fadeCoroutine =
|
||||
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
|
||||
_activeCoroutines.Add(fadeCoroutine);
|
||||
// Alternate left/right for boot prints (flip horizontally)
|
||||
bool flipX = !isMounted && (totalPrintIndex % 2 == 1);
|
||||
|
||||
yield return new WaitForSeconds(printInterval);
|
||||
// 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
|
||||
|
||||
+27
-8
@@ -480,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;
|
||||
@@ -1531,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
|
||||
@@ -1576,10 +1581,14 @@ namespace Shardok {
|
||||
break;
|
||||
case AnimationType.Move:
|
||||
if (moveAnimator != null && attackerUnit != null) {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
if (movePath != null && movePath.Count >= 2) {
|
||||
moveAnimator.AnimateMove(movePath, attackerUnit.Battalion.Type);
|
||||
} else {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case AnimationType.LightningBolt:
|
||||
@@ -1897,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(
|
||||
@@ -1912,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());
|
||||
|
||||
+12
-30
@@ -6,7 +6,6 @@ using eagle0;
|
||||
using UnityEngine;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using UnityGoDiceInterface;
|
||||
using BattalionId = System.Int32;
|
||||
using UnitId = System.Int32;
|
||||
using PlayerId = System.Int32;
|
||||
@@ -133,8 +132,6 @@ public class ShardokGameModel {
|
||||
|
||||
private const int StartingHistoryCapacity = 100;
|
||||
|
||||
private readonly RollFetcher _rollFetcher;
|
||||
|
||||
public ShardokGameModel(
|
||||
EagleGameId eagleGameId,
|
||||
ShardokGameId shardokGameId,
|
||||
@@ -146,13 +143,11 @@ public class ShardokGameModel {
|
||||
List<PlayerWithHostility> players,
|
||||
Dictionary<HeroId, String> heroNameTextIds,
|
||||
Dictionary<HeroId, String> heroImages,
|
||||
Dictionary<BattalionId, String> battalionNames,
|
||||
RollFetcher rollFetcher) {
|
||||
Dictionary<BattalionId, String> battalionNames) {
|
||||
EagleGameId = eagleGameId;
|
||||
ShardokGameId = shardokGameId;
|
||||
History = new List<ActionResultView>(StartingHistoryCapacity);
|
||||
_persistentClientConnection = persistentClientConnection;
|
||||
this._rollFetcher = rollFetcher;
|
||||
PlayerId = playerId;
|
||||
this.players = new List<PlayerWithHostility>(players);
|
||||
|
||||
@@ -274,16 +269,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;
|
||||
@@ -352,25 +345,14 @@ public class ShardokGameModel {
|
||||
var index = AvailableCommands.IndexOf(action);
|
||||
AvailableCommands.Clear();
|
||||
|
||||
if (action.RollRequest != null) {
|
||||
_rollFetcher.GetRoll(action.RollRequest, roll => {
|
||||
_persistentClientConnection.PostShardokCommand(
|
||||
EagleGameId,
|
||||
ShardokGameId,
|
||||
PlayerId,
|
||||
History.Count(),
|
||||
index,
|
||||
roll);
|
||||
});
|
||||
} else {
|
||||
_persistentClientConnection.PostShardokCommand(
|
||||
EagleGameId,
|
||||
ShardokGameId,
|
||||
PlayerId,
|
||||
History.Count(),
|
||||
index,
|
||||
null);
|
||||
}
|
||||
// Physical dice roll support removed - server generates random rolls
|
||||
_persistentClientConnection.PostShardokCommand(
|
||||
EagleGameId,
|
||||
ShardokGameId,
|
||||
PlayerId,
|
||||
History.Count(),
|
||||
index,
|
||||
null);
|
||||
}
|
||||
|
||||
private void PostGameSetupActions() {
|
||||
|
||||
+709
@@ -0,0 +1,709 @@
|
||||
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 command panel tutorials (shown after onboarding)
|
||||
RegisterCommandPanelTutorials(registry);
|
||||
|
||||
// Register strategic contextual tutorials
|
||||
RegisterStrategicTutorials(registry);
|
||||
|
||||
// Register tactical contextual tutorials
|
||||
RegisterTacticalTutorials(registry);
|
||||
}
|
||||
|
||||
// ========== ONBOARDING SEQUENCE ==========
|
||||
|
||||
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 =
|
||||
"You are a new ruler in a fractured realm. Build your power, recruit heroes, and expand your domain.\n\n" +
|
||||
"Let's look at your first province!\n\n" +
|
||||
"<size=80%><color=#888888>(You can skip this tutorial and restart it later from Settings.)</color></size>",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 2: Province Info Panel - Stats Overview
|
||||
new TutorialStep {
|
||||
StepId = "province_stats",
|
||||
Title = "Province Statistics",
|
||||
Description =
|
||||
"This panel shows your province's vital statistics. <b>Hover over icons and numbers</b> for detailed tooltips.\n\n" +
|
||||
"• <b>Agriculture</b> produces Food to feed your troops\n" +
|
||||
"• <b>Economy</b> generates Gold for hiring, gifts, and equipment\n" +
|
||||
"• <b>Infrastructure</b> improves troop armament and disaster resilience\n\n" +
|
||||
"All three stats increase your storage capacity.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
BlocksInteraction = false,
|
||||
PanelAnchor = TutorialPanelAnchor.Right,
|
||||
AdjacentTargetPath = "ProvinceInfoPanel",
|
||||
TargetGameObjectPath = "ProvinceInfoPanel",
|
||||
HighlightPulsing = true,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 3: Province Info Panel - Support (CRITICAL)
|
||||
new TutorialStep {
|
||||
StepId = "province_support",
|
||||
Title = "Support is Critical!",
|
||||
Description =
|
||||
"<b>Support</b> measures how much your people trust you. This is your most important number right now!\n\n" +
|
||||
"You need <b>40 Support by January</b> to collect taxes. Taxes provide both <b>Gold</b> and <b>Food</b>:\n" +
|
||||
"• <b>Food</b> feeds your troops and lets you Give Alms\n" +
|
||||
"• <b>Gold</b> pays for hiring, hero gifts, equipment, and more\n\n" +
|
||||
"Use <b>Improve</b> and <b>Give Alms</b> to raise Support quickly.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
BlocksInteraction = false,
|
||||
PanelAnchor = TutorialPanelAnchor.Right,
|
||||
AdjacentTargetPath = "ProvinceInfoPanel",
|
||||
TargetGameObjectPath = "SupportField",
|
||||
HighlightPulsing = true,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 4: Heroes Panel - Warlord
|
||||
new TutorialStep {
|
||||
StepId = "heroes_warlord",
|
||||
Title = "Your Warlord",
|
||||
Description =
|
||||
"The <b>Resident Heroes</b> panel shows heroes in this province. The first hero is your <b>Warlord</b> - this is essentially <i>you</i>.\n\n" +
|
||||
"<b>Hover over a hero</b> to see their backstory, stats, and abilities. Your Warlord has a special profession that grants unique powers.\n\n" +
|
||||
"<color=#FF6666>Your Warlord is very important - protect them!</color>",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
BlocksInteraction = false,
|
||||
PanelAnchor = TutorialPanelAnchor.Right,
|
||||
AdjacentTargetPath = "HeroesPanel",
|
||||
TargetGameObjectPath = "WarlordRow",
|
||||
HighlightPulsing = true,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 5: Heroes Panel - Vassals
|
||||
new TutorialStep {
|
||||
StepId = "heroes_vassals",
|
||||
Title = "Your Vassals",
|
||||
Description =
|
||||
"The other heroes in your service are your <b>Vassals</b>. They lead troops, govern provinces, and perform tasks for you.\n\n" +
|
||||
"<b>Watch their Loyalty!</b> At the end of each year, vassal loyalty may drop. If it falls below <b>50</b>, they might leave your service - or worse.\n\n" +
|
||||
"Keep vassals happy with gifts and feasts!",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
BlocksInteraction = false,
|
||||
PanelAnchor = TutorialPanelAnchor.Right,
|
||||
AdjacentTargetPath = "HeroesPanel",
|
||||
TargetGameObjectPath = "VassalRow1",
|
||||
AdditionalHighlightTargets = new[] { "VassalRow2" },
|
||||
HighlightPulsing = true,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 6: Command Buttons - Focus on Improve/Alms
|
||||
new TutorialStep {
|
||||
StepId = "command_buttons",
|
||||
Title = "Province Commands",
|
||||
Description =
|
||||
"The buttons below let you issue commands. <b>Feel free to click them</b> - nothing happens until you click <b>Commit</b>!\n\n" +
|
||||
"For now, focus on:\n" +
|
||||
"• <b>Improve</b> - Raises province stats AND Support\n" +
|
||||
"• <b>Give Alms</b> - Quickly boosts Support (costs Food)\n\n" +
|
||||
"Get to <b>40 Support before January</b> to collect taxes!",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
BlocksInteraction = false,
|
||||
PanelAnchor = TutorialPanelAnchor.Top,
|
||||
AdjacentTargetPath = "CommandButtonsPanel",
|
||||
TargetGameObjectPath = "CommandButtonsPanel",
|
||||
HighlightPulsing = true,
|
||||
HighlightBoundsFromChildren = true,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 7: Turn cycle explanation
|
||||
new TutorialStep {
|
||||
StepId = "turn_cycle",
|
||||
Title = "The Turn Cycle",
|
||||
Description =
|
||||
"Eagle0 uses <b>simultaneous turns</b>. All players give orders at the same time, then everything resolves together.\n\n" +
|
||||
"Your turn ends when you've given an order in each of your provinces. Once all players are ready, the server processes commands and shows results.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 8: Completion of strategic intro
|
||||
new TutorialStep {
|
||||
StepId = "strategic_complete",
|
||||
Title = "You're Ready to Begin!",
|
||||
Description =
|
||||
"That's the basics of managing your province. Remember:\n\n" +
|
||||
"• Get <b>Support to 40</b> before January for taxes (Gold + Food)\n" +
|
||||
"• <b>Hover over everything</b> for tooltips\n" +
|
||||
"• Protect your <b>Warlord</b> at all costs!\n" +
|
||||
"• Keep your <b>Vassals</b> loyal with gifts\n\n" +
|
||||
"When armies clash, you'll learn tactical combat. Good luck, ruler!",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = false,
|
||||
// Mark onboarding complete here so command tutorials can appear
|
||||
// Tactical tutorial continues in background when battle becomes available
|
||||
MarksOnboardingComplete = true
|
||||
},
|
||||
|
||||
// === TACTICAL TUTORIAL (triggered later when battle occurs) ===
|
||||
|
||||
// Step 9: 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 10: Battle introduction
|
||||
new TutorialStep {
|
||||
StepId = "battle_intro",
|
||||
Title = "Battle Time!",
|
||||
Description =
|
||||
"When armies collide, you fight tactical battles on a hex grid.\n\n" +
|
||||
"You command individual units - infantry, cavalry, archers, and heroes with special abilities.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 11: Battle button highlight
|
||||
new TutorialStep {
|
||||
StepId = "enter_battle",
|
||||
Title = "Enter the Battle",
|
||||
Description = "Tap the <b>Battle</b> button to enter tactical combat.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "battle_entered",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 12: Tactical overview
|
||||
new TutorialStep {
|
||||
StepId = "tactical_overview",
|
||||
Title = "Tactical Combat",
|
||||
Description =
|
||||
"Each unit has movement points and attack power. Position your troops wisely!\n\n" +
|
||||
"Units attack adjacent enemies. <b>Flanking</b> (attacking from multiple sides) deals bonus damage.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 13: 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\n" +
|
||||
"<b>Blue hexes</b> show where you can move.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "battle_action",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 14: Attack an enemy
|
||||
new TutorialStep {
|
||||
StepId = "attack_enemy",
|
||||
Title = "Attack!",
|
||||
Description = "Move next to an enemy unit, then tap the enemy to attack.\n\n" +
|
||||
"<b>Red highlights</b> show valid attack targets.",
|
||||
DisplayMode = TutorialDisplayMode.Overlay,
|
||||
CompletionType = TutorialCompletionType.GameEvent,
|
||||
CompletionEventId = "battle_action",
|
||||
AllowSkip = true
|
||||
},
|
||||
|
||||
// Step 15: Final completion
|
||||
new TutorialStep {
|
||||
StepId = "complete",
|
||||
Title = "Tutorial Complete!",
|
||||
Description =
|
||||
"You now know the basics of Eagle0!\n\n" +
|
||||
"Explore diplomacy, recruit powerful heroes, and conquer the realm. May your reign be long and prosperous!",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = false
|
||||
}
|
||||
};
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
// ========== COMMAND PANEL TUTORIALS ==========
|
||||
// These appear the first time a command panel is shown, after onboarding completes.
|
||||
|
||||
private static void RegisterCommandPanelTutorials(TutorialTriggerRegistry registry) {
|
||||
// Improve command - most important early game command
|
||||
var improve = CreateCommandPanelTutorial(
|
||||
"command_improve",
|
||||
"Improve Province",
|
||||
"Use <b>Improve</b> to develop your province and increase Support.\n\n" +
|
||||
"• <b>Agriculture</b> - Increases food production\n" +
|
||||
"• <b>Economy</b> - Increases gold income\n" +
|
||||
"• <b>Infrastructure</b> - Improves armament and resilience\n" +
|
||||
"• <b>Repair Devastation</b> - Restores damaged stats\n\n" +
|
||||
"<b>Tip:</b> Select a hero using the dropdown, or click a hero in the <b>Resident Heroes</b> panel to the left!");
|
||||
registry.RegisterTutorial(improve, "command_panel_ImproveCommand");
|
||||
|
||||
// Alms command
|
||||
var alms = CreateCommandPanelTutorial(
|
||||
"command_alms",
|
||||
"Give Alms",
|
||||
"Give Alms distributes food to the people, quickly raising <b>Support</b>.\n\n" +
|
||||
"This is the fastest way to boost Support, but costs Food from your stores. Use it when you need Support urgently!");
|
||||
registry.RegisterTutorial(alms, "command_panel_AlmsCommand");
|
||||
|
||||
// March command
|
||||
var march = CreateCommandPanelTutorial(
|
||||
"command_march",
|
||||
"March",
|
||||
"March moves your army to another province.\n\n" +
|
||||
"• Select a <b>destination</b> province\n" +
|
||||
"• Choose which <b>heroes and battalions</b> to take\n" +
|
||||
"• Optionally bring <b>supplies</b> (food and gold)\n\n" +
|
||||
"If you march into enemy territory, battle may ensue!");
|
||||
registry.RegisterTutorial(march, "command_panel_MarchCommand");
|
||||
|
||||
// Defend command
|
||||
var defend = CreateCommandPanelTutorial(
|
||||
"command_defend",
|
||||
"Defend",
|
||||
"Defend prepares your forces to repel attackers.\n\n" +
|
||||
"Choose a <b>rally point</b> where your troops will gather. Units defending have advantages in tactical combat.");
|
||||
registry.RegisterTutorial(defend, "command_panel_DefendCommand");
|
||||
|
||||
// Rest command
|
||||
var rest = CreateCommandPanelTutorial(
|
||||
"command_rest",
|
||||
"Rest",
|
||||
"Rest restores your heroes' <b>Vigor</b>.\n\n" +
|
||||
"Heroes need rest to recover from exertion. Use this when your heroes are tired and you don't need to take other actions.");
|
||||
registry.RegisterTutorial(rest, "command_panel_RestCommand");
|
||||
|
||||
// Trade command
|
||||
var trade = CreateCommandPanelTutorial(
|
||||
"command_trade",
|
||||
"Trade",
|
||||
"Trade lets you exchange <b>Food</b> for <b>Gold</b> or vice versa.\n\n" +
|
||||
"The market takes its cut - buy and sell prices differ. Useful when you have surplus of one resource and need the other.");
|
||||
registry.RegisterTutorial(trade, "command_panel_TradeCommand");
|
||||
|
||||
// Feast command
|
||||
var feast = CreateCommandPanelTutorial(
|
||||
"command_feast",
|
||||
"Hold Feast",
|
||||
"Hold a Feast to boost hero <b>Loyalty</b> and <b>Vigor</b>.\n\n" +
|
||||
"The cost depends on how many heroes are in the province. A feast keeps your vassals happy and well-rested!");
|
||||
registry.RegisterTutorial(feast, "command_panel_FeastCommand");
|
||||
|
||||
// Hero Gift command
|
||||
var heroGift = CreateCommandPanelTutorial(
|
||||
"command_hero_gift",
|
||||
"Give Gift",
|
||||
"Give a gift to increase a hero's <b>Loyalty</b>.\n\n" +
|
||||
"Select a hero and choose how much gold to spend. More expensive gifts have greater effect.");
|
||||
registry.RegisterTutorial(heroGift, "command_panel_HeroGiftCommand");
|
||||
|
||||
// Train command
|
||||
var train = CreateCommandPanelTutorial(
|
||||
"command_train",
|
||||
"Train",
|
||||
"Train improves your troops' <b>combat effectiveness</b>.\n\n" +
|
||||
"Select a hero to lead training. Better trainers produce better results!");
|
||||
registry.RegisterTutorial(train, "command_panel_TrainCommand");
|
||||
|
||||
// Arm Troops command
|
||||
var armTroops = CreateCommandPanelTutorial(
|
||||
"command_arm_troops",
|
||||
"Arm Troops",
|
||||
"Arm Troops equips your battalions with better weapons and armor.\n\n" +
|
||||
"Higher <b>Infrastructure</b> allows better equipment. Well-armed troops are more effective in battle.");
|
||||
registry.RegisterTutorial(armTroops, "command_panel_ArmTroopsCommand");
|
||||
|
||||
// Organize Troops command
|
||||
var organizeTroops = CreateCommandPanelTutorial(
|
||||
"command_organize_troops",
|
||||
"Organize Troops",
|
||||
"Organize Troops lets you <b>hire new battalions</b> and restructure existing ones.\n\n" +
|
||||
"Light Infantry and Longbowmen are always available. Other battalion types require minimum <b>Economy</b> or <b>Agriculture</b> levels. You can also combine, split, or reassign troops between battalions.");
|
||||
registry.RegisterTutorial(organizeTroops, "command_panel_OrganizeTroopsCommand");
|
||||
|
||||
// Recruit Heroes command
|
||||
var recruitHeroes = CreateCommandPanelTutorial(
|
||||
"command_recruit_heroes",
|
||||
"Recruit Heroes",
|
||||
"Recruit a free hero to join your faction!\n\n" +
|
||||
"Heroes lead armies, govern provinces, and have special abilities. Choose wisely - their stats and professions vary.");
|
||||
registry.RegisterTutorial(recruitHeroes, "command_panel_RecruitHeroesCommand");
|
||||
|
||||
// Travel command
|
||||
var travel = CreateCommandPanelTutorial(
|
||||
"command_travel",
|
||||
"Travel",
|
||||
"Travel sends your hero to <b>town</b> within the province.\n\n" +
|
||||
"While traveling, you can do as many actions as you want in a turn: trade, arm troops, divine or recruit heroes, and manage prisoners. Use <b>Return</b> when done.");
|
||||
registry.RegisterTutorial(travel, "command_panel_TravelCommand");
|
||||
|
||||
// Return command
|
||||
var returnCmd = CreateCommandPanelTutorial(
|
||||
"command_return",
|
||||
"Return to Camp",
|
||||
"Return your province leader from town back to camp.\n\n" +
|
||||
"This ends <b>Travel</b> mode and completes your turn. Use it when you're done with town activities.");
|
||||
registry.RegisterTutorial(returnCmd, "command_panel_ReturnCommand");
|
||||
|
||||
// Diplomacy command
|
||||
var diplomacy = CreateCommandPanelTutorial(
|
||||
"command_diplomacy",
|
||||
"Diplomacy",
|
||||
"Send a diplomatic envoy to another faction.\n\n" +
|
||||
"Propose truces to pause hostilities, form alliances for mutual defense, or invite factions to join your cause. Select an ambassador - sending your leader is risky!");
|
||||
registry.RegisterTutorial(diplomacy, "command_panel_DiplomacyCommand");
|
||||
|
||||
// Ransom command (uses DiplomacyCommand type but separate selector)
|
||||
// Note: RansomCommandSelector also uses DiplomacyCommand, triggered by same event
|
||||
|
||||
// Send Supplies command
|
||||
var sendSupplies = CreateCommandPanelTutorial(
|
||||
"command_send_supplies",
|
||||
"Send Supplies",
|
||||
"Send gold and food to another province.\n\n" +
|
||||
"Useful for resupplying distant armies or supporting provinces under siege. Select a hero to escort the supplies.");
|
||||
registry.RegisterTutorial(sendSupplies, "command_panel_SendSuppliesCommand");
|
||||
|
||||
// Recon command
|
||||
var recon = CreateCommandPanelTutorial(
|
||||
"command_recon",
|
||||
"Recon",
|
||||
"Send a hero to scout an enemy province.\n\n" +
|
||||
"Gather intelligence on enemy forces, resources, and defenses. Your scout may encounter danger!");
|
||||
registry.RegisterTutorial(recon, "command_panel_ReconCommand");
|
||||
|
||||
// Divine command
|
||||
var divine = CreateCommandPanelTutorial(
|
||||
"command_divine",
|
||||
"Divine Heroes",
|
||||
"Use divination to learn a hero's hidden potential.\n\n" +
|
||||
"Costs gold but reveals the hero's true abilities and stats. Divine all available heroes at once for efficiency.");
|
||||
registry.RegisterTutorial(divine, "command_panel_DivineCommand");
|
||||
|
||||
// Issue Orders command
|
||||
var issueOrders = CreateCommandPanelTutorial(
|
||||
"command_issue_orders",
|
||||
"Issue Orders",
|
||||
"Set standing orders for your provinces.\n\n" +
|
||||
"Orders determine how provinces behave each turn - prioritize military, economy, or defense. You can also designate a Focus Province for special attention.");
|
||||
registry.RegisterTutorial(issueOrders, "command_panel_IssueOrdersCommand");
|
||||
|
||||
// Control Weather command
|
||||
var controlWeather = CreateCommandPanelTutorial(
|
||||
"command_control_weather",
|
||||
"Control Weather",
|
||||
"Use magical power to control the weather.\n\n" +
|
||||
"Create favorable conditions for your forces or harsh weather to hinder enemies. Requires a hero with weather magic.");
|
||||
registry.RegisterTutorial(
|
||||
controlWeather,
|
||||
"command_panel_ControlWeatherAvailableCommand");
|
||||
|
||||
// Swear Brotherhood command
|
||||
var swearBrotherhood = CreateCommandPanelTutorial(
|
||||
"command_swear_brotherhood",
|
||||
"Swear Brotherhood",
|
||||
"Swear an oath of brotherhood with a hero.\n\n" +
|
||||
"Creates a powerful bond that boosts loyalty and grants special bonuses. Choose your sworn brothers wisely - this bond is sacred!");
|
||||
registry.RegisterTutorial(swearBrotherhood, "command_panel_SwearBrotherhoodCommand");
|
||||
|
||||
// Apprehend Outlaw command
|
||||
var apprehendOutlaw = CreateCommandPanelTutorial(
|
||||
"command_apprehend_outlaw",
|
||||
"Apprehend Outlaw",
|
||||
"Capture an outlaw hiding in your province.\n\n" +
|
||||
"Send a hero and optionally a battalion to apprehend the fugitive. Outlaws may be former heroes from defeated factions.");
|
||||
registry.RegisterTutorial(apprehendOutlaw, "command_panel_ApprehendOutlawCommand");
|
||||
|
||||
// Suppress Beasts command
|
||||
var suppressBeasts = CreateCommandPanelTutorial(
|
||||
"command_suppress_beasts",
|
||||
"Suppress Beasts",
|
||||
"Deal with dangerous creatures threatening your province.\n\n" +
|
||||
"Send a hero with a battalion to suppress the beasts. Going without troops is risky - your hero could be killed!");
|
||||
registry.RegisterTutorial(suppressBeasts, "command_panel_SuppressBeastsCommand");
|
||||
|
||||
// Exile Vassal command
|
||||
var exileVassal = CreateCommandPanelTutorial(
|
||||
"command_exile_vassal",
|
||||
"Exile Vassal",
|
||||
"Exile a hero from your faction.\n\n" +
|
||||
"Sometimes a disloyal or troublesome vassal must be removed. The exiled hero becomes an outlaw and may seek revenge!");
|
||||
registry.RegisterTutorial(exileVassal, "command_panel_ExileVassalCommand");
|
||||
|
||||
// Handle Captured Hero command
|
||||
var handleCapturedHero = CreateCommandPanelTutorial(
|
||||
"command_handle_captured_hero",
|
||||
"Handle Captured Hero",
|
||||
"Decide the fate of a captured enemy hero.\n\n" +
|
||||
"You can recruit them to your cause, imprison them for ransom, exile them, execute them, or return them for diplomatic favor.");
|
||||
registry.RegisterTutorial(
|
||||
handleCapturedHero,
|
||||
"command_panel_HandleCapturedHeroCommand");
|
||||
|
||||
// Manage Prisoners command
|
||||
var managePrisoners = CreateCommandPanelTutorial(
|
||||
"command_manage_prisoners",
|
||||
"Manage Prisoners",
|
||||
"Manage heroes you've imprisoned.\n\n" +
|
||||
"Release them, move them between provinces, exile them, execute them, or return them to their faction for diplomatic benefit.");
|
||||
registry.RegisterTutorial(managePrisoners, "command_panel_ManagePrisonerCommand");
|
||||
|
||||
// Decline Quest command
|
||||
var declineQuest = CreateCommandPanelTutorial(
|
||||
"command_decline_quest",
|
||||
"Decline Quest",
|
||||
"Decline a quest offered by a hero.\n\n" +
|
||||
"Some heroes seek specific quests before joining. If you can't fulfill their request, you may decline - but they may leave.");
|
||||
registry.RegisterTutorial(declineQuest, "command_panel_DeclineQuestCommand");
|
||||
|
||||
// Start Epidemic command
|
||||
var startEpidemic = CreateCommandPanelTutorial(
|
||||
"command_start_epidemic",
|
||||
"Start Plague",
|
||||
"Unleash a deadly plague upon an enemy province.\n\n" +
|
||||
"A devastating but dishonorable tactic. Requires a hero with plague magic. The disease will spread and cause great suffering.");
|
||||
registry.RegisterTutorial(startEpidemic, "command_panel_StartEpidemicCommand");
|
||||
|
||||
// Handle Riot - Crack Down command
|
||||
var handleRiotCrackDown = CreateCommandPanelTutorial(
|
||||
"command_handle_riot_crack_down",
|
||||
"Crack Down",
|
||||
"Use force to suppress the rioting populace.\n\n" +
|
||||
"Send a hero with troops to restore order. Effective but may damage your reputation and reduce support.");
|
||||
registry.RegisterTutorial(
|
||||
handleRiotCrackDown,
|
||||
"command_panel_HandleRiotCrackDownAvailableCommand");
|
||||
|
||||
// Handle Riot - Do Nothing command
|
||||
var handleRiotDoNothing = CreateCommandPanelTutorial(
|
||||
"command_handle_riot_do_nothing",
|
||||
"Ignore Riot",
|
||||
"Choose to ignore the riot and let it run its course.\n\n" +
|
||||
"The unrest may subside on its own, or it may worsen. A risky choice that avoids immediate costs.");
|
||||
registry.RegisterTutorial(
|
||||
handleRiotDoNothing,
|
||||
"command_panel_HandleRiotDoNothingAvailableCommand");
|
||||
|
||||
// Handle Riot - Give command
|
||||
var handleRiotGive = CreateCommandPanelTutorial(
|
||||
"command_handle_riot_give",
|
||||
"Give to Populace",
|
||||
"Appease the rioters with gifts of food or gold.\n\n" +
|
||||
"A peaceful solution that costs resources but preserves your reputation. Choose how much to give.");
|
||||
registry.RegisterTutorial(
|
||||
handleRiotGive,
|
||||
"command_panel_HandleRiotGiveAvailableCommand");
|
||||
|
||||
// Attack Decision command
|
||||
var attackDecision = CreateCommandPanelTutorial(
|
||||
"command_attack_decision",
|
||||
"Attack Decision",
|
||||
"Your army has encountered enemy forces! Choose your action.\n\n" +
|
||||
"Advance to attack, demand tribute, request safe passage, or withdraw. Consider your strength before engaging.");
|
||||
registry.RegisterTutorial(attackDecision, "command_panel_AttackDecisionCommand");
|
||||
|
||||
// Free For All Decision command
|
||||
var freeForAllDecision = CreateCommandPanelTutorial(
|
||||
"command_free_for_all_decision",
|
||||
"Free-For-All Battle",
|
||||
"Multiple armies have converged! A chaotic battle is imminent.\n\n" +
|
||||
"You must decide whether to join the fray or attempt to withdraw from this dangerous situation.");
|
||||
registry.RegisterTutorial(
|
||||
freeForAllDecision,
|
||||
"command_panel_FreeForAllDecisionCommand");
|
||||
|
||||
// Resolve Tribute command
|
||||
var resolveTribute = CreateCommandPanelTutorial(
|
||||
"command_resolve_tribute",
|
||||
"Tribute Demanded",
|
||||
"Another faction is demanding tribute from you.\n\n" +
|
||||
"You can pay to avoid conflict, refuse and risk war, or imprison the messenger (a hostile act).");
|
||||
registry.RegisterTutorial(resolveTribute, "command_panel_ResolveTributeCommand");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a command panel tutorial with onboarding as a prerequisite.
|
||||
/// </summary>
|
||||
private static TutorialSequence
|
||||
CreateCommandPanelTutorial(string id, string title, string description) {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = id;
|
||||
sequence.DisplayName = title;
|
||||
sequence.IsOnboarding = false;
|
||||
|
||||
// Require onboarding completion before showing command tutorials
|
||||
sequence.RequiredCompletedTutorials = new List<string> { "onboarding" };
|
||||
|
||||
sequence.Steps = new List<TutorialStep> { new TutorialStep {
|
||||
StepId = id + "_step",
|
||||
Title = title,
|
||||
Description = description,
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
BlocksInteraction = false,
|
||||
// Position above the command selector panel
|
||||
PanelAnchor = TutorialPanelAnchor.Top,
|
||||
AdjacentTargetPath = "CommandPanel",
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
} };
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
// ========== STRATEGIC CONTEXTUAL TUTORIALS ==========
|
||||
|
||||
private static void RegisterStrategicTutorials(TutorialTriggerRegistry registry) {
|
||||
// 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
|
||||
+31
@@ -64,6 +64,37 @@ namespace Eagle0.Tutorial {
|
||||
/// </summary>
|
||||
public int StepCount => Steps?.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of visible steps up to and including the first MarksOnboardingComplete step.
|
||||
/// This gives an accurate count for the current "phase" of onboarding.
|
||||
/// </summary>
|
||||
public int VisibleStepCount {
|
||||
get {
|
||||
if (Steps == null) return 0;
|
||||
int count = 0;
|
||||
foreach (var step in Steps) {
|
||||
if (step.DisplayMode != TutorialDisplayMode.None) count++;
|
||||
// Stop counting after the step that marks onboarding complete
|
||||
if (step.MarksOnboardingComplete) break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the visible step index for a given actual step index.
|
||||
/// Returns 0-based index among visible steps only.
|
||||
/// </summary>
|
||||
public int GetVisibleStepIndex(int actualIndex) {
|
||||
if (Steps == null || actualIndex < 0) return 0;
|
||||
|
||||
int visibleIndex = 0;
|
||||
for (int i = 0; i < actualIndex && i < Steps.Count; i++) {
|
||||
if (Steps[i].DisplayMode != TutorialDisplayMode.None) visibleIndex++;
|
||||
}
|
||||
return visibleIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all prerequisites are met.
|
||||
/// </summary>
|
||||
|
||||
+39
@@ -22,6 +22,26 @@ namespace Eagle0.Tutorial {
|
||||
None
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where to position the tutorial panel on screen.
|
||||
/// </summary>
|
||||
public enum TutorialPanelAnchor {
|
||||
/// <summary>Centered on screen (default modal behavior).</summary>
|
||||
Center,
|
||||
|
||||
/// <summary>Panel anchored to left side of screen.</summary>
|
||||
Left,
|
||||
|
||||
/// <summary>Panel anchored to right side of screen.</summary>
|
||||
Right,
|
||||
|
||||
/// <summary>Panel anchored to top of screen.</summary>
|
||||
Top,
|
||||
|
||||
/// <summary>Panel anchored to bottom of screen.</summary>
|
||||
Bottom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the tutorial step is completed/advanced.
|
||||
/// </summary>
|
||||
@@ -66,16 +86,31 @@ namespace Eagle0.Tutorial {
|
||||
[Tooltip("How this step should be displayed")]
|
||||
public TutorialDisplayMode DisplayMode = TutorialDisplayMode.Modal;
|
||||
|
||||
[Tooltip("Whether this step blocks game interaction (false = user can interact with game)")]
|
||||
public bool BlocksInteraction = true;
|
||||
|
||||
[Tooltip("Where to position the tutorial panel on screen")]
|
||||
public TutorialPanelAnchor PanelAnchor = TutorialPanelAnchor.Center;
|
||||
|
||||
[Header("UI Targeting")]
|
||||
[Tooltip("Path to GameObject to highlight (e.g., 'Canvas/CommandPanel/MarchButton')")]
|
||||
public string TargetGameObjectPath;
|
||||
|
||||
[Tooltip("Additional targets to highlight together (combined bounding box)")]
|
||||
public string[] AdditionalHighlightTargets;
|
||||
|
||||
[Tooltip("Path to GameObject to position the panel adjacent to (for non-blocking modals)")]
|
||||
public string AdjacentTargetPath;
|
||||
|
||||
[Tooltip("Offset from target element for tooltip/arrow positioning")]
|
||||
public Vector2 HighlightOffset;
|
||||
|
||||
[Tooltip("Whether the highlight should pulse")]
|
||||
public bool HighlightPulsing = true;
|
||||
|
||||
[Tooltip("Compute highlight bounds from active children instead of target's RectTransform")]
|
||||
public bool HighlightBoundsFromChildren;
|
||||
|
||||
[Header("Completion")]
|
||||
[Tooltip("How this step is completed")]
|
||||
public TutorialCompletionType CompletionType = TutorialCompletionType.ButtonClick;
|
||||
@@ -93,6 +128,10 @@ namespace Eagle0.Tutorial {
|
||||
[Tooltip("Jump to specific step ID instead of sequential (empty = next step)")]
|
||||
public string NextStepOverride;
|
||||
|
||||
[Tooltip(
|
||||
"Marks onboarding as complete when this step finishes (for mid-sequence completion)")]
|
||||
public bool MarksOnboardingComplete;
|
||||
|
||||
[Header("Audio")]
|
||||
[Tooltip("Optional narration audio")]
|
||||
public AudioClip NarrationClip;
|
||||
|
||||
+147
-8
@@ -1,5 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using eagle;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using Shardok;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -76,6 +80,36 @@ namespace Eagle0.Tutorial {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Command Panel Events ==========
|
||||
|
||||
// Track the last command type shown so we can re-trigger after onboarding
|
||||
private AvailableCommand.SealedValueOneofCase? _lastCommandTypeShown;
|
||||
|
||||
/// <summary>
|
||||
/// Called when a command panel is shown in the strategic view.
|
||||
/// Triggers contextual tutorials for each command type.
|
||||
/// </summary>
|
||||
public void OnCommandPanelShown(AvailableCommand.SealedValueOneofCase commandType) {
|
||||
_lastCommandTypeShown = commandType;
|
||||
string eventId = $"command_panel_{commandType}";
|
||||
OnGameEvent(eventId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-triggers the last command panel tutorial.
|
||||
/// Called after onboarding completes to show tutorial for already-selected command.
|
||||
/// </summary>
|
||||
public void RetriggerLastCommandTutorial() {
|
||||
if (_lastCommandTypeShown.HasValue) {
|
||||
Debug.Log(
|
||||
$"[Tutorial] RetriggerLastCommandTutorial: Triggering for {_lastCommandTypeShown.Value}");
|
||||
string eventId = $"command_panel_{_lastCommandTypeShown.Value}";
|
||||
OnGameEvent(eventId);
|
||||
} else {
|
||||
Debug.Log("[Tutorial] RetriggerLastCommandTutorial: No last command type recorded");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Strategic Layer Events ==========
|
||||
|
||||
/// <summary>
|
||||
@@ -92,10 +126,51 @@ namespace Eagle0.Tutorial {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for diplomacy offers
|
||||
// Check for riots
|
||||
// Check for hero recruitment opportunities
|
||||
// etc. - These would check model state changes
|
||||
// Check available commands for strategic tutorials
|
||||
CheckStrategicCommandsAvailable(model, previousModel);
|
||||
|
||||
// Check for province state changes
|
||||
CheckProvinceStateChanges(model, previousModel);
|
||||
|
||||
// Check for free heroes (recruitment opportunity)
|
||||
CheckHeroRecruitmentAvailable(model, previousModel);
|
||||
}
|
||||
|
||||
private void CheckStrategicCommandsAvailable(IGameModel model, IGameModel previousModel) {
|
||||
if (model.AvailableCommandsByProvince == null) return;
|
||||
|
||||
var allCommands =
|
||||
model.AvailableCommandsByProvince.Values.SelectMany(opac => opac.Commands)
|
||||
.ToList();
|
||||
|
||||
// Check for diplomacy commands
|
||||
bool hasDiplomacy = allCommands.Any(cmd => cmd.DiplomacyCommand != null);
|
||||
if (hasDiplomacy && !_manager.State.HasCompletedTutorial("diplomacy_intro")) {
|
||||
OnGameEvent("diplomacy_available");
|
||||
}
|
||||
|
||||
// Check for weather control
|
||||
bool hasWeatherControl =
|
||||
allCommands.Any(cmd => cmd.ControlWeatherAvailableCommand != null);
|
||||
if (hasWeatherControl && !_manager.State.HasCompletedTutorial("weather_control")) {
|
||||
OnGameEvent("weather_control_available");
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckProvinceStateChanges(IGameModel model, IGameModel previousModel) {
|
||||
// Note: Riot status is not exposed in ProvinceView, so we can't detect riots here.
|
||||
// Province state change detection can be added when the view exposes relevant fields.
|
||||
}
|
||||
|
||||
private void CheckHeroRecruitmentAvailable(IGameModel model, IGameModel previousModel) {
|
||||
if (model.Heroes == null) return;
|
||||
|
||||
// Check for free heroes (heroes with no faction)
|
||||
var freeHeroes =
|
||||
model.Heroes.Values.Where(h => h.FactionId == null || h.FactionId == 0);
|
||||
if (freeHeroes.Any() && !_manager.State.HasCompletedTutorial("hero_recruitment")) {
|
||||
OnGameEvent("hero_recruitment_available");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -113,8 +188,18 @@ namespace Eagle0.Tutorial {
|
||||
// Track first command for onboarding
|
||||
OnGameEvent("command_issued", command);
|
||||
|
||||
// Could also check command type for specific tutorials
|
||||
// e.g., first diplomacy command, first weather control, etc.
|
||||
// Check specific command types
|
||||
if (command is SelectedCommand selectedCommand) {
|
||||
if (selectedCommand.DiplomacyCommand != null) {
|
||||
OnGameEvent("diplomacy_command_issued", selectedCommand);
|
||||
}
|
||||
if (selectedCommand.ControlWeatherSelectedCommand != null) {
|
||||
OnGameEvent("weather_command_issued", selectedCommand);
|
||||
}
|
||||
if (selectedCommand.ManagePrisonersCommand != null) {
|
||||
OnGameEvent("prisoner_command_issued", selectedCommand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Tactical Layer Events ==========
|
||||
@@ -132,8 +217,62 @@ namespace Eagle0.Tutorial {
|
||||
public void OnBattleAction(object actionResult) {
|
||||
OnGameEvent("battle_action", actionResult);
|
||||
|
||||
// Check for specific action types that need tutorials
|
||||
// e.g., first spell cast, first charge, etc.
|
||||
// Check for specific action types
|
||||
if (actionResult is ActionResultView result) { CheckTacticalActionType(result); }
|
||||
}
|
||||
|
||||
private void CheckTacticalActionType(ActionResultView result) {
|
||||
switch (result.Type) {
|
||||
// Spell tutorials
|
||||
case ActionType.LightningBolt: OnGameEvent("spell_lightning_cast", result); break;
|
||||
case ActionType.MeteorStart:
|
||||
case ActionType.MeteorTarget:
|
||||
case ActionType.MeteorCast: OnGameEvent("spell_meteor_cast", result); break;
|
||||
case ActionType.HolyWave:
|
||||
case ActionType.HolyWaveDamage: OnGameEvent("spell_holywave_cast", result); break;
|
||||
case ActionType.RaisedUndead:
|
||||
case ActionType.FailedRaiseUndead:
|
||||
OnGameEvent("spell_raisedead_cast", result);
|
||||
break;
|
||||
|
||||
// Combat tutorials
|
||||
case ActionType.ChargeAttack: OnGameEvent("ability_charge_used", result); break;
|
||||
|
||||
// Terrain tutorials
|
||||
case ActionType.FireDamage:
|
||||
case ActionType.FireSpread: OnGameEvent("terrain_fire_encountered", result); break;
|
||||
case ActionType.CrossedWater:
|
||||
case ActionType.CrossWaterFailed:
|
||||
OnGameEvent("terrain_water_encountered", result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when available commands change in tactical view.
|
||||
/// Used to detect when special abilities become available.
|
||||
/// </summary>
|
||||
public void OnTacticalCommandsAvailable(ShardokGameModel model) {
|
||||
if (model?.AvailableCommands == null) return;
|
||||
|
||||
// Check for spell availability
|
||||
foreach (var cmd in model.AvailableCommands) {
|
||||
switch (cmd.Type) {
|
||||
case CommandType.LightningBoltCommand:
|
||||
OnGameEvent("spell_lightning_available");
|
||||
break;
|
||||
case CommandType.MeteorStartCommand:
|
||||
OnGameEvent("spell_meteor_available");
|
||||
break;
|
||||
case CommandType.HolyWaveCommand:
|
||||
OnGameEvent("spell_holywave_available");
|
||||
break;
|
||||
case CommandType.RaiseDeadCommand:
|
||||
OnGameEvent("spell_raisedead_available");
|
||||
break;
|
||||
case CommandType.ChargeCommand: OnGameEvent("ability_charge_available"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,6 +23,9 @@ namespace Eagle0.Tutorial {
|
||||
[Tooltip("Reference to the tutorial UI manager")]
|
||||
public TutorialUIManager UIManager;
|
||||
|
||||
[Tooltip("Registry of UI targets for tutorials")]
|
||||
public TutorialTargetRegistry TargetRegistry;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable verbose logging")]
|
||||
public bool DebugLogging;
|
||||
@@ -69,6 +72,9 @@ namespace Eagle0.Tutorial {
|
||||
_state = TutorialState.Load();
|
||||
_triggerRegistry = new TutorialTriggerRegistry(this);
|
||||
|
||||
// Register all tutorial content
|
||||
TutorialContentDefinitions.RegisterAll(this, _triggerRegistry);
|
||||
|
||||
Log("TutorialManager initialized");
|
||||
}
|
||||
|
||||
@@ -144,7 +150,47 @@ namespace Eagle0.Tutorial {
|
||||
/// </summary>
|
||||
public void TriggerContextualTutorial(string tutorialId) {
|
||||
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
|
||||
if (_state.HasCompletedTutorial(tutorialId)) return;
|
||||
|
||||
bool isAlreadyCompleted = _state.HasCompletedTutorial(tutorialId);
|
||||
bool isCommandTutorial = tutorialId.StartsWith("command_");
|
||||
bool activeIsCommandTutorial =
|
||||
_activeSequence?.SequenceId.StartsWith("command_") ?? false;
|
||||
|
||||
// If switching to a completed command tutorial while another command tutorial
|
||||
// is showing, just hide the current one (don't mark it complete)
|
||||
if (isAlreadyCompleted && isCommandTutorial && activeIsCommandTutorial) {
|
||||
Log($"TriggerContextualTutorial: '{tutorialId}' already completed, " +
|
||||
$"hiding active command tutorial '{_activeSequence.SequenceId}'");
|
||||
StopCurrentSequence(skipped: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAlreadyCompleted) return;
|
||||
|
||||
// Check if we should allow interruption
|
||||
if (_activeSequence != null) {
|
||||
var currentStep = CurrentStep;
|
||||
bool isHiddenStep = currentStep?.DisplayMode == TutorialDisplayMode.None;
|
||||
|
||||
// Allow interruption if:
|
||||
// 1. Current step is hidden (DisplayMode.None) - waiting for async events
|
||||
// 2. Both tutorials are command tutorials - allow switching between commands
|
||||
bool bothAreCommandTutorials = isCommandTutorial && activeIsCommandTutorial;
|
||||
|
||||
if (!isHiddenStep && !bothAreCommandTutorials) {
|
||||
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
|
||||
return;
|
||||
}
|
||||
|
||||
if (bothAreCommandTutorials && _activeSequence.SequenceId == tutorialId) {
|
||||
// Same command tutorial already showing, don't restart
|
||||
return;
|
||||
}
|
||||
|
||||
Log($"TriggerContextualTutorial: Allowing '{tutorialId}' to interrupt " +
|
||||
(isHiddenStep ? "hidden step"
|
||||
: $"command tutorial '{_activeSequence.SequenceId}'"));
|
||||
}
|
||||
|
||||
// Look up the tutorial sequence by ID
|
||||
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
|
||||
@@ -158,6 +204,18 @@ namespace Eagle0.Tutorial {
|
||||
if (_activeSequence == null) return;
|
||||
|
||||
var currentStep = CurrentStep;
|
||||
|
||||
// Check if this step marks onboarding as complete (mid-sequence)
|
||||
bool shouldRetriggerCommandTutorial = false;
|
||||
if (currentStep != null && currentStep.MarksOnboardingComplete &&
|
||||
_activeSequence.IsOnboarding) {
|
||||
Log("Marking onboarding complete (mid-sequence)");
|
||||
_state.CompleteOnboarding();
|
||||
_state.MarkTutorialCompleted(_activeSequence.SequenceId);
|
||||
// Defer re-triggering until after we advance to the hidden step
|
||||
shouldRetriggerCommandTutorial = true;
|
||||
}
|
||||
|
||||
string nextStepId = currentStep?.NextStepOverride;
|
||||
|
||||
// Determine next step index
|
||||
@@ -182,6 +240,11 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
Log($"Advanced to step {_currentStepIndex}: '{CurrentStep?.StepId}'");
|
||||
ShowCurrentStep();
|
||||
|
||||
// Now that we've advanced to the hidden step, re-trigger command tutorial
|
||||
if (shouldRetriggerCommandTutorial) {
|
||||
_triggerRegistry?.RetriggerLastCommandTutorial();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -287,12 +350,14 @@ namespace Eagle0.Tutorial {
|
||||
// Handle different display modes
|
||||
switch (step.DisplayMode) {
|
||||
case TutorialDisplayMode.Modal:
|
||||
// Use visible step index/count for better UX (excludes hidden steps)
|
||||
int visibleIndex = _activeSequence.GetVisibleStepIndex(_currentStepIndex);
|
||||
int visibleCount = _activeSequence.VisibleStepCount;
|
||||
UIManager?.ShowModal(
|
||||
step,
|
||||
_currentStepIndex,
|
||||
_activeSequence.StepCount,
|
||||
visibleIndex,
|
||||
visibleCount,
|
||||
OnStepComplete,
|
||||
OnStepSkip,
|
||||
_activeSequence.IsOnboarding);
|
||||
break;
|
||||
|
||||
@@ -333,8 +398,6 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
private void OnStepComplete() { AdvanceStep(); }
|
||||
|
||||
private void OnStepSkip() { SkipCurrentStep(); }
|
||||
|
||||
private void CompleteCurrentSequence() {
|
||||
if (_activeSequence == null) return;
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Registry of UI elements that tutorials can reference.
|
||||
/// Assign static targets via Inspector, register dynamic targets via code.
|
||||
/// </summary>
|
||||
public class TutorialTargetRegistry : MonoBehaviour {
|
||||
[Header("Province Info Panel")]
|
||||
[Tooltip("The main Province Info panel")]
|
||||
public RectTransform ProvinceInfoPanel;
|
||||
|
||||
[Tooltip("The Support value display")]
|
||||
public RectTransform SupportField;
|
||||
|
||||
[Tooltip("The Agriculture value display")]
|
||||
public RectTransform AgricultureField;
|
||||
|
||||
[Tooltip("The Economy value display")]
|
||||
public RectTransform EconomyField;
|
||||
|
||||
[Tooltip("The Infrastructure value display")]
|
||||
public RectTransform InfrastructureField;
|
||||
|
||||
[Header("Heroes Panel")]
|
||||
[Tooltip("The Resident Heroes panel")]
|
||||
public RectTransform HeroesPanel;
|
||||
|
||||
[Header("Command Panel")]
|
||||
[Tooltip("The command selector panel (parent of individual command selectors)")]
|
||||
public RectTransform CommandPanel;
|
||||
|
||||
[Header("Command Buttons")]
|
||||
[Tooltip("Container for command buttons")]
|
||||
public RectTransform CommandButtonsPanel;
|
||||
|
||||
[Tooltip("The Commit button")]
|
||||
public RectTransform CommitButton;
|
||||
|
||||
// Dynamic targets registered at runtime (e.g., command buttons from prefabs)
|
||||
private Dictionary<string, RectTransform> _dynamicTargets =
|
||||
new Dictionary<string, RectTransform>();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a target at runtime. Use for prefab-instantiated UI elements.
|
||||
/// </summary>
|
||||
public void RegisterTarget(string targetId, RectTransform target) {
|
||||
if (string.IsNullOrEmpty(targetId) || target == null) return;
|
||||
_dynamicTargets[targetId] = target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a dynamically registered target.
|
||||
/// </summary>
|
||||
public void UnregisterTarget(string targetId) {
|
||||
if (!string.IsNullOrEmpty(targetId)) { _dynamicTargets.Remove(targetId); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a target by its string ID.
|
||||
/// Checks static Inspector fields first, then dynamic registrations.
|
||||
/// </summary>
|
||||
public RectTransform GetTarget(string targetId) {
|
||||
if (string.IsNullOrEmpty(targetId)) return null;
|
||||
|
||||
// Check static targets first
|
||||
var staticTarget = targetId switch { "ProvinceInfoPanel" => ProvinceInfoPanel,
|
||||
"SupportField" => SupportField,
|
||||
"AgricultureField" => AgricultureField,
|
||||
"EconomyField" => EconomyField,
|
||||
"InfrastructureField" => InfrastructureField,
|
||||
"HeroesPanel" => HeroesPanel,
|
||||
"CommandPanel" => CommandPanel,
|
||||
"CommandButtonsPanel" => CommandButtonsPanel,
|
||||
"CommitButton" => CommitButton,
|
||||
_ => null };
|
||||
|
||||
if (staticTarget != null) return staticTarget;
|
||||
|
||||
// Check dynamic targets
|
||||
if (_dynamicTargets.TryGetValue(targetId, out var dynamicTarget)) {
|
||||
return dynamicTarget;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a target, with fallback to GameObject.Find for unregistered paths.
|
||||
/// </summary>
|
||||
public Transform GetTargetOrFind(string targetId) {
|
||||
var registered = GetTarget(targetId);
|
||||
if (registered != null) return registered;
|
||||
|
||||
// Fallback to path-based lookup for unregistered targets
|
||||
var found = GameObject.Find(targetId);
|
||||
if (found != null) {
|
||||
Debug.LogWarning(
|
||||
$"TutorialTargetRegistry: '{targetId}' not registered, using GameObject.Find fallback");
|
||||
return found.transform;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be44269baefd349e99d5ed5f6004234b
|
||||
+16
-28
@@ -85,7 +85,7 @@ namespace Eagle0.Tutorial {
|
||||
RectTransform panelRect = panel.AddComponent<RectTransform>();
|
||||
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
panelRect.sizeDelta = new Vector2(800, 550);
|
||||
panelRect.sizeDelta = new Vector2(540, 500);
|
||||
panelRect.anchoredPosition = Vector2.zero;
|
||||
|
||||
// Panel Background
|
||||
@@ -100,8 +100,8 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
// Add vertical layout group
|
||||
VerticalLayoutGroup layout = panel.AddComponent<VerticalLayoutGroup>();
|
||||
layout.padding = new RectOffset(40, 40, 30, 30);
|
||||
layout.spacing = 20;
|
||||
layout.padding = new RectOffset(20, 20, 15, 15);
|
||||
layout.spacing = 10;
|
||||
layout.childAlignment = TextAnchor.UpperCenter;
|
||||
layout.childControlHeight = false;
|
||||
layout.childControlWidth = true;
|
||||
@@ -134,18 +134,18 @@ namespace Eagle0.Tutorial {
|
||||
titleObj.transform.SetParent(parent, false);
|
||||
|
||||
RectTransform rect = titleObj.AddComponent<RectTransform>();
|
||||
rect.sizeDelta = new Vector2(720, 60);
|
||||
rect.sizeDelta = new Vector2(460, 36);
|
||||
|
||||
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
|
||||
text.text = "Tutorial";
|
||||
text.fontSize = 42;
|
||||
text.fontSize = 28;
|
||||
text.fontStyle = FontStyles.Bold;
|
||||
text.color = TitleColor;
|
||||
text.alignment = TextAlignmentOptions.Center;
|
||||
if (font != null) text.font = font;
|
||||
|
||||
LayoutElement layoutElement = titleObj.AddComponent<LayoutElement>();
|
||||
layoutElement.preferredHeight = 60;
|
||||
layoutElement.preferredHeight = 36;
|
||||
}
|
||||
|
||||
private static void CreateIcon(Transform parent) {
|
||||
@@ -172,11 +172,11 @@ namespace Eagle0.Tutorial {
|
||||
descObj.transform.SetParent(parent, false);
|
||||
|
||||
RectTransform rect = descObj.AddComponent<RectTransform>();
|
||||
rect.sizeDelta = new Vector2(720, 200);
|
||||
rect.sizeDelta = new Vector2(460, 180);
|
||||
|
||||
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
|
||||
text.text = "";
|
||||
text.fontSize = 26;
|
||||
text.fontSize = 18;
|
||||
text.color = TextColor;
|
||||
text.alignment = TextAlignmentOptions.TopLeft;
|
||||
text.enableWordWrapping = true;
|
||||
@@ -184,8 +184,8 @@ namespace Eagle0.Tutorial {
|
||||
if (font != null) text.font = font;
|
||||
|
||||
LayoutElement layoutElement = descObj.AddComponent<LayoutElement>();
|
||||
layoutElement.preferredHeight = 200;
|
||||
layoutElement.minHeight = 100;
|
||||
layoutElement.preferredHeight = 180;
|
||||
layoutElement.minHeight = 80;
|
||||
}
|
||||
|
||||
private static void CreateSpacer(Transform parent, float height) {
|
||||
@@ -233,7 +233,7 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
TextMeshProUGUI progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||
progressText.text = "Step 1 of 1";
|
||||
progressText.fontSize = 20;
|
||||
progressText.fontSize = 14;
|
||||
progressText.color = TextColor;
|
||||
progressText.alignment = TextAlignmentOptions.Center;
|
||||
if (font != null) progressText.font = font;
|
||||
@@ -308,10 +308,6 @@ namespace Eagle0.Tutorial {
|
||||
panel.ContinueButton.onClick.RemoveAllListeners();
|
||||
panel.ContinueButton.onClick.AddListener(panel.OnContinueClicked);
|
||||
}
|
||||
if (panel.SkipButton != null) {
|
||||
panel.SkipButton.onClick.RemoveAllListeners();
|
||||
panel.SkipButton.onClick.AddListener(panel.OnSkipClicked);
|
||||
}
|
||||
if (panel.SkipAllButton != null) {
|
||||
panel.SkipAllButton.onClick.RemoveAllListeners();
|
||||
panel.SkipAllButton.onClick.AddListener(panel.OnSkipAllClicked);
|
||||
@@ -334,13 +330,10 @@ namespace Eagle0.Tutorial {
|
||||
layout.childForceExpandWidth = false;
|
||||
|
||||
LayoutElement layoutElement = buttonRow.AddComponent<LayoutElement>();
|
||||
layoutElement.preferredHeight = 60;
|
||||
layoutElement.preferredHeight = 44;
|
||||
|
||||
// Skip Button (left)
|
||||
CreateButton(buttonRow.transform, "SkipButton", "Skip", 150, 50, font);
|
||||
|
||||
// Skip All Button
|
||||
CreateButton(buttonRow.transform, "SkipAllButton", "Skip All", 150, 50, font);
|
||||
// Skip Tutorial Button (left) - only shown during onboarding
|
||||
CreateButton(buttonRow.transform, "SkipAllButton", "Skip Tutorial", 140, 36, font);
|
||||
|
||||
// Spacer
|
||||
GameObject spacer = new GameObject("ButtonSpacer");
|
||||
@@ -350,7 +343,7 @@ namespace Eagle0.Tutorial {
|
||||
spacerLayout.flexibleWidth = 1;
|
||||
|
||||
// Continue Button (right)
|
||||
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 180, 50, font);
|
||||
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 120, 36, font);
|
||||
}
|
||||
|
||||
private static GameObject CreateButton(
|
||||
@@ -398,7 +391,7 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
|
||||
buttonText.text = text;
|
||||
buttonText.fontSize = 24;
|
||||
buttonText.fontSize = 16;
|
||||
buttonText.color = ButtonTextColor;
|
||||
buttonText.alignment = TextAlignmentOptions.Center;
|
||||
if (font != null) buttonText.font = font;
|
||||
@@ -450,11 +443,6 @@ namespace Eagle0.Tutorial {
|
||||
// Button Row
|
||||
Transform buttonRow = containerTransform.Find("ButtonRow");
|
||||
if (buttonRow != null) {
|
||||
Transform skipTransform = buttonRow.Find("SkipButton");
|
||||
if (skipTransform != null) {
|
||||
panel.SkipButton = skipTransform.GetComponent<Button>();
|
||||
}
|
||||
|
||||
Transform skipAllTransform = buttonRow.Find("SkipAllButton");
|
||||
if (skipAllTransform != null) {
|
||||
panel.SkipAllButton = skipAllTransform.GetComponent<Button>();
|
||||
|
||||
+208
-25
@@ -26,10 +26,7 @@ namespace Eagle0.Tutorial {
|
||||
[Tooltip("Text on continue button")]
|
||||
public TextMeshProUGUI ContinueButtonText;
|
||||
|
||||
[Tooltip("Skip this step button")]
|
||||
public Button SkipButton;
|
||||
|
||||
[Tooltip("Skip all tutorials button (onboarding only)")]
|
||||
[Tooltip("Skip tutorial button (onboarding only)")]
|
||||
public Button SkipAllButton;
|
||||
|
||||
[Header("Progress")]
|
||||
@@ -52,12 +49,10 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
// Callbacks
|
||||
private Action _onContinue;
|
||||
private Action _onSkip;
|
||||
|
||||
private void Awake() {
|
||||
// Set up button listeners
|
||||
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
|
||||
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
|
||||
if (SkipAllButton != null) { SkipAllButton.onClick.AddListener(OnSkipAllClicked); }
|
||||
|
||||
// Start hidden
|
||||
@@ -72,10 +67,8 @@ namespace Eagle0.Tutorial {
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onContinue,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
_onContinue = onContinue;
|
||||
_onSkip = onSkip;
|
||||
|
||||
// Set content
|
||||
if (TitleText != null) { TitleText.text = step.Title ?? ""; }
|
||||
@@ -96,8 +89,7 @@ namespace Eagle0.Tutorial {
|
||||
ContinueButtonText.text = currentIndex >= totalSteps - 1 ? "Got it!" : "Continue";
|
||||
}
|
||||
|
||||
// Show/hide skip buttons
|
||||
if (SkipButton != null) { SkipButton.gameObject.SetActive(step.AllowSkip); }
|
||||
// Show Skip Tutorial button only during onboarding (and when skipping is allowed)
|
||||
if (SkipAllButton != null) {
|
||||
SkipAllButton.gameObject.SetActive(isOnboarding && step.AllowSkip);
|
||||
}
|
||||
@@ -112,8 +104,19 @@ namespace Eagle0.Tutorial {
|
||||
ProgressSlider.gameObject.SetActive(totalSteps > 1);
|
||||
}
|
||||
|
||||
// Show panel
|
||||
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
|
||||
// Position panel - either adjacent to target or based on anchor setting
|
||||
if (!string.IsNullOrEmpty(step.AdjacentTargetPath)) {
|
||||
PositionAdjacentTo(step.AdjacentTargetPath, step.PanelAnchor);
|
||||
} else {
|
||||
PositionPanel(step.PanelAnchor);
|
||||
}
|
||||
|
||||
// Show panel - ensure all parent containers are active first
|
||||
ActivateParents();
|
||||
|
||||
// Only show modal blocker if step blocks interaction
|
||||
if (ModalBlocker != null) { ModalBlocker.SetActive(step.BlocksInteraction); }
|
||||
|
||||
if (PanelContainer != null) { PanelContainer.SetActive(true); }
|
||||
gameObject.SetActive(true);
|
||||
|
||||
@@ -121,6 +124,181 @@ namespace Eagle0.Tutorial {
|
||||
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions the panel based on the anchor setting.
|
||||
/// </summary>
|
||||
private void PositionPanel(TutorialPanelAnchor anchor) {
|
||||
if (PanelContainer == null) return;
|
||||
|
||||
RectTransform rect = PanelContainer.GetComponent<RectTransform>();
|
||||
if (rect == null) return;
|
||||
|
||||
// Store original size
|
||||
Vector2 size = rect.sizeDelta;
|
||||
|
||||
switch (anchor) {
|
||||
case TutorialPanelAnchor.Center:
|
||||
rect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
rect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rect.pivot = new Vector2(0.5f, 0.5f);
|
||||
rect.anchoredPosition = Vector2.zero;
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Left:
|
||||
rect.anchorMin = new Vector2(0f, 0.5f);
|
||||
rect.anchorMax = new Vector2(0f, 0.5f);
|
||||
rect.pivot = new Vector2(0f, 0.5f);
|
||||
rect.anchoredPosition = new Vector2(20f, 0f); // 20px padding from edge
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Right:
|
||||
rect.anchorMin = new Vector2(1f, 0.5f);
|
||||
rect.anchorMax = new Vector2(1f, 0.5f);
|
||||
rect.pivot = new Vector2(1f, 0.5f);
|
||||
rect.anchoredPosition = new Vector2(-20f, 0f); // 20px padding from edge
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Top:
|
||||
rect.anchorMin = new Vector2(0.5f, 1f);
|
||||
rect.anchorMax = new Vector2(0.5f, 1f);
|
||||
rect.pivot = new Vector2(0.5f, 1f);
|
||||
rect.anchoredPosition = new Vector2(0f, -20f); // 20px padding from edge
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Bottom:
|
||||
rect.anchorMin = new Vector2(0.5f, 0f);
|
||||
rect.anchorMax = new Vector2(0.5f, 0f);
|
||||
rect.pivot = new Vector2(0.5f, 0f);
|
||||
rect.anchoredPosition = new Vector2(0f, 20f); // 20px padding from edge
|
||||
break;
|
||||
}
|
||||
|
||||
// Restore size after changing anchors
|
||||
rect.sizeDelta = size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions the panel adjacent to a target GameObject.
|
||||
/// </summary>
|
||||
private void PositionAdjacentTo(string targetId, TutorialPanelAnchor side) {
|
||||
if (PanelContainer == null) return;
|
||||
|
||||
// Use registry to find target
|
||||
Transform target = GetTarget(targetId);
|
||||
if (target == null) {
|
||||
Debug.LogWarning($"TutorialModalPanel: Could not find target '{targetId}'");
|
||||
PositionPanel(side);
|
||||
return;
|
||||
}
|
||||
|
||||
RectTransform targetRect = target.GetComponent<RectTransform>();
|
||||
if (targetRect == null) {
|
||||
PositionPanel(side);
|
||||
return;
|
||||
}
|
||||
|
||||
RectTransform panelRect = PanelContainer.GetComponent<RectTransform>();
|
||||
if (panelRect == null) return;
|
||||
|
||||
Vector2 size = panelRect.sizeDelta;
|
||||
|
||||
// Get target bounds in screen space
|
||||
Vector3[] targetCorners = new Vector3[4];
|
||||
targetRect.GetWorldCorners(targetCorners);
|
||||
|
||||
// Get canvas for coordinate conversion
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
|
||||
|
||||
if (canvasRect == null) {
|
||||
PositionPanel(side);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert target corners to canvas space
|
||||
for (int i = 0; i < 4; i++) {
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
canvasRect,
|
||||
RectTransformUtility.WorldToScreenPoint(
|
||||
canvas.worldCamera,
|
||||
targetCorners[i]),
|
||||
canvas.worldCamera,
|
||||
out Vector2 localPoint);
|
||||
targetCorners[i] = localPoint;
|
||||
}
|
||||
|
||||
// Calculate target bounds
|
||||
float targetLeft = Mathf.Min(targetCorners[0].x, targetCorners[1].x);
|
||||
float targetRight = Mathf.Max(targetCorners[2].x, targetCorners[3].x);
|
||||
float targetBottom = Mathf.Min(targetCorners[0].y, targetCorners[3].y);
|
||||
float targetTop = Mathf.Max(targetCorners[1].y, targetCorners[2].y);
|
||||
float targetCenterX = (targetLeft + targetRight) / 2f;
|
||||
float targetCenterY = (targetTop + targetBottom) / 2f;
|
||||
|
||||
// Use center anchor for the panel
|
||||
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
panelRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
float padding = 20f;
|
||||
Vector2 position;
|
||||
|
||||
// Position based on which side
|
||||
switch (side) {
|
||||
case TutorialPanelAnchor.Right:
|
||||
// Position panel to the right of target
|
||||
position = new Vector2(targetRight + size.x / 2f + padding, targetCenterY);
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Left:
|
||||
// Position panel to the left of target
|
||||
position = new Vector2(targetLeft - size.x / 2f - padding, targetCenterY);
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Top:
|
||||
// Position panel above target
|
||||
position = new Vector2(targetCenterX, targetTop + size.y / 2f + padding);
|
||||
break;
|
||||
|
||||
case TutorialPanelAnchor.Bottom:
|
||||
// Position panel below target
|
||||
position = new Vector2(targetCenterX, targetBottom - size.y / 2f - padding);
|
||||
break;
|
||||
|
||||
default:
|
||||
// For Center, fall back to standard positioning
|
||||
PositionPanel(side);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp to screen bounds (with padding)
|
||||
float canvasHalfWidth = canvasRect.rect.width / 2f;
|
||||
float canvasHalfHeight = canvasRect.rect.height / 2f;
|
||||
float screenPadding = 10f;
|
||||
|
||||
float minX = -canvasHalfWidth + size.x / 2f + screenPadding;
|
||||
float maxX = canvasHalfWidth - size.x / 2f - screenPadding;
|
||||
float minY = -canvasHalfHeight + size.y / 2f + screenPadding;
|
||||
float maxY = canvasHalfHeight - size.y / 2f - screenPadding;
|
||||
|
||||
position.x = Mathf.Clamp(position.x, minX, maxX);
|
||||
position.y = Mathf.Clamp(position.y, minY, maxY);
|
||||
|
||||
panelRect.anchoredPosition = position;
|
||||
panelRect.sizeDelta = size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
|
||||
/// </summary>
|
||||
private void ActivateParents() {
|
||||
Transform parent = transform.parent;
|
||||
while (parent != null) {
|
||||
if (!parent.gameObject.activeSelf) { parent.gameObject.SetActive(true); }
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides the modal panel.
|
||||
/// </summary>
|
||||
@@ -132,7 +310,6 @@ namespace Eagle0.Tutorial {
|
||||
gameObject.SetActive(false);
|
||||
|
||||
_onContinue = null;
|
||||
_onSkip = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -146,22 +323,28 @@ namespace Eagle0.Tutorial {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when Skip button is clicked.
|
||||
/// Public to allow external setup of button listeners.
|
||||
/// </summary>
|
||||
public void OnSkipClicked() {
|
||||
var callback = _onSkip;
|
||||
Hide();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when Skip All button is clicked.
|
||||
/// Public to allow external setup of button listeners.
|
||||
/// Called when Skip Tutorial button is clicked.
|
||||
/// Skips all remaining tutorial steps. User can restart from Settings.
|
||||
/// </summary>
|
||||
public void OnSkipAllClicked() {
|
||||
Hide();
|
||||
TutorialManager.Instance?.SkipAllOnboarding();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a target Transform by ID, using the registry if available.
|
||||
/// Falls back to GameObject.Find for unregistered targets.
|
||||
/// </summary>
|
||||
private Transform GetTarget(string targetId) {
|
||||
if (string.IsNullOrEmpty(targetId)) return null;
|
||||
|
||||
// Try registry first
|
||||
var registry = TutorialManager.Instance?.TargetRegistry;
|
||||
if (registry != null) { return registry.GetTargetOrFind(targetId); }
|
||||
|
||||
// Fallback to GameObject.Find
|
||||
var targetObj = GameObject.Find(targetId);
|
||||
return targetObj?.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+259
-19
@@ -100,7 +100,8 @@ namespace Eagle0.Tutorial {
|
||||
CenterTooltip();
|
||||
}
|
||||
|
||||
// Show
|
||||
// Show - ensure all parent containers are active first
|
||||
ActivateParents();
|
||||
gameObject.SetActive(true);
|
||||
StartCoroutine(FadeIn());
|
||||
|
||||
@@ -111,11 +112,15 @@ namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Shows only the highlight without tooltip (for emphasis).
|
||||
/// </summary>
|
||||
public void HighlightOnly(Transform target) {
|
||||
public void HighlightOnly(Transform target, bool boundsFromChildren = false) {
|
||||
_currentTarget = target;
|
||||
|
||||
if (target != null) {
|
||||
PositionHighlight(target);
|
||||
if (boundsFromChildren) {
|
||||
PositionHighlightFromChildren(target);
|
||||
} else {
|
||||
PositionHighlight(target);
|
||||
}
|
||||
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
|
||||
}
|
||||
|
||||
@@ -123,10 +128,109 @@ namespace Eagle0.Tutorial {
|
||||
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
|
||||
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
|
||||
|
||||
// Ensure all parent containers are active first
|
||||
ActivateParents();
|
||||
gameObject.SetActive(true);
|
||||
_pulseCoroutine = StartCoroutine(PulseHighlight());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows highlight around multiple targets with a combined bounding box.
|
||||
/// </summary>
|
||||
public void HighlightMultiple(Transform[] targets) {
|
||||
if (targets == null || targets.Length == 0) {
|
||||
ClearHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first target as "current" for tracking
|
||||
_currentTarget = targets[0];
|
||||
|
||||
PositionHighlightMultiple(targets);
|
||||
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
|
||||
|
||||
// Hide tooltip elements
|
||||
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
|
||||
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
|
||||
|
||||
// Ensure all parent containers are active first
|
||||
ActivateParents();
|
||||
gameObject.SetActive(true);
|
||||
_pulseCoroutine = StartCoroutine(PulseHighlight());
|
||||
}
|
||||
|
||||
private void PositionHighlightMultiple(Transform[] targets) {
|
||||
if (HighlightFrame == null || targets == null || targets.Length == 0) return;
|
||||
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
|
||||
if (canvasRect == null) {
|
||||
HighlightFrame.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Camera cam = canvas.worldCamera;
|
||||
|
||||
// Initialize bounds with extreme values
|
||||
float minX = float.MaxValue, maxX = float.MinValue;
|
||||
float minY = float.MaxValue, maxY = float.MinValue;
|
||||
|
||||
foreach (var target in targets) {
|
||||
if (target == null) continue;
|
||||
|
||||
RectTransform targetRect = target as RectTransform;
|
||||
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
|
||||
if (targetRect == null) continue;
|
||||
|
||||
// Get target bounds in world space
|
||||
Vector3[] corners = new Vector3[4];
|
||||
targetRect.GetWorldCorners(corners);
|
||||
|
||||
// Convert to canvas-local and expand bounds
|
||||
for (int i = 0; i < 4; i++) {
|
||||
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
canvasRect,
|
||||
screenPoint,
|
||||
cam,
|
||||
out Vector2 localPoint);
|
||||
|
||||
minX = Mathf.Min(minX, localPoint.x);
|
||||
maxX = Mathf.Max(maxX, localPoint.x);
|
||||
minY = Mathf.Min(minY, localPoint.y);
|
||||
maxY = Mathf.Max(maxY, localPoint.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (minX == float.MaxValue) {
|
||||
HighlightFrame.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp bounds to stay within canvas (accounting for padding that will be added)
|
||||
float canvasHalfWidth = canvasRect.rect.width / 2f;
|
||||
float canvasHalfHeight = canvasRect.rect.height / 2f;
|
||||
float margin = 5f + HighlightPadding;
|
||||
|
||||
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
|
||||
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
|
||||
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
|
||||
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
|
||||
|
||||
// Position and size highlight to encompass all targets
|
||||
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
Vector2 size = new Vector2(
|
||||
maxX - minX + HighlightPadding * 2,
|
||||
maxY - minY + HighlightPadding * 2);
|
||||
|
||||
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.anchoredPosition = center;
|
||||
HighlightFrame.sizeDelta = size;
|
||||
HighlightFrame.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears highlight without hiding overlay.
|
||||
/// </summary>
|
||||
@@ -145,15 +249,20 @@ namespace Eagle0.Tutorial {
|
||||
/// Hides the overlay completely.
|
||||
/// </summary>
|
||||
public void HideOverlay() {
|
||||
// Can't start coroutine on inactive GameObject
|
||||
if (!gameObject.activeInHierarchy) return;
|
||||
|
||||
if (_pulseCoroutine != null) {
|
||||
StopCoroutine(_pulseCoroutine);
|
||||
_pulseCoroutine = null;
|
||||
}
|
||||
|
||||
StartCoroutine(FadeOut());
|
||||
// If we can start coroutines, fade out nicely
|
||||
if (gameObject.activeInHierarchy) {
|
||||
StartCoroutine(FadeOut());
|
||||
} else {
|
||||
// Otherwise just deactivate immediately
|
||||
gameObject.SetActive(false);
|
||||
_currentTarget = null;
|
||||
_onComplete = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void PositionHighlight(Transform target) {
|
||||
@@ -163,35 +272,155 @@ namespace Eagle0.Tutorial {
|
||||
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
|
||||
|
||||
if (targetRect != null) {
|
||||
// Get target bounds in screen space
|
||||
// Get target bounds in world space
|
||||
Vector3[] corners = new Vector3[4];
|
||||
targetRect.GetWorldCorners(corners);
|
||||
|
||||
// Convert to canvas space
|
||||
// Get canvas for coordinate conversion
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
|
||||
Camera cam = canvas.worldCamera ?? Camera.main;
|
||||
for (int i = 0; i < 4; i++) { corners[i] = cam.WorldToScreenPoint(corners[i]); }
|
||||
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
|
||||
|
||||
if (canvasRect == null) {
|
||||
HighlightFrame.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
float minX = Mathf.Min(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
|
||||
float maxX = Mathf.Max(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
|
||||
float minY = Mathf.Min(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
|
||||
float maxY = Mathf.Max(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
|
||||
// Convert world corners to screen space, then to canvas-local space
|
||||
Vector2[] localCorners = new Vector2[4];
|
||||
Camera cam = canvas.worldCamera;
|
||||
|
||||
// Position and size highlight
|
||||
for (int i = 0; i < 4; i++) {
|
||||
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
canvasRect,
|
||||
screenPoint,
|
||||
cam,
|
||||
out localCorners[i]);
|
||||
}
|
||||
|
||||
// Calculate bounds in canvas-local space
|
||||
float minX = Mathf.Min(
|
||||
localCorners[0].x,
|
||||
localCorners[1].x,
|
||||
localCorners[2].x,
|
||||
localCorners[3].x);
|
||||
float maxX = Mathf.Max(
|
||||
localCorners[0].x,
|
||||
localCorners[1].x,
|
||||
localCorners[2].x,
|
||||
localCorners[3].x);
|
||||
float minY = Mathf.Min(
|
||||
localCorners[0].y,
|
||||
localCorners[1].y,
|
||||
localCorners[2].y,
|
||||
localCorners[3].y);
|
||||
float maxY = Mathf.Max(
|
||||
localCorners[0].y,
|
||||
localCorners[1].y,
|
||||
localCorners[2].y,
|
||||
localCorners[3].y);
|
||||
|
||||
// Clamp bounds to stay within canvas (accounting for padding that will be added)
|
||||
float canvasHalfWidth = canvasRect.rect.width / 2f;
|
||||
float canvasHalfHeight = canvasRect.rect.height / 2f;
|
||||
float margin = 5f + HighlightPadding;
|
||||
|
||||
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
|
||||
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
|
||||
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
|
||||
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
|
||||
|
||||
// Position and size highlight in canvas-local coordinates
|
||||
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
Vector2 size = new Vector2(
|
||||
maxX - minX + HighlightPadding * 2,
|
||||
maxY - minY + HighlightPadding * 2);
|
||||
|
||||
HighlightFrame.position = center;
|
||||
// Set highlight position and size using anchored position (canvas-local)
|
||||
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.anchoredPosition = center;
|
||||
HighlightFrame.sizeDelta = size;
|
||||
HighlightFrame.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions highlight based on active children of the target rather than target's own
|
||||
/// bounds. Useful for containers where the RectTransform is larger than the visible
|
||||
/// content.
|
||||
/// </summary>
|
||||
private void PositionHighlightFromChildren(Transform target) {
|
||||
if (HighlightFrame == null || target == null) return;
|
||||
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
|
||||
if (canvasRect == null) {
|
||||
HighlightFrame.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Camera cam = canvas.worldCamera;
|
||||
|
||||
// Collect bounds from all active children with RectTransforms
|
||||
float minX = float.MaxValue, maxX = float.MinValue;
|
||||
float minY = float.MaxValue, maxY = float.MinValue;
|
||||
|
||||
foreach (Transform child in target) {
|
||||
if (!child.gameObject.activeInHierarchy) continue;
|
||||
|
||||
RectTransform childRect = child.GetComponent<RectTransform>();
|
||||
if (childRect == null) continue;
|
||||
|
||||
Vector3[] corners = new Vector3[4];
|
||||
childRect.GetWorldCorners(corners);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
canvasRect,
|
||||
screenPoint,
|
||||
cam,
|
||||
out Vector2 localPoint);
|
||||
|
||||
minX = Mathf.Min(minX, localPoint.x);
|
||||
maxX = Mathf.Max(maxX, localPoint.x);
|
||||
minY = Mathf.Min(minY, localPoint.y);
|
||||
maxY = Mathf.Max(maxY, localPoint.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (minX == float.MaxValue) {
|
||||
// No active children found, fall back to target bounds
|
||||
PositionHighlight(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp bounds to stay within canvas (accounting for padding)
|
||||
float canvasHalfWidth = canvasRect.rect.width / 2f;
|
||||
float canvasHalfHeight = canvasRect.rect.height / 2f;
|
||||
float margin = 5f + HighlightPadding;
|
||||
|
||||
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
|
||||
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
|
||||
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
|
||||
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
|
||||
|
||||
// Position and size highlight
|
||||
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
Vector2 size = new Vector2(
|
||||
maxX - minX + HighlightPadding * 2,
|
||||
maxY - minY + HighlightPadding * 2);
|
||||
|
||||
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
|
||||
HighlightFrame.anchoredPosition = center;
|
||||
HighlightFrame.sizeDelta = size;
|
||||
HighlightFrame.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
private void PositionTooltip(Transform target, Vector2 offset) {
|
||||
if (TooltipContainer == null) return;
|
||||
|
||||
@@ -281,5 +510,16 @@ namespace Eagle0.Tutorial {
|
||||
PositionHighlight(_currentTarget);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
|
||||
/// </summary>
|
||||
private void ActivateParents() {
|
||||
Transform parent = transform.parent;
|
||||
while (parent != null) {
|
||||
if (!parent.gameObject.activeSelf) { parent.gameObject.SetActive(true); }
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-56
@@ -46,7 +46,6 @@ namespace Eagle0.Tutorial {
|
||||
private bool _fallbackModalVisible;
|
||||
private TutorialStep _fallbackStep;
|
||||
private Action _fallbackOnContinue;
|
||||
private Action _fallbackOnSkip;
|
||||
private int _fallbackCurrentIndex;
|
||||
private int _fallbackTotalSteps;
|
||||
private bool _fallbackIsOnboarding;
|
||||
@@ -106,16 +105,43 @@ namespace Eagle0.Tutorial {
|
||||
private void OnGUI() {
|
||||
if (!_fallbackModalVisible || _fallbackStep == null) return;
|
||||
|
||||
// Semi-transparent background
|
||||
GUI.color = new Color(0, 0, 0, 0.7f);
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
|
||||
GUI.color = Color.white;
|
||||
// Only draw background blocker if step blocks interaction
|
||||
if (_fallbackStep.BlocksInteraction) {
|
||||
GUI.color = new Color(0, 0, 0, 0.7f);
|
||||
GUI.DrawTexture(
|
||||
new Rect(0, 0, Screen.width, Screen.height),
|
||||
Texture2D.whiteTexture);
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
|
||||
// Modal window
|
||||
float windowWidth = Mathf.Min(1200, Screen.width - 40);
|
||||
float windowHeight = 600;
|
||||
float windowX = (Screen.width - windowWidth) / 2;
|
||||
float windowY = (Screen.height - windowHeight) / 2;
|
||||
// Calculate window position based on anchor
|
||||
float windowWidth = Mathf.Min(800, Screen.width - 40);
|
||||
float windowHeight = 500;
|
||||
float windowX, windowY;
|
||||
|
||||
switch (_fallbackStep.PanelAnchor) {
|
||||
case TutorialPanelAnchor.Left:
|
||||
windowX = 20;
|
||||
windowY = (Screen.height - windowHeight) / 2;
|
||||
break;
|
||||
case TutorialPanelAnchor.Right:
|
||||
windowX = Screen.width - windowWidth - 20;
|
||||
windowY = (Screen.height - windowHeight) / 2;
|
||||
break;
|
||||
case TutorialPanelAnchor.Top:
|
||||
windowX = (Screen.width - windowWidth) / 2;
|
||||
windowY = 20;
|
||||
break;
|
||||
case TutorialPanelAnchor.Bottom:
|
||||
windowX = (Screen.width - windowWidth) / 2;
|
||||
windowY = Screen.height - windowHeight - 20;
|
||||
break;
|
||||
case TutorialPanelAnchor.Center:
|
||||
default:
|
||||
windowX = (Screen.width - windowWidth) / 2;
|
||||
windowY = (Screen.height - windowHeight) / 2;
|
||||
break;
|
||||
}
|
||||
|
||||
GUIStyle windowStyle = new GUIStyle(GUI.skin.window);
|
||||
windowStyle.fontSize = 48;
|
||||
@@ -165,26 +191,15 @@ namespace Eagle0.Tutorial {
|
||||
// Buttons
|
||||
GUILayout.BeginHorizontal();
|
||||
|
||||
if (_fallbackStep.AllowSkip) {
|
||||
// Skip Tutorial button (only for onboarding, when step allows skipping)
|
||||
if (_fallbackIsOnboarding && _fallbackStep.AllowSkip) {
|
||||
if (GUILayout.Button(
|
||||
"Skip",
|
||||
"Skip Tutorial",
|
||||
buttonStyle,
|
||||
GUILayout.Width(200),
|
||||
GUILayout.Width(280),
|
||||
GUILayout.Height(80))) {
|
||||
var callback = _fallbackOnSkip;
|
||||
HideFallbackModal();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
if (_fallbackIsOnboarding) {
|
||||
if (GUILayout.Button(
|
||||
"Skip All",
|
||||
buttonStyle,
|
||||
GUILayout.Width(200),
|
||||
GUILayout.Height(80))) {
|
||||
HideFallbackModal();
|
||||
TutorialManager.Instance?.SkipAllOnboarding();
|
||||
}
|
||||
TutorialManager.Instance?.SkipAllOnboarding();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +226,11 @@ namespace Eagle0.Tutorial {
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onContinue,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
_fallbackStep = step;
|
||||
_fallbackCurrentIndex = currentIndex;
|
||||
_fallbackTotalSteps = totalSteps;
|
||||
_fallbackOnContinue = onContinue;
|
||||
_fallbackOnSkip = onSkip;
|
||||
_fallbackIsOnboarding = isOnboarding;
|
||||
_fallbackModalVisible = true;
|
||||
}
|
||||
@@ -226,7 +239,6 @@ namespace Eagle0.Tutorial {
|
||||
_fallbackModalVisible = false;
|
||||
_fallbackStep = null;
|
||||
_fallbackOnContinue = null;
|
||||
_fallbackOnSkip = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -237,16 +249,56 @@ namespace Eagle0.Tutorial {
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onComplete,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
// Clear any existing highlight before showing new one
|
||||
OverlayController?.ClearHighlight();
|
||||
|
||||
// Show highlight on target element(s) if specified (works alongside modal)
|
||||
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
|
||||
if (OverlayController == null) {
|
||||
Debug.LogWarning(
|
||||
"TutorialUIManager: OverlayController is null, cannot highlight");
|
||||
} else {
|
||||
// Collect all targets (primary + additional)
|
||||
var targets = new List<Transform>();
|
||||
var primaryTarget = GetTarget(step.TargetGameObjectPath);
|
||||
if (primaryTarget != null) { targets.Add(primaryTarget); }
|
||||
|
||||
if (step.AdditionalHighlightTargets != null) {
|
||||
foreach (var additionalId in step.AdditionalHighlightTargets) {
|
||||
var additionalTarget = GetTarget(additionalId);
|
||||
if (additionalTarget != null) { targets.Add(additionalTarget); }
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.Count > 1) {
|
||||
OverlayController.HighlightMultiple(targets.ToArray());
|
||||
} else if (targets.Count == 1) {
|
||||
OverlayController.HighlightOnly(
|
||||
targets[0],
|
||||
step.HighlightBoundsFromChildren);
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"TutorialUIManager: Target '{step.TargetGameObjectPath}' not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ModalPanel != null) {
|
||||
// Ensure canvas is active
|
||||
if (TutorialCanvas != null && !TutorialCanvas.gameObject.activeSelf) {
|
||||
TutorialCanvas.gameObject.SetActive(true);
|
||||
}
|
||||
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
|
||||
|
||||
// Wrap callback to clear highlight when modal is dismissed
|
||||
Action wrappedCallback = () => {
|
||||
OverlayController?.ClearHighlight();
|
||||
onComplete?.Invoke();
|
||||
};
|
||||
|
||||
ModalPanel.Show(step, currentIndex, totalSteps, wrappedCallback, isOnboarding);
|
||||
} else if (UseFallbackUI) {
|
||||
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
|
||||
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, isOnboarding);
|
||||
} else {
|
||||
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned and fallback disabled");
|
||||
onComplete?.Invoke();
|
||||
@@ -263,16 +315,11 @@ namespace Eagle0.Tutorial {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find target element
|
||||
Transform target = null;
|
||||
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
|
||||
var targetObj = GameObject.Find(step.TargetGameObjectPath);
|
||||
if (targetObj != null) {
|
||||
target = targetObj.transform;
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
|
||||
}
|
||||
// Find target element using registry
|
||||
Transform target = GetTarget(step.TargetGameObjectPath);
|
||||
if (!string.IsNullOrEmpty(step.TargetGameObjectPath) && target == null) {
|
||||
Debug.LogWarning(
|
||||
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
|
||||
}
|
||||
|
||||
OverlayController.ShowOverlay(
|
||||
@@ -305,13 +352,8 @@ namespace Eagle0.Tutorial {
|
||||
// Don't show duplicate hints
|
||||
if (_activeHints.ContainsKey(hintId)) return;
|
||||
|
||||
// Find target
|
||||
Transform target = null;
|
||||
if (!string.IsNullOrEmpty(targetPath)) {
|
||||
var targetObj = GameObject.Find(targetPath);
|
||||
if (targetObj != null) { target = targetObj.transform; }
|
||||
}
|
||||
|
||||
// Find target using registry
|
||||
Transform target = GetTarget(targetPath);
|
||||
if (target == null) {
|
||||
Debug.LogWarning($"TutorialUIManager: Could not find hint target '{targetPath}'");
|
||||
return;
|
||||
@@ -356,15 +398,10 @@ namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Highlights a specific UI element (without showing tutorial content).
|
||||
/// </summary>
|
||||
public void HighlightElement(string gameObjectPath) {
|
||||
public void HighlightElement(string targetId) {
|
||||
if (OverlayController == null) return;
|
||||
|
||||
Transform target = null;
|
||||
if (!string.IsNullOrEmpty(gameObjectPath)) {
|
||||
var targetObj = GameObject.Find(gameObjectPath);
|
||||
if (targetObj != null) { target = targetObj.transform; }
|
||||
}
|
||||
|
||||
Transform target = GetTarget(targetId);
|
||||
if (target != null) { OverlayController.HighlightOnly(target); }
|
||||
}
|
||||
|
||||
@@ -372,5 +409,21 @@ namespace Eagle0.Tutorial {
|
||||
/// Clears any active highlight.
|
||||
/// </summary>
|
||||
public void ClearHighlight() { OverlayController?.ClearHighlight(); }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a target Transform by ID, using the registry if available.
|
||||
/// Falls back to GameObject.Find for unregistered targets.
|
||||
/// </summary>
|
||||
private Transform GetTarget(string targetId) {
|
||||
if (string.IsNullOrEmpty(targetId)) return null;
|
||||
|
||||
// Try registry first
|
||||
var registry = TutorialManager.Instance?.TargetRegistry;
|
||||
if (registry != null) { return registry.GetTargetOrFind(targetId); }
|
||||
|
||||
// Fallback to GameObject.Find
|
||||
var targetObj = GameObject.Find(targetId);
|
||||
return targetObj?.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -223,6 +223,15 @@ namespace common {
|
||||
return RowAt(index).GetComponent<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the RectTransform of the row at the specified index.
|
||||
/// Returns null if index is out of bounds.
|
||||
/// </summary>
|
||||
public RectTransform GetRowRectTransform(int index) {
|
||||
if (index < 0 || index >= _rows.Count) return null;
|
||||
return _rows[index].GetComponent<RectTransform>();
|
||||
}
|
||||
|
||||
private void Awake() {
|
||||
if (ShadeAlternateRows) {
|
||||
_evenColor = rowPrefab.GetComponent<Image>().color;
|
||||
|
||||
@@ -136,9 +136,6 @@
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\unaffiliated_hero_type.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\common\unaffiliated_hero_type.proto</Link>
|
||||
</Protobuf>
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\internal\unaffiliated_hero.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\internal\unaffiliated_hero.proto</Link>
|
||||
</Protobuf>
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\chronicle_entry.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\common\chronicle_entry.proto</Link>
|
||||
</Protobuf>
|
||||
|
||||
@@ -109,27 +109,38 @@ function cancelUpload() {
|
||||
</script>
|
||||
|
||||
{{if .Games}}
|
||||
<div id="batch-actions" class="batch-actions" style="display: none; margin-bottom: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px;">
|
||||
<span id="selected-count">0</span> game(s) selected
|
||||
<button class="btn-small btn-danger" onclick="showBatchDeleteModal()" style="margin-left: 1rem;">Delete Selected</button>
|
||||
<button class="btn-small btn-secondary" onclick="clearSelection()" style="margin-left: 0.5rem;">Clear</button>
|
||||
</div>
|
||||
|
||||
{{range .Games}}
|
||||
<article class="game-card">
|
||||
<h3>
|
||||
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
|
||||
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
|
||||
{{.RunStatus}}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="meta">
|
||||
Round {{.CurrentRound}} • {{.ActionCount}} actions
|
||||
</div>
|
||||
<div class="players">
|
||||
{{range .Players}}
|
||||
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
|
||||
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
|
||||
<button class="btn-small btn-danger" onclick="showDeleteModal('{{.GameID}}')">Delete</button>
|
||||
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
|
||||
<input type="checkbox" class="game-checkbox" data-game-id="{{.GameID}}" onchange="updateSelection()" style="margin-top: 0.3rem; width: 18px; height: 18px;">
|
||||
<div style="flex: 1;">
|
||||
<h3>
|
||||
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
|
||||
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
|
||||
{{.RunStatus}}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="meta">
|
||||
Round {{.CurrentRound}} • {{.ActionCount}} actions
|
||||
</div>
|
||||
<div class="players">
|
||||
{{range .Players}}
|
||||
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
|
||||
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
|
||||
<button class="btn-small btn-danger" onclick="showDeleteModal('{{.GameID}}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -158,7 +169,98 @@ function cancelUpload() {
|
||||
|
||||
<div id="delete-result"></div>
|
||||
|
||||
<!-- Batch Delete Modal -->
|
||||
<dialog id="batch-delete-modal">
|
||||
<form method="dialog">
|
||||
<h3>Delete Multiple Games</h3>
|
||||
<p>Are you sure you want to delete <strong id="batch-delete-count"></strong> game(s)?</p>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="batch-delete-save-files" name="delete_save_files">
|
||||
Also delete save files from disk
|
||||
</label>
|
||||
</div>
|
||||
<p class="warning" style="color: #e74c3c; font-size: 0.9em;">
|
||||
<strong>Warning:</strong> This action cannot be undone.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="document.getElementById('batch-delete-modal').close()">Cancel</button>
|
||||
<button type="button" class="btn-danger" onclick="submitBatchDelete()">Delete All</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function getSelectedGameIds() {
|
||||
var checkboxes = document.querySelectorAll('.game-checkbox:checked');
|
||||
var ids = [];
|
||||
checkboxes.forEach(function(cb) {
|
||||
ids.push(cb.getAttribute('data-game-id'));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function updateSelection() {
|
||||
var selectedIds = getSelectedGameIds();
|
||||
var batchActions = document.getElementById('batch-actions');
|
||||
var selectedCount = document.getElementById('selected-count');
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
batchActions.style.display = 'block';
|
||||
selectedCount.textContent = selectedIds.length;
|
||||
} else {
|
||||
batchActions.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
var checkboxes = document.querySelectorAll('.game-checkbox');
|
||||
checkboxes.forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function showBatchDeleteModal() {
|
||||
var selectedIds = getSelectedGameIds();
|
||||
if (selectedIds.length === 0) {
|
||||
alert('No games selected');
|
||||
return;
|
||||
}
|
||||
document.getElementById('batch-delete-count').textContent = selectedIds.length;
|
||||
document.getElementById('batch-delete-save-files').checked = false;
|
||||
document.getElementById('batch-delete-modal').showModal();
|
||||
}
|
||||
|
||||
function submitBatchDelete() {
|
||||
var selectedIds = getSelectedGameIds();
|
||||
var deleteSaveFiles = document.getElementById('batch-delete-save-files').checked;
|
||||
|
||||
document.getElementById('batch-delete-modal').close();
|
||||
document.getElementById('delete-result').innerHTML = '<div class="alert">Deleting ' + selectedIds.length + ' game(s)...</div>';
|
||||
|
||||
var promises = selectedIds.map(function(gameId) {
|
||||
return fetch('/games/' + gameId + '/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'delete_save_files=' + (deleteSaveFiles ? 'true' : 'false')
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises).then(function(responses) {
|
||||
var failed = responses.filter(function(r) { return !r.ok; });
|
||||
if (failed.length === 0) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
document.getElementById('delete-result').innerHTML =
|
||||
'<div class="alert alert-error">' + failed.length + ' of ' + selectedIds.length + ' deletions failed. Refresh to see current state.</div>';
|
||||
}
|
||||
}).catch(function(error) {
|
||||
document.getElementById('delete-result').innerHTML =
|
||||
'<div class="alert alert-error">Batch delete failed: ' + error + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteModal(gameId) {
|
||||
document.getElementById('delete-game-id').value = gameId;
|
||||
document.getElementById('delete-game-id-display').textContent = gameId;
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
<main class="container">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<!-- JFR Stop Dialog -->
|
||||
<dialog id="jfr-stop-dialog">
|
||||
<article style="max-width: 400px;">
|
||||
<h3>Stop JFR Recording</h3>
|
||||
<p>Download the recording before stopping?</p>
|
||||
<footer style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
||||
<button class="secondary outline" onclick="jfrStopCancel()">Cancel</button>
|
||||
<button class="secondary" onclick="jfrStopOnly()">Stop Only</button>
|
||||
<button onclick="jfrStopWithDownload()">Download & Stop</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
// Handle JFR status response and update controls
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
@@ -54,7 +68,7 @@
|
||||
if (isRecording) {
|
||||
container.innerHTML = `
|
||||
<span class="jfr-status jfr-on">JFR: ON</span>
|
||||
<button hx-post="/jfr/stop" hx-swap="none" class="btn-small secondary">Stop</button>
|
||||
<button onclick="stopJfr()" class="btn-small secondary">Stop</button>
|
||||
<a href="/jfr/download" class="btn-small">Download</a>
|
||||
`;
|
||||
} else {
|
||||
@@ -65,6 +79,31 @@
|
||||
}
|
||||
htmx.process(container);
|
||||
}
|
||||
|
||||
function stopJfr() {
|
||||
// Show dialog to offer download before stopping
|
||||
const dialog = document.getElementById('jfr-stop-dialog');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
function jfrStopWithDownload() {
|
||||
document.getElementById('jfr-stop-dialog').close();
|
||||
// Trigger download in new tab, then stop
|
||||
window.open('/jfr/download', '_blank');
|
||||
// Small delay to ensure download starts before stopping
|
||||
setTimeout(function() {
|
||||
htmx.ajax('POST', '/jfr/stop', {target: '#jfr-controls', swap: 'none'});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function jfrStopOnly() {
|
||||
document.getElementById('jfr-stop-dialog').close();
|
||||
htmx.ajax('POST', '/jfr/stop', {target: '#jfr-controls', swap: 'none'});
|
||||
}
|
||||
|
||||
function jfrStopCancel() {
|
||||
document.getElementById('jfr-stop-dialog').close();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,7 @@ go_library(
|
||||
name = "authservice_lib",
|
||||
srcs = [
|
||||
"admin_handlers.go",
|
||||
"apple_auth.go",
|
||||
"handlers.go",
|
||||
"invitation_handlers.go",
|
||||
"invitations.go",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// AppleTokenResponse represents the response from Apple's token endpoint
|
||||
type AppleTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
// AppleIDTokenClaims represents the claims in Apple's id_token
|
||||
type AppleIDTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified string `json:"email_verified,omitempty"`
|
||||
IsPrivateEmail string `json:"is_private_email,omitempty"`
|
||||
RealUserStatus int `json:"real_user_status,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateAppleClientSecret generates a JWT client_secret for Apple Sign-In
|
||||
func GenerateAppleClientSecret() (string, error) {
|
||||
teamID := os.Getenv("APPLE_TEAM_ID")
|
||||
clientID := os.Getenv("APPLE_SIGNIN_CLIENT_ID")
|
||||
keyID := os.Getenv("APPLE_SIGNIN_KEY_ID")
|
||||
privateKeyPEM := os.Getenv("APPLE_SIGNIN_PRIVATE_KEY")
|
||||
|
||||
if teamID == "" || clientID == "" || keyID == "" || privateKeyPEM == "" {
|
||||
return "", fmt.Errorf("Apple Sign-In not fully configured")
|
||||
}
|
||||
|
||||
// Parse the private key
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
// Try base64 decoding if not PEM format
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(privateKeyPEM)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode private key: %v", err)
|
||||
}
|
||||
block = &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}
|
||||
}
|
||||
|
||||
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse private key: %v", err)
|
||||
}
|
||||
|
||||
ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not ECDSA")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.RegisteredClaims{
|
||||
Issuer: teamID,
|
||||
Subject: clientID,
|
||||
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(6 * 30 * 24 * time.Hour)), // ~6 months
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["kid"] = keyID
|
||||
|
||||
signedToken, err := token.SignedString(ecdsaKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %v", err)
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// ExchangeAppleCode exchanges an authorization code for tokens from Apple
|
||||
func (s *OAuthService) ExchangeAppleCode(config OAuthProviderConfig, code string) (*AppleTokenResponse, error) {
|
||||
clientSecret, err := GenerateAppleClientSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", config.ClientID)
|
||||
data.Set("client_secret", clientSecret)
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", s.callbackURL)
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Apple token exchange failed: %d - %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp AppleTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// ParseAppleIDToken extracts user info from Apple's id_token JWT
|
||||
func ParseAppleIDToken(idToken string) (*ProviderUserInfo, error) {
|
||||
// Split the JWT into parts
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid id_token format")
|
||||
}
|
||||
|
||||
// Decode the payload (middle part)
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode id_token payload: %v", err)
|
||||
}
|
||||
|
||||
var claims AppleIDTokenClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse id_token claims: %v", err)
|
||||
}
|
||||
|
||||
// Apple user ID is in the 'sub' claim
|
||||
sub := claims.Subject
|
||||
|
||||
// Use email prefix as username fallback since Apple doesn't provide username
|
||||
username := claims.Email
|
||||
if idx := strings.Index(username, "@"); idx > 0 {
|
||||
username = username[:idx]
|
||||
}
|
||||
|
||||
return &ProviderUserInfo{
|
||||
ID: sub,
|
||||
Email: claims.Email,
|
||||
Username: username,
|
||||
AvatarURL: "", // Apple doesn't provide avatar
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleAppleCallback handles the Apple OAuth callback
|
||||
// Note: Apple sends a POST request to the callback URL, not GET
|
||||
func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// Apple POSTs the callback data
|
||||
if r.Method != "POST" {
|
||||
// Fallback to GET for the state parameter only
|
||||
state := r.URL.Query().Get("state")
|
||||
if state != "" {
|
||||
// This might be an error redirect
|
||||
errorParam := r.URL.Query().Get("error")
|
||||
if errorParam != "" {
|
||||
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
|
||||
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Apple Sign-In requires POST callback", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.FormValue("code")
|
||||
state := r.FormValue("state")
|
||||
errorParam := r.FormValue("error")
|
||||
|
||||
if errorParam != "" {
|
||||
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
|
||||
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" || state == "" {
|
||||
http.Error(w, "Missing code or state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get pending OAuth info
|
||||
stateData, ok := s.pendingOAuth.Load(state)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
oauthState := stateData.(OAuthState)
|
||||
if time.Since(oauthState.CreatedAt) >= stateExpiration {
|
||||
s.pendingOAuth.Delete(state)
|
||||
s.completeWithError(state, "OAuth session expired")
|
||||
http.Error(w, "OAuth session expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for tokens (Apple requires JWT client_secret)
|
||||
config := s.configs[oauthState.Provider]
|
||||
tokenResp, err := s.ExchangeAppleCode(config, code)
|
||||
if err != nil {
|
||||
s.completeWithError(state, err.Error())
|
||||
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user info from id_token
|
||||
userInfo, err := ParseAppleIDToken(tokenResp.IDToken)
|
||||
if err != nil {
|
||||
s.completeWithError(state, err.Error())
|
||||
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Apple may provide user info in the POST body on first auth
|
||||
// This includes the user's name which isn't in the id_token
|
||||
if userName := r.FormValue("user"); userName != "" {
|
||||
var user struct {
|
||||
Name struct {
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
} `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(userName), &user); err == nil {
|
||||
if user.Name.FirstName != "" || user.Name.LastName != "" {
|
||||
userInfo.Username = strings.TrimSpace(user.Name.FirstName + " " + user.Name.LastName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
s.pendingOAuth.Delete(state)
|
||||
s.completedOAuth.Store(state, OAuthResult{
|
||||
Success: true,
|
||||
UserInfo: userInfo,
|
||||
Provider: oauthState.Provider,
|
||||
InvitationCode: oauthState.InvitationCode,
|
||||
})
|
||||
|
||||
// Redirect or show close message
|
||||
if oauthState.ReturnURL != "" {
|
||||
http.Redirect(w, r, oauthState.ReturnURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Successful</title></head>
|
||||
<body>
|
||||
<h1>Login Successful!</h1>
|
||||
<p>You can close this window and return to the game.</p>
|
||||
<script>window.close();</script>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
@@ -146,6 +146,7 @@ func main() {
|
||||
|
||||
// Start HTTP server for OAuth callbacks and invitation pages
|
||||
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
|
||||
http.HandleFunc("/oauth/apple/callback", oauthSvc.HandleAppleCallback) // Apple uses POST
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "OK")
|
||||
@@ -180,5 +181,22 @@ func getOAuthConfigs() map[string]OAuthProviderConfig {
|
||||
UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
},
|
||||
"github": {
|
||||
ClientID: os.Getenv("GH_OAUTH_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GH_OAUTH_CLIENT_SECRET"),
|
||||
AuthorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||
TokenEndpoint: "https://github.com/login/oauth/access_token",
|
||||
UserInfoEndpoint: "https://api.github.com/user",
|
||||
Scopes: []string{"read:user", "user:email"},
|
||||
},
|
||||
"apple": {
|
||||
ClientID: os.Getenv("APPLE_SIGNIN_CLIENT_ID"),
|
||||
ClientSecret: "", // Generated dynamically from private key
|
||||
AuthorizationEndpoint: "https://appleid.apple.com/auth/authorize",
|
||||
TokenEndpoint: "https://appleid.apple.com/auth/token",
|
||||
UserInfoEndpoint: "", // Not used - info comes from id_token
|
||||
Scopes: []string{"name", "email"},
|
||||
CallbackPath: "/oauth/apple/callback", // Apple uses POST callback
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type OAuthProviderConfig struct {
|
||||
TokenEndpoint string
|
||||
UserInfoEndpoint string
|
||||
Scopes []string
|
||||
CallbackPath string // Optional: custom callback path (e.g., "/oauth/apple/callback")
|
||||
}
|
||||
|
||||
// OAuthState represents a pending OAuth session
|
||||
@@ -111,13 +112,29 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationC
|
||||
|
||||
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
|
||||
|
||||
// Use custom callback path if configured, otherwise use default
|
||||
callbackURL := s.callbackURL
|
||||
if config.CallbackPath != "" {
|
||||
// Extract base URL and append custom path
|
||||
baseURL := s.callbackURL
|
||||
if idx := strings.LastIndex(baseURL, "/oauth/callback"); idx > 0 {
|
||||
baseURL = baseURL[:idx]
|
||||
}
|
||||
callbackURL = baseURL + config.CallbackPath
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
params.Set("redirect_uri", s.callbackURL)
|
||||
params.Set("redirect_uri", callbackURL)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", strings.Join(config.Scopes, " "))
|
||||
params.Set("state", state)
|
||||
|
||||
// Apple-specific parameters
|
||||
if provider == "apple" {
|
||||
params.Set("response_mode", "form_post") // Apple posts the callback
|
||||
}
|
||||
|
||||
authURL = config.AuthorizationEndpoint + "?" + params.Encode()
|
||||
return authURL, state, nil
|
||||
}
|
||||
@@ -349,6 +366,23 @@ func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUse
|
||||
AvatarURL: picture,
|
||||
}, nil
|
||||
|
||||
case "github":
|
||||
// GitHub returns id as a number
|
||||
var id string
|
||||
if idNum, ok := result["id"].(float64); ok {
|
||||
id = fmt.Sprintf("%.0f", idNum)
|
||||
}
|
||||
login, _ := result["login"].(string)
|
||||
email, _ := result["email"].(string)
|
||||
avatarURL, _ := result["avatar_url"].(string)
|
||||
|
||||
return &ProviderUserInfo{
|
||||
ID: id,
|
||||
Username: login,
|
||||
Email: email,
|
||||
AvatarURL: avatarURL,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
}
|
||||
|
||||
@@ -249,15 +249,18 @@ func uploadAppcast(bb aws.BucketBasics, appcast *Appcast) error {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 5 {
|
||||
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> <sparkle_private_key_path>")
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> [sparkle_private_key_path]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appPath := os.Args[1]
|
||||
version := os.Args[2]
|
||||
buildNumber := os.Args[3]
|
||||
privateKeyPath := os.Args[4]
|
||||
privateKeyPath := ""
|
||||
if len(os.Args) >= 5 {
|
||||
privateKeyPath = os.Args[4]
|
||||
}
|
||||
|
||||
if _, err := os.Stat(appPath); os.IsNotExist(err) {
|
||||
log.Fatalf("App not found: %s", appPath)
|
||||
@@ -284,13 +287,18 @@ func main() {
|
||||
}
|
||||
log.Printf("ZIP size: %d bytes", fileSize)
|
||||
|
||||
// Sign the ZIP with Sparkle EdDSA
|
||||
log.Println("Signing ZIP with Sparkle...")
|
||||
signature, err := signWithSparkle(zipPath, privateKeyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to sign: %v", err)
|
||||
// Sign the ZIP with Sparkle EdDSA (optional)
|
||||
var signature string
|
||||
if privateKeyPath != "" {
|
||||
log.Println("Signing ZIP with Sparkle...")
|
||||
signature, err = signWithSparkle(zipPath, privateKeyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to sign: %v", err)
|
||||
}
|
||||
log.Printf("Signature: %s", signature)
|
||||
} else {
|
||||
log.Println("Skipping Sparkle signing (no private key provided)")
|
||||
}
|
||||
log.Printf("Signature: %s", signature)
|
||||
|
||||
// Upload ZIP to S3
|
||||
remotePath := buildsRoot + zipFileName
|
||||
@@ -306,39 +314,43 @@ func main() {
|
||||
log.Fatalf("Failed to upload latest: %v", err)
|
||||
}
|
||||
|
||||
// Update appcast.xml
|
||||
log.Println("Updating appcast.xml...")
|
||||
appcast, err := fetchAppcast(bb)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch appcast: %v", err)
|
||||
}
|
||||
|
||||
// Create new item
|
||||
// Update appcast.xml (only if Sparkle signing was done)
|
||||
downloadURL := fmt.Sprintf("https://assets.eagle0.net/%s%s", buildsRoot, zipFileName)
|
||||
newItem := Item{
|
||||
Title: fmt.Sprintf("Version %s", version),
|
||||
PubDate: time.Now().Format(time.RFC1123Z),
|
||||
SparkleVersion: buildNumber,
|
||||
SparkleShortVersion: version,
|
||||
Description: "",
|
||||
Enclosure: Enclosure{
|
||||
URL: downloadURL,
|
||||
Length: fileSize,
|
||||
Type: "application/octet-stream",
|
||||
EdSig: signature,
|
||||
},
|
||||
}
|
||||
if privateKeyPath != "" {
|
||||
log.Println("Updating appcast.xml...")
|
||||
appcast, err := fetchAppcast(bb)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch appcast: %v", err)
|
||||
}
|
||||
|
||||
// Prepend new item (most recent first)
|
||||
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
|
||||
// Create new item
|
||||
newItem := Item{
|
||||
Title: fmt.Sprintf("Version %s", version),
|
||||
PubDate: time.Now().Format(time.RFC1123Z),
|
||||
SparkleVersion: buildNumber,
|
||||
SparkleShortVersion: version,
|
||||
Description: "",
|
||||
Enclosure: Enclosure{
|
||||
URL: downloadURL,
|
||||
Length: fileSize,
|
||||
Type: "application/octet-stream",
|
||||
EdSig: signature,
|
||||
},
|
||||
}
|
||||
|
||||
// Keep only last 10 versions
|
||||
if len(appcast.Channel.Items) > 10 {
|
||||
appcast.Channel.Items = appcast.Channel.Items[:10]
|
||||
}
|
||||
// Prepend new item (most recent first)
|
||||
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
|
||||
|
||||
if err := uploadAppcast(bb, appcast); err != nil {
|
||||
log.Fatalf("Failed to upload appcast: %v", err)
|
||||
// Keep only last 10 versions
|
||||
if len(appcast.Channel.Items) > 10 {
|
||||
appcast.Channel.Items = appcast.Channel.Items[:10]
|
||||
}
|
||||
|
||||
if err := uploadAppcast(bb, appcast); err != nil {
|
||||
log.Fatalf("Failed to upload appcast: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Skipping appcast.xml update (no Sparkle signing)")
|
||||
}
|
||||
|
||||
// Clean up local ZIP
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "client_download_lib",
|
||||
srcs = ["client_download.go"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/client_download",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = ["//src/main/go/net/eagle0/util/aws"],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "client_download",
|
||||
embed = [":client_download_lib"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getAsset(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
var bucketName string
|
||||
var destinationRoot string
|
||||
var fileName string
|
||||
|
||||
components := strings.SplitN(urlPath, "/", 3)
|
||||
if len(components) < 3 {
|
||||
http.Error(w, "Invalid URL path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
baseComponent := components[1] // Get the first component of the path
|
||||
fileName = components[2] // Get the path after the first "/"
|
||||
// Check if the URL path starts with "/unity3d/"
|
||||
|
||||
switch baseComponent {
|
||||
case "unity3d":
|
||||
bucketName = "eagle0-windows"
|
||||
destinationRoot = "unity3d/"
|
||||
case "installer":
|
||||
bucketName = "eagle0-windows"
|
||||
destinationRoot = "installer/"
|
||||
case "headshots":
|
||||
bucketName = "eagle0-headshots"
|
||||
destinationRoot = ""
|
||||
default:
|
||||
// If the URL path does not start with either, return an error
|
||||
http.Error(w, "Invalid URL path", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Create a presigned URL for the file
|
||||
url, err := aws.GetPresignedURL(bucketName, destinationRoot+fileName, 10*time.Minute)
|
||||
if err != nil {
|
||||
http.Error(w, "Error generating presigned URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect the client to the presigned URL
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("GET /", getAsset)
|
||||
|
||||
err := http.ListenAndServe(":3333", nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,6 @@ type BucketBasics struct {
|
||||
S3Client *s3.Client
|
||||
}
|
||||
|
||||
type Presigner struct {
|
||||
PresignClient *s3.PresignClient
|
||||
}
|
||||
|
||||
func readConfig() (map[string]string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -90,16 +86,7 @@ func NewBucketBasics() (BucketBasics, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewPresigner() (Presigner, error) {
|
||||
bucketBasics, err := NewBucketBasics()
|
||||
if err != nil {
|
||||
return Presigner{}, err
|
||||
}
|
||||
presignClient := s3.NewPresignClient(bucketBasics.S3Client)
|
||||
return Presigner{PresignClient: presignClient}, nil
|
||||
}
|
||||
|
||||
// DownloadFile gets an object from a bucket and stores it in a local file.
|
||||
// FetchBytes gets an object from a bucket and returns it as bytes.
|
||||
func (basics BucketBasics) FetchBytes(bucketName string, objectKey string) ([]byte, error) {
|
||||
result, err := basics.S3Client.GetObject(basics.ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
@@ -196,21 +183,3 @@ func (basics BucketBasics) UploadBytesWithACL(bucketName string, objectKey strin
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPresignedURL(bucketName string, objectKey string, duration time.Duration) (string, error) {
|
||||
basics, err := NewBucketBasics()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
presigner, err := NewPresigner()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, _ := presigner.PresignClient.PresignGetObject(basics.ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
}, s3.WithPresignExpires(duration))
|
||||
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
@@ -396,9 +396,10 @@ done:
|
||||
}
|
||||
|
||||
// waitForResponse waits for a response matching the predicate
|
||||
// Use 90s timeout because CreateGame can be slow on cold JVM (JIT not warmed up)
|
||||
// Use 180s timeout because CreateGame can be very slow on cold JVM (JIT not warmed up)
|
||||
// In production we've seen CreateGame take >90s on first boot
|
||||
func waitForResponse(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool) (*eagle.UpdateStreamResponse, error) {
|
||||
return waitForResponseWithTimeout(stream, matches, 90*time.Second)
|
||||
return waitForResponseWithTimeout(stream, matches, 180*time.Second)
|
||||
}
|
||||
|
||||
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user