Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 91c990da04 Update C# client for ReconProvincesQuest rename and new ReconSpecificProvincesQuest
- Update DisplayNames.cs with renamed and new quest types
- Update UnaffiliatedHeroRowController.cs to handle both quest types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 06:50:24 -08:00
adminandClaude Opus 4.5 f15aab6fe7 Rename ReconSpecificProvincesQuest to ReconProvincesQuest and add new ReconSpecificProvincesQuest
- Rename existing ReconSpecificProvincesQuest (count-based) to ReconProvincesQuest
- Add new ReconSpecificProvincesQuest that tracks specific province IDs to recon
- Update proto definitions, Quest.scala, QuestConverter.scala
- Update LLM prompt generators for both quest types
- Update CheckForFulfilledQuestsAction comment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 06:48:13 -08:00
778 changed files with 43262 additions and 73839 deletions
+1 -3
View File
@@ -42,7 +42,5 @@ common --tool_java_language_version=25
common --tool_java_runtime_version=remotejdk_25
# Workspace status for build stamping (git commit, timestamp)
# Only targets with stamp=1 will use these values; we intentionally
# do NOT set "common --stamp" so non-stamped targets can share the
# remote cache across CI workflows.
common --workspace_status_command=tools/workspace_status.sh
common --stamp
-16
View File
@@ -11,19 +11,3 @@ src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merg
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
# Unity Smart Merge - use UnityYAMLMerge for Unity files
# Requires configuring unityyamlmerge in ~/.gitconfig:
# [merge]
# tool = unityyamlmerge
# [mergetool "unityyamlmerge"]
# trustExitCode = false
# cmd = '/Applications/Unity/Hub/Editor/*/Unity.app/Contents/Tools/UnityYAMLMerge' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"
*.unity merge=unityyamlmerge
*.prefab merge=unityyamlmerge
*.asset merge=unityyamlmerge
*.mat merge=unityyamlmerge
*.anim merge=unityyamlmerge
*.controller merge=unityyamlmerge
*.physicsMaterial2D merge=unityyamlmerge
*.physicMaterial merge=unityyamlmerge
+1 -2
View File
@@ -41,8 +41,7 @@ jobs:
set -ex
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
# --stamp is needed for authservice x_defs (git commit, build time)
bazel build --stamp //ci:auth_server_image
bazel build //ci:auth_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_server_image)
@@ -13,8 +13,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Show disk usage before cleanup
run: |
+2 -13
View File
@@ -23,16 +23,13 @@ on:
- '!src/main/csharp/**'
- '!src/test/csharp/**'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -40,14 +37,6 @@ jobs:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
+51 -91
View File
@@ -62,10 +62,8 @@ jobs:
# Build ALL images in a single bazel command - Bazel parallelizes internally
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
# --stamp is needed for admin_server x_defs (git commit, build time)
echo "=== Building Docker images ==="
bazel build \
--stamp \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
@@ -91,13 +89,6 @@ jobs:
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Upload warmup binary
uses: actions/upload-artifact@v4
with:
name: warmup-binary
path: scripts/bin/warmup
retention-days: 1
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
@@ -119,59 +110,40 @@ jobs:
GIT_SHA=$(git rev-parse --short=8 HEAD)
# Get crane (cached across runs to avoid rebuilding bazel target)
CRANE_VERSION="v0.20.2"
CRANE_DIR="${HOME}/.local/bin"
CRANE="${CRANE_DIR}/crane"
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane ${CRANE_VERSION}..."
mkdir -p "$CRANE_DIR"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
chmod +x "$CRANE"
else
echo "Using cached crane at $CRANE"
fi
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
# Set up image paths and tags
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
# Push all three images in parallel
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG" &
PID_EAGLE=$!
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG" &
PID_ADMIN=$!
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG" &
PID_JFR=$!
# Wait for all pushes to complete
PUSH_FAILED=0
wait $PID_EAGLE || PUSH_FAILED=1
wait $PID_ADMIN || PUSH_FAILED=1
wait $PID_JFR || PUSH_FAILED=1
if [ $PUSH_FAILED -ne 0 ]; then
echo "ERROR: One or more image pushes failed"
if [ -z "$CRANE" ] || [ ! -x "$CRANE" ]; then
echo "ERROR: crane not found in runfiles"
find "$RUNFILES" -name crane 2>/dev/null || echo "No crane found at all"
exit 1
fi
echo "All images pushed, tagging :latest..."
# Tag :latest in parallel
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest" &
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest" &
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest" &
wait
echo "Using crane: $CRANE"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
echo "=== All images pushed successfully ==="
@@ -220,13 +192,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
sparse-checkout: |
docker-compose.prod.yml
nginx
scripts
sparse-checkout-cone-mode: false
- name: Setup SSH key
run: |
@@ -235,11 +200,11 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Download warmup binary
uses: actions/download-artifact@v4
with:
name: warmup-binary
path: scripts/bin/
- name: Build warmup tool
run: |
bazel build //ci:warmup_tar
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
- name: Copy config files to droplet
run: |
@@ -357,34 +322,29 @@ jobs:
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Install crane for pulling OCI images (cached across deploys)
CRANE_VERSION="v0.20.2"
if [ ! -x crane ] || ! ./crane version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane \${CRANE_VERSION}..."
rm -f crane
curl -sL "https://github.com/google/go-containerregistry/releases/download/\${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" | tar xzf - crane
chmod +x crane
else
echo "Using cached crane"
fi
# Install crane for pulling OCI images
echo "Installing crane..."
rm -f crane
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Pull all images in parallel, then load sequentially
echo "Pulling all images in parallel..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar &
./crane pull "\${ADMIN_IMAGE}" admin.tar &
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar &
docker pull nginx:alpine &
docker pull certbot/certbot &
wait
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
echo "Loading images..."
docker load -i eagle.tar && rm eagle.tar
docker load -i admin.tar && rm admin.tar
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
# Tag as :latest locally so docker-compose fallback uses correct image
docker tag "\${ADMIN_IMAGE}" registry.digitalocean.com/eagle0/admin-server:latest
docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
echo "All images pulled and loaded successfully"
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# =================================================================
# Verify Shardok connectivity before proceeding with deployment
+11 -5
View File
@@ -1,7 +1,17 @@
name: Eagle Build
on:
# Main pushes are covered by docker_build.yml which builds the same target
push:
branches: [ "main" ]
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
pull_request:
paths:
- 'src/main/scala/**'
@@ -13,10 +23,6 @@ on:
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+12 -21
View File
@@ -20,10 +20,6 @@ on:
- "ci/github_actions/upload_addressables.sh"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -50,12 +46,7 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
- name: Clean stale files
run: |
git clean -ffd
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
clean: true
- name: Fetch LFS files
run: |
@@ -65,9 +56,20 @@ jobs:
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Addressables
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
@@ -75,17 +77,6 @@ jobs:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Purge CDN cache for iOS addressables
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["addressables/iOS/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
+75 -32
View File
@@ -11,6 +11,7 @@ on:
permissions:
contents: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
@@ -20,7 +21,9 @@ env:
jobs:
build-unity:
runs-on: [self-hosted, macOS, testflight]
runs-on: [self-hosted, macOS, unity-mac]
outputs:
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
steps:
- name: Prune stale PR refs
@@ -37,14 +40,9 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
clean: true
fetch-depth: 0
- name: Clean stale files
run: |
git clean -ffd
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
- name: Fetch LFS files
run: |
git lfs install
@@ -53,10 +51,22 @@ jobs:
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Unity Project
id: build
run: |
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
echo "xcode_project_path=$EAGLE0_BUILD_DIR/eagle0iOS" >> $GITHUB_OUTPUT
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success()
@@ -65,16 +75,18 @@ jobs:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Purge CDN cache for iOS addressables
if: success()
env:
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
- name: Zip Xcode project for artifact
run: |
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["addressables/iOS/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
cd $EAGLE0_BUILD_DIR
# Use tar for speed - Xcode projects have many small files
tar -czf eagle0iOS.tar.gz eagle0iOS
- name: Upload Xcode project
uses: actions/upload-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0iOS.tar.gz
retention-days: 1
- name: Archive Build Log
if: success() || failure()
@@ -83,12 +95,37 @@ jobs:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, testflight]
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
ci
scripts
- name: Clean download directory
run: rm -rf $EAGLE0_BUILD_DIR/eagle0iOS
- name: Download Xcode project
uses: actions/download-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}
- name: Extract Xcode project
run: |
cd $EAGLE0_BUILD_DIR
tar -xzf eagle0iOS.tar.gz
rm eagle0iOS.tar.gz
ls -la eagle0iOS/
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
@@ -147,23 +184,11 @@ jobs:
- name: Upload to TestFlight
if: ${{ github.event.inputs.skip_upload != 'true' }}
env:
# App Store Connect API Key (replaces deprecated altool with Apple ID)
# Create at: https://appstoreconnect.apple.com/access/api
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
run: |
# Write API key to file (xcodebuild needs a file path)
API_KEY_PATH=$(mktemp)
echo "$APP_STORE_CONNECT_API_KEY" > "$API_KEY_PATH"
export APP_STORE_CONNECT_API_KEY_PATH="$API_KEY_PATH"
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.xcarchive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
# Cleanup
rm -f "$API_KEY_PATH"
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
@@ -181,3 +206,21 @@ jobs:
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
cleanup:
needs: [build-unity, archive-and-upload]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete intermediate artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_name="xcode-project-${{ github.run_id }}"
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
+50 -58
View File
@@ -25,19 +25,25 @@ on:
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
pull_request:
# On PRs, only build Mac when Mac-specific files change. Shared C#/proto
# changes are covered by the Windows build — if it passes, Mac will too.
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/build_sparkle_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_submit.sh"
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -47,10 +53,6 @@ on:
default: 'false'
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
@@ -83,30 +85,9 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
clean: true # Remove untracked files like old SparklePlugin.bundle
fetch-depth: 0 # For version numbering from git history
- name: Clean stale files
run: |
git clean -ffd
# Only clear Bee/ when C# files were added/deleted/renamed (structural
# changes that stale the DAG). Content-only modifications are handled by
# Bee's incremental compilation. See persist_library.sh for background.
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
LAST_SHA=$(cat "$SHA_FILE")
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
rm -rf "$BEE_DIR"
else
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
fi
else
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
rm -rf "$BEE_DIR"
fi
- name: Fetch LFS files
run: |
git lfs install
@@ -115,12 +96,19 @@ jobs:
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- 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 "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
- name: Save build SHA for Bee/ cache invalidation
- name: Persist Library/
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
@@ -297,8 +285,6 @@ jobs:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
outputs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
steps:
- uses: actions/checkout@v4
@@ -321,7 +307,6 @@ jobs:
rm eagle0.app.zip
- name: Deploy Mac Build
id: deploy-mac
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -352,37 +337,44 @@ jobs:
rm "$SPARKLE_PRIVATE_KEY_PATH"
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
# Export version for notify step
echo "DEPLOYED_VERSION=$VERSION" >> $GITHUB_ENV
# Artifact cleanup is handled by the cleanup job on ubuntu-latest
- name: Notify clients of update
if: success()
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=$DEPLOYED_VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Delete this run's Mac app artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
echo "Deleting artifact ID: $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
done
echo "Cleanup complete"
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
notify-mac:
needs: deploy
if: needs.deploy.outputs.deployed_version != ''
runs-on: ubuntu-latest
steps:
- name: Purge CDN cache and notify clients
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
# Purge CDN cache so clients get the latest files immediately
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["mac/*", "addressables/StandaloneOSX/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=${{ needs.deploy.outputs.deployed_version }}&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
# Cleanup job runs regardless of success/failure to prevent artifact accumulation
cleanup:
needs: [build-and-sign, wait-notarization, deploy, notify-mac]
needs: [build-and-sign, wait-notarization, deploy]
if: always()
runs-on: ubuntu-latest
+15 -20
View File
@@ -20,10 +20,6 @@ on:
default: 'true'
type: boolean
concurrency:
group: shardok-arm64-deploy
cancel-in-progress: false
permissions:
contents: read
@@ -64,14 +60,12 @@ jobs:
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
# od -tx2 reads as a 16-bit value in host byte order (little-endian on macOS),
# so the little-endian ELF bytes b7 00 are displayed as 00b7.
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
echo "ELF e_machine: $E_MACHINE"
if [ "$E_MACHINE" = "00b7" ]; then
if [ "$E_MACHINE" = "b700" ]; then
echo "SUCCESS: Binary is ARM64 (aarch64)"
else
echo "WARNING: Binary e_machine is $E_MACHINE (expected 00b7 for aarch64)"
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
fi
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
@@ -134,19 +128,20 @@ jobs:
exit 1
fi
# Get crane (cached across runs to avoid rebuilding bazel target,
# which would discard the ARM64 analysis cache due to platform change)
CRANE_VERSION="v0.20.2"
CRANE_DIR="${HOME}/.local/bin"
CRANE="${CRANE_DIR}/crane"
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane ${CRANE_VERSION}..."
mkdir -p "$CRANE_DIR"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
chmod +x "$CRANE"
else
echo "Using cached crane at $CRANE"
# Build a push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Find the Darwin crane binary
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
GIT_SHA=$(git rev-parse --short=8 HEAD)
+12 -5
View File
@@ -1,7 +1,18 @@
name: Shardok Build
on:
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/proto/net/eagle0/shardok/**'
- 'src/main/proto/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
pull_request:
paths:
- 'src/main/cpp/**'
@@ -14,10 +25,6 @@ on:
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+26 -57
View File
@@ -15,6 +15,8 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
@@ -34,16 +36,14 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -54,8 +54,6 @@ env:
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
outputs:
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
steps:
- name: Prune stale PR refs
@@ -72,28 +70,7 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
- name: Clean stale files
run: |
git clean -ffd
# Only clear Bee/ when C# files were added/deleted/renamed (structural
# changes that stale the DAG). Content-only modifications are handled by
# Bee's incremental compilation. See persist_library.sh for background.
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
LAST_SHA=$(cat "$SHA_FILE")
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
rm -rf "$BEE_DIR"
else
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
fi
else
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
rm -rf "$BEE_DIR"
fi
clean: true # Remove untracked files from previous builds
- name: Fetch LFS files
run: |
@@ -101,13 +78,17 @@ jobs:
git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- 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 "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
- name: Save build SHA for Bee/ cache invalidation
- name: Persist Library/
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
@@ -145,12 +126,21 @@ jobs:
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Export deployed version
id: get-version
- name: Notify clients of update
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Get version from manifest
VERSION=$(grep "^version=" /tmp/unity_manifest.txt | cut -d= -f2 || date +%Y.%m.%d)
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=$VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Archive build log
if: success() || failure()
@@ -161,25 +151,4 @@ jobs:
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
notify-windows:
needs: windows-unity
if: needs.windows-unity.outputs.deployed_version != ''
runs-on: ubuntu-latest
steps:
- name: Purge CDN cache and notify clients
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
# Purge CDN cache so clients get the latest files immediately
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["windows/*", "addressables/StandaloneWindows64/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
-3
View File
@@ -37,8 +37,5 @@ scripts/refresh_name_layers/refresh_name_layers.zip
.metals
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/GeneratedProtos/
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
tools/map_generator/output/
-5
View File
@@ -225,9 +225,6 @@ to be used for different players or game situations within the same server proce
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
- Seamless transition between strategic gameplay and hex-based tactical combat
- **NEVER add defensive null checks on Unity Inspector fields** - these hide configuration bugs. If a field isn't
linked in the editor, it should throw a NullReferenceException so the problem is immediately obvious. Silently
skipping code when a required field is null makes bugs harder to find.
**Go (Build Tools):**
@@ -336,8 +333,6 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
**Data Files:** TSV format for battalions, heroes, and other game data
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
## Deployment
- Bazel handles multi-language builds and dependencies
+2 -31
View File
@@ -126,26 +126,6 @@ use_repo(
"org_golang_x_sys",
)
#
# Language Support - Rust
#
bazel_dep(name = "rules_rust", version = "0.68.1")
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(edition = "2021")
use_repo(rust, "rust_toolchains")
register_toolchains("@rust_toolchains//:all")
crate = use_extension("@rules_rust//crate_universe:extension.bzl", "crate")
crate.from_cargo(
name = "map_generator_crates",
cargo_lockfile = "//src/main/rust/net/eagle0/eagle/map_generator:Cargo.lock",
manifests = ["//src/main/rust/net/eagle0/eagle/map_generator:Cargo.toml"],
)
use_repo(crate, "map_generator_crates")
#
# Platform Support - Apple/iOS
#
@@ -154,7 +134,7 @@ use_repo(crate, "map_generator_crates")
# in a separate workspace at sparkle_workspace/ to isolate the conflict.
#
bazel_dep(name = "apple_support", version = "1.24.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "apple_support", version = "1.23.1", repo_name = "build_bazel_apple_support")
# rules_swift is a transitive dep of grpc and flatbuffers with conflicting versions:
# - grpc 1.76.0.bcr.1 requires rules_swift 3.x (compatibility level 3)
@@ -180,9 +160,8 @@ use_repo(apple_cc_configure, "local_config_apple_cc")
bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf")
bazel_dep(name = "rules_proto", version = "7.1.0")
bazel_dep(name = "rules_cc", version = "0.2.14")
bazel_dep(name = "grpc", version = "1.74.1")
bazel_dep(name = "grpc", version = "1.74.0")
bazel_dep(name = "grpc-java", version = "1.78.0")
bazel_dep(name = "rules_proto_grpc_csharp", version = "5.8.0")
bazel_dep(name = "flatbuffers", version = "25.12.19")
#
@@ -201,12 +180,8 @@ bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 25 JRE - smaller runtime, no dev tools needed)
# Digest pinned for Bazel repository cache hit; update with:
# TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/eclipse-temurin:pull" | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")
# curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" "https://registry-1.docker.io/v2/library/eclipse-temurin/manifests/25-jre" | python3 -c "import json,sys; m=json.load(sys.stdin); [print(p['digest']) for p in m.get('manifests',[]) if p['platform']['architecture']=='amd64']"
oci.pull(
name = "eclipse_temurin_25_jre",
digest = "sha256:98236ffdfb61cff3fc4f8e40e5b80460a5a4c35a0f56a5b4182a926844a7db49",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jre",
@@ -215,7 +190,6 @@ oci.pull(
# Base image for JFR sidecar (Java 25 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_25_jdk",
digest = "sha256:bc2d562355813350f47bc472b9721b0b84761866671ad95d9a9f1151fa69da6e",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jdk",
@@ -309,9 +283,6 @@ maven.install(
# Error tracking
"io.sentry:sentry:8.31.0",
# SQLite for client text storage
"org.xerial:sqlite-jdbc:3.46.1.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
+10 -2083
View File
File diff suppressed because one or more lines are too long
-11
View File
@@ -31,17 +31,6 @@ fi
echo "Found Xcode project: $XCODEPROJ"
# Ensure export compliance key is set in Info.plist
# This bypasses the "Missing Compliance" prompt in App Store Connect.
# Set here rather than relying solely on Unity's DeepLinkPostProcessor,
# which depends on UNITY_IOS being defined at script compilation time.
INFO_PLIST="$XCODE_PROJECT_PATH/Info.plist"
if [ -f "$INFO_PLIST" ]; then
/usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption false" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool false" "$INFO_PLIST"
echo "Set ITSAppUsesNonExemptEncryption=false in Info.plist"
fi
# Unity always generates "Unity-iPhone" as the main app scheme
SCHEME="Unity-iPhone"
echo "Using scheme: $SCHEME"
+33
View File
@@ -0,0 +1,33 @@
#!/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
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 --exclude='Bee/' src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ "$CACHE_DIR/"
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
+16
View File
@@ -0,0 +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
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/
+26 -80
View File
@@ -1,102 +1,48 @@
#!/usr/bin/env bash
# Upload xcarchive to TestFlight using App Store Connect API Key
# This replaces the deprecated altool which was removed in Xcode 14+
#
# Uses xcodebuild -exportArchive with destination=upload, which is Apple's
# recommended approach for CI/CD pipelines.
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
ARCHIVE_PATH=${1:?Usage: upload_testflight.sh <xcarchive_path> <team_id> <profile_uuid>}
TEAM_ID=${2:?Missing team ID}
PROFILE_UUID=${3:?Missing provisioning profile UUID}
if [ ! -d "$ARCHIVE_PATH" ]; then
echo "Error: xcarchive not found: $ARCHIVE_PATH"
if [ ! -f "$IPA_PATH" ]; then
echo "Error: IPA file not found: $IPA_PATH"
exit 1
fi
echo "Uploading to TestFlight: $ARCHIVE_PATH"
echo "Uploading to TestFlight: $IPA_PATH"
# Requires App Store Connect API Key:
# - APP_STORE_CONNECT_API_KEY_ID: Key ID from App Store Connect
# - APP_STORE_CONNECT_API_ISSUER_ID: Issuer ID from App Store Connect
# - APP_STORE_CONNECT_API_KEY_PATH: Path to .p8 private key file
#
# To create an API key:
# 1. Go to https://appstoreconnect.apple.com/access/api
# 2. Click the + button to create a new key
# 3. Give it a name and Admin or App Manager access
# 4. Download the .p8 file (you can only download it once!)
# 5. Note the Key ID and Issuer ID shown on the page
# Uses same credentials as Mac notarization:
# - APPLE_ID: Your Apple ID email
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
if [ -z "${APP_STORE_CONNECT_API_KEY_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_ID environment variable not set"
echo "Create an API key at https://appstoreconnect.apple.com/access/api"
if [ -z "${APPLE_ID:-}" ]; then
echo "Error: APPLE_ID environment variable not set"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
echo "Uploading with xcrun altool..."
# Capture output to check for errors (altool may return 0 even on failure)
OUTPUT=$(xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--username "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$OUTPUT"
# Check for error indicators in output
if echo "$OUTPUT" | grep -q "ERROR:"; then
echo "ERROR: Upload failed. See error messages above."
exit 1
fi
if [ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]; then
echo "Error: API key file not found: $APP_STORE_CONNECT_API_KEY_PATH"
exit 1
fi
# Create export options plist with upload destination
EXPORT_OPTIONS_PLIST=$(mktemp)
trap "rm -f $EXPORT_OPTIONS_PLIST" EXIT
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>upload</string>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Export options:"
cat "$EXPORT_OPTIONS_PLIST"
echo "Uploading to App Store Connect..."
# Use xcodebuild to upload with App Store Connect API authentication
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
-218
View File
@@ -1,218 +0,0 @@
# Programmatic Map Image Generation
## Overview
Create a tool to programmatically regenerate the Eagle strategic map images with more equalized province sizes while preserving neighbor relationships. Also implement dynamic text rendering for province names that scales with zoom.
## Current Map Assets
| File | Description | Dimensions |
|------|-------------|------------|
| `Assets/Eagle/rawGray.gz.bytes` | Province ID lookup map (gzip compressed) | 3786 x 1834 |
| `Assets/Eagle/map_bw_labels.png` | B&W map with baked-in province labels | 3786 x 1834, RGBA |
| `Assets/Eagle/Materials/1.png` - `43.png` | Individual province mask images | 3786 x 1834, grayscale |
## Province Data
- 43 provinces defined in `src/main/resources/net/eagle0/eagle/province_map.tsv`
- Each has: id, name, neighbors, neighborPositions, hexMapName
- Problem provinces (too small): **Shumal** (id=1), **Kojaria** (id=39), **Usvol** (id=33), **Tumala** (id=35), **Chia** (id=43)
## Goals
1. **Equalize province sizes** - Reduce disparity so small provinces are easier to click
2. **Preserve topology** - Maintain all neighbor relationships
3. **Preserve coastline** - Keep ocean/land boundaries
4. **Dynamic text rendering** - Province names scale with zoom level
---
## Part 1: Province Size Equalization Tool
### Algorithm: Constrained Voronoi Relaxation
1. **Extract current state from rawGray:**
- Calculate centroid of each province
- Calculate area (pixel count) of each province
- Identify ocean pixels (value 0 or 255)
2. **Iterative relaxation:**
```
For each iteration:
For each province:
Calculate target area (average of all provinces)
If province is too small:
Move centroid away from larger neighbors
If province is too large:
Move centroid toward smaller neighbors
Constraint: Don't break neighbor relationships
```
3. **Generate new boundaries:**
- Use weighted Voronoi diagram from relaxed centroids
- Weight by target area
- Mask with original coastline
4. **Output new images:**
- New rawGray (province ID map)
- New province masks (1.png - 43.png)
- New B&W base map (derived from boundaries)
### Implementation Options
**Option A: Python Script (Recommended)**
- Use numpy, scipy, PIL/Pillow
- Can run offline, generate assets once
- Easier to iterate and debug
- Output: New image files to replace existing
**Option B: Unity Editor Script**
- C# with Unity's Texture2D
- Integrated into build process
- Slower, harder to debug
### Tool Location
`tools/map_generator/` or `scripts/generate_map.py`
---
## Part 2: Dynamic Province Name Rendering
### Current State
- Province names are baked into `map_bw_labels.png`
- Not visible when zoomed out, too small when zoomed in
- `SetUpCenterText()` in MapController shows selected/hovered province name in a fixed UI panel
### New Approach
1. **Create province label GameObjects:**
- One TextMeshPro label per province
- Position at province centroid
- Child of map content (moves with zoom/pan)
2. **Dynamic scaling:**
- Scale inversely with zoom level
- At zoom 1.0: Show major provinces only (or none)
- At zoom 2.0+: Show all province names
- Font size adjusts to fit province area
3. **Implementation:**
```csharp
public class ProvinceLabelsController : MonoBehaviour {
public MapPinchZoomHandler zoomHandler;
public TextMeshProUGUI labelPrefab;
private Dictionary<int, TextMeshProUGUI> _labels;
void Update() {
float zoom = zoomHandler.CurrentZoom;
foreach (var label in _labels.Values) {
label.transform.localScale = Vector3.one / zoom;
label.gameObject.SetActive(zoom >= 1.5f);
}
}
}
```
4. **Province centroid data:**
- Add `centroid_x`, `centroid_y` columns to `province_map.tsv`
- Or calculate at runtime from rawGray
---
## Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `tools/map_generator/generate_map.py` | Python script to generate new map images |
| `tools/map_generator/requirements.txt` | Python dependencies |
| `Assets/Eagle/ProvinceLabelsController.cs` | Dynamic province name rendering |
### Modified Files
| File | Changes |
|------|---------|
| `Assets/Eagle/rawGray.gz.bytes` | Replaced with equalized version |
| `Assets/Eagle/Materials/*.png` | Replaced province masks |
| `Assets/Eagle/map_bw_labels.png` | Either: regenerated, or removed if using dynamic labels |
| `province_map.tsv` | Add centroid columns |
| `Assets/Gameplay.unity` | Add ProvinceLabelsController, label prefab instances |
---
## Verification
1. **Visual inspection:**
- Compare old vs new province sizes
- Verify small provinces (Shumal, Kojaria, Usvol) are larger
- Verify neighbor relationships preserved
2. **Functional testing:**
- Click on each province at various zoom levels
- Verify correct province is selected
- Verify province colors/weather still work
3. **Label testing:**
- Zoom in/out, verify labels scale appropriately
- Pan around, verify labels move with map
- Verify labels don't overlap excessively
---
## Design Decisions
1. **Equalization approach:** Reduce disparity only
- Make small provinces (Shumal, Kojaria, Usvol, etc.) 2-3x larger
- Minimal changes to large provinces
- Not targeting perfectly equal areas
2. **Coastline:** Allow simplification
- Coastline can be smoothed/simplified as part of generation
- Voronoi edges are acceptable
3. **Map style:** Solid fills with borders
- Clean, simple look
- Easier to generate programmatically
- Province boundaries rendered as dark lines
---
## Implementation Steps
### Step 1: Python Map Generator Tool
```
tools/map_generator/
├── generate_map.py # Main script
├── requirements.txt # numpy, scipy, Pillow
└── README.md # Usage instructions
```
**Algorithm:**
1. Load current rawGray.gz.bytes, decompress
2. Calculate each province's centroid and area
3. Identify land mask (non-ocean pixels)
4. Run constrained Voronoi relaxation:
- Small provinces push neighbors away
- Large provinces pull neighbors in
- Stop when min province is >= 50% of average
5. Generate weighted Voronoi from relaxed centroids
6. Output:
- `rawGray_new.bytes` (province ID map)
- `1.png` - `43.png` (province masks)
- `map_bw.png` (solid fill + borders)
- `centroids.json` (for label positioning)
### Step 2: Dynamic Province Labels (Unity)
1. Create `ProvinceLabelsController.cs`
2. Load centroids from JSON or calculate from rawGray
3. Spawn TextMeshPro labels at each centroid
4. Update label scale/visibility based on zoom
### Step 3: Integration
1. Replace asset files with generated versions
2. Update `MapController` to use new assets
3. Add `ProvinceLabelsController` to scene
4. Remove baked label image dependency
-150
View File
@@ -1,150 +0,0 @@
# rules_apple Workspace Separation
This document explains why `rules_apple` is built in a separate Bazel workspace (`sparkle_workspace/`) and what conditions need to be met before it can be reintegrated into the main workspace.
## Background
The main workspace cannot include `rules_apple` due to transitive dependency conflicts with `rules_swift`. Multiple dependencies require different major versions of `rules_swift`:
| Dependency | rules_swift Version | Compatibility Level |
|------------|---------------------|---------------------|
| grpc | 3.x | 3 |
| flatbuffers | 2.x | 2 |
| rules_apple | 2.x | 2 |
Bzlmod's `single_version_override` can force a single version, but compatibility levels 2 and 3 are incompatible. Forcing `rules_swift` 3.x (required for grpc) breaks `rules_apple` and `flatbuffers`.
## Current Solution
The `SparklePlugin` (the only component requiring `rules_apple`) is built in an isolated workspace:
```
sparkle_workspace/
├── MODULE.bazel # Minimal deps: apple_support, rules_apple, sparkle
├── BUILD.bazel # Builds SparklePlugin.bundle
├── SparklePlugin.m # Objective-C source
├── Info.plist # Bundle metadata
└── external/
└── BUILD.sparkle # Build file for Sparkle framework
```
### How It Works
1. **Build script**: `scripts/build_sparkle_plugin.sh` builds the plugin from the separate workspace
2. **Output**: The built `SparklePlugin.bundle` is placed in `Assets/Plugins/macOS/`
3. **CI integration**: `mac_build.yml` calls `inject_sparkle.sh` to embed Sparkle into the Mac app
The main workspace uses `single_version_override` for `rules_swift` 3.x to satisfy grpc, and includes a comment noting that `rules_apple` is intentionally excluded.
## What SparklePlugin Does
SparklePlugin is a native macOS library that:
- Initializes the [Sparkle](https://sparkle-project.org/) auto-update framework
- Exposes C functions for Unity to call via P/Invoke:
- `SparklePlugin_Initialize`
- `SparklePlugin_CheckForUpdates`
- `SparklePlugin_CheckForUpdatesInBackground`
- `SparklePlugin_IsCheckingForUpdates`
- `SparklePlugin_GetAutomaticallyChecksForUpdates`
- `SparklePlugin_SetAutomaticallyChecksForUpdates`
- `SparklePlugin_IsRunningFromReadOnlyVolume`
- `SparklePlugin_ShowDMGWarning`
## Conditions for Reintegration
To move `rules_apple` back into the main workspace, **all** of the following must be true:
### 1. rules_swift Version Alignment
Check if grpc, flatbuffers, and rules_apple all support the same `rules_swift` major version:
```bash
# Check what rules_swift version each dependency requires
bazel mod graph 2>&1 | grep -A2 "rules_swift"
```
Or check BCR directly:
- https://registry.bazel.build/modules/grpc
- https://registry.bazel.build/modules/flatbuffers
- https://registry.bazel.build/modules/rules_apple
**Test**: After updating versions, verify you can add this to MODULE.bazel without errors:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z")
```
### 2. Build Test
If the dependency can be added, test that both the main build and SparklePlugin work:
```bash
# Main workspace builds
bazel build //src/main/scala/net/eagle0/eagle:eagle_server
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
bazel build //ci:eagle_server_image
# SparklePlugin builds (would move to main workspace)
bazel build //sparkle:SparklePlugin # After moving files
```
### 3. Files to Move
When reintegrating, move these from `sparkle_workspace/` to the main workspace:
| Source | Destination |
|--------|-------------|
| `sparkle_workspace/SparklePlugin.m` | `src/main/objc/net/eagle0/sparkle/SparklePlugin.m` |
| `sparkle_workspace/Info.plist` | `src/main/objc/net/eagle0/sparkle/Info.plist` |
| `sparkle_workspace/BUILD.bazel` | `src/main/objc/net/eagle0/sparkle/BUILD.bazel` |
| `sparkle_workspace/external/BUILD.sparkle` | (inline into MODULE.bazel http_archive) |
### 4. MODULE.bazel Changes
Add to the main MODULE.bazel:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z", repo_name = "build_bazel_rules_apple")
# Sparkle framework
http_archive(
name = "sparkle",
build_file_content = """...""", # Contents from external/BUILD.sparkle
sha256 = "...",
url = "https://github.com/sparkle-project/Sparkle/releases/download/...",
)
```
Remove the `single_version_override` for `rules_swift` if no longer needed.
### 5. Update Build Scripts
- Update `scripts/build_sparkle_plugin.sh` to build from main workspace
- Update `scripts/build_plugins.sh` if it references the separate workspace
- Verify `mac_build.yml` still works
### 6. Cleanup
After successful integration:
```bash
rm -rf sparkle_workspace/
```
Update comments in MODULE.bazel that reference the separate workspace.
## Version History
| Date | Event |
|------|-------|
| 2026-02-04 | Separated sparkle_workspace (PR #5882) to enable Bazel 8 upgrade |
| 2026-02-05 | Upgraded to Bazel 8.5.1 (PR #5883) |
## Monitoring
Periodically check if the upstream dependencies have aligned:
1. **grpc releases**: https://github.com/grpc/grpc/releases
2. **flatbuffers releases**: https://github.com/google/flatbuffers/releases
3. **rules_apple releases**: https://github.com/bazelbuild/rules_apple/releases
4. **BCR updates**: https://registry.bazel.build/
When a new version of any of these is released, check if the `rules_swift` requirements have converged.
-1
View File
@@ -77,4 +77,3 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
## Nice to have
- [x] "What's new" changelog (fetch JSON, show entries since last launch)
- [ ] Windows code signing ([Azure Artifact Signing](https://azure.microsoft.com/en-us/products/artifact-signing/), ~$10/mo) to eliminate SmartScreen "unknown publisher" warning
-240
View File
@@ -1,240 +0,0 @@
# Tutorial Battle System
This document describes the tutorial battle system for first-time players. The system provides a scripted introductory battle that teaches combat basics while telling a story.
---
## Overview
When a new player starts their first game in tutorial mode, they experience:
1. **Narrative Intro** - Story screens introducing the scenario
2. **Immediate Battle** - Skip strategic map, start directly in combat
3. **Scripted Flee** - Enemy flees when player is "on the ropes"
4. **Reinforcements** - Allied heroes arrive mid-battle
5. **Heroes Join** - Reinforcement heroes join player's faction after battle
---
## Battle Configuration
### Defender (Player - John Ranil)
- **Heroes**: John Ranil + 2 random vassals (3 total)
- **Units**:
- 2x Light Infantry (300 troops each, 60 training/armament)
- 1x Longbowmen (200 troops, 60 training/armament)
- **Province**: 14 (Onmaa) with 40 support, 500 gold, 3000 food
- **Restriction**: Cannot flee (must defend)
### Attacker (Ikhaan Tarn)
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
- **Units**:
- 1x Heavy Cavalry (600 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
### Flee Trigger
Attacker flees when ANY of these conditions are met:
- Player loses 1 unit
- 5 rounds have passed
### Reinforcements
When Tarn flees, these heroes arrive as player reinforcements:
- Elena Fyar
- Hedrick
- The Boulder
---
## Implementation Architecture
### Phase 1: Narrative Intro System
**Proto Changes:**
- `game_state_view.proto`: Added `TutorialNarrativeScreen` message and `pending_narrative_screens` field
- `game_parameters.proto`: Added `TutorialNarrativeScreen` and `tutorial_narrative_screens` field
**Unity Client:**
- `NarrativeScreenController.cs`: Modal screen with title, body text, optional image
- `EagleGameController.cs`: Checks for pending narrative screens on game start
**Narrative Content** (defined in `tutorial_game_parameters.json`):
```
Screen 1: "The Engineer of Onmaa" - Player backstory
Screen 2: "The Traitor Arrives" - Tarn's attack
Screen 3: "Defend Your Home" - Call to action
```
### Phase 2: Auto-Start Battle
**Configuration:**
- `tutorial_game_parameters.json`: Contains `tutorialBattle` config with attacker, target, flee conditions
- `game_parameters.proto`: `TutorialBattleConfig` message
**Game Creation:**
- `NewGameCreation.scala`: `setupTutorialBattle()` method creates:
- `HostileArmyGroup` with `Attacking` status (bypasses decision phase)
- `Army` for defender
- Both added to target province
**Battle Creation:**
- When round advances to `BattleRequest` phase, `RequestBattlesAction` automatically creates the `ShardokBattle`
### Phase 3: Scripted Flee Mechanism
**C++ Component:**
- `TutorialBattleController.hpp/cpp`: Controller for scripted battle logic
**Key Methods:**
```cpp
// Check all events and execute triggered actions (called at end of each round)
std::vector<ActionResult> CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator);
```
**Integration:**
- `ShardokEngine.cpp`: Calls `CheckAndExecuteEvents()` at end of each round in `HandlePlayerTurnEnd()`
- Events fire in config order, each event fires at most once
### Phase 4: Mid-Battle Reinforcements
**Action Types** (in `action_type.proto`):
- `TUTORIAL_ENEMY_FLED = 78`
- `TUTORIAL_REINFORCEMENTS_ARRIVED = 79`
**Implementation (TODO):**
- When scripted flee triggers, create reinforcement units in defender's reserves
- Units have `UnitStatus_RESERVE_UNIT` status
- Reinforcements enter battle on subsequent turns
### Phase 5: Tutorial Popups
**Content Definitions** (in `TutorialContentDefinitions.cs`):
- `tutorial_battle_started`: "Defend your province!"
- `tutorial_enemy_fled`: "The enemy retreats!"
- `tutorial_reinforcements_arrived`: "Allies have arrived!"
- `tutorial_battle_victory`: Victory celebration
**Trigger Integration:**
- `TutorialTriggerRegistry.cs`: Handles tutorial action types from `ActionResultView`
- `ShardokGameController.cs`: Detects tutorial events and triggers popups
### Phase 6: Post-Battle Hero Joining
**Implementation (TODO):**
- `ResolveBattleAction.scala`: Check if battle was tutorial and defender won
- Create `TutorialHeroJoined` action results adding reinforcement heroes to player faction
- Set appropriate loyalty, vigor, location for joined heroes
---
## File Summary
### New Files
| File | Purpose |
|------|---------|
| `TutorialBattleController.hpp/cpp` | C++ scripted flee and reinforcement logic |
| `NarrativeScreenController.cs` | Unity narrative screen display |
### Modified Files
| File | Changes |
|------|---------|
| `tutorial_game_parameters.json` | Battle configuration and narrative content |
| `game_state_view.proto` | Narrative screens field |
| `game_parameters.proto` | Tutorial battle config |
| `player_info.proto` | Tutorial battle flags for Shardok |
| `action_type.proto` | Tutorial action types |
| `NewGameCreation.scala` | Auto-start battle setup |
| `EagleGameController.cs` | Narrative display integration |
| `TutorialContentDefinitions.cs` | Battle tutorial content |
| `TutorialTriggerRegistry.cs` | Tutorial event triggers |
---
## Remaining Implementation
### Disable Player Flee
In `FleeCommandFactory.cpp`:
- Check if tutorial mode is enabled
- If defender, don't offer flee commands
### Reinforcement Units
In `TutorialBattleController::ExecuteScriptedFlee()`:
- Create `Unit` objects for Elena Fyar, Hedrick, The Boulder
- Add to defender's reserve units
- Generate `TUTORIAL_REINFORCEMENTS_ARRIVED` action
### Post-Battle Hero Joining
In Eagle's battle resolution:
- Detect tutorial battle completion
- Create heroes in player's faction with proper stats
- Generate notification action results
---
## Testing Checklist
- [ ] New user sees narrative screens before battle
- [ ] Battle starts immediately after narratives (no strategic map)
- [ ] Player has correct units (3 heroes, 3 battalions)
- [ ] Attacker has correct units (3 heroes, 3 battalions)
- [ ] Player cannot flee
- [ ] Tarn flees after player loses 1 unit
- [ ] Tarn flees after 5 rounds (if no units lost)
- [ ] Reinforcements appear when Tarn flees
- [ ] Tutorial popups appear at correct moments
- [ ] Reinforcement heroes join faction after battle victory
---
## Configuration Reference
### tutorial_game_parameters.json
```json
{
"tutorialBattle": {
"attackerFactionHead": "Ikhaan Tarn",
"targetProvinceId": 14,
"fleeAfterDefenderUnitsLost": 1,
"fleeAfterRounds": 5,
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
},
"tutorialNarrativeScreens": [
{ "title": "...", "bodyText": "...", "imagePath": "" }
]
}
```
### TutorialBattleConfig Proto (Shardok)
```protobuf
message TutorialBattleConfig {
bool enabled = 1;
repeated TutorialEvent events = 2;
repeated UnitInitialSize initial_sizes = 3; // For damage tracking
}
message TutorialEvent {
string event_id = 1;
TutorialTrigger trigger = 2;
TutorialAction action = 3;
}
message TutorialTrigger {
oneof trigger_type {
RoundTrigger after_round = 1;
UnitsLostTrigger units_lost = 2;
DamageTakenTrigger damage_taken = 3;
UnitKilledTrigger unit_killed = 4;
}
}
message TutorialAction {
oneof action_type {
FleeAction flee = 1;
ReinforcementsAction reinforcements = 2; // Uses CommonUnit
}
}
```
-113
View File
@@ -1,113 +0,0 @@
# AI Quest Completion Behavior
This document describes which quests the AI attempts to complete proactively to recruit unaffiliated heroes.
## Overview
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
## Quests the AI Actively Completes
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
### Resource Giving Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `AlmsToProvinceQuest` | `AlmsToProvinceQuestCommandChooser` | Gives food to the specified province |
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
## Implementation Details
The quest completion logic is located in:
- `FulfillQuestsCommandSelector.scala` - Main entry point, iterates through choosers
- `quest_command_selectors/` - Individual quest handlers
Each chooser extends either:
- `QuestCommandChooser` - For quests requiring randomness
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
@@ -1,230 +0,0 @@
# Plan: Eliminate Battalion Backstory LLM Updates
## Goal
Dramatically reduce LLM request volume by eliminating battalion backstory updates. Battalions will retain:
- An **initial backstory** (generated once when created)
- A **history of events** (structured data, not LLM-generated prose)
The history will be fed into other LLM updates (hero backstories, chronicle) but will no longer be regenerated into prose for each battalion.
## Current System Overview
### Data Flow
```
Battalion Created → Initial Backstory LLM Request → Text stored
Battalion participates in events → Events accumulated → Update LLM Request → New text version
BattalionView.backstoryTextId → Client displays on hover
```
### Event Types (8 total)
Events are structured data already captured in `EventForBattalionBackstory`:
- OrganizedTroops, SuppressedBeasts, ApprehendedOutlaws, FoughtInBattle
- SuppressedRiot, Trained, Armed, SurvivedStarvation
Each event has date, province, hero, and event-specific details.
### Key Files
| Component | File |
|-----------|------|
| Unity Display | `Assets/Eagle/HeroesAndBattalionsPanelController.cs` |
| View Proto | `views/battalion_view.proto` |
| View Filter | `BattalionViewFilter.scala` |
| Update Action | `BattalionBackstoryUpdateAction.scala` |
| Update Generator | `BattalionBackstoryUpdateActionGenerator.scala` |
| Update Prompt | `BattalionBackstoryUpdatePromptGenerator.scala` |
| Event-to-Text | `BattalionBackstoryUpdatePromptGenerator.textForEvent()` |
| BattalionDescriptions | `BattalionDescriptions.scala` (used in other LLM prompts) |
---
## Stage 1: Stop Displaying Battalion Backstories in Client
**Goal:** Remove the UI that shows battalion backstory text on hover.
### Files to Modify
**`Assets/Eagle/HeroesAndBattalionsPanelController.cs`**
- In `BattalionLongHoverRowChanged()` (lines 143-169):
- Remove or comment out the backstory text population
- Keep the header/name display, remove `battalionPopupPanelBackstory.TextId = ...`
**Unity Scene/Prefab**
- Hide or remove the `battalionPopupPanelBackstory` UI element
### Verification
- [ ] Battalion hover popup no longer shows backstory text
- [ ] No errors when hovering over battalions
- [ ] Other battalion info (name, type, size) still displays
---
## Stage 2: Stop Populating Backstory in BattalionView
**Goal:** Stop sending backstory text ID to the client.
### Files to Modify
**`BattalionViewFilter.scala`**
- In `filteredBattalionViewBelongingToPlayer()`:
- Set `backstoryTextId = ""` instead of `battalion.backstoryTextId`
- In `limitedBattalionView()`:
- Same change
### Verification
- [ ] BattalionView messages have empty backstoryTextId
- [ ] Client handles empty backstoryTextId gracefully (Stage 1 should have removed the display)
---
## Stage 3: Remove backstory_text_id from BattalionView Proto
**Goal:** Clean up the proto schema.
### Files to Modify
**`views/battalion_view.proto`**
- Remove field: `string backstory_text_id = 7;`
- Mark field 7 as reserved to prevent reuse
**`Assets/Eagle/HeroesAndBattalionsPanelController.cs`**
- Remove any remaining references to `BackstoryTextId`
**`BattalionViewFilter.scala`**
- Remove `backstoryTextId` from view construction
### Verification
- [ ] Proto compiles
- [ ] C# client compiles without BackstoryTextId references
- [ ] Full test suite passes
---
## Stage 4: Replace Backstory Usage in BattalionDescriptions
**Goal:** When other LLM prompts need battalion context, use initial backstory + structured history instead of the latest LLM-generated prose.
### Current Usage in `BattalionDescriptions.scala`
```scala
def backstoryText(): TextGenerationResult =
battalion.backstoryVersions.lastOption
.map(v => clientTextStore.getText(v.textId))
.getOrElse(TextGenerationSuccess(""))
def fullDescription(): String =
s"${battalion.name} is $description. $backstoryText"
```
### New Approach
Create `historyDescription()` that:
1. Gets initial backstory (first version's text, if available)
2. Appends structured event history using the same `textForEvent()` logic from `BattalionBackstoryUpdatePromptGenerator`
```scala
def historyDescription(): String = {
val initialBackstory = battalion.backstoryVersions.headOption
.map(v => clientTextStore.getText(v.textId))
.collect { case TextGenerationSuccess(text) => text }
.getOrElse("")
val eventHistory = battalion.backstoryEvents
.map(textForEvent) // Reuse existing event-to-text conversion
.mkString(" ")
if eventHistory.isEmpty then initialBackstory
else s"$initialBackstory $eventHistory"
}
```
### Files to Modify
**`BattalionDescriptions.scala`**
- Add `historyDescription()` method
- Update `fullDescription()` to use `historyDescription()` instead of `backstoryText()`
- Keep `textForEvent()` logic (move from `BattalionBackstoryUpdatePromptGenerator` if needed)
**Move/refactor `textForEvent()`**
- Currently in `BattalionBackstoryUpdatePromptGenerator.scala` (lines 78-262)
- Move to `BattalionDescriptions.scala` or a shared utility
- This converts events to human-readable text without LLM
### Verification
- [ ] Other LLM prompts that use `BattalionDescriptions` still get meaningful context
- [ ] Chronicle updates include battalion history
- [ ] Hero backstory updates include relevant battalion history
---
## Stage 5: Stop Generating Battalion Backstory Updates
**Goal:** Remove the LLM request generation entirely.
### Files to Modify
**`BattalionBackstoryUpdateActionGenerator.scala`**
- Return empty Vector instead of generating actions
- Or delete the file entirely
**`BattalionBackstoryUpdateAction.scala`**
- Delete or mark as deprecated
**`BattalionBackstoryUpdatePromptGenerator.scala`**
- Delete or keep only `textForEvent()` if moved
**`LlmResolver.scala`**
- Remove case for `BattalionBackstoryUpdateRequest`
- Optionally throw error if somehow called
**`EndPlayerCommandsPhaseAction.scala`** (and other callers)
- Find where `BattalionBackstoryUpdateActionGenerator` is called
- Remove those calls
### Files to Search for Callers
```bash
grep -r "BattalionBackstoryUpdateAction" src/
```
### Verification
- [ ] No `BattalionBackstoryUpdateRequest` LLM requests generated
- [ ] Battalions still accumulate events (for history description)
- [ ] Full test suite passes
- [ ] Game runs without errors
---
## Future Considerations
### Keep or Remove Initial Backstory Generation?
The initial backstory (`BattalionInitialBackstoryRequest`) is generated once per battalion. Options:
1. **Keep it** - Low volume, provides flavor text for history
2. **Remove it** - Use a template-based approach instead
Recommendation: Keep for now, evaluate later based on volume.
### Event History Cleanup
Events currently accumulate forever on battalions. Consider:
- Capping event history length
- Summarizing old events
- Only keeping "significant" events
### Affected Tests
Search for tests that verify backstory update behavior:
```bash
bazel query 'tests(//src/test/scala/...) intersect rdeps(//src/test/scala/..., //src/main/scala/net/eagle0/eagle/library/actions/impl/action:battalion_backstory_update_action)'
```
---
## Summary of LLM Request Reduction
| Request Type | Current Volume | After Change |
|--------------|---------------|--------------|
| BattalionInitialBackstoryRequest | 1 per battalion | 1 per battalion (unchanged) |
| BattalionBackstoryUpdateRequest | ~N per battalion per game | **0** |
For a typical game with ~50 battalions and ~20 rounds, this could eliminate **hundreds** of LLM requests.
-93
View File
@@ -1,93 +0,0 @@
# Province Event 3D Effects
Ideas for replacing 2D shader-based province event effects with 3D particle systems.
## Current State
| Event | Effect Type | Notes |
|-------|-------------|-------|
| Festival | 3D particles | Complete |
| Epidemic | 3D particles | Complete (rising skulls + green haze) |
| Blizzard | 2D shader | Not visible on unowned (white) provinces |
| Drought | 2D shader | |
| Flood | 2D shader | |
| Beasts | None | Tricky - can be any animal or human |
## Proposed 3D Effects
### Blizzard
- Falling snowflakes with slight blue tint (ensures visibility on white backgrounds)
- Diagonal wind-blown particles to convey harsh weather
- Swirling vortex effect near ground level
- Frosty sparkle/glitter particles for magical winter feel
### Drought
- Rising dust/sand particles (tan/brown coloring)
- Small dust devils (spinning particle columns)
- Shimmering golden particles rising to suggest heat distortion
- Dried leaves/debris blowing across the province
### Flood
- Rain falling (blue-tinted streaks or droplets)
- Water splashes/ripples rising from ground
- Mist/spray particles hovering low
- Floating debris particles (leaves, sticks)
### Beasts
This is challenging since "beasts" can represent any dangerous creature. Universal approaches:
- **Circling vultures/crows overhead** - Predators attract scavengers; works for any beast type and is visually distinctive
- Dust clouds suggesting movement at province edges
- Glowing eyes appearing/disappearing in shadows
- Stylized claw/scratch mark effects
- Paw prints materializing on the ground
- Red "danger" aura pulsing from ground level
The vultures/crows approach is recommended as the most universal solution.
## Implementation Status
Controllers have been created for all 4 event types:
- `ProvinceBlizzardController.cs` - ready, needs prefab
- `ProvinceDroughtController.cs` - ready, needs prefab
- `ProvinceFloodController.cs` - ready, needs prefab
- `ProvinceBeastsController.cs` - ready, needs prefab
### Prefabs Needed
Create these prefabs in `Assets/Eagle/Effects/`:
1. **BlizzardEffect.prefab**
- Particle System with falling snowflakes
- Blue-tinted particles for visibility on white
- Diagonal velocity for wind effect
- RectTransform for UI positioning
2. **DroughtEffect.prefab**
- Particle System with rising dust particles
- Tan/brown coloring
- Upward velocity
- Optional spinning sub-emitters for dust devils
3. **FloodEffect.prefab**
- Particle System with falling rain streaks
- Blue tint
- Fast downward velocity
- Optional splash sub-emitter
4. **BeastsEffect.prefab**
- Particle System with bird silhouettes
- Circular orbit motion around center
- Black/dark particles
- Could use a crow/vulture sprite texture
### Scene Setup
Wire up in `Gameplay.unity`:
1. Add controller components to the Map GameObject
2. Assign `mapContainer`, `centroidsJson` (shared with other controllers)
3. Assign the effect prefab for each controller
-27
View File
@@ -60,7 +60,6 @@
"org.scalamock:scalamock_3": -610483078,
"org.slf4j:slf4j-api": 1609745898,
"org.slf4j:slf4j-simple": 1319506696,
"org.xerial:sqlite-jdbc": -1707012205,
"repositories": -1949687017,
"software.amazon.awssdk:aws-core": 1764800397,
"software.amazon.awssdk:http-client-spi": 1697477859,
@@ -174,7 +173,6 @@
"org.scalamock:scalamock_3": -1778657085,
"org.slf4j:slf4j-api": -771685699,
"org.slf4j:slf4j-simple": -776509812,
"org.xerial:sqlite-jdbc": 1311600081,
"software.amazon.awssdk:annotations": -324257201,
"software.amazon.awssdk:apache-client": 2066024214,
"software.amazon.awssdk:arns": -1240703529,
@@ -818,12 +816,6 @@
},
"version": "2.0.16"
},
"org.xerial:sqlite-jdbc": {
"shasums": {
"jar": "6dc7464e3803648d3ff18a7359bab6adf079fcd8495b18991f6f5edcb8ac6e3b"
},
"version": "3.46.1.0"
},
"software.amazon.awssdk:annotations": {
"shasums": {
"jar": "ed03bb4ff78900307dc96bacdec70ddf80c17df3eb6761219573025649f08ff3"
@@ -1360,9 +1352,6 @@
"org.slf4j:slf4j-simple": [
"org.slf4j:slf4j-api"
],
"org.xerial:sqlite-jdbc": [
"org.slf4j:slf4j-api"
],
"software.amazon.awssdk:apache-client": [
"commons-codec:commons-codec",
"org.apache.httpcomponents:httpclient",
@@ -2415,16 +2404,6 @@
"org.slf4j:slf4j-simple": [
"org.slf4j.simple"
],
"org.xerial:sqlite-jdbc": [
"org.sqlite",
"org.sqlite.core",
"org.sqlite.date",
"org.sqlite.javax",
"org.sqlite.jdbc3",
"org.sqlite.jdbc4",
"org.sqlite.nativeimage",
"org.sqlite.util"
],
"software.amazon.awssdk:annotations": [
"software.amazon.awssdk.annotations"
],
@@ -2835,7 +2814,6 @@
"org.scalamock:scalamock_3",
"org.slf4j:slf4j-api",
"org.slf4j:slf4j-simple",
"org.xerial:sqlite-jdbc",
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:apache-client",
"software.amazon.awssdk:arns",
@@ -2912,11 +2890,6 @@
"org.slf4j.simple.SimpleServiceProvider"
]
},
"org.xerial:sqlite-jdbc": {
"java.sql.Driver": [
"org.sqlite.JDBC"
]
},
"software.amazon.awssdk:apache-client": {
"software.amazon.awssdk.http.SdkHttpService": [
"software.amazon.awssdk.http.apache.ApacheSdkHttpService"
+4 -54
View File
@@ -2,57 +2,7 @@
set -euxo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UNITY_ROOT="$REPO_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0"
OUTPUT_DIR="$UNITY_ROOT/Assets/GeneratedProtos"
# All C# protobuf generation targets
TARGETS=(
"//src/main/protobuf/net/eagle0/common:common_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/shardok/storage:storage_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/shardok/common:common_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/shardok/api:api_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/common:common_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/views:views_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/api:api_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/api:api_csharp_grpc_srcs"
"//src/main/protobuf/net/eagle0/eagle/api/command/util:util_csharp_proto_srcs"
)
# Subdirectory for each target (avoids filename collisions across packages)
SUBDIRS=(
"net/eagle0/common"
"net/eagle0/shardok/storage"
"net/eagle0/shardok/common"
"net/eagle0/shardok/api"
"net/eagle0/eagle/common"
"net/eagle0/eagle/views"
"net/eagle0/eagle/api"
"net/eagle0/eagle/api"
"net/eagle0/eagle/api/command/util"
)
/bin/echo "Building C# protobuf sources..."
bazel build "${TARGETS[@]}"
/bin/echo "Copying generated .cs files to $OUTPUT_DIR..."
rm -rf "$OUTPUT_DIR"
for i in "${!TARGETS[@]}"; do
target="${TARGETS[$i]}"
subdir="${SUBDIRS[$i]}"
# Convert target label to bazel-bin path
# //src/main/protobuf/net/eagle0/common:common_csharp_proto_srcs
# -> bazel-bin/src/main/protobuf/net/eagle0/common/common_csharp_proto_srcs
target_path="${target#//}"
pkg="${target_path%%:*}"
name="${target_path##*:}"
bin_dir="$REPO_ROOT/bazel-bin/$pkg/$name"
dest_dir="$OUTPUT_DIR/$subdir"
mkdir -p "$dest_dir"
find "$bin_dir" -name "*.cs" -exec cp {} "$dest_dir/" \;
done
/bin/echo "Done. Generated C# proto sources in $OUTPUT_DIR"
/bin/echo "build protos"
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
dotnet build src/main/csharp/net/eagle0/clients/unity/eagle0/protos/protos.csproj \
-o src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
+5 -26
View File
@@ -22,31 +22,11 @@ NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Cache for expensive bazel query results
LIBRARY_DEPS_CACHE=""
MAIN_DEPS_CACHE=""
# Get deps of library/ (cached)
get_library_deps() {
if [ -z "$LIBRARY_DEPS_CACHE" ]; then
LIBRARY_DEPS_CACHE=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...)' 2>/dev/null || true)
fi
echo "$LIBRARY_DEPS_CACHE"
}
# Get deps of src/main (cached)
get_main_deps() {
if [ -z "$MAIN_DEPS_CACHE" ]; then
MAIN_DEPS_CACHE=$(bazel query 'deps(//src/main/...)' 2>/dev/null || true)
fi
echo "$MAIN_DEPS_CACHE"
}
# Rule 1: src/main should not depend on src/test
check_main_depends_on_test() {
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
violations=$(get_main_deps | grep "^//src/test/" || true)
violations=$(bazel query 'deps(//src/main/...) intersect //src/test/...' 2>/dev/null || true)
if [ -n "$violations" ]; then
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
@@ -63,7 +43,7 @@ check_main_depends_on_test() {
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(get_library_deps | grep "^//src/main/protobuf/.*_scala_proto$" || true)
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$violations" ]; then
count=0
else
@@ -85,7 +65,7 @@ check_library_depends_on_scala_proto() {
check_library_depends_on_proto_converters() {
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
violations=$(get_library_deps | grep "^//src/main/scala/net/eagle0/eagle/model/proto_converters/" || true)
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/scala/net/eagle0/eagle/model/proto_converters/...' 2>/dev/null | grep "^//" || true)
if [ -n "$violations" ]; then
count=$(echo "$violations" | wc -l | tr -d ' ')
@@ -105,8 +85,7 @@ check_library_depends_on_proto_converters() {
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
library_deps=$(get_library_deps)
scala_proto_results=$(echo "$library_deps" | grep "^//src/main/protobuf/.*_scala_proto$" || true)
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$scala_proto_results" ]; then
scala_proto_count=0
else
@@ -115,7 +94,7 @@ count_proto_deps() {
echo "library/ Scala proto deps: $scala_proto_count"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(echo "$library_deps" | grep -c "^//src/main/protobuf/" || echo "0")
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
}
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > /dev/null
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
@@ -27,7 +27,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/actions:update_game_status_action",
"//src/main/cpp/net/eagle0/shardok/library/actions:update_opponent_knowledge_action",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
"//src/main/cpp/net/eagle0/shardok/library/tutorial:tutorial_battle_controller",
"//src/main/cpp/net/eagle0/shardok/library/util:game_state_validator",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:action_result_filter",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:game_state_filter",
@@ -286,29 +286,6 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
true /* atEndOfRound */)
.Execute(gameState, randomGenerator));
// Check for tutorial events at end of round
if (!GameIsOver() && tutorialController_.IsEnabled()) {
auto tutorialResults = tutorialController_.CheckAndExecuteEvents(
gameState,
settingsGetter,
randomGenerator);
if (!tutorialResults.empty()) {
ApplyAndAddActionResults(tutorialResults);
// Update game status after tutorial events (e.g., attackers fled, defender wins)
ApplyAndAddActionResults(UpdateGameStatusAction(
GetCurrentGameState(),
criticalTileCoords,
settingsGetter,
true /* atEndOfRound */)
.Execute(gameState, randomGenerator));
// If tutorial events ended the game, don't start new round
if (GameIsOver()) { return; }
}
}
ApplyAndAddActionResults(
UpdateOpponentKnowledgeAction(settingsGetter).Execute(gameState, randomGenerator));
@@ -20,7 +20,6 @@
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/tutorial/TutorialBattleController.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/action_result_view.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/api/unit_view.pb.h"
@@ -37,7 +36,6 @@ using net::eagle0::shardok::api::UnitView;
using PlayerInfoProto = net::eagle0::shardok::common::PlayerInfo;
using net::eagle0::shardok::storage::ShardokActionWithResultingState;
using HexMapProto = net::eagle0::shardok::common::HexMap;
using TutorialBattleConfigProto = net::eagle0::common::TutorialBattleConfig;
class ShardokEngine {
private:
@@ -54,8 +52,6 @@ private:
const CoordsSet criticalTileCoords;
TutorialBattleController tutorialController_;
mutable CommandListSPtr cachedAvailableCommands{};
void ApplyAndAddActionResult(const ActionResult &result);
@@ -201,11 +197,6 @@ public:
[[nodiscard]] auto GetGameSettings() const -> GameSettingsSPtr { return gameSettings; }
/// Configure the tutorial battle controller (call after construction if needed)
void SetTutorialBattleConfig(const TutorialBattleConfigProto &config) {
tutorialController_ = TutorialBattleController(config);
}
static inline auto GameIsOver(const fb::GameStatus *status) -> bool {
return (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY);
}
@@ -1,18 +0,0 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "tutorial_battle_controller",
srcs = ["TutorialBattleController.cpp"],
hdrs = ["TutorialBattleController.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:action_result_flatbuffer_helpers",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:action_type_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_cc_proto",
],
)
@@ -1,236 +0,0 @@
//
// TutorialBattleController.cpp
// eagle0
//
// Created for tutorial battle system.
// Copyright 2025 none. All rights reserved.
//
#include "TutorialBattleController.hpp"
#include <ranges>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/util/ActionResultFlatbufferHelpers.hpp"
namespace shardok {
using UnitStatusFB = net::eagle0::shardok::storage::fb::UnitStatus;
using ActionType = net::eagle0::shardok::common::ActionType;
using FleeActionProto = net::eagle0::common::FleeAction;
using ReinforcementsActionProto = net::eagle0::common::ReinforcementsAction;
using UnitKilledTriggerProto = net::eagle0::common::UnitKilledTrigger;
TutorialBattleController::TutorialBattleController()
: enabled_(false),
config_(),
firedEventIds_() {}
TutorialBattleController::TutorialBattleController(const TutorialBattleConfigProto& protoConfig)
: enabled_(protoConfig.enabled()),
config_(protoConfig),
firedEventIds_() {}
auto TutorialBattleController::CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) -> std::vector<ActionResult> {
if (!enabled_) { return {}; }
std::vector<ActionResult> allResults;
// Check events in config order
for (const auto& event : config_.events()) {
// Skip already-fired events
if (firedEventIds_.contains(event.event_id())) { continue; }
// Check if trigger condition is met
if (EvaluateTrigger(event.trigger(), state)) {
// Mark as fired before executing (to prevent re-triggering)
firedEventIds_.insert(event.event_id());
// Execute the action
auto results = ExecuteAction(event.action(), state, settings, randomGenerator);
allResults.insert(allResults.end(), results.begin(), results.end());
}
}
return allResults;
}
auto TutorialBattleController::EvaluateTrigger(
const TutorialTriggerProto& trigger,
const GameStateW& state) const -> bool {
switch (trigger.trigger_type_case()) {
case TutorialTriggerProto::kAfterRound: {
const int currentRound = state->current_round();
return currentRound >= trigger.after_round().round_number();
}
case TutorialTriggerProto::kUnitsLost: {
const auto& unitsLost = trigger.units_lost();
const int lostCount = CountLostUnits(state, unitsLost.player_id());
return lostCount >= unitsLost.threshold();
}
case TutorialTriggerProto::kDamageTaken: {
const auto& damageTaken = trigger.damage_taken();
const int totalDamage = GetTotalDamageTaken(state, damageTaken.player_id());
return totalDamage >= damageTaken.threshold();
}
case TutorialTriggerProto::kUnitKilled: {
return IsUnitKilled(state, trigger.unit_killed());
}
case TutorialTriggerProto::TRIGGER_TYPE_NOT_SET:
default: return false;
}
}
auto TutorialBattleController::ExecuteAction(
const TutorialActionProto& action,
[[maybe_unused]] const GameStateW& state,
[[maybe_unused]] const SettingsGetter& settings,
[[maybe_unused]] const std::shared_ptr<RandomGenerator>& randomGenerator) const
-> std::vector<ActionResult> {
switch (action.action_type_case()) {
case TutorialActionProto::kFlee: return ExecuteFleeAction(action.flee(), state);
case TutorialActionProto::kReinforcements:
return ExecuteReinforcementsAction(action.reinforcements());
case TutorialActionProto::ACTION_TYPE_NOT_SET:
default: return {};
}
}
auto TutorialBattleController::CountLostUnits(const GameStateW& state, const PlayerId playerId)
const -> int {
int count = 0;
for (const auto* unit : *state->units()) {
if (unit->player_id() != playerId) { continue; }
// Count units that are no longer in battle (destroyed, captured, fled, retreated)
const auto status = unit->status();
if (status != UnitStatusFB::UnitStatus_NORMAL_UNIT &&
status != UnitStatusFB::UnitStatus_RESERVE_UNIT &&
status != UnitStatusFB::UnitStatus_NEVER_ENTERED_UNIT &&
status != UnitStatusFB::UnitStatus_RESERVED_SLOT) {
count++;
}
}
return count;
}
auto TutorialBattleController::GetTotalDamageTaken(const GameStateW& state, const PlayerId playerId)
const -> int {
int totalDamage = 0;
for (const auto* unit : *state->units()) {
if (unit->player_id() != playerId) { continue; }
// Calculate damage as difference between initial and current size
// Initial sizes are stored in config since flatbuffers don't track starting sizes
int initialSize = 0;
for (const auto& initial : config_.initial_sizes()) {
if (initial.unit_id() == unit->unit_id()) {
initialSize = initial.size();
break;
}
}
if (initialSize == 0) { continue; }
const int currentSize = unit->battalion().size();
if (initialSize > currentSize) { totalDamage += (initialSize - currentSize); }
}
return totalDamage;
}
auto TutorialBattleController::IsUnitKilled(
const GameStateW& state,
const UnitKilledTriggerProto& trigger) const -> bool {
for (const auto* unit : *state->units()) {
// Check if this unit matches the identifier
bool matches = false;
switch (trigger.unit_identifier_case()) {
case UnitKilledTriggerProto::kUnitId:
matches = (unit->unit_id() == trigger.unit_id());
break;
case UnitKilledTriggerProto::kEagleHeroId:
if (unit->has_attached_hero()) {
matches = (unit->attached_hero().eagle_hero_id() == trigger.eagle_hero_id());
}
break;
case UnitKilledTriggerProto::UNIT_IDENTIFIER_NOT_SET:
default: continue;
}
if (!matches) { continue; }
// Check if the unit is "killed" (destroyed/captured status)
const auto status = unit->status();
if (status == UnitStatusFB::UnitStatus_DESTROYED_SUMMONED_UNIT ||
status == UnitStatusFB::UnitStatus_CAPTURED_UNIT) {
return true;
}
}
return false;
}
auto TutorialBattleController::ExecuteFleeAction(
const FleeActionProto& fleeAction,
const GameStateW& state) const -> std::vector<ActionResult> {
std::vector<ActionResult> results;
const PlayerId fleeingPlayerId = fleeAction.player_id();
// First, create a "tutorial enemy fled" notification
ActionResult fleeNotification{};
fleeNotification.set_type(ActionType::TUTORIAL_ENEMY_FLED);
results.push_back(fleeNotification);
// Determine which units should flee
std::set<int32_t> specificUnits(fleeAction.unit_ids().begin(), fleeAction.unit_ids().end());
const bool fleeAllUnits = specificUnits.empty();
// Find all matching units that are still active and make them flee
for (const auto* unit : *state->units()) {
if (unit->player_id() != fleeingPlayerId) { continue; }
if (unit->status() != UnitStatusFB::UnitStatus_NORMAL_UNIT) { continue; }
// Check if this is a specific unit to flee, or if we're fleeing all
if (!fleeAllUnits && !specificUnits.contains(unit->unit_id())) { continue; }
// Create flee result for this unit (100% success - guaranteed flee)
ActionResult fleeResult{};
fleeResult.set_type(ActionType::FLED);
fleeResult.mutable_player()->set_value(fleeingPlayerId);
fleeResult.mutable_actor()->set_value(unit->unit_id());
auto* resolvedUnit = fleeResult.add_resolved_units();
AddUnitToResolved(*resolvedUnit, *unit);
resolvedUnit->set_status(net::eagle0::shardok::storage::ResolvedUnit_UnitStatus_FLED_UNIT);
results.push_back(fleeResult);
}
return results;
}
auto TutorialBattleController::ExecuteReinforcementsAction(
const ReinforcementsActionProto& reinforcementsAction) const -> std::vector<ActionResult> {
std::vector<ActionResult> results;
// Add reinforcements arrival notification if there are units
if (!reinforcementsAction.units().empty()) {
ActionResult reinforcementsResult{};
reinforcementsResult.set_type(ActionType::TUTORIAL_REINFORCEMENTS_ARRIVED);
results.push_back(reinforcementsResult);
}
return results;
}
} // namespace shardok
@@ -1,102 +0,0 @@
//
// TutorialBattleController.hpp
// eagle0
//
// Created for tutorial battle system.
// Copyright 2025 none. All rights reserved.
//
#ifndef TutorialBattleController_hpp
#define TutorialBattleController_hpp
#include <set>
#include <string>
#include <vector>
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/common/tutorial_battle_config.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/storage/action_result.pb.h"
namespace shardok {
using ActionResult = net::eagle0::shardok::storage::ActionResult;
using TutorialBattleConfigProto = net::eagle0::common::TutorialBattleConfig;
using TutorialEventProto = net::eagle0::common::TutorialEvent;
using TutorialTriggerProto = net::eagle0::common::TutorialTrigger;
using TutorialActionProto = net::eagle0::common::TutorialAction;
/// Controller for scripted tutorial battles with event-driven triggers and actions.
/// Events are checked at the end of each round and fire in config order.
/// Each event fires at most once (tracked by event_id).
class TutorialBattleController {
public:
/// Creates a disabled controller.
TutorialBattleController();
/// Creates a controller from proto configuration.
explicit TutorialBattleController(const TutorialBattleConfigProto& protoConfig);
/// Returns true if the controller is enabled for this battle.
[[nodiscard]] auto IsEnabled() const -> bool { return enabled_; }
/// Check all events at end of round, return actions for triggered events.
/// Events fire in config order - first triggered event executes, then next.
/// Each event fires at most once.
[[nodiscard]] auto CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) -> std::vector<ActionResult>;
/// Gets the set of fired event IDs (for testing/debugging).
[[nodiscard]] auto GetFiredEventIds() const -> const std::set<std::string>& {
return firedEventIds_;
}
private:
bool enabled_ = false;
TutorialBattleConfigProto config_;
std::set<std::string> firedEventIds_; // Track which events have fired
/// Evaluates whether a trigger condition is met.
[[nodiscard]] auto EvaluateTrigger(const TutorialTriggerProto& trigger, const GameStateW& state)
const -> bool;
/// Executes an action and returns the resulting action results.
[[nodiscard]] auto ExecuteAction(
const TutorialActionProto& action,
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) const
-> std::vector<ActionResult>;
// Helper methods for trigger evaluation
/// Counts units lost (destroyed, captured, fled, retreated) for a player.
[[nodiscard]] auto CountLostUnits(const GameStateW& state, PlayerId playerId) const -> int;
/// Gets total damage taken (cumulative size reduction) for a player.
[[nodiscard]] auto GetTotalDamageTaken(const GameStateW& state, PlayerId playerId) const -> int;
/// Checks if a specific unit has been killed.
[[nodiscard]] auto IsUnitKilled(
const GameStateW& state,
const net::eagle0::common::UnitKilledTrigger& trigger) const -> bool;
// Helper methods for action execution
/// Executes a flee action for specified units.
[[nodiscard]] auto ExecuteFleeAction(
const net::eagle0::common::FleeAction& fleeAction,
const GameStateW& state) const -> std::vector<ActionResult>;
/// Executes a reinforcements action.
[[nodiscard]] auto ExecuteReinforcementsAction(
const net::eagle0::common::ReinforcementsAction& reinforcementsAction) const
-> std::vector<ActionResult>;
};
} // namespace shardok
#endif /* TutorialBattleController_hpp */
@@ -19,7 +19,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/settings_loader",
"//src/main/cpp/net/eagle0/shardok/util:battalion_type_registrar",
"//src/main/cpp/net/eagle0/shardok/util:map_loader",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_cc_proto",
"//src/main/protobuf/net/eagle0/common:victory_condition_cc_proto",
],
)
@@ -193,7 +193,6 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
mapName,
customMapBytes,
month,
request.tutorial_battle_config(),
request.SerializeAsString());
}
@@ -30,7 +30,6 @@ auto SetUpController(
int month,
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerSetupInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController>;
ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSettings) {
@@ -78,7 +77,6 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
const string &mapName,
const string &customMapBytes,
const int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId {
// Check for existing game with this ID and early-exit
std::shared_lock<std::shared_mutex> lk(runningControllersLock);
@@ -113,7 +111,6 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
month,
units,
playerInfos,
tutorialConfig,
serializedRequest);
return LockedCreateGame(controller, playerSetupInfos, serializedRequest);
@@ -140,7 +137,6 @@ auto SetUpController(
const int month,
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController> {
auto unplacedUnits = std::vector<Unit>();
@@ -159,8 +155,6 @@ auto SetUpController(
gameId,
playerInfos);
engine->SetTutorialBattleConfig(tutorialConfig);
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
}
@@ -17,7 +17,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/common/tutorial_battle_config.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/game_status.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/player_info.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/victory_condition.pb.h"
@@ -26,7 +25,6 @@ class GameStatePersister;
using net::eagle0::shardok::common::GameStatus;
using PlayerInfoProto = net::eagle0::shardok::common::PlayerInfo;
using TutorialBattleConfigProto = net::eagle0::common::TutorialBattleConfig;
using std::string;
using std::vector;
using VictoryConditionProto = net::eagle0::shardok::common::VictoryCondition;
@@ -103,7 +101,6 @@ public:
const string &mapName,
const string &customMapBytes,
int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId;
auto UnlockedCreateFromGameStateBytes(
File diff suppressed because it is too large Load Diff
@@ -272,8 +272,9 @@ namespace Auth {
/// <summary>
/// Start OAuth login with specified provider.
/// In editor: Opens browser and polls for completion.
/// In standalone: Opens browser, quits app, deep link relaunches with token.
/// Opens system browser for user consent, then quits the app.
/// The browser's "Return to Eagle" deep link will relaunch the app
/// with the auth session, which HandleSessionTransferAsync will process.
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
@@ -287,13 +288,6 @@ namespace Auth {
Debug.Log($"[OAuthManager] Opening browser for OAuth: {authUrl}");
Application.OpenURL(authUrl);
#if UNITY_EDITOR
// In editor: poll for OAuth completion since deep links don't work
Debug.Log("[OAuthManager] Editor mode - polling for OAuth completion");
var response = await _authClient.PollForOAuthCompletionAsync(state);
HandleOAuthResponse(response);
return response;
#else
// Quit the app - the browser's "Return to Eagle" deep link will
// relaunch a fresh instance with the auth session code.
// This avoids the dual-instance problem where both the original
@@ -305,7 +299,6 @@ namespace Auth {
// to satisfy the compiler. The new instance will handle auth
// via OnDeepLinkActivated -> HandleSessionTransferAsync.
return null;
#endif
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
@@ -313,28 +306,6 @@ namespace Auth {
}
}
/// <summary>
/// Handle OAuth response from polling (editor mode).
/// </summary>
private void HandleOAuthResponse(CheckOAuthStatusResponse response) {
var providerName = response.User.Provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName,
providerName);
if (response.IsNewUser || string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] New user needs to set display name");
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
Debug.Log($"[OAuthManager] OAuth successful for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
}
}
/// <summary>
/// Set display name for new user after OAuth.
/// </summary>
@@ -131,8 +131,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject availableGamesListArea;
public GameObject availableGamesListItemPrefab;
public GameObject waitingGamesListItemPrefab;
public CreateGameItem createGameItem;
public Button tutorialButton;
public GameObject createGameListItemPrefab;
public DropGameConfirmationPanel dropGameConfirmationPanel;
@@ -143,6 +142,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
List<GameInfo> fetchedRunningGames;
List<AvailableLeader> fetchedNewGameLeaders;
int fetchedMaxPlayerCount;
private EagleConnection eagleConnection;
private PersistentClientConnection _persistentClientConnection;
private HttpClient _httpClient;
@@ -411,25 +411,27 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void SetupLobbyUI() {
customBattleButton.onClick.AddListener(CustomBattle);
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
tutorialButton.gameObject.SetActive(TokenStorage.DisplayName == "nolen");
tutorialButton.onClick.AddListener(CreateTutorialGame);
if (customBattleButton != null) { customBattleButton.onClick.AddListener(CustomBattle); }
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
// Set up environment dropdown (only in Unity Editor)
if (!Application.isEditor) {
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
if (lobbyEnvironmentDropdown != null) {
if (!Application.isEditor) {
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
}
}
private void UpdateLobbyStatusDisplays() {
// Update environment dropdown selection
if (_connectedEnvironmentIndex >= 0) {
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
// Temporarily remove listener to avoid triggering reconnect
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
@@ -437,7 +439,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
// Show current user from OAuth
lobbyUserText.text = TokenStorage.DisplayName ?? "";
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
}
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
@@ -685,14 +687,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
fetchedNewGameLeaders = new List<AvailableLeader>();
fetchedNewGameLeaders.AddRange(lobbyResponse.NewGameOptions.AvailableLeaders);
MainQueue.Q.Enqueue(() => {
// Skip lobby UI updates if we're already in an Eagle game.
// The lobby response may arrive after the game has started (e.g., when creating a new
// game), and the connection canvas is inactive, causing GetComponentInParent to fail.
// This check must be inside the queue callback since the state may change between
// when the response arrives and when this callback executes.
if (eagleCanvas.gameObject.activeSelf) return;
fetchedMaxPlayerCount = lobbyResponse.NewGameOptions.MaxSupportedPlayers;
MainQueue.Q.Enqueue(() => {
ResourceFetcher.headshotFetcher.Prefetch(
fetchedNewGameLeaders.Select(a => a.ImagePath));
@@ -709,7 +706,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var listItem = Instantiate(
runningGamesListItemPrefab,
runningGamesListArea.transform,
false);
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(
runningGame.GameId,
@@ -726,7 +724,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var listItem = Instantiate(
waitingGamesListItemPrefab,
availableGamesListArea.transform,
false);
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
listItem.GetComponent<WaitingGameItem>().SetWaitingGame(
waitingGame.GameId,
string.Format(
@@ -736,8 +735,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
waitingGame.SelectedLeader);
}
createGameItem.SetCreationOptions(fetchedNewGameLeaders);
createGameItem.CreateCallback = this.CreateGame;
var createGameItem =
Instantiate(createGameListItemPrefab, availableGamesListArea.transform, true);
// var singlePlayerListItem = Instantiate(availableGamesListItemPrefab);
createGameItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
createGameItem.GetComponent<CreateGameItem>().SetCreationOptions(
fetchedNewGameLeaders,
fetchedMaxPlayerCount);
createGameItem.GetComponent<CreateGameItem>().CreateCallback = this.CreateGame;
foreach (var availableGame in lobbyResponse.AvailableGames) {
var listItem = Instantiate(
@@ -871,7 +876,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
};
_persistentClientConnection.Connect();
PersistentClientConnection.Current = _persistentClientConnection;
errorHandler.PersistentClientConnection = _persistentClientConnection;
ResourceFetcher.SetUpConnection(_httpClient);
}
@@ -948,17 +952,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
}
public void CreateTutorialGame() {
// Tutorial is always single-player
TutorialManager.IsMultiplayerGame = false;
// Eagle will set up the hardcoded tutorial scenario when isTutorial is true
var game = _internalCreateGame(
desiredLeaderTextId: "",
humanPlayerCount: 1,
totalPlayerCount: 2,
isTutorial: true);
}
public void QuitButtonClicked() { Application.Quit(); }
public void HandleJoinGameResponse(JoinGameResponse response) {
@@ -976,9 +969,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public void HandleCreateGameResponse(CreateGameResponse response) {
try {
switch (response.Result) {
case JoinGameResult.SuccessJoinGameResult: SelectEagleGame(response.GameId); break;
case JoinGameResult.SuccessJoinGameResult:
SelectEagleGame(response.GameId);
// Automatically start the game if it's one player?
break;
default: Debug.LogError($"Game creation failed: {response.Result}"); break;
default:
// FIXME: display something
break;
}
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
@@ -990,11 +988,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
private async Task<bool> _internalCreateGame(
string desiredLeaderTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorial = false) {
private async Task<bool>
_internalCreateGame(string desiredLeaderTextId, int humanPlayerCount, int totalPlayerCount) {
try {
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest {
@@ -1002,8 +997,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
new CreateGameRequest {
DesiredLeaderTextId = desiredLeaderTextId,
TotalPlayerCount = totalPlayerCount,
HumanPlayerCount = humanPlayerCount,
IsTutorial = isTutorial
HumanPlayerCount = humanPlayerCount
}
});
} catch (WebException e) {
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 5315bdd018d9e420dbbf55a0508a7f14
guid: 81b03eaddf4de4a4aa385617e4881511
PrefabImporter:
externalObjects: {}
userData:
@@ -11,10 +11,9 @@ using UnityEngine.UI;
using Random = System.Random;
public class CreateGameItem : MonoBehaviour {
private const int TotalPlayerCount = 7;
public HeroDropdownController heroDropdownController;
public TMP_Dropdown totalHumansDropdown;
public TMP_Dropdown totalPlayersDropdown;
public Button createButton;
public TextMeshProUGUI buttonText;
@@ -33,20 +32,30 @@ public class CreateGameItem : MonoBehaviour {
}
}
// Use this for initialization
void Start() {}
public void LeaderDropdownSelected(TMP_Dropdown dropdown) {}
public void TotalPlayersDropdownSelected(TMP_Dropdown dropdown) { SetHumanPlayersDropdown(); }
public void CreateClicked() {
CreateCallback(SelectedLeaderTextId, totalHumansDropdown.value + 1, TotalPlayerCount);
CreateCallback(
SelectedLeaderTextId,
totalHumansDropdown.value + 1,
totalPlayersDropdown.value + 1);
}
public void SetHumanPlayersDropdown() {
var currentValue = totalHumansDropdown.value;
totalHumansDropdown.ClearOptions();
totalHumansDropdown.AddOptions(Enumerable.Range(1, TotalPlayerCount)
.Select(i => i == 1 ? "1 player" : $"{i} players")
totalHumansDropdown.AddOptions(Enumerable.Range(1, totalPlayersDropdown.value + 1)
.Select(i => i.ToString())
.ToList());
totalHumansDropdown.value = Math.Min(TotalPlayerCount - 1, currentValue);
totalHumansDropdown.value = Math.Min(totalPlayersDropdown.value, currentValue);
}
public void SetCreationOptions(List<AvailableLeader> leaders) {
public void SetCreationOptions(List<AvailableLeader> leaders, int maxPlayerCount) {
this.availableLeaders = leaders;
buttonText.text = "CREATE";
@@ -68,10 +77,16 @@ public class CreateGameItem : MonoBehaviour {
heroDropdownController.PreResolvedNames =
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
.ToDictionary(l => l.NameTextId, l => l.Name);
heroDropdownController.FallbackText = "Random Warlord";
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
heroDropdownController.HeadshotImage.gameObject.SetActive(false);
totalPlayersDropdown.ClearOptions();
totalPlayersDropdown.AddOptions(
Enumerable.Range(1, maxPlayerCount).Select(i => i.ToString()).ToList());
// Default to 7 total players (index 6 = 7 players, so 1 human + 6 AI)
totalPlayersDropdown.value = Math.Min(6, maxPlayerCount - 1);
SetHumanPlayersDropdown();
// Default to 1 human (index 0)
totalHumansDropdown.value = 0;
@@ -631,99 +631,6 @@ MonoBehaviour:
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &5462510071559864326
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7404235247014569559}
- component: {fileID: 7896497708396401296}
- component: {fileID: 6993077635068771622}
- component: {fileID: 1252979331277989500}
m_Layer: 5
m_Name: Profession Image
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7404235247014569559
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
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 &7896497708396401296
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_CullTransparentMesh: 1
--- !u!114 &6993077635068771622
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
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_Texture: {fileID: 2800000, guid: d19d0fed4745dc14fa93e86993f9f2d6, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
--- !u!114 &1252979331277989500
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 32
m_PreferredHeight: 32
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &5643565463360785033
GameObject:
m_ObjectHideFlags: 0
@@ -759,7 +666,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 7404235247014569559}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
@@ -768,8 +674,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: -600}
m_SizeDelta: {x: 0, y: -1080}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1107237753715810996
CanvasRenderer:
@@ -827,7 +733,7 @@ MonoBehaviour:
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 3
m_Spacing: 5
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
@@ -851,7 +757,7 @@ MonoBehaviour:
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
professionImage: {fileID: 6993077635068771622}
goButton: {fileID: 0}
dropButton: {fileID: 4081637582368108930}
--- !u!114 &2208043217657811503
MonoBehaviour:
@@ -10,7 +10,7 @@ public class RunningGameItem : MonoBehaviour {
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public TextMeshProUGUI lastPlayedField;
public RawImage professionImage;
public Button goButton;
public Button dropButton;
public Action<long> GoCallback;
@@ -43,11 +43,6 @@ public class RunningGameItem : MonoBehaviour {
leaderName,
DisplayNames.ProfessionNames[leader.Profession]);
// Set profession image
var textures = gameObject.GetComponentInParent<EagleCommonTextures>();
professionImage.texture = textures.Profession(leader.Profession);
professionImage.color = professionImage.texture != null ? Color.white : Color.clear;
// Display last played time in user's local timezone
if (lastPlayedField != null) {
if (lastPlayedTimestampMillis > 0) {
@@ -1,151 +0,0 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a beasts/wildlife particle effect with circling birds (vultures/crows).
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class BeastsEffect : MonoBehaviour {
[Header("Bird Settings")]
public Texture2D birdTexture;
public Material birdMaterial; // Assign in prefab to ensure shader is included in build
public float birdCount = 5f;
public float birdLifetime = 8f;
public float birdSize = 12f;
public float circleRadius = 35f;
public float circleSpeed = 1f;
public Color birdColor = Color.white;
[Header("Flight Pattern")]
public float verticalOscillation = 10f;
public float radiusVariation = 10f;
private ParticleSystem _birdSystem;
private Material _materialInstance;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
var birdObj = new GameObject("BirdEffect");
birdObj.transform.SetParent(transform, false);
_birdSystem = birdObj.AddComponent<ParticleSystem>();
SetupBirdSystem();
}
private void SetupBirdSystem() {
var main = _birdSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = birdLifetime;
main.startSpeed = 0f;
main.startSize = birdSize;
main.startColor = birdColor;
main.startRotation3D = true;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 10;
var emission = _birdSystem.emission;
emission.enabled = true;
// Emit in bursts to maintain constant population
emission.rateOverTime = 0f;
emission.SetBursts(
new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, (short)birdCount) });
// Start on circle perimeter
var shape = _birdSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Circle;
shape.radius = circleRadius;
shape.radiusThickness = 0f; // Only emit on edge
shape.rotation = new Vector3(-90f, 0f, 0f);
shape.arc = 360f;
shape.arcMode = ParticleSystemShapeMultiModeValue.Random;
// Orbital velocity for circling motion
var velocityOverLifetime = _birdSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
// All axes must use same curve mode (MinMaxCurve)
velocityOverLifetime.orbitalX = new ParticleSystem.MinMaxCurve(0f, 0f);
velocityOverLifetime.orbitalY =
new ParticleSystem.MinMaxCurve(circleSpeed, circleSpeed);
velocityOverLifetime.orbitalZ = new ParticleSystem.MinMaxCurve(0f, 0f);
velocityOverLifetime.radial = new ParticleSystem.MinMaxCurve(0f, 0f);
// Add vertical bobbing with noise
var noise = _birdSystem.noise;
noise.enabled = true;
noise.separateAxes = true;
noise.strengthX = radiusVariation;
noise.strengthY = verticalOscillation;
noise.strengthZ = 0f;
noise.frequency = 0.3f;
noise.scrollSpeed = 0.5f;
noise.damping = false;
noise.octaveCount = 1;
// Rotate to face direction of travel
var rotationOverLifetime = _birdSystem.rotationOverLifetime;
rotationOverLifetime.enabled = true;
rotationOverLifetime.separateAxes = true;
// Slight banking as they turn
rotationOverLifetime.z = new ParticleSystem.MinMaxCurve(-0.1f, 0.1f);
// No fade - birds stay visible while circling
var colorOverLifetime = _birdSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.05f),
new GradientAlphaKey(1f, 0.95f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _birdSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use billboard facing camera
renderer.renderMode = ParticleSystemRenderMode.Billboard;
renderer.alignment = ParticleSystemRenderSpace.View;
// Use provided material if available (ensures shader is in build)
if (birdMaterial != null) {
_materialInstance = new Material(birdMaterial);
if (birdTexture != null) { _materialInstance.mainTexture = birdTexture; }
renderer.material = _materialInstance;
} else {
// Fallback for editor testing
var particleShader = Shader.Find("Particles/Standard Unlit");
if (particleShader != null) {
_materialInstance = new Material(particleShader);
_materialInstance.SetFloat("_ColorMode", 1);
_materialInstance.SetFloat("_Surface", 1);
_materialInstance.SetFloat("_Blend", 0);
_materialInstance.SetInt(
"_SrcBlend",
(int)UnityEngine.Rendering.BlendMode.SrcAlpha);
_materialInstance.SetInt(
"_DstBlend",
(int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
_materialInstance.EnableKeyword("_ALPHABLEND_ON");
_materialInstance.renderQueue = 3000;
if (birdTexture != null) { _materialInstance.mainTexture = birdTexture; }
renderer.material = _materialInstance;
}
}
renderer.sortingOrder = 102;
}
}
void OnDestroy() {
if (_materialInstance != null) { Destroy(_materialInstance); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a5ee82ebc029341c4858ef5b7e7cf201
@@ -1,220 +0,0 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a blizzard particle effect with falling snowflakes.
/// Uses province masking shader to clip particles to province boundaries.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class BlizzardEffect : MonoBehaviour {
[Header("Snowflake Settings")]
public Texture2D snowflakeTexture;
public Material particleMaterial; // Assign in prefab to ensure shader is in build
public float snowEmitRate = 40f; // More particles
public float snowFallSpeed = 50f;
public float snowLifetime = 4f; // Longer lifetime
public float snowSize = 10f; // Slightly larger
public float snowDrift = 15f; // More drift
public Color snowColor = Color.white;
[Header("Wind Settings")]
public float windStrength = 5f;
public float windVariation = 3f;
[Header("Province Masking")]
public Texture2D provinceIdMap;
public int targetProvinceId;
private ParticleSystem _snowSystem;
private Material _material;
private RectTransform _mapContainer;
private Canvas _canvas;
private Vector3 _baseShapeScale;
private Vector3 _baseShapePosition;
private float _baseEmitRate;
private float _lastZoom = 1f;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
var snowObj = new GameObject("SnowEffect");
snowObj.transform.SetParent(transform, false);
_snowSystem = snowObj.AddComponent<ParticleSystem>();
SetupSnowSystem();
}
private void SetupSnowSystem() {
var main = _snowSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = snowLifetime;
main.startSpeed = 0f;
main.startSize = new ParticleSystem.MinMaxCurve(snowSize * 0.5f, snowSize);
main.startColor = snowColor;
main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f);
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 300; // More particles for full coverage
var emission = _snowSystem.emission;
emission.enabled = true;
emission.rateOverTime = snowEmitRate;
_baseEmitRate = snowEmitRate;
// Emit across full province height - particles spawn at varying Y positions
var shape = _snowSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Box;
shape.scale = new Vector3(150f, 150f, 1f); // Wide and very tall emission area
shape.position = new Vector3(0f, 40f, 0f); // Centered above effect origin
// Store base values for zoom compensation
_baseShapeScale = shape.scale;
_baseShapePosition = shape.position;
// Fall downward with horizontal drift
var velocityOverLifetime = _snowSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(
-snowDrift - windStrength,
snowDrift + windStrength);
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-snowFallSpeed, -snowFallSpeed);
velocityOverLifetime.z = new ParticleSystem.MinMaxCurve(0f, 0f);
// Gentle rotation as they fall
var rotationOverLifetime = _snowSystem.rotationOverLifetime;
rotationOverLifetime.enabled = true;
rotationOverLifetime.z = new ParticleSystem.MinMaxCurve(-0.5f, 0.5f);
// Use noise for fluttering motion
var noise = _snowSystem.noise;
noise.enabled = true;
noise.strength = windVariation;
noise.frequency = 0.5f;
noise.scrollSpeed = 0.2f;
noise.damping = false;
// Fade in and out
var colorOverLifetime = _snowSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.1f),
new GradientAlphaKey(1f, 0.8f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _snowSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use provided material if available (ensures shader is in build)
if (particleMaterial != null) {
_material = new Material(particleMaterial);
_material.renderQueue = 3000;
if (snowflakeTexture != null) { _material.mainTexture = snowflakeTexture; }
renderer.material = _material;
} else {
// Fallback for editor testing
string[] shaderNames = {
"Eagle/ProvinceParticle",
"Universal Render Pipeline/Particles/Unlit",
"Particles/Standard Unlit",
"Legacy Shaders/Particles/Alpha Blended"
};
Shader shader = null;
foreach (var name in shaderNames) {
shader = Shader.Find(name);
if (shader != null) break;
}
if (shader != null) {
_material = new Material(shader);
_material.renderQueue = 3000;
if (snowflakeTexture != null) { _material.mainTexture = snowflakeTexture; }
renderer.material = _material;
} else {
Debug.LogWarning("BlizzardEffect: No particle shader found");
}
}
renderer.sortingOrder = 100;
}
}
public void SetProvinceId(int provinceId) {
targetProvinceId = provinceId;
if (_material != null) { _material.SetFloat("_TargetProvinceId", provinceId); }
}
public void SetProvinceIdMap(Texture2D idMap) {
provinceIdMap = idMap;
if (_material != null) { _material.SetTexture("_ProvinceIDMap", idMap); }
}
public void SetMapContainer(RectTransform container) {
_mapContainer = container;
_canvas = container?.GetComponentInParent<Canvas>();
}
void LateUpdate() {
UpdateMapBounds();
UpdateZoomCompensation();
}
private void UpdateMapBounds() {
if (_mapContainer == null || _material == null) { return; }
Vector3[] corners = new Vector3[4];
_mapContainer.GetWorldCorners(corners);
Camera cam = _canvas?.worldCamera ?? Camera.main;
Vector3 screenBL, screenTR;
if (_canvas != null && _canvas.renderMode == RenderMode.ScreenSpaceOverlay) {
screenBL = corners[0];
screenTR = corners[2];
} else {
screenBL = cam != null ? cam.WorldToScreenPoint(corners[0]) : corners[0];
screenTR = cam != null ? cam.WorldToScreenPoint(corners[2]) : corners[2];
}
var mapBounds = new Vector4(
screenBL.x,
screenBL.y,
screenTR.x - screenBL.x,
screenTR.y - screenBL.y);
_material.SetVector("_MapBounds", mapBounds);
}
private void UpdateZoomCompensation() {
if (_mapContainer == null || _snowSystem == null) { return; }
float zoom = _mapContainer.localScale.x;
if (Mathf.Approximately(zoom, _lastZoom)) { return; }
_lastZoom = zoom;
// Counter-scale particle system to maintain screen-space appearance
float inverseZoom = 1f / zoom;
_snowSystem.transform.localScale = Vector3.one * inverseZoom;
// Expand emission area to still cover the province
var shape = _snowSystem.shape;
shape.scale = _baseShapeScale * zoom;
shape.position = _baseShapePosition * zoom;
// Increase emission rate to maintain screen-space density (area scales as zoom²)
var emission = _snowSystem.emission;
emission.rateOverTime = _baseEmitRate * zoom * zoom;
}
void OnDestroy() {
if (_material != null) { Destroy(_material); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c15980e88e57845ce920e43f29d35fcc
@@ -264,16 +264,15 @@ namespace eagle {
}
private int Cost(List<int> armaments) {
int totalCost = 0;
double totalCost = 0.0;
for (int i = 0; i < armaments.Count; i++) {
var battalion = _battalions[i];
var armamentDelta = Math.Max(armaments[i] - battalion.Armament.Value, 0.0);
// Ceiling per-battalion to match server calculation
totalCost += (int)Math.Ceiling(_costs[i] * armamentDelta);
totalCost += _costs[i] * Math.Max(armaments[i] - battalion.Armament.Value, 0.0);
}
return totalCost;
return (int)Math.Ceiling(Math.Max(totalCost, 0.0));
}
private int Cost() { return Cost(_newArmaments); }
@@ -45,9 +45,14 @@ namespace eagle {
public Toggle demandTributeToggle;
public Toggle withdrawToggle;
public Toggle safePassageToggle;
public ToggleGroup decisionToggleGroup;
public GameObject tributeContainer;
public GameObject advanceImage;
public GameObject demandTributeImage;
public GameObject withdrawImage;
public GameObject safePassageImage;
public Image goldIcon;
public Image foodIcon;
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldLabel;
@@ -135,10 +140,21 @@ namespace eagle {
}
}
ConfigureToggle(advanceToggle, enableAttack);
ConfigureToggle(withdrawToggle, enableWithdraw);
ConfigureToggle(demandTributeToggle, enableDemandTribute);
ConfigureToggle(safePassageToggle, enableSafePassage);
advanceToggle.enabled = enableAttack;
advanceToggle.gameObject.SetActive(enableAttack);
advanceImage.SetActive(enableAttack);
withdrawToggle.enabled = enableWithdraw;
withdrawToggle.gameObject.SetActive(enableWithdraw);
withdrawImage.SetActive(enableWithdraw);
demandTributeToggle.enabled = enableDemandTribute;
demandTributeToggle.gameObject.SetActive(enableDemandTribute);
demandTributeImage.SetActive(enableDemandTribute);
safePassageToggle.enabled = enableSafePassage;
safePassageToggle.gameObject.SetActive(enableSafePassage);
safePassageImage.SetActive(enableSafePassage);
goldSlider.minValue = 0;
goldSlider.maxValue = maxGoldTribute;
@@ -186,7 +202,14 @@ namespace eagle {
foodLabel.text = ((int)newValue).ToString();
}
private void EnableSliders(bool newEnabled) { tributeContainer.SetActive(newEnabled); }
private void EnableSliders(bool newEnabled) {
goldIcon.gameObject.SetActive(newEnabled);
foodIcon.gameObject.SetActive(newEnabled);
goldSlider.gameObject.SetActive(newEnabled);
foodSlider.gameObject.SetActive(newEnabled);
goldLabel.gameObject.SetActive(newEnabled);
foodLabel.gameObject.SetActive(newEnabled);
}
public void ToggleChanged(bool value) {
bool enableSliders =
@@ -195,24 +218,5 @@ namespace eagle {
EnableSliders(enableSliders);
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
}
}
}
@@ -23,7 +23,6 @@ namespace eagle {
public Toggle AdvanceToggle;
public Toggle WithdrawToggle;
public ToggleGroup decisionToggleGroup;
private FreeForAllDecisionAvailableCommand FreeForAllDecisionCommand =>
_availableCommand.FreeForAllDecisionCommand;
@@ -64,32 +63,10 @@ namespace eagle {
row.Origin = _model.Provinces[army.OriginProvinceId].Name;
});
bool withdrawAvailable = FreeForAllDecisionCommand.AvailableDecisions.Any(
x => x.SealedValueCase == AttackDecisionType.SealedValueOneofCase.Withdraw);
ConfigureToggle(AdvanceToggle, true);
ConfigureToggle(WithdrawToggle, withdrawAvailable);
AdvanceToggle.isOn = true;
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
AdvanceToggle.enabled = true;
WithdrawToggle.enabled = FreeForAllDecisionCommand.AvailableDecisions.Any(
x => x.SealedValueCase == AttackDecisionType.SealedValueOneofCase.Withdraw);
}
// Use this for initialization
@@ -15,7 +15,12 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public ToggleGroup decisionToggleGroup;
public RawImage RecruitIcon;
public RawImage ImprisonIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public HeroDetailsController heroDetails;
@@ -90,54 +95,51 @@ namespace eagle {
messageUpdater.TextId = SelectedHeroWithOptions.MessageId;
var options = SelectedHeroWithOptions.Options;
ConfigureToggle(
RecruitToggle,
options.Contains(CapturedHeroOption.RecruitCapturedHeroOption));
ConfigureToggle(
ImprisonToggle,
options.Contains(CapturedHeroOption.ImprisonCapturedHeroOption));
ConfigureToggle(
ExileToggle,
options.Contains(CapturedHeroOption.ExileCapturedHeroOption));
ConfigureToggle(
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
options.Contains(CapturedHeroOption.ExecuteCapturedHeroOption));
ConfigureToggle(
ExecuteIcon,
CapturedHeroOption.ExecuteCapturedHeroOption);
ConditionallyEnable(ExileToggle, ExileIcon, CapturedHeroOption.ExileCapturedHeroOption);
ConditionallyEnable(
ReturnToggle,
options.Contains(CapturedHeroOption.ReturnCapturedHeroOption));
// Select first available option (priority order: Recruit > Imprison > Exile > Execute >
// Return)
if (options.Contains(CapturedHeroOption.RecruitCapturedHeroOption))
RecruitToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ImprisonCapturedHeroOption))
ImprisonToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ExileCapturedHeroOption))
ExileToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ExecuteCapturedHeroOption))
ExecuteToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ReturnCapturedHeroOption))
ReturnToggle.isOn = true;
ReturnIcon,
CapturedHeroOption.ReturnCapturedHeroOption);
ConditionallyEnable(
ImprisonToggle,
ImprisonIcon,
CapturedHeroOption.ImprisonCapturedHeroOption);
ConditionallyEnable(
RecruitToggle,
RecruitIcon,
CapturedHeroOption.RecruitCapturedHeroOption);
}
private const float DisabledAlpha = 0.35f;
private bool ConditionallyEnable(Toggle toggle, CapturedHeroOption option) {
bool enable = SelectedHeroWithOptions.Options.Contains(option);
toggle.gameObject.SetActive(enable);
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
ImprisonToggle.isOn = false;
RecruitToggle.isOn = false;
ReturnToggle.isOn = false;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
toggle.isOn = true;
} else
toggle.isOn = false;
if (!available) { toggle.isOn = false; }
return enable;
}
toggle.group = available ? decisionToggleGroup : null;
private void ConditionallyEnable(Toggle toggle, RawImage img, CapturedHeroOption option) {
bool enable = ConditionallyEnable(toggle, option);
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
}
}
}
@@ -15,6 +15,7 @@ namespace eagle {
public class ImproveCommandSelector : CommandSelector {
public HeroDropdownController heroDropdownController;
public Toggle lockToggle;
// Toggle buttons for each improvement type (assign to same ToggleGroup in Unity)
public Toggle economyToggle;
@@ -23,12 +24,6 @@ namespace eagle {
public Toggle devastationToggle;
public ToggleGroup improvementToggleGroup;
// Lock toggles - one per improvement type
public Toggle economyLockToggle;
public Toggle agricultureLockToggle;
public Toggle infrastructureLockToggle;
public Toggle devastationLockToggle;
// Labels showing type name and value
public TMP_Text economyLabel;
public TMP_Text agricultureLabel;
@@ -125,38 +120,6 @@ namespace eagle {
}
}
private Toggle GetLockToggleForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyLockToggle;
case ImprovementType.Agriculture: return agricultureLockToggle;
case ImprovementType.Infrastructure: return infrastructureLockToggle;
case ImprovementType.Devastation: return devastationLockToggle;
default: return null;
}
}
private void UpdateLockToggleVisibility() {
var selectedType = SelectedType;
var types = new[] {
ImprovementType.Economy,
ImprovementType.Agriculture,
ImprovementType.Infrastructure,
ImprovementType.Devastation
};
foreach (var type in types) {
var lockToggle = GetLockToggleForType(type);
if (lockToggle != null) {
// Only show lock for the selected type, hide all others
lockToggle.gameObject.SetActive(type == selectedType);
}
}
}
private void OnImprovementToggleChanged(bool isOn) {
// Only respond when a toggle is turned on (ToggleGroup handles turning off others)
if (isOn) { UpdateLockToggleVisibility(); }
}
private TMP_Text GetLabelForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyLabel;
@@ -242,43 +205,23 @@ namespace eagle {
ImprovementType.Devastation,
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
// Wire up toggle change listeners for lock visibility updates
if (economyToggle != null) {
economyToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
if (agricultureToggle != null) {
agricultureToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
if (infrastructureToggle != null) {
infrastructureToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
if (devastationToggle != null) {
devastationToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
// Initialize all lock toggles to off
if (economyLockToggle != null) economyLockToggle.isOn = false;
if (agricultureLockToggle != null) agricultureLockToggle.isOn = false;
if (infrastructureLockToggle != null) infrastructureLockToggle.isOn = false;
if (devastationLockToggle != null) devastationLockToggle.isOn = false;
// Determine which type to select and whether to lock it
// Determine which type to select
ImprovementType selectedType;
if (improveCommand.LockedType != ImprovementType.None) {
var lockedType = improveCommand.LockedType;
if (improveCommand.AvailableTypes.Contains(lockedType)) {
selectedType = lockedType;
var lockToggle = GetLockToggleForType(lockedType);
if (lockToggle != null) lockToggle.isOn = true;
lockToggle.isOn = true;
} else {
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
} else {
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
SelectType(selectedType);
UpdateLockToggleVisibility();
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -297,35 +240,13 @@ namespace eagle {
heroDropdownController.SelectedHeroId = heroId;
}
public override SelectedCommand Command {
get {
var selectedType = SelectedType;
var lockToggle = GetLockToggleForType(selectedType);
return new SelectedCommand {
ImproveCommand =
new ImproveSelectedCommand {
ImprovementType = selectedType,
ActingHeroId = heroDropdownController.SelectedHeroId,
LockType = lockToggle != null && lockToggle.isOn
}
};
}
}
void OnDestroy() {
// Clean up listeners
if (economyToggle != null) {
economyToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
if (agricultureToggle != null) {
agricultureToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
if (infrastructureToggle != null) {
infrastructureToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
if (devastationToggle != null) {
devastationToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
}
public override SelectedCommand Command => new SelectedCommand {
ImproveCommand =
new ImproveSelectedCommand {
ImprovementType = SelectedType,
ActingHeroId = heroDropdownController.SelectedHeroId,
LockType = lockToggle.isOn
}
};
}
}
@@ -21,7 +21,12 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public ToggleGroup decisionToggleGroup;
public RawImage ReleaseIcon;
public RawImage MoveIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public TMP_Dropdown moveToDropdown;
@@ -71,33 +76,6 @@ namespace eagle {
public override string CommitWarningText =>
$"This will create a treaty with {_model.FactionName(SelectedHero.FactionId.Value)}";
private List<PrisonerManagementOption> MoveOptions =>
SelectedPrisonerToManage.AvailableOptions
.Where(opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move)
.ToList();
protected override ProvinceId? TargetedProvince {
get {
if (MoveToggle.isOn && moveToDropdown.value >= 0 &&
moveToDropdown.value < MoveOptions.Count) {
return MoveOptions[moveToDropdown.value].Move.ToProvinceId;
}
return null;
}
set {
if (!value.HasValue) return;
var moveOptions = MoveOptions;
int index = moveOptions.FindIndex(opt => opt.Move.ToProvinceId == value.Value);
if (index != -1) {
MoveToggle.isOn = true;
moveToDropdown.value = index;
moveToDropdown.gameObject.SetActive(true);
}
}
}
private PrisonerManagementOption SelectedOption {
get {
if (ExecuteToggle.isOn)
@@ -165,43 +143,28 @@ namespace eagle {
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
var options = SelectedPrisonerToManage.AvailableOptions;
bool hasRelease = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Release);
bool hasMove = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move);
bool hasExile = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Exile);
bool hasExecute = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Execute);
bool hasReturn = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Return);
ConfigureToggle(ReleaseToggle, hasRelease);
ConfigureToggle(MoveToggle, hasMove);
ConfigureToggle(ExileToggle, hasExile);
ConfigureToggle(ExecuteToggle, hasExecute);
ConfigureToggle(ReturnToggle, hasReturn);
// Select first available option (priority order: Release > Move > Exile > Execute >
// Return)
if (hasRelease) ReleaseToggle.isOn = true;
else if (hasMove)
MoveToggle.isOn = true;
else if (hasExile)
ExileToggle.isOn = true;
else if (hasExecute)
ExecuteToggle.isOn = true;
else if (hasReturn)
ReturnToggle.isOn = true;
// Update dropdown visibility based on selected toggle
moveToDropdown.gameObject.SetActive(MoveToggle.isOn);
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
ExecuteIcon,
PrisonerManagementOption.SealedValueOneofCase.Execute);
ConditionallyEnable(
ExileToggle,
ExileIcon,
PrisonerManagementOption.SealedValueOneofCase.Exile);
ConditionallyEnable(
ReturnToggle,
ReturnIcon,
PrisonerManagementOption.SealedValueOneofCase.Return);
ConditionallyEnable(
MoveToggle,
MoveIcon,
PrisonerManagementOption.SealedValueOneofCase.Move);
ConditionallyEnable(
ReleaseToggle,
ReleaseIcon,
PrisonerManagementOption.SealedValueOneofCase.Release);
}
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
@@ -219,7 +182,7 @@ namespace eagle {
if (!matches) continue;
var heroName = GetHeroDisplayName(uh.HeroId);
questMessages.Add($"{heroName} wants you to {actionVerb}.");
questMessages.Add($"{heroName} wants you to {actionVerb} this prisoner.");
}
}
@@ -231,24 +194,24 @@ namespace eagle {
switch (details.SealedValueCase) {
case QuestDetails.SealedValueOneofCase.ExecutePrisonerQuest:
if (details.ExecutePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "execute this prisoner");
return (true, "execute");
}
break;
case QuestDetails.SealedValueOneofCase.ExilePrisonerQuest:
if (details.ExilePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "exile this prisoner");
return (true, "exile");
}
break;
case QuestDetails.SealedValueOneofCase.FreePrisonerQuest:
if (details.FreePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "release this prisoner");
return (true, "release");
}
break;
case QuestDetails.SealedValueOneofCase.ReturnPrisonerQuest:
if (details.ReturnPrisonerQuest.PrisonerHeroId == prisonerId) {
var factionName =
_model.FactionName(details.ReturnPrisonerQuest.ToFactionId);
return (true, $"return this prisoner to {factionName}");
return (true, $"return to {factionName}");
}
break;
}
@@ -262,23 +225,36 @@ namespace eagle {
: "A free hero";
}
private const float DisabledAlpha = 0.35f;
private bool ConditionallyEnable(
Toggle toggle,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = SelectedPrisonerToManage.AvailableOptions.Any(
opt => opt.SealedValueCase == option);
toggle.gameObject.SetActive(enable);
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
MoveToggle.isOn = false;
ReleaseToggle.isOn = false;
ReturnToggle.isOn = false;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
toggle.isOn = true;
} else
toggle.isOn = false;
if (!available) { toggle.isOn = false; }
return enable;
}
toggle.group = available ? decisionToggleGroup : null;
private void ConditionallyEnable(
Toggle toggle,
RawImage img,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = ConditionallyEnable(toggle, option);
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
}
}
}
@@ -40,10 +40,9 @@ namespace eagle {
public TMP_Dropdown fromDropdown;
public TMP_Dropdown toDropdown;
// Columns to show/hide based on layout mode
// Columns to hide in narrow layout mode (users select via side panels instead)
public GameObject heroesColumn;
public GameObject battalionsColumn;
public GameObject battalionsColumnNarrow; // Alternate battalions view for narrow layout
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldTakenLabel;
@@ -303,11 +302,9 @@ namespace eagle {
}
public override void OnLayoutModeChanged() {
// Heroes always visible (user may march from different province than the viewed one)
if (heroesColumn != null) heroesColumn.SetActive(true);
// Toggle between wide and narrow battalion views
// Update hero/battalion column visibility when layout changes
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
if (battalionsColumnNarrow != null) battalionsColumnNarrow.SetActive(IsNarrowLayout);
}
protected override void SetUpUI() {
@@ -16,7 +16,6 @@ namespace eagle {
public class EventBasedUnitSelector : MonoBehaviour {
public GameObject availableHeroesContent;
public GameObject availableBattalionsContent;
public GameObject availableBattalionsContentNarrow; // Optional alternate for narrow layout
public GameObject selectedUnitsContent;
public GameObject availableHeroRow;
@@ -199,20 +198,10 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
PopulateBattalionsContent(availableBattalionsContent, oddColor);
if (availableBattalionsContentNarrow != null) {
PopulateBattalionsContent(availableBattalionsContentNarrow, oddColor);
foreach (Transform row in availableBattalionsContent.transform) {
Destroy(row.gameObject);
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
private void PopulateBattalionsContent(GameObject content, Color oddColor) {
foreach (Transform row in content.transform) { Destroy(row.gameObject); }
var parentTransform = content.transform;
parentTransform = availableBattalionsContent.transform;
for (int i = 0; i < AvailableBattalions.Count; i++) {
var battalion = AvailableBattalions[i];
@@ -228,6 +217,10 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
public void availableHeroButtonClicked(int index) {
@@ -23,9 +23,7 @@ namespace eagle {
/// Attach to a TextMeshProUGUI component to display status.
/// </summary>
public class ConnectionStatusUI : MonoBehaviour {
[Header("UI References")]
public TextMeshProUGUI statusText;
private TextMeshProUGUI _textComponent;
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
private string _environmentName;
@@ -41,8 +39,10 @@ namespace eagle {
private float _timeSinceLastUpdate = 0f;
void Start() {
if (statusText == null) {
Debug.LogError("ConnectionStatusUI: statusText reference not assigned");
_textComponent = GetComponent<TextMeshProUGUI>();
if (_textComponent == null) {
Debug.LogError(
"ConnectionStatusUI must be attached to a TextMeshProUGUI component");
enabled = false;
return;
}
@@ -78,7 +78,7 @@ namespace eagle {
}
void Update() {
if (_connection == null || statusText == null) { return; }
if (_connection == null || _textComponent == null) { return; }
_timeSinceLastUpdate += Time.deltaTime;
if (_timeSinceLastUpdate < UpdateInterval) { return; }
@@ -97,15 +97,16 @@ namespace eagle {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
statusText.text = $"<color=red>●</color> Server down. Retry in {seconds}s";
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
return;
}
}
statusText.text = "<color=red>●</color> Server unavailable";
_textComponent.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
statusText.text = "<color=yellow>●</color> Testing connection...";
_textComponent.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -118,7 +119,7 @@ namespace eagle {
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string displayText = state switch {
string statusText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
@@ -127,7 +128,7 @@ namespace eagle {
_ => "<color=gray>●</color> Unknown"
};
statusText.text = displayText;
_textComponent.text = statusText;
}
private string GetConnectedStatusText() {
@@ -1,121 +0,0 @@
using TMPro;
using UnityEngine;
namespace eagle {
/// <summary>
/// Curves TextMeshPro text along an arc.
/// Attach to a GameObject with TextMeshProUGUI component.
/// </summary>
[RequireComponent(typeof(TextMeshProUGUI))]
public class CurvedText : MonoBehaviour {
[Tooltip(
"Curvature amount. Positive curves upward (convex), negative curves downward (concave). 0 = no curve.")]
public float curvature = 0f;
private TextMeshProUGUI _textComponent;
private bool _needsUpdate = true;
void Awake() { _textComponent = GetComponent<TextMeshProUGUI>(); }
void OnEnable() {
TMPro_EventManager.TEXT_CHANGED_EVENT.Add(OnTextChanged);
_needsUpdate = true;
}
void OnDisable() { TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(OnTextChanged); }
void OnTextChanged(Object obj) {
if (obj == _textComponent) { _needsUpdate = true; }
}
void LateUpdate() {
if (_needsUpdate && _textComponent != null) {
_needsUpdate = false;
ApplyCurve();
}
}
public void SetCurvature(float newCurvature) {
curvature = newCurvature;
_needsUpdate = true;
}
private void ApplyCurve() {
if (Mathf.Approximately(curvature, 0f)) { return; }
_textComponent.ForceMeshUpdate();
TMP_TextInfo textInfo = _textComponent.textInfo;
if (textInfo == null || textInfo.characterCount == 0) { return; }
// Find the bounds of all characters
float minX = float.MaxValue;
float maxX = float.MinValue;
for (int i = 0; i < textInfo.characterCount; i++) {
if (!textInfo.characterInfo[i].isVisible) { continue; }
var charInfo = textInfo.characterInfo[i];
minX = Mathf.Min(minX, charInfo.bottomLeft.x);
maxX = Mathf.Max(maxX, charInfo.bottomRight.x);
}
float textWidth = maxX - minX;
if (textWidth <= 0) { return; }
float centerX = (minX + maxX) / 2f;
// Apply curve to each character
for (int i = 0; i < textInfo.characterCount; i++) {
if (!textInfo.characterInfo[i].isVisible) { continue; }
var charInfo = textInfo.characterInfo[i];
int materialIndex = charInfo.materialReferenceIndex;
int vertexIndex = charInfo.vertexIndex;
Vector3[] vertices = textInfo.meshInfo[materialIndex].vertices;
// Get character center X position
float charCenterX = (vertices[vertexIndex].x + vertices[vertexIndex + 2].x) / 2f;
// Calculate normalized position (-1 to 1) from center
float normalizedX = (charCenterX - centerX) / (textWidth / 2f);
// Calculate Y offset using parabola: y = -curvature * (1 - x^2)
// Negated because Unity Y is up, but our curvature convention has positive = convex
// down. Scale by 0.25 to reduce intensity.
float scaledCurvature = curvature * 0.25f;
float yOffset = -scaledCurvature * (1f - normalizedX * normalizedX);
// Calculate rotation angle based on tangent of the curve
// Derivative of y = -c * (1 - x^2) is dy/dx = 2cx
float tangentSlope = 2f * scaledCurvature * normalizedX / (textWidth / 2f);
float angle = Mathf.Atan(tangentSlope) * Mathf.Rad2Deg;
// Apply transformation to all 4 vertices of the character
Vector3 charCenter = new Vector3(
charCenterX,
(vertices[vertexIndex].y + vertices[vertexIndex + 2].y) / 2f,
0);
for (int j = 0; j < 4; j++) {
Vector3 offset = vertices[vertexIndex + j] - charCenter;
// Rotate around character center
float rad = angle * Mathf.Deg2Rad;
float rotatedX = offset.x * Mathf.Cos(rad) - offset.y * Mathf.Sin(rad);
float rotatedY = offset.x * Mathf.Sin(rad) + offset.y * Mathf.Cos(rad);
vertices[vertexIndex + j] =
charCenter + new Vector3(rotatedX, rotatedY + yOffset, offset.z);
}
}
// Update the mesh
for (int i = 0; i < textInfo.meshInfo.Length; i++) {
textInfo.meshInfo[i].mesh.vertices = textInfo.meshInfo[i].vertices;
_textComponent.UpdateGeometry(textInfo.meshInfo[i].mesh, i);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 299f66fc896a34cdeb95e92e927a6f10
@@ -15,7 +15,6 @@ namespace eagle {
}
public static readonly Dictionary<Profession, string> ProfessionNames = new() {
{ Profession.UnknownProfession, "Unknown" },
{ Profession.NoProfession, "None" },
{ Profession.Engineer, "Engineer" },
{ Profession.Mage, "Mage" },
@@ -26,7 +25,6 @@ namespace eagle {
};
public static readonly Dictionary<Profession, string> ShortProfessionNames = new() {
{ Profession.UnknownProfession, "?" },
{ Profession.NoProfession, "None" },
{ Profession.Engineer, "Engr" },
{ Profession.Mage, "Mage" },
@@ -106,9 +104,6 @@ namespace eagle {
return $"{name} is unlikely to stay here long enough to impress with a quest.";
case RecruitmentStatus.Sworn: return $"{name} is dedicated to another faction.";
case RecruitmentStatus.WouldNeverJoin:
return $"{name} would never join your faction.";
}
return $"Unknown!";
@@ -133,8 +128,6 @@ namespace eagle {
case RecruitmentStatus.Traveler: return "Traveler";
case RecruitmentStatus.Sworn: return "Dedicated to another faction";
case RecruitmentStatus.WouldNeverJoin: return "Would never join";
}
return $"Unknown!";
@@ -182,7 +175,6 @@ namespace eagle {
case SealedValueOneofCase.WinBattlesQuest: return "Win Battles";
case SealedValueOneofCase.ApprehendOutlawQuest: return "Apprehend Outlaw";
case SealedValueOneofCase.StartBlizzardQuest: return "Start Blizzard";
case SealedValueOneofCase.StartDroughtQuest: return "Start Drought";
case SealedValueOneofCase.StartEpidemicQuest: return "Start Epidemic";
case SealedValueOneofCase.SpendOnFeastsQuest: return "Host Feasts";
case SealedValueOneofCase.SendSuppliesQuest: return "Send Supplies";
@@ -1,152 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
/// <summary>
/// Creates a drought effect with a hot sun and heat shimmer overlay.
/// The heat shimmer distorts the province using a shader that masks to province boundaries.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class DroughtEffect : MonoBehaviour {
[Header("Sun Settings")]
public Sprite sunSprite;
public float sunSize = 40f;
public float sunVerticalOffset = 40f;
public Color sunColor = new Color(1f, 0.9f, 0.3f, 0.9f);
public float sunPulseSpeed = 1.5f;
public float sunPulseAmount = 0.15f;
public float sunWaveAmountX = 8f; // Horizontal wave distance
public float sunWaveAmountY = 5f; // Vertical wave distance
public float sunWaveSpeedX = 0.4f; // Horizontal wave speed
public float sunWaveSpeedY =
0.6f; // Vertical wave speed (slightly different for organic feel)
[Header("Heat Shimmer Settings")]
public Material shimmerMaterial;
public float shimmerSize = 300f; // Large enough to cover province
[Header("Province Masking")]
public Texture2D provinceIdMap;
public int targetProvinceId;
public Vector4 mapBounds; // x=minX, y=minY, z=width, w=height
private GameObject _sunObject;
private Image _sunImage;
private GameObject _shimmerObject;
private RawImage _shimmerImage; // RawImage renders without sprite
private Material _shimmerMatInstance;
private Texture2D _whiteTexture;
private float _baseSunScale;
private Vector2 _baseSunPosition;
void Start() { SetupEffect(); }
private void SetupEffect() {
SetupSun();
// Note: Shimmer disabled - GrabPass doesn't work well with UI Canvas
// TODO: Consider alternative shimmer approach (animated texture overlay)
// SetupShimmer();
}
private void SetupSun() {
_sunObject = new GameObject("Sun");
_sunObject.transform.SetParent(transform, false);
_sunImage = _sunObject.AddComponent<Image>();
if (sunSprite != null) { _sunImage.sprite = sunSprite; }
_sunImage.color = sunColor;
var rectTransform = _sunObject.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(sunSize, sunSize);
_baseSunPosition = new Vector2(0f, sunVerticalOffset);
rectTransform.anchoredPosition = _baseSunPosition;
_baseSunScale = 1f;
}
private void SetupShimmer() {
// Try to find the shader and create material
var shader = Shader.Find("Eagle/HeatShimmer");
if (shader == null) {
Debug.LogWarning("DroughtEffect: Could not find Eagle/HeatShimmer shader");
return;
}
_shimmerObject = new GameObject("HeatShimmer");
_shimmerObject.transform.SetParent(transform, false);
// Put shimmer behind sun
_shimmerObject.transform.SetAsFirstSibling();
// Create a white texture for RawImage (required for rendering)
_whiteTexture = new Texture2D(1, 1);
_whiteTexture.SetPixel(0, 0, Color.white);
_whiteTexture.Apply();
// Use RawImage which renders without needing a sprite
_shimmerImage = _shimmerObject.AddComponent<RawImage>();
_shimmerImage.texture = _whiteTexture;
_shimmerMatInstance = new Material(shader);
_shimmerImage.material = _shimmerMatInstance;
_shimmerImage.color = Color.white;
var rectTransform = _shimmerObject.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(shimmerSize, shimmerSize);
rectTransform.anchoredPosition = Vector2.zero;
// Set shader properties
if (provinceIdMap != null) {
_shimmerMatInstance.SetTexture("_ProvinceIDMap", provinceIdMap);
}
_shimmerMatInstance.SetFloat("_TargetProvinceId", targetProvinceId);
_shimmerMatInstance.SetVector("_MapBounds", mapBounds);
}
public void SetProvinceId(int provinceId) {
targetProvinceId = provinceId;
if (_shimmerMatInstance != null) {
_shimmerMatInstance.SetFloat("_TargetProvinceId", provinceId);
}
}
public void SetProvinceIdMap(Texture2D idMap) {
provinceIdMap = idMap;
if (_shimmerMatInstance != null) {
_shimmerMatInstance.SetTexture("_ProvinceIDMap", idMap);
}
}
public void SetMapBounds(Vector4 bounds) {
mapBounds = bounds;
if (_shimmerMatInstance != null) {
_shimmerMatInstance.SetVector("_MapBounds", bounds);
}
}
public void SetShimmerSize(float size) {
shimmerSize = size;
if (_shimmerObject != null) {
var rt = _shimmerObject.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(size, size);
}
}
void Update() {
if (_sunObject != null) {
// Pulse the sun size
float pulse = 1f + Mathf.Sin(Time.time * sunPulseSpeed) * sunPulseAmount;
_sunObject.transform.localScale = Vector3.one * (_baseSunScale * pulse);
// Wave the sun position slowly
var rectTransform = _sunObject.GetComponent<RectTransform>();
float waveX = Mathf.Sin(Time.time * sunWaveSpeedX) * sunWaveAmountX;
float waveY = Mathf.Sin(Time.time * sunWaveSpeedY) * sunWaveAmountY;
rectTransform.anchoredPosition = _baseSunPosition + new Vector2(waveX, waveY);
}
}
void OnDestroy() {
if (_shimmerMatInstance != null) { Destroy(_shimmerMatInstance); }
if (_whiteTexture != null) { Destroy(_whiteTexture); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f64938ef171de43dba1b0a865fc86f60
@@ -21,15 +21,12 @@ public class EagleCommonTextures : MonoBehaviour {
public Texture2D Profession(Profession profession) {
if (profession == Net.Eagle0.Eagle.Common.Profession.NoProfession)
return professionTextures.Last();
if (profession == Net.Eagle0.Eagle.Common.Profession.UnknownProfession) return null;
return professionTextures[(int)profession];
}
public Texture2D Profession(HeroView.Types.ProfessionSpecificInfo.Types.Profession profession) {
if (profession == HeroView.Types.ProfessionSpecificInfo.Types.Profession.NoProfession)
return professionTextures.Last();
if (profession == HeroView.Types.ProfessionSpecificInfo.Types.Profession.Unknown)
return null;
return professionTextures[(int)profession];
}
@@ -33,6 +33,8 @@ namespace eagle {
[Header("Status Labels")]
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
[Header("Main Panels")]
public ProvinceInfoPanelController provinceInfoPanelController;
@@ -143,6 +145,16 @@ namespace eagle {
if (newWidth == _lastWidth && newHeight == _lastHeight) { return; }
// Initialize ConnectionStatusUI component once when connection is available
if (!_connectionStatusUIInitialized &&
errorHandler.PersistentClientConnection != null) {
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) {
statusUI.SetConnection(errorHandler.PersistentClientConnection);
_connectionStatusUIInitialized = true;
}
}
_lastWidth = newWidth;
_lastHeight = newHeight;
@@ -213,21 +225,12 @@ namespace eagle {
}
SwapModel();
Model = null;
// Clear stale province state from the previous game so it doesn't
// get used with the next game's hero/battalion maps.
provinceInfoPanelController.Province = null;
freeHeroesTableController.CurrentProvince = null;
movingArmiesTableControllerNarrow.CurrentProvince = null;
movingArmiesTableControllerWide.CurrentProvince = null;
factionsTableControllerNarrow.CurrentProvince = null;
factionsTableControllerWide.CurrentProvince = null;
heroesAndBattalionsPanelController.CurrentProvince = null;
chronicleCanvasController.Entries = new List<ChronicleEntry>();
SetMusic();
// Clear game state provider when leaving game
PersistentUIManager.Instance?.connectionStatusUI?.SetGameStateProvider(null);
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) { statusUI.SetGameStateProvider(null); }
}
void MapControllerChangedTarget(List<ProvinceId> newTarget) {
@@ -332,7 +335,7 @@ namespace eagle {
ModelUpdater.ErrorHandler = errorHandler;
// Set up game state provider and environment for connection status UI
var statusUI = PersistentUIManager.Instance?.connectionStatusUI;
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) {
statusUI.SetGameStateProvider(ModelUpdater);
statusUI.SetEnvironment(environmentName);
@@ -792,6 +795,7 @@ namespace eagle {
}
shardokController.SetUpGame(shardokModel);
shardokController.SetConnection(errorHandler.PersistentClientConnection);
gameObject.SetActive(false);
}
@@ -260,7 +260,7 @@ namespace eagle {
if (battleView == null) {
// Battle was removed (e.g., it ended) before we could create the model.
// This is expected when Eagle's RemovedBattleIds update arrives before a
// pending Shardok update - the UI already shows "Exit Battle" via
// pending Shardok update - the UI already shows "Back to Eagle" via
// MarkBattleEnded(), so we just skip this stale update.
return null;
}
@@ -1,60 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 618063871244848148}
m_Layer: 0
m_Name: BeastsEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &618063871244848148
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a5ee82ebc029341c4858ef5b7e7cf201, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.BeastsEffect
birdTexture: {fileID: 2800000, guid: 7b6ab1e235eea4e4e9319c88bb1cdc70, type: 3}
birdMaterial: {fileID: 2100000, guid: 67dcb765c9c324ff9a103624340bc9b5, type: 2}
birdCount: 5
birdLifetime: 8
birdSize: 12
circleRadius: 35
circleSpeed: 1
birdColor: {r: 1, g: 1, b: 1, a: 1}
verticalOscillation: 10
radiusVariation: 10
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 1c093deb39df94fbca5faa9f015a2331
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,63 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 3572684436619911535}
m_Layer: 0
m_Name: BlizzardEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &3572684436619911535
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c15980e88e57845ce920e43f29d35fcc, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.BlizzardEffect
snowflakeTexture: {fileID: 2800000, guid: 9a5b4e8c3d2f6b0a7c1e9d5f8a3b2c4d, type: 3}
particleMaterial: {fileID: 2100000, guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6, type: 2}
snowEmitRate: 15
snowFallSpeed: 40
snowLifetime: 3
snowSize: 8
snowDrift: 10
snowColor: {r: 1, g: 1, b: 1, a: 1}
windStrength: 5
windVariation: 3
provinceIdMap: {fileID: 0}
targetProvinceId: 0
mapBounds: {x: 0, y: 0, z: 0, w: 0}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 0cf4724a235af4fb8abab6456789936a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,61 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: -4077358403987153371}
m_Layer: 0
m_Name: DroughtEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &-4077358403987153371
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f64938ef171de43dba1b0a865fc86f60, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.DroughtEffect
sunSprite: {fileID: 21300000, guid: ecdaab62f65f19745aee51dc3e5be2fc, type: 3}
sunSize: 20
sunVerticalOffset: 30
sunColor: {r: 1, g: 0.9019608, b: 0.29803923, a: 0.68235296}
sunPulseSpeed: 1.5
sunPulseAmount: 0.1
shimmerMaterial: {fileID: 10303, guid: 0000000000000000f000000000000000, type: 0}
shimmerSize: 15
provinceIdMap: {fileID: 0}
targetProvinceId: 0
mapBounds: {x: 0, y: 0, z: 0, w: 0}
@@ -1,63 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 8607881802583290962}
m_Layer: 0
m_Name: EpidemicEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &8607881802583290962
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 727093fab7ebc43dc94ee59675164706, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.EpidemicEffect
skullMaterial: {fileID: 2100000, guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7, type: 2}
skullEmitRate: 1.5
skullRiseSpeed: 25
skullLifetime: 3
skullSize: 25
skullSway: 8
skullColor: {r: 1, g: 1, b: 1, a: 1}
hazeMaterial: {fileID: 2100000, guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7, type: 2}
hazeEmitRate: 10
hazeSpeed: 8
hazeLifetime: 4
hazeSize: 10
hazeColor: {r: 0.4, g: 0.54901963, b: 0.34901962, a: 0.50980395}
@@ -1,61 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &529533522556784756
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7895772391506986011}
- component: {fileID: 4807333260855875379}
m_Layer: 0
m_Name: Fireworks
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7895772391506986011
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 529533522556784756}
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: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &4807333260855875379
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 529533522556784756}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 01a76cb7235d04623be14e4b01f18754, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.FireworksEffect
burstInterval: 1.5
particlesPerBurst: 30
particleLifetime: 1.5
startSpeed: 200
gravity: 100
fireworkColors:
- {r: 1, g: 0.3, b: 0.3, a: 1}
- {r: 1, g: 0.8, b: 0.2, a: 1}
- {r: 0.3, g: 0.8, b: 1, a: 1}
- {r: 0.3, g: 1, b: 0.3, a: 1}
- {r: 1, g: 0.5, b: 1, a: 1}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 27861cc746ec947e88dac44d9a058e10
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,65 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 8954796869750569591}
m_Layer: 0
m_Name: FloodEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &8954796869750569591
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 336c92d7cae77487fa6915c9839d38f6, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.FloodEffect
rainTexture: {fileID: 2800000, guid: 8f4a3e7b2c1d5a9e6b0f8c4d7a2e1b3c, type: 3}
particleMaterial: {fileID: 2100000, guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6, type: 2}
rainEmitRate: 40
rainFallSpeed: 80
rainLifetime: 1.5
rainSize: 4
rainStretch: 3
rainColor: {r: 0.7, g: 0.8, b: 0.9, a: 0.6}
splashEmitRate: 10
splashLifetime: 0.5
splashSize: 8
splashColor: {r: 0.7, g: 0.85, b: 1, a: 0.4}
provinceIdMap: {fileID: 0}
targetProvinceId: 0
mapBounds: {x: 0, y: 0, z: 0, w: 0}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 384a5a10723d4475e8dba09c8ef66eca
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,45 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: ParticleAlphaBlendMaterial
m_Shader: {fileID: 4800000, guid: a60360570f7264533a1146b26d028b00, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords:
- _ALPHABLEND_ON
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 5fb52be5b250f5f42ace466a01c19bed, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ProvinceIDMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _DstBlend: 10
- _InvFade: 1
- _SrcBlend: 5
- _TargetProvinceId: 0
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _TintColor: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -1,40 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: ProvinceParticleMaterial
m_Shader: {fileID: 4800000, guid: a60360570f7264533a1146b26d028b00, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: 3000
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ProvinceIDMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _TargetProvinceId: 0
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -1,206 +0,0 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates an epidemic particle effect with skulls rising and green haze.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class EpidemicEffect : MonoBehaviour {
[Header("Skull Settings")]
public Material skullMaterial;
public float skullEmitRate = 1.5f;
public float skullRiseSpeed = 25f;
public float skullLifetime = 3f;
public float skullSize = 12f;
public float skullSway = 8f;
public Color skullColor = Color.white;
[Header("Haze Settings")]
public Material hazeMaterial;
public float hazeEmitRate = 5f;
public float hazeSpeed = 8f;
public float hazeLifetime = 4f;
public float hazeSize = 40f;
public Color hazeColor = new Color(0.4f, 0.55f, 0.35f, 0.25f);
private ParticleSystem _skullSystem;
private ParticleSystem _hazeSystem;
private Material _skullMatInstance;
private Material _hazeMatInstance;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
// Create haze first (renders behind skulls)
var hazeObj = new GameObject("HazeEffect");
hazeObj.transform.SetParent(transform, false);
_hazeSystem = hazeObj.AddComponent<ParticleSystem>();
SetupHazeSystem();
// Create skull particle system
var skullObj = new GameObject("SkullEffect");
skullObj.transform.SetParent(transform, false);
_skullSystem = skullObj.AddComponent<ParticleSystem>();
SetupSkullSystem();
}
private void SetupSkullSystem() {
var main = _skullSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = skullLifetime;
main.startSpeed = 0f;
main.startSize = skullSize;
main.startColor = skullColor;
main.startRotation = new ParticleSystem.MinMaxCurve(-0.2f, 0.2f); // Slight random tilt
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 20;
var emission = _skullSystem.emission;
emission.enabled = true;
emission.rateOverTime = skullEmitRate;
// Emit from a small area at the base with random direction for sway
var shape = _skullSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Circle;
shape.radius = 15f;
shape.rotation = new Vector3(-90f, 0f, 0f);
shape.randomDirectionAmount = 0.3f; // Add horizontal sway variation
// Rise upward with horizontal drift for swaying motion
var velocityOverLifetime = _skullSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
// Use constant mode for all axes to avoid mode mismatch
velocityOverLifetime.x = skullSway; // More horizontal drift
velocityOverLifetime.y = skullRiseSpeed;
velocityOverLifetime.z = 0f;
// Grow as they rise (start at 1/4 size, end at full size)
var sizeOverLifetime = _skullSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 0.25f), new Keyframe(1f, 1f)));
// Fade out as they rise
var colorOverLifetime = _skullSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 0.7f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.1f),
new GradientAlphaKey(1f, 0.6f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
// Disable rotation over lifetime (causes spinning)
var rotationOverLifetime = _skullSystem.rotationOverLifetime;
rotationOverLifetime.enabled = false;
// Use noise module for gentle rocking/swaying rotation
var noise = _skullSystem.noise;
noise.enabled = true;
noise.separateAxes = true;
noise.strengthX = 0f; // No position noise
noise.strengthY = 0f;
noise.strengthZ = 0f;
noise.rotationAmount = 0.2f; // ~11 degrees of rotation sway
noise.frequency = 0.5f; // Slow oscillation
noise.scrollSpeed = 0f;
noise.damping = false;
noise.octaveCount = 1;
var renderer = _skullSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
if (skullMaterial == null) {
Debug.LogError("[EpidemicEffect] skullMaterial is not assigned!");
return;
}
_skullMatInstance = new Material(skullMaterial);
renderer.material = _skullMatInstance;
renderer.sortingOrder = 101;
}
}
private void SetupHazeSystem() {
var main = _hazeSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = hazeLifetime;
main.startSpeed = hazeSpeed;
main.startSize = new ParticleSystem.MinMaxCurve(hazeSize * 0.5f, hazeSize);
main.startColor = hazeColor;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 30;
var emission = _hazeSystem.emission;
emission.enabled = true;
emission.rateOverTime = hazeEmitRate;
// Emit from ground area, spread outward and up
var shape = _hazeSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Cone;
shape.angle = 40f;
shape.radius = 10f;
shape.rotation = new Vector3(-90f, 0f, 0f);
// Slow down over time
var velocityOverLifetime = _hazeSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.speedModifier = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.2f)));
// Grow and fade
var sizeOverLifetime = _hazeSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 0.5f), new Keyframe(1f, 2f)));
var colorOverLifetime = _hazeSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(0.6f, 0.2f),
new GradientAlphaKey(0.6f, 0.5f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _hazeSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
if (hazeMaterial == null) {
Debug.LogError("[EpidemicEffect] hazeMaterial is not assigned!");
return;
}
_hazeMatInstance = new Material(hazeMaterial);
renderer.material = _hazeMatInstance;
renderer.sortingOrder = 100;
}
}
void OnDestroy() {
if (_skullMatInstance != null) { Destroy(_skullMatInstance); }
if (_hazeMatInstance != null) { Destroy(_hazeMatInstance); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 727093fab7ebc43dc94ee59675164706
@@ -1,217 +0,0 @@
using System.Collections;
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a fireworks particle effect for festival events.
/// Rockets fly upward and burst into colorful explosions.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class FireworksEffect : MonoBehaviour {
[Header("Rocket Settings")]
public float launchInterval = 2f;
public float rocketSpeed = 80f;
public float rocketLifetime = 0.8f;
public float rocketSize = 6f;
[Header("Burst Settings")]
public int burstParticles = 40;
public float burstSpeed = 50f;
public float burstLifetime = 1.2f;
public float burstGravity = 30f;
[Header("Colors")]
public Color[] fireworkColors = {
new Color(1f, 0.3f, 0.3f), // Red
new Color(1f, 0.8f, 0.2f), // Gold
new Color(0.3f, 0.8f, 1f), // Blue
new Color(0.3f, 1f, 0.3f), // Green
new Color(1f, 0.5f, 1f), // Pink
new Color(1f, 1f, 0.4f) // Yellow
};
private ParticleSystem _rocketSystem;
private ParticleSystem _burstSystem;
private float _timer;
private int _colorIndex;
void Awake() {
_colorIndex = Random.Range(0, fireworkColors.Length);
SetupParticleSystems();
}
private void SetupParticleSystems() {
// Create rocket particle system on child GameObject (not on RectTransform)
var rocketObj = new GameObject("RocketEffect");
rocketObj.transform.SetParent(transform, false);
_rocketSystem = rocketObj.AddComponent<ParticleSystem>();
SetupRocketSystem();
// Create burst particle system (separate, not sub-emitter)
var burstObj = new GameObject("BurstEffect");
burstObj.transform.SetParent(transform, false);
_burstSystem = burstObj.AddComponent<ParticleSystem>();
SetupBurstSystem();
StartCoroutine(LaunchSequence());
}
private void SetupRocketSystem() {
var main = _rocketSystem.main;
main.playOnAwake = false;
main.loop = false;
main.startLifetime = rocketLifetime;
main.startSpeed = 0f; // We set velocity explicitly in EmitParams
main.startSize = rocketSize;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 10;
main.startColor = new Color(1f, 0.9f, 0.6f); // Bright orange/yellow
var emission = _rocketSystem.emission;
emission.enabled = false;
// Disable shape - we set velocity explicitly in EmitParams to avoid Z movement
var shape = _rocketSystem.shape;
shape.enabled = false;
// Add slight arc to rocket path (gravity slowing upward movement)
var velocityOverLifetime = _rocketSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-20f); // Slight downward pull
// Rocket trail
var trails = _rocketSystem.trails;
trails.enabled = true;
trails.ratio = 1f;
trails.lifetime = new ParticleSystem.MinMaxCurve(0.4f);
trails.minVertexDistance = 2f;
trails.worldSpace = false;
trails.dieWithParticles = true;
trails.widthOverTrail = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.3f)));
trails.colorOverLifetime = new ParticleSystem.MinMaxGradient(
new Color(1f, 0.8f, 0.3f),
new Color(1f, 0.5f, 0.1f));
var renderer = _rocketSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use a shader that respects vertex colors
var particleShader = Shader.Find("Particles/Standard Unlit");
if (particleShader != null) {
var mat = new Material(particleShader);
mat.SetFloat("_ColorMode", 1); // Multiply mode
renderer.material = mat;
renderer.trailMaterial = mat;
}
renderer.sortingOrder = 100;
}
}
private void SetupBurstSystem() {
var main = _burstSystem.main;
main.playOnAwake = false;
main.loop = false;
main.startLifetime = burstLifetime;
main.startSpeed = burstSpeed;
main.startSize = new ParticleSystem.MinMaxCurve(4f, 7f);
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 200;
var emission = _burstSystem.emission;
emission.enabled = false;
var shape = _burstSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 1f;
// Gravity pulls burst particles down
var velocityOverLifetime = _burstSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-burstGravity);
// Disable colorOverLifetime to preserve startColor from EmitParams
var colorOverLifetime = _burstSystem.colorOverLifetime;
colorOverLifetime.enabled = false;
// Shrink over lifetime
var sizeOverLifetime = _burstSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.2f)));
// Sparkle trails on burst particles
var trails = _burstSystem.trails;
trails.enabled = true;
trails.ratio = 0.4f;
trails.lifetime = new ParticleSystem.MinMaxCurve(0.2f);
trails.minVertexDistance = 1f;
trails.worldSpace = false;
trails.dieWithParticles = true;
trails.widthOverTrail = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 0.6f), new Keyframe(1f, 0f)));
trails.inheritParticleColor = true;
var burstRenderer = _burstSystem.GetComponent<ParticleSystemRenderer>();
if (burstRenderer != null) {
// Use a shader that respects vertex colors
var particleShader = Shader.Find("Particles/Standard Unlit");
if (particleShader != null) {
var mat = new Material(particleShader);
mat.SetFloat("_ColorMode", 1); // Multiply mode
burstRenderer.material = mat;
burstRenderer.trailMaterial = mat;
}
burstRenderer.sortingOrder = 101;
}
}
private IEnumerator LaunchSequence() {
while (true) {
LaunchRocket();
yield return new WaitForSeconds(launchInterval);
}
}
private void LaunchRocket() {
if (_rocketSystem == null || _burstSystem == null) { return; }
Color burstColor = fireworkColors[_colorIndex % fireworkColors.Length];
_colorIndex++;
// Launch rocket with explicit velocity and color
// X: random variation for slight angle, Y: upward, Z: must be negative to render
float xVariation = Random.Range(-15f, 15f);
var rocketParams = new ParticleSystem.EmitParams();
rocketParams.position = Vector3.zero;
rocketParams.velocity = new Vector3(xVariation, rocketSpeed, -1f);
rocketParams.startColor = new Color(1f, 0.9f, 0.5f); // Bright yellow-orange
_rocketSystem.Emit(rocketParams, 1);
// Schedule burst at rocket death position
StartCoroutine(EmitBurstAfterDelay(burstColor));
}
private IEnumerator EmitBurstAfterDelay(Color color) {
// Wait for rocket to reach peak
yield return new WaitForSeconds(rocketLifetime);
// Calculate burst position (where rocket ended)
Vector3 burstPosition = new Vector3(0f, rocketSpeed * rocketLifetime, 0f);
// Move burst system to rocket's end position
_burstSystem.transform.localPosition = burstPosition;
// Emit particles with explicit color using EmitParams
var emitParams = new ParticleSystem.EmitParams();
emitParams.startColor = color;
for (int i = 0; i < burstParticles; i++) { _burstSystem.Emit(emitParams, 1); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 01a76cb7235d04623be14e4b01f18754
@@ -1,316 +0,0 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a flood/rain particle effect with falling raindrops and splashes.
/// Uses province masking shader to clip particles to province boundaries.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class FloodEffect : MonoBehaviour {
[Header("Rain Settings")]
public Texture2D rainTexture;
public Material particleMaterial; // Assign in prefab to ensure shader is in build
public float rainEmitRate = 80f; // More rain
public float rainFallSpeed = 100f;
public float rainLifetime = 2f; // Longer lifetime
public float rainSize = 5f;
public float rainStretch = 4f;
public Color rainColor = new Color(0.7f, 0.8f, 0.9f, 0.6f);
[Header("Splash Settings")]
public float splashEmitRate = 25f; // More splashes
public float splashLifetime = 0.6f;
public float splashSize = 10f;
public Color splashColor = new Color(0.7f, 0.85f, 1f, 0.4f);
[Header("Province Masking")]
public Texture2D provinceIdMap;
public int targetProvinceId;
private ParticleSystem _rainSystem;
private ParticleSystem _splashSystem;
private Material _rainMaterial;
private Material _splashMaterial;
private RectTransform _mapContainer;
private Canvas _canvas;
private Vector3 _baseRainShapeScale;
private Vector3 _baseRainShapePosition;
private float _baseSplashRadius;
private float _baseRainEmitRate;
private float _baseSplashEmitRate;
private float _lastZoom = 1f;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
// Create splash first (renders behind rain)
var splashObj = new GameObject("SplashEffect");
splashObj.transform.SetParent(transform, false);
_splashSystem = splashObj.AddComponent<ParticleSystem>();
SetupSplashSystem();
// Create rain particle system
var rainObj = new GameObject("RainEffect");
rainObj.transform.SetParent(transform, false);
_rainSystem = rainObj.AddComponent<ParticleSystem>();
SetupRainSystem();
}
private void SetupRainSystem() {
var main = _rainSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = rainLifetime;
main.startSpeed = 0f;
main.startSize = rainSize;
main.startColor = rainColor;
main.startRotation = 0f;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 400; // More particles for coverage
var emission = _rainSystem.emission;
emission.enabled = true;
emission.rateOverTime = rainEmitRate;
_baseRainEmitRate = rainEmitRate;
// Emit across full province height - particles spawn at varying Y positions
var shape = _rainSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Box;
shape.scale = new Vector3(150f, 150f, 1f); // Wide and very tall emission area
shape.position = new Vector3(0f, 40f, 0f); // Centered above effect origin
_baseRainShapeScale = shape.scale;
_baseRainShapePosition = shape.position;
// Fall downward with slight angle
var velocityOverLifetime = _rainSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-5f, -5f);
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-rainFallSpeed, -rainFallSpeed);
velocityOverLifetime.z = new ParticleSystem.MinMaxCurve(0f, 0f);
// Fade in at top, stay visible
var colorOverLifetime = _rainSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.1f),
new GradientAlphaKey(1f, 0.9f),
new GradientAlphaKey(0.5f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _rainSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
renderer.renderMode = ParticleSystemRenderMode.Stretch;
renderer.lengthScale = rainStretch;
renderer.velocityScale = 0f;
// Use provided material if available (ensures shader is in build)
if (particleMaterial != null) {
_rainMaterial = new Material(particleMaterial);
_rainMaterial.renderQueue = 3000;
if (rainTexture != null) { _rainMaterial.mainTexture = rainTexture; }
renderer.material = _rainMaterial;
} else {
// Fallback for editor testing
string[] shaderNames = {
"Eagle/ProvinceParticle",
"Universal Render Pipeline/Particles/Unlit",
"Particles/Standard Unlit",
"Legacy Shaders/Particles/Alpha Blended"
};
Shader shader = null;
foreach (var name in shaderNames) {
shader = Shader.Find(name);
if (shader != null) break;
}
if (shader != null) {
_rainMaterial = new Material(shader);
_rainMaterial.renderQueue = 3000;
if (rainTexture != null) { _rainMaterial.mainTexture = rainTexture; }
renderer.material = _rainMaterial;
} else {
Debug.LogWarning("FloodEffect: No particle shader found");
}
}
renderer.sortingOrder = 101;
}
}
private void SetupSplashSystem() {
var main = _splashSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = splashLifetime;
main.startSpeed = new ParticleSystem.MinMaxCurve(5f, 15f);
main.startSize = new ParticleSystem.MinMaxCurve(splashSize * 0.5f, splashSize);
main.startColor = splashColor;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 150; // More splashes
main.gravityModifier = 2f;
var emission = _splashSystem.emission;
emission.enabled = true;
emission.rateOverTime = splashEmitRate;
_baseSplashEmitRate = splashEmitRate;
// Emit from ground area - large radius since shader masks
var shape = _splashSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Circle;
shape.radius = 60f; // Larger splash area
_baseSplashRadius = shape.radius;
shape.rotation = new Vector3(-90f, 0f, 0f);
shape.arc = 180f;
shape.arcMode = ParticleSystemShapeMultiModeValue.Random;
// Quick fade out
var colorOverLifetime = _splashSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0.8f, 0f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
// Shrink as they fade
var sizeOverLifetime = _splashSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.2f)));
var renderer = _splashSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use provided material if available (ensures shader is in build)
if (particleMaterial != null) {
_splashMaterial = new Material(particleMaterial);
_splashMaterial.renderQueue = 3000;
renderer.material = _splashMaterial;
} else {
// Fallback for editor testing
string[] shaderNames = {
"Eagle/ProvinceParticle",
"Universal Render Pipeline/Particles/Unlit",
"Particles/Standard Unlit",
"Legacy Shaders/Particles/Alpha Blended"
};
Shader shader = null;
foreach (var name in shaderNames) {
shader = Shader.Find(name);
if (shader != null) break;
}
if (shader != null) {
_splashMaterial = new Material(shader);
_splashMaterial.renderQueue = 3000;
renderer.material = _splashMaterial;
}
}
renderer.sortingOrder = 100;
}
}
public void SetProvinceId(int provinceId) {
targetProvinceId = provinceId;
if (_rainMaterial != null) { _rainMaterial.SetFloat("_TargetProvinceId", provinceId); }
if (_splashMaterial != null) {
_splashMaterial.SetFloat("_TargetProvinceId", provinceId);
}
}
public void SetProvinceIdMap(Texture2D idMap) {
provinceIdMap = idMap;
if (_rainMaterial != null) { _rainMaterial.SetTexture("_ProvinceIDMap", idMap); }
if (_splashMaterial != null) { _splashMaterial.SetTexture("_ProvinceIDMap", idMap); }
}
public void SetMapContainer(RectTransform container) {
_mapContainer = container;
_canvas = container?.GetComponentInParent<Canvas>();
}
void LateUpdate() {
UpdateMapBounds();
UpdateZoomCompensation();
}
private void UpdateMapBounds() {
if (_mapContainer == null) { return; }
Vector3[] corners = new Vector3[4];
_mapContainer.GetWorldCorners(corners);
Camera cam = _canvas?.worldCamera ?? Camera.main;
Vector3 screenBL, screenTR;
if (_canvas != null && _canvas.renderMode == RenderMode.ScreenSpaceOverlay) {
screenBL = corners[0];
screenTR = corners[2];
} else {
screenBL = cam != null ? cam.WorldToScreenPoint(corners[0]) : corners[0];
screenTR = cam != null ? cam.WorldToScreenPoint(corners[2]) : corners[2];
}
var mapBounds = new Vector4(
screenBL.x,
screenBL.y,
screenTR.x - screenBL.x,
screenTR.y - screenBL.y);
if (_rainMaterial != null) { _rainMaterial.SetVector("_MapBounds", mapBounds); }
if (_splashMaterial != null) { _splashMaterial.SetVector("_MapBounds", mapBounds); }
}
private void UpdateZoomCompensation() {
if (_mapContainer == null) { return; }
float zoom = _mapContainer.localScale.x;
if (Mathf.Approximately(zoom, _lastZoom)) { return; }
_lastZoom = zoom;
float inverseZoom = 1f / zoom;
// Counter-scale rain system
if (_rainSystem != null) {
_rainSystem.transform.localScale = Vector3.one * inverseZoom;
var shape = _rainSystem.shape;
shape.scale = _baseRainShapeScale * zoom;
shape.position = _baseRainShapePosition * zoom;
var emission = _rainSystem.emission;
emission.rateOverTime = _baseRainEmitRate * zoom * zoom;
}
// Counter-scale splash system
if (_splashSystem != null) {
_splashSystem.transform.localScale = Vector3.one * inverseZoom;
var shape = _splashSystem.shape;
shape.radius = _baseSplashRadius * zoom;
var emission = _splashSystem.emission;
emission.rateOverTime = _baseSplashEmitRate * zoom * zoom;
}
}
void OnDestroy() {
if (_rainMaterial != null) { Destroy(_rainMaterial); }
if (_splashMaterial != null) { Destroy(_splashMaterial); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 336c92d7cae77487fa6915c9839d38f6
@@ -9,11 +9,6 @@ namespace eagle {
public class FreeHeroesTableController : MonoBehaviour {
public GameObject panel;
[Header("Hero Backstory Popup")]
public GameObject popupPanel;
public HeroDetailsController popupPanelDetailsController;
public GeneratedTextUpdater popupPanelBackstory;
private ProvinceView _currentProvince;
public ProvinceView CurrentProvince {
get => _currentProvince;
@@ -78,21 +73,6 @@ namespace eagle {
UpdateUnaffiliatedHeroSelections();
}
public void LongHoverRowChanged(int? newIndex) {
if (popupPanel == null) return;
if (newIndex is {} index) {
var heroId = UnaffiliatedHeroes.ElementAt(index).HeroId;
if (Model.Heroes.TryGetValue(heroId, out var hero)) {
popupPanelDetailsController.SetHero(hero, Model);
popupPanelBackstory.TextId = hero.BackstoryTextId;
popupPanel.SetActive(true);
}
} else {
popupPanel.SetActive(false);
}
}
private void setUpUnaffiliatedHeroesTable() {
unaffiliatedHeroesTable.RowCount = UnaffiliatedHeroCount;
@@ -108,10 +88,7 @@ namespace eagle {
});
}
void Start() {
panel.SetActive(Model != null);
if (popupPanel != null) { popupPanel.SetActive(false); }
}
void Start() { panel.SetActive(Model != null); }
private bool HasProvinceInfo =>
(CurrentProvince != null && CurrentProvince.FullInfo != null);
@@ -141,9 +141,31 @@ namespace eagle {
}
public void BattalionLongHoverRowChanged(int? newIndex) {
// Battalion backstory popups disabled - backstory updates are being eliminated
// to reduce LLM request volume. See docs/eliminate-battalion-backstory-updates.md
if (battalionPopupPanel != null) { battalionPopupPanel.SetActive(false); }
if (battalionPopupPanel == null) return;
if (newIndex is {} index) {
var battalion = Battalions.ElementAt(index);
if (!string.IsNullOrEmpty(battalion.BackstoryTextId)) {
// Populate header
if (battalionPopupPanelName != null) {
// Use PrefixedText for plain name; ready for NameTextId when available
battalionPopupPanelName.TextId = "";
battalionPopupPanelName.PrefixedText = battalion.Name;
}
if (battalionPopupPanelTypeIcon != null) {
var textures = GetComponentInParent<EagleCommonTextures>();
battalionPopupPanelTypeIcon.texture =
textures?.BattalionType(battalion.Type);
}
battalionPopupPanelBackstory.TextId = battalion.BackstoryTextId;
battalionPopupPanel.SetActive(true);
} else {
battalionPopupPanel.SetActive(false);
}
} else {
battalionPopupPanel.SetActive(false);
}
}
private void SetUpHeroesTable() {
@@ -4,6 +4,7 @@ using System.Linq;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using ProvinceId = System.Int32;
using FactionId = System.Int32;
@@ -11,15 +12,9 @@ namespace eagle {
public class MapController : MonoBehaviour {
public EagleGameController eagleGameController;
public ProvinceColorManager provinceColorManager;
public RawImage mapBWImage;
public List<RawImage> provinceImagesList;
public ProvinceWeatherController weatherController;
public ProvinceFestivalController festivalController;
public ProvinceEpidemicController epidemicController;
public ProvinceBlizzardController blizzardController;
public ProvinceDroughtController droughtController;
public ProvinceFloodController floodController;
public ProvinceBeastsController beastsController;
public MapPinchZoomHandler zoomHandler;
public TMP_Text provinceNameText;
public TMP_Text provinceOwnerText;
@@ -39,9 +34,16 @@ namespace eagle {
}
private void SetProvinceColor(ProvinceId provinceId, Color color) {
if (provinceColorManager != null) {
provinceColorManager.SetProvinceColor(provinceId, color);
var image = ImageForProvince(provinceId);
if (!image.texture) {
Texture2D texture = new Texture2D(1, 1);
texture.wrapMode = TextureWrapMode.Repeat;
image.texture = texture;
}
Texture2D t2d = (Texture2D)image.texture;
t2d.SetPixel(0, 0, color);
t2d.Apply();
}
private ProvinceId? _selectedProvinceId;
@@ -113,18 +115,16 @@ namespace eagle {
}
if (weatherController != null) { weatherController.UpdateWeatherEffects(value); }
if (festivalController != null) { festivalController.UpdateFestivalEffects(value); }
if (epidemicController != null) { epidemicController.UpdateEpidemicEffects(value); }
if (blizzardController != null) { blizzardController.UpdateBlizzardEffects(value); }
if (droughtController != null) { droughtController.UpdateDroughtEffects(value); }
if (floodController != null) { floodController.UpdateFloodEffects(value); }
if (beastsController != null) { beastsController.UpdateBeastsEffects(value); }
eagleGameController.ProvinceWasSelected(_selectedProvinceId);
}
}
private RawImage ImageForProvince(ProvinceId id) => provinceImagesList[id - 1];
public ProvinceIDLoader provinceIDLoader;
public TextAsset rawGray;
private int _imageWidth;
private int _imageHeight;
private readonly Color _selectionColor = Color.grey;
private readonly Color _targetColor = Color.red;
@@ -147,7 +147,9 @@ namespace eagle {
}
void Start() {
_mapIdBytes = provinceIDLoader.DecompressedBytes;
_mapIdBytes = rawGray.bytes;
_imageWidth = mapBWImage.texture.width;
_imageHeight = mapBWImage.texture.height;
Enabled = true;
_previouslyHoveredProvinceId = null;
}
@@ -156,18 +158,17 @@ namespace eagle {
var mousePosition = new Vector2(point.x, Screen.height - point.y);
var mapRect = gameObject.GetComponentInParent<PanelPositions>().MapRect;
int mapWidth = provinceIDLoader.mapWidth;
int mapHeight = provinceIDLoader.mapHeight;
var relativeX = (int)((mousePosition.x - mapRect.x) * mapWidth / mapRect.width);
var relativeX = (int)((mousePosition.x - mapRect.x) * _imageWidth / mapRect.width);
var relativeY =
(int)(mapHeight - (mousePosition.y - mapRect.y) * mapHeight / mapRect.height);
(int)(_imageHeight -
(mousePosition.y - mapRect.y) * _imageHeight / mapRect.height);
if (relativeX < 0 || relativeX >= mapWidth || relativeY < 0 || relativeY >= mapHeight) {
if (relativeX < 0 || relativeX >= _imageWidth || relativeY < 0 ||
relativeY >= _imageHeight) {
return null;
}
var provinceId = _mapIdBytes[relativeY * mapWidth + relativeX];
var provinceId = _mapIdBytes[relativeY * _imageWidth + relativeX];
if (provinceId == 0 || provinceId == 0xFF) { return null; }
return provinceId;
}
@@ -1,187 +0,0 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace eagle {
/// <summary>
/// Handles pinch-to-zoom and scroll-wheel zoom for a map displayed in a ScrollRect.
/// Attach this to the same GameObject as the ScrollRect.
/// </summary>
[RequireComponent(typeof(ScrollRect))]
public class MapPinchZoomHandler : MonoBehaviour, IScrollHandler {
public float minZoom = 1.0f;
public float maxZoom = 3.0f;
public float scrollZoomSpeed = 0.1f;
public float pinchZoomSpeed = 0.01f;
public float zoomSmoothTime = 0.1f; // Time to smooth zoom changes
private ScrollRect _scrollRect;
private RectTransform _content;
private RectTransform _viewport;
private Canvas _canvas;
private float _currentZoom = 1.0f;
private float _targetZoom = 1.0f;
private float _zoomVelocity = 0f;
private Vector2 _lastZoomCenter;
// For coordinate conversion (used by MapController)
public float CurrentZoom => _currentZoom;
public Vector2 PanOffset {
get {
if (_content == null) return Vector2.zero;
return _content.anchoredPosition;
}
}
// Pinch tracking
private bool _isPinching;
private float _previousPinchDistance;
private Vector2 _previousPinchCenter;
void Awake() {
_scrollRect = GetComponent<ScrollRect>();
_content = _scrollRect.content;
_viewport = _scrollRect.viewport != null ? _scrollRect.viewport
: GetComponent<RectTransform>();
_canvas = GetComponentInParent<Canvas>();
// Disable scrolling at zoom level 1 (no panning needed)
UpdateScrollability();
}
void Update() {
// Handle touch input on iOS
if (Application.platform == RuntimePlatform.IPhonePlayer) { HandleTouchInput(); }
// Smoothly interpolate toward target zoom
if (!Mathf.Approximately(_currentZoom, _targetZoom)) {
float previousZoom = _currentZoom;
_currentZoom = Mathf.SmoothDamp(
_currentZoom,
_targetZoom,
ref _zoomVelocity,
zoomSmoothTime);
// Apply the smoothed zoom
ApplyCurrentZoom(_lastZoomCenter, previousZoom);
}
}
private void HandleTouchInput() {
if (Input.touchCount == 2) {
Touch touch0 = Input.GetTouch(0);
Touch touch1 = Input.GetTouch(1);
float currentDistance = Vector2.Distance(touch0.position, touch1.position);
Vector2 currentCenter = (touch0.position + touch1.position) / 2f;
if (!_isPinching) {
_isPinching = true;
_previousPinchDistance = currentDistance;
_previousPinchCenter = currentCenter;
// Disable scroll rect during pinch to prevent fighting
_scrollRect.enabled = false;
return;
}
// Calculate zoom delta from pinch
float distanceDelta = currentDistance - _previousPinchDistance;
float zoomDelta = distanceDelta * pinchZoomSpeed;
ApplyZoom(zoomDelta, currentCenter);
_previousPinchDistance = currentDistance;
_previousPinchCenter = currentCenter;
} else {
if (_isPinching) {
_isPinching = false;
UpdateScrollability();
}
}
}
// IScrollHandler - captures scroll wheel events
public void OnScroll(PointerEventData eventData) {
float scrollDelta = eventData.scrollDelta.y;
if (scrollDelta != 0) { ApplyZoom(scrollDelta * scrollZoomSpeed, eventData.position); }
}
private void ApplyZoom(float delta, Vector2 screenCenter) {
// Set target zoom - actual zoom is applied smoothly in Update()
_targetZoom = Mathf.Clamp(_targetZoom + delta, minZoom, maxZoom);
_lastZoomCenter = screenCenter;
}
private void ApplyCurrentZoom(Vector2 screenCenter, float previousZoom) {
// Get the cursor position relative to viewport (0,0 = bottom-left, 1,1 = top-right)
// Use canvas camera for ScreenSpaceCamera mode, null for ScreenSpaceOverlay
Camera cam = _canvas != null ? _canvas.worldCamera : null;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_viewport,
screenCenter,
cam,
out Vector2 viewportLocalPoint);
Vector2 viewportSize = _viewport.rect.size;
Vector2 cursorViewportNorm = new Vector2(
(viewportLocalPoint.x + viewportSize.x * _viewport.pivot.x) / viewportSize.x,
(viewportLocalPoint.y + viewportSize.y * _viewport.pivot.y) / viewportSize.y);
// Calculate content size before and after zoom
Vector2 contentSizeBefore = _content.rect.size * previousZoom;
Vector2 contentSizeAfter = _content.rect.size * _currentZoom;
// Calculate the scrollable range (content size - viewport size)
Vector2 scrollRangeBefore = contentSizeBefore - viewportSize;
Vector2 scrollRangeAfter = contentSizeAfter - viewportSize;
// Get current scroll offset in pixels
Vector2 scrollOffsetBefore = new Vector2(
scrollRangeBefore.x > 0 ? _scrollRect.normalizedPosition.x * scrollRangeBefore.x
: 0,
scrollRangeBefore.y > 0 ? _scrollRect.normalizedPosition.y * scrollRangeBefore.y
: 0);
// The point under cursor in content space (from bottom-left of content)
Vector2 cursorContentPoint = scrollOffsetBefore + cursorViewportNorm * viewportSize;
// After zoom, where should scroll offset be to keep same content point under cursor?
float zoomRatio = _currentZoom / previousZoom;
Vector2 cursorContentPointAfter = cursorContentPoint * zoomRatio;
Vector2 scrollOffsetAfter = cursorContentPointAfter - cursorViewportNorm * viewportSize;
// Apply the zoom scale
_content.localScale = Vector3.one * _currentZoom;
// Update scrollability based on new zoom level
UpdateScrollability();
// Convert back to normalized position
if (_currentZoom > 1.0f && scrollRangeAfter.x > 0 && scrollRangeAfter.y > 0) {
Vector2 newNormalizedPos = new Vector2(
Mathf.Clamp01(scrollOffsetAfter.x / scrollRangeAfter.x),
Mathf.Clamp01(scrollOffsetAfter.y / scrollRangeAfter.y));
_scrollRect.normalizedPosition = newNormalizedPos;
} else if (_currentZoom <= 1.0f) {
// At zoom 1, center the content
_scrollRect.normalizedPosition = new Vector2(0.5f, 0.5f);
}
}
private void UpdateScrollability() {
// Only allow scrolling when zoomed in
bool canScroll = _currentZoom > 1.0f;
_scrollRect.horizontal = canScroll;
_scrollRect.vertical = canScroll;
_scrollRect.enabled = canScroll || !_isPinching;
}
public void ResetZoom() {
_currentZoom = 1.0f;
_targetZoom = 1.0f;
_zoomVelocity = 0f;
_content.localScale = Vector3.one;
_scrollRect.normalizedPosition = new Vector2(0.5f, 0.5f);
UpdateScrollability();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: cc258a4d89a7a41278242c1405ccebe5
@@ -1,13 +1,13 @@
fileFormatVersion: 2
guid: 44b7f6bedeca4c3a8c2f501e0d5cd8d7
guid: 2f6e3de2b19c54f4f831c3b5b58b9910
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
serializedVersion: 10
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
sRGBTexture: 0
linearTexture: 0
fadeOut: 0
borderMipMap: 0
@@ -20,12 +20,9 @@ TextureImporter:
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -34,12 +31,12 @@ TextureImporter:
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
@@ -51,80 +48,80 @@ TextureImporter:
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaUsage: 2
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
spritePackingTag:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,13 +1,13 @@
fileFormatVersion: 2
guid: 7b6ab1e235eea4e4e9319c88bb1cdc70
guid: 0bc9ccd5f98af42ed87bbbb4052fd300
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
serializedVersion: 10
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
enableMipMap: 1
sRGBTexture: 0
linearTexture: 0
fadeOut: 0
borderMipMap: 0
@@ -20,12 +20,9 @@ TextureImporter:
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -34,16 +31,16 @@ TextureImporter:
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -51,80 +48,80 @@ TextureImporter:
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaUsage: 2
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 4096
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 20
crunchedCompression: 1
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:

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