mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 09:35:42 +00:00
Compare commits
219
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf6e9cc3f | ||
|
|
aa62cbdae5 | ||
|
|
b5ce58a448 | ||
|
|
561babd7bc | ||
|
|
4325385544 | ||
|
|
88781d779b | ||
|
|
b8e1e0d8b5 | ||
|
|
023503e831 | ||
|
|
3058538772 | ||
|
|
fe72e870a2 | ||
|
|
69da124203 | ||
|
|
67fad55d47 | ||
|
|
a879243fd8 | ||
|
|
a8ded5df45 | ||
|
|
9fbc409df9 | ||
|
|
ac260323c0 | ||
|
|
3a897b6241 | ||
|
|
161fa78b42 | ||
|
|
8dc9d12fdf | ||
|
|
df105c9e31 | ||
|
|
d62120b833 | ||
|
|
de1ad34005 | ||
|
|
5f366c04ed | ||
|
|
dfec602889 | ||
|
|
3f83cf2c45 | ||
|
|
6edc3fcb34 | ||
|
|
846e618fdd | ||
|
|
759202b310 | ||
|
|
68e876bc93 | ||
|
|
cccbbb9d37 | ||
|
|
3e1f303da5 | ||
|
|
350edc3515 | ||
|
|
f1a87cb1b5 | ||
|
|
8587003f1b | ||
|
|
e9c63dfb42 | ||
|
|
7ca4038696 | ||
|
|
c7c8dccf3c | ||
|
|
63eae8b57d | ||
|
|
bb18b2f598 | ||
|
|
2115c3969c | ||
|
|
03bc1d62ed | ||
|
|
b76ec8a55d | ||
|
|
bf186ce59b | ||
|
|
87644c610a | ||
|
|
64931a8aae | ||
|
|
45f1a70ef5 | ||
|
|
05e7307e0e | ||
|
|
86861a2ddf | ||
|
|
82de1a01d1 | ||
|
|
1c90d3eb2d | ||
|
|
83c2174a65 | ||
|
|
bd9554b21f | ||
|
|
21e41fc3ef | ||
|
|
f8575577db | ||
|
|
bd4298d050 | ||
|
|
846a9a3f65 | ||
|
|
d6ecb9d44e | ||
|
|
15f82c098c | ||
|
|
f55b766beb | ||
|
|
1ef4f05222 | ||
|
|
dc46b07954 | ||
|
|
ebb6784e42 | ||
|
|
b7b0c85656 | ||
|
|
e850cb53fa | ||
|
|
3928bab616 | ||
|
|
0313e0ce54 | ||
|
|
32d6830a4b | ||
|
|
ceb56060be | ||
|
|
877d330e24 | ||
|
|
0050365e85 | ||
|
|
c36ff351bb | ||
|
|
6b52f781a4 | ||
|
|
cce889d253 | ||
|
|
7acb90b037 | ||
|
|
c71a73dc12 | ||
|
|
c6b51c4729 | ||
|
|
9da7805e16 | ||
|
|
c91bf118b1 | ||
|
|
b9c88d28c4 | ||
|
|
6d1253d6ff | ||
|
|
5cb80ce185 | ||
|
|
c9c74f5e0d | ||
|
|
b2ade6b348 | ||
|
|
9bc51c918b | ||
|
|
3529d64c4a | ||
|
|
87d1d2f1ed | ||
|
|
ae613954ca | ||
|
|
6ddf930e5c | ||
|
|
0a0777024e | ||
|
|
9ea49e0ac3 | ||
|
|
df8bc8ef36 | ||
|
|
daa5f8c644 | ||
|
|
c97c810de4 | ||
|
|
59d059656f | ||
|
|
c4dce575a4 | ||
|
|
24b66b8872 | ||
|
|
22e6ff9b50 | ||
|
|
8ef90d9542 | ||
|
|
08c8a52654 | ||
|
|
226a582838 | ||
|
|
e890cc8a54 | ||
|
|
16a4b8f87e | ||
|
|
90ebd1a5be | ||
|
|
7ee5b35696 | ||
|
|
444828221a | ||
|
|
b4f87366e7 | ||
|
|
8b729d61fb | ||
|
|
2c4b5d22e7 | ||
|
|
046a6aa3c9 | ||
|
|
c672ee5522 | ||
|
|
c0d5ad539d | ||
|
|
e1444d72e9 | ||
|
|
4603e449c0 | ||
|
|
6b022f9c9f | ||
|
|
06aea9ebfd | ||
|
|
645a4fcb89 | ||
|
|
ba6d405a04 | ||
|
|
ab9ce45544 | ||
|
|
1522699745 | ||
|
|
063ec74193 | ||
|
|
c7db77e241 | ||
|
|
46bc012a7b | ||
|
|
3ddf092f8e | ||
|
|
69433cc7f4 | ||
|
|
94b5d95dd3 | ||
|
|
7db53c56ac | ||
|
|
98dc80a77e | ||
|
|
521e87cb91 | ||
|
|
98fb1e773c | ||
|
|
18581f003b | ||
|
|
9ab6aab8e4 | ||
|
|
c7ab71a928 | ||
|
|
9a6521b4d4 | ||
|
|
fe4e390ab9 | ||
|
|
b91113039f | ||
|
|
b6af020fcc | ||
|
|
80d3c341cf | ||
|
|
6998b66233 | ||
|
|
367ca2d48d | ||
|
|
005316aea7 | ||
|
|
8c0cacfc96 | ||
|
|
2f45b409b0 | ||
|
|
a05a836b71 | ||
|
|
3b26fccc44 | ||
|
|
6ba35fc7c6 | ||
|
|
f33822104c | ||
|
|
8ed6a21b6b | ||
|
|
db90fbf126 | ||
|
|
7980bf69a2 | ||
|
|
6368a64ef7 | ||
|
|
09bc074198 | ||
|
|
d34215308a | ||
|
|
b1a8653189 | ||
|
|
e7244fa84f | ||
|
|
be28563d29 | ||
|
|
63d553c49f | ||
|
|
827a0d50e6 | ||
|
|
64c86ebd2b | ||
|
|
bfde365afd | ||
|
|
f7cc8cc2fc | ||
|
|
47b92265db | ||
|
|
5a91a6c175 | ||
|
|
3eac8bbc0f | ||
|
|
d041c68b99 | ||
|
|
60a147e31c | ||
|
|
d8402694c2 | ||
|
|
e847f4ee80 | ||
|
|
77e8b1fbe3 | ||
|
|
b2a0577121 | ||
|
|
b1601e19e0 | ||
|
|
f83ca0573a | ||
|
|
2d4b5efac2 | ||
|
|
7a2f797e13 | ||
|
|
9934fb2ce9 | ||
|
|
8bd143ec67 | ||
|
|
839f75e697 | ||
|
|
466ec331d2 | ||
|
|
ca73c6991b | ||
|
|
a3174d9d5e | ||
|
|
be1f9cc67c | ||
|
|
5e173d55ce | ||
|
|
0830ccf5d9 | ||
|
|
776790e44d | ||
|
|
34ff1b9a1b | ||
|
|
70079782ec | ||
|
|
d514606b00 | ||
|
|
61df596aa1 | ||
|
|
3e33c3c932 | ||
|
|
88d3b37a4c | ||
|
|
794a1fb503 | ||
|
|
97c243fa95 | ||
|
|
e0e0f88358 | ||
|
|
8e541e6d11 | ||
|
|
7afbde69f1 | ||
|
|
e02afd112e | ||
|
|
20b82af479 | ||
|
|
282b5fefa9 | ||
|
|
9549cf32e4 | ||
|
|
a1df9e87ee | ||
|
|
0bd6319c29 | ||
|
|
608a5fc0a5 | ||
|
|
201f637843 | ||
|
|
fa43fd6f48 | ||
|
|
37f9acf847 | ||
|
|
2ce4c7f0fb | ||
|
|
9cd57390e7 | ||
|
|
a1b26841dc | ||
|
|
61a2d2d988 | ||
|
|
cd5aed4cde | ||
|
|
f1c825b37e | ||
|
|
6d68d5dc8b | ||
|
|
748d5afee2 | ||
|
|
c1bbb11edc | ||
|
|
1e9953bc56 | ||
|
|
647ad25bb4 | ||
|
|
7221ee48d6 | ||
|
|
6bd0934144 | ||
|
|
b656ba4518 | ||
|
|
f4902727a5 |
@@ -42,5 +42,7 @@ 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
|
||||
|
||||
@@ -11,3 +11,19 @@ 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
|
||||
|
||||
@@ -41,7 +41,8 @@ jobs:
|
||||
set -ex
|
||||
|
||||
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:auth_server_image
|
||||
# --stamp is needed for authservice x_defs (git commit, build time)
|
||||
bazel build --stamp //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,6 +13,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Show disk usage before cleanup
|
||||
run: |
|
||||
|
||||
@@ -23,13 +23,16 @@ on:
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -37,6 +40,14 @@ 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
|
||||
|
||||
@@ -62,8 +62,10 @@ 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 \
|
||||
@@ -89,6 +91,13 @@ 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:
|
||||
@@ -110,40 +119,59 @@ jobs:
|
||||
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
# 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
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push Eagle image
|
||||
# Set up image paths and tags
|
||||
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}"
|
||||
|
||||
# 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"
|
||||
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
$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"
|
||||
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 "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
|
||||
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== All images pushed successfully ==="
|
||||
@@ -192,6 +220,13 @@ 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: |
|
||||
@@ -200,11 +235,11 @@ jobs:
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Build warmup tool
|
||||
run: |
|
||||
bazel build //ci:warmup_tar
|
||||
mkdir -p scripts/bin
|
||||
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
|
||||
- name: Download warmup binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: warmup-binary
|
||||
path: scripts/bin/
|
||||
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
@@ -322,29 +357,34 @@ jobs:
|
||||
|
||||
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Pull and load all images
|
||||
echo "Pulling Eagle image..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
|
||||
# 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
|
||||
|
||||
echo "Pulling Admin image..."
|
||||
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
|
||||
echo "Loading images..."
|
||||
docker load -i eagle.tar && rm eagle.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 "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"
|
||||
echo "All images pulled and loaded successfully"
|
||||
|
||||
# =================================================================
|
||||
# Verify Shardok connectivity before proceeding with deployment
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
name: Eagle Build
|
||||
|
||||
on:
|
||||
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'
|
||||
# Main pushes are covered by docker_build.yml which builds the same target
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/scala/**'
|
||||
@@ -23,6 +13,10 @@ on:
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/eagle_build.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ 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
|
||||
|
||||
@@ -46,7 +50,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: true
|
||||
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/
|
||||
|
||||
- name: Fetch LFS files
|
||||
run: |
|
||||
@@ -56,20 +65,9 @@ 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:
|
||||
@@ -77,6 +75,17 @@ 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
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
env:
|
||||
# Runner-specific build directory to allow parallel builds on multiple runners
|
||||
@@ -21,9 +20,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-unity:
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
outputs:
|
||||
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
|
||||
steps:
|
||||
- name: Prune stale PR refs
|
||||
@@ -40,9 +37,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: true
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
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
|
||||
@@ -51,22 +53,10 @@ 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()
|
||||
@@ -75,18 +65,16 @@ jobs:
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: ./ci/github_actions/upload_addressables.sh iOS
|
||||
|
||||
- name: Zip Xcode project for artifact
|
||||
- name: Purge CDN cache for iOS addressables
|
||||
if: success()
|
||||
env:
|
||||
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
|
||||
run: |
|
||||
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
|
||||
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()
|
||||
@@ -95,37 +83,12 @@ 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, unity-mac]
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
|
||||
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 }}
|
||||
@@ -184,11 +147,23 @@ jobs:
|
||||
- name: Upload to TestFlight
|
||||
if: ${{ github.event.inputs.skip_upload != 'true' }}
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
# 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 }}
|
||||
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.ipa"
|
||||
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.xcarchive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
|
||||
|
||||
# Cleanup
|
||||
rm -f "$API_KEY_PATH"
|
||||
|
||||
- name: Upload IPA artifact
|
||||
# Only keep artifact if we skipped TestFlight upload (for debugging)
|
||||
@@ -206,21 +181,3 @@ 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
|
||||
|
||||
@@ -25,25 +25,19 @@ 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:
|
||||
@@ -53,6 +47,10 @@ 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
|
||||
@@ -85,9 +83,30 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: true # Remove untracked files like old SparklePlugin.bundle
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
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
|
||||
@@ -96,19 +115,12 @@ 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: Persist Library/
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: mac
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
@@ -285,6 +297,8 @@ 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
|
||||
@@ -307,6 +321,7 @@ 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 }}
|
||||
@@ -337,44 +352,37 @@ jobs:
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
# Export version for notify step
|
||||
echo "DEPLOYED_VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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"
|
||||
# Artifact cleanup is handled by the cleanup job on ubuntu-latest
|
||||
- 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]
|
||||
needs: [build-and-sign, wait-notarization, deploy, notify-mac]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ on:
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: shardok-arm64-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -60,12 +64,14 @@ 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" = "b700" ]; then
|
||||
if [ "$E_MACHINE" = "00b7" ]; then
|
||||
echo "SUCCESS: Binary is ARM64 (aarch64)"
|
||||
else
|
||||
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
|
||||
echo "WARNING: Binary e_machine is $E_MACHINE (expected 00b7 for aarch64)"
|
||||
fi
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
@@ -128,20 +134,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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)
|
||||
# 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"
|
||||
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)
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
name: Shardok Build
|
||||
|
||||
on:
|
||||
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'
|
||||
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
@@ -25,6 +14,10 @@ on:
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ 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"
|
||||
@@ -36,14 +34,16 @@ 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,6 +54,8 @@ 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
|
||||
@@ -70,7 +72,28 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: true # Remove untracked files from previous builds
|
||||
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
|
||||
|
||||
- name: Fetch LFS files
|
||||
run: |
|
||||
@@ -78,17 +101,13 @@ 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: Persist Library/
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: windows
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
env:
|
||||
@@ -126,21 +145,12 @@ jobs:
|
||||
# Cleanup
|
||||
rm -f /tmp/manifest_signing_key
|
||||
|
||||
- name: Notify clients of update
|
||||
- name: Export deployed version
|
||||
id: get-version
|
||||
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)
|
||||
|
||||
# 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)"
|
||||
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
@@ -151,4 +161,25 @@ jobs:
|
||||
retention-days: 5
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
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)"
|
||||
@@ -37,5 +37,8 @@ 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/
|
||||
|
||||
@@ -225,6 +225,9 @@ 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):**
|
||||
|
||||
@@ -333,6 +336,8 @@ 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
|
||||
|
||||
+31
-2
@@ -126,6 +126,26 @@ 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
|
||||
#
|
||||
@@ -134,7 +154,7 @@ use_repo(
|
||||
# in a separate workspace at sparkle_workspace/ to isolate the conflict.
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.23.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "apple_support", version = "1.24.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)
|
||||
@@ -160,8 +180,9 @@ 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.0")
|
||||
bazel_dep(name = "grpc", version = "1.74.1")
|
||||
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")
|
||||
|
||||
#
|
||||
@@ -180,8 +201,12 @@ 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",
|
||||
@@ -190,6 +215,7 @@ 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",
|
||||
@@ -283,6 +309,9 @@ 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,
|
||||
|
||||
Generated
+2083
-10
File diff suppressed because one or more lines are too long
@@ -31,6 +31,17 @@ 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"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/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/
|
||||
@@ -1,48 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
|
||||
# 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.
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
|
||||
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
if [ ! -f "$IPA_PATH" ]; then
|
||||
echo "Error: IPA file not found: $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"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading to TestFlight: $IPA_PATH"
|
||||
echo "Uploading to TestFlight: $ARCHIVE_PATH"
|
||||
|
||||
# Uses same credentials as Mac notarization:
|
||||
# - APPLE_ID: Your Apple ID email
|
||||
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
|
||||
# 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
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ]; then
|
||||
echo "Error: APPLE_ID environment variable not set"
|
||||
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"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
|
||||
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
|
||||
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
|
||||
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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."
|
||||
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
|
||||
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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
|
||||
@@ -77,3 +77,4 @@ 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
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# 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
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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
|
||||
@@ -60,6 +60,7 @@
|
||||
"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,
|
||||
@@ -173,6 +174,7 @@
|
||||
"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,
|
||||
@@ -816,6 +818,12 @@
|
||||
},
|
||||
"version": "2.0.16"
|
||||
},
|
||||
"org.xerial:sqlite-jdbc": {
|
||||
"shasums": {
|
||||
"jar": "6dc7464e3803648d3ff18a7359bab6adf079fcd8495b18991f6f5edcb8ac6e3b"
|
||||
},
|
||||
"version": "3.46.1.0"
|
||||
},
|
||||
"software.amazon.awssdk:annotations": {
|
||||
"shasums": {
|
||||
"jar": "ed03bb4ff78900307dc96bacdec70ddf80c17df3eb6761219573025649f08ff3"
|
||||
@@ -1352,6 +1360,9 @@
|
||||
"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",
|
||||
@@ -2404,6 +2415,16 @@
|
||||
"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"
|
||||
],
|
||||
@@ -2814,6 +2835,7 @@
|
||||
"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",
|
||||
@@ -2890,6 +2912,11 @@
|
||||
"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"
|
||||
|
||||
+54
-4
@@ -2,7 +2,57 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
/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/
|
||||
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"
|
||||
|
||||
@@ -22,11 +22,31 @@ 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=$(bazel query 'deps(//src/main/...) intersect //src/test/...' 2>/dev/null || true)
|
||||
violations=$(get_main_deps | grep "^//src/test/" || true)
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
|
||||
@@ -43,7 +63,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=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
|
||||
violations=$(get_library_deps | grep "^//src/main/protobuf/.*_scala_proto$" || true)
|
||||
if [ -z "$violations" ]; then
|
||||
count=0
|
||||
else
|
||||
@@ -65,7 +85,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=$(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)
|
||||
violations=$(get_library_deps | grep "^//src/main/scala/net/eagle0/eagle/model/proto_converters/" || true)
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
count=$(echo "$violations" | wc -l | tr -d ' ')
|
||||
@@ -85,7 +105,8 @@ check_library_depends_on_proto_converters() {
|
||||
count_proto_deps() {
|
||||
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
|
||||
|
||||
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
|
||||
library_deps=$(get_library_deps)
|
||||
scala_proto_results=$(echo "$library_deps" | grep "^//src/main/protobuf/.*_scala_proto$" || true)
|
||||
if [ -z "$scala_proto_results" ]; then
|
||||
scala_proto_count=0
|
||||
else
|
||||
@@ -94,7 +115,7 @@ count_proto_deps() {
|
||||
echo "library/ Scala proto deps: $scala_proto_count"
|
||||
|
||||
# C++/Go proto deps are expected (map generation tools)
|
||||
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
|
||||
all_proto_count=$(echo "$library_deps" | grep -c "^//src/main/protobuf/" || echo "0")
|
||||
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 > src/main/resources/net/eagle0/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_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,6 +27,7 @@ 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,6 +286,29 @@ 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,6 +20,7 @@
|
||||
#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"
|
||||
@@ -36,6 +37,7 @@ 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:
|
||||
@@ -52,6 +54,8 @@ private:
|
||||
|
||||
const CoordsSet criticalTileCoords;
|
||||
|
||||
TutorialBattleController tutorialController_;
|
||||
|
||||
mutable CommandListSPtr cachedAvailableCommands{};
|
||||
|
||||
void ApplyAndAddActionResult(const ActionResult &result);
|
||||
@@ -197,6 +201,11 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
//
|
||||
// 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
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// 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,6 +19,7 @@ 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,6 +193,7 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
|
||||
mapName,
|
||||
customMapBytes,
|
||||
month,
|
||||
request.tutorial_battle_config(),
|
||||
request.SerializeAsString());
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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) {
|
||||
@@ -77,6 +78,7 @@ 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);
|
||||
@@ -111,6 +113,7 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
|
||||
month,
|
||||
units,
|
||||
playerInfos,
|
||||
tutorialConfig,
|
||||
serializedRequest);
|
||||
|
||||
return LockedCreateGame(controller, playerSetupInfos, serializedRequest);
|
||||
@@ -137,6 +140,7 @@ 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>();
|
||||
|
||||
@@ -155,6 +159,8 @@ auto SetUpController(
|
||||
gameId,
|
||||
playerInfos);
|
||||
|
||||
engine->SetTutorialBattleConfig(tutorialConfig);
|
||||
|
||||
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
#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"
|
||||
@@ -25,6 +26,7 @@ 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;
|
||||
@@ -101,6 +103,7 @@ 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,9 +272,8 @@ namespace Auth {
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth login with specified provider.
|
||||
/// 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.
|
||||
/// In editor: Opens browser and polls for completion.
|
||||
/// In standalone: Opens browser, quits app, deep link relaunches with token.
|
||||
/// </summary>
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
@@ -288,6 +287,13 @@ 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
|
||||
@@ -299,6 +305,7 @@ 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);
|
||||
@@ -306,6 +313,28 @@ 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>
|
||||
|
||||
+47
-41
@@ -131,7 +131,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public GameObject availableGamesListArea;
|
||||
public GameObject availableGamesListItemPrefab;
|
||||
public GameObject waitingGamesListItemPrefab;
|
||||
public GameObject createGameListItemPrefab;
|
||||
public CreateGameItem createGameItem;
|
||||
public Button tutorialButton;
|
||||
|
||||
public DropGameConfirmationPanel dropGameConfirmationPanel;
|
||||
|
||||
@@ -142,7 +143,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
List<GameInfo> fetchedRunningGames;
|
||||
List<AvailableLeader> fetchedNewGameLeaders;
|
||||
int fetchedMaxPlayerCount;
|
||||
private EagleConnection eagleConnection;
|
||||
private PersistentClientConnection _persistentClientConnection;
|
||||
private HttpClient _httpClient;
|
||||
@@ -411,27 +411,25 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void SetupLobbyUI() {
|
||||
if (customBattleButton != null) { customBattleButton.onClick.AddListener(CustomBattle); }
|
||||
if (cancelCustomBattleButton != null) {
|
||||
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
|
||||
}
|
||||
customBattleButton.onClick.AddListener(CustomBattle);
|
||||
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
|
||||
tutorialButton.gameObject.SetActive(TokenStorage.DisplayName == "nolen");
|
||||
tutorialButton.onClick.AddListener(CreateTutorialGame);
|
||||
|
||||
// Set up environment dropdown (only in Unity Editor)
|
||||
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);
|
||||
}
|
||||
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 (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
|
||||
if (_connectedEnvironmentIndex >= 0) {
|
||||
// Temporarily remove listener to avoid triggering reconnect
|
||||
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
|
||||
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
|
||||
@@ -439,7 +437,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
// Show current user from OAuth
|
||||
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
|
||||
lobbyUserText.text = TokenStorage.DisplayName ?? "";
|
||||
}
|
||||
|
||||
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
|
||||
@@ -687,9 +685,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
fetchedNewGameLeaders = new List<AvailableLeader>();
|
||||
fetchedNewGameLeaders.AddRange(lobbyResponse.NewGameOptions.AvailableLeaders);
|
||||
|
||||
fetchedMaxPlayerCount = lobbyResponse.NewGameOptions.MaxSupportedPlayers;
|
||||
|
||||
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;
|
||||
|
||||
ResourceFetcher.headshotFetcher.Prefetch(
|
||||
fetchedNewGameLeaders.Select(a => a.ImagePath));
|
||||
|
||||
@@ -706,8 +709,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
var listItem = Instantiate(
|
||||
runningGamesListItemPrefab,
|
||||
runningGamesListArea.transform,
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
false);
|
||||
var runningGameItem = listItem.GetComponent<RunningGameItem>();
|
||||
runningGameItem.SetRunningGame(
|
||||
runningGame.GameId,
|
||||
@@ -724,8 +726,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
var listItem = Instantiate(
|
||||
waitingGamesListItemPrefab,
|
||||
availableGamesListArea.transform,
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
false);
|
||||
listItem.GetComponent<WaitingGameItem>().SetWaitingGame(
|
||||
waitingGame.GameId,
|
||||
string.Format(
|
||||
@@ -735,14 +736,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
waitingGame.SelectedLeader);
|
||||
}
|
||||
|
||||
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;
|
||||
createGameItem.SetCreationOptions(fetchedNewGameLeaders);
|
||||
createGameItem.CreateCallback = this.CreateGame;
|
||||
|
||||
foreach (var availableGame in lobbyResponse.AvailableGames) {
|
||||
var listItem = Instantiate(
|
||||
@@ -876,6 +871,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
};
|
||||
_persistentClientConnection.Connect();
|
||||
|
||||
PersistentClientConnection.Current = _persistentClientConnection;
|
||||
errorHandler.PersistentClientConnection = _persistentClientConnection;
|
||||
ResourceFetcher.SetUpConnection(_httpClient);
|
||||
}
|
||||
@@ -952,6 +948,17 @@ 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) {
|
||||
@@ -969,14 +976,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public void HandleCreateGameResponse(CreateGameResponse response) {
|
||||
try {
|
||||
switch (response.Result) {
|
||||
case JoinGameResult.SuccessJoinGameResult:
|
||||
SelectEagleGame(response.GameId);
|
||||
// Automatically start the game if it's one player?
|
||||
break;
|
||||
case JoinGameResult.SuccessJoinGameResult: SelectEagleGame(response.GameId); break;
|
||||
|
||||
default:
|
||||
// FIXME: display something
|
||||
break;
|
||||
default: Debug.LogError($"Game creation failed: {response.Result}"); break;
|
||||
}
|
||||
} catch (Exception e) { Console.WriteLine(e.ToString()); }
|
||||
}
|
||||
@@ -988,8 +990,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
} catch (Exception e) { Console.WriteLine(e.ToString()); }
|
||||
}
|
||||
|
||||
private async Task<bool>
|
||||
_internalCreateGame(string desiredLeaderTextId, int humanPlayerCount, int totalPlayerCount) {
|
||||
private async Task<bool> _internalCreateGame(
|
||||
string desiredLeaderTextId,
|
||||
int humanPlayerCount,
|
||||
int totalPlayerCount,
|
||||
bool isTutorial = false) {
|
||||
try {
|
||||
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
|
||||
new UpdateStreamRequest {
|
||||
@@ -997,7 +1002,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
new CreateGameRequest {
|
||||
DesiredLeaderTextId = desiredLeaderTextId,
|
||||
TotalPlayerCount = totalPlayerCount,
|
||||
HumanPlayerCount = humanPlayerCount
|
||||
HumanPlayerCount = humanPlayerCount,
|
||||
IsTutorial = isTutorial
|
||||
}
|
||||
});
|
||||
} catch (WebException e) {
|
||||
|
||||
-3653
File diff suppressed because it is too large
Load Diff
+8
-23
@@ -11,9 +11,10 @@ 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;
|
||||
|
||||
@@ -32,30 +33,20 @@ 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,
|
||||
totalPlayersDropdown.value + 1);
|
||||
CreateCallback(SelectedLeaderTextId, totalHumansDropdown.value + 1, TotalPlayerCount);
|
||||
}
|
||||
|
||||
public void SetHumanPlayersDropdown() {
|
||||
var currentValue = totalHumansDropdown.value;
|
||||
totalHumansDropdown.ClearOptions();
|
||||
totalHumansDropdown.AddOptions(Enumerable.Range(1, totalPlayersDropdown.value + 1)
|
||||
.Select(i => i.ToString())
|
||||
totalHumansDropdown.AddOptions(Enumerable.Range(1, TotalPlayerCount)
|
||||
.Select(i => i == 1 ? "1 player" : $"{i} players")
|
||||
.ToList());
|
||||
totalHumansDropdown.value = Math.Min(totalPlayersDropdown.value, currentValue);
|
||||
totalHumansDropdown.value = Math.Min(TotalPlayerCount - 1, currentValue);
|
||||
}
|
||||
|
||||
public void SetCreationOptions(List<AvailableLeader> leaders, int maxPlayerCount) {
|
||||
public void SetCreationOptions(List<AvailableLeader> leaders) {
|
||||
this.availableLeaders = leaders;
|
||||
|
||||
buttonText.text = "CREATE";
|
||||
@@ -77,16 +68,10 @@ public class CreateGameItem : MonoBehaviour {
|
||||
heroDropdownController.PreResolvedNames =
|
||||
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
|
||||
.ToDictionary(l => l.NameTextId, l => l.Name);
|
||||
heroDropdownController.FallbackText = "Random";
|
||||
heroDropdownController.FallbackText = "Random Warlord";
|
||||
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;
|
||||
|
||||
+98
-4
@@ -631,6 +631,99 @@ 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
|
||||
@@ -666,6 +759,7 @@ RectTransform:
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 2008335870574158510}
|
||||
- {fileID: 7404235247014569559}
|
||||
- {fileID: 6674238761151365000}
|
||||
- {fileID: 5589171621630283209}
|
||||
- {fileID: 1348356180262758599}
|
||||
@@ -674,8 +768,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: -600}
|
||||
m_SizeDelta: {x: 0, y: -1080}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &1107237753715810996
|
||||
CanvasRenderer:
|
||||
@@ -733,7 +827,7 @@ MonoBehaviour:
|
||||
m_Top: 0
|
||||
m_Bottom: 0
|
||||
m_ChildAlignment: 3
|
||||
m_Spacing: 0
|
||||
m_Spacing: 5
|
||||
m_ChildForceExpandWidth: 0
|
||||
m_ChildForceExpandHeight: 0
|
||||
m_ChildControlWidth: 1
|
||||
@@ -757,7 +851,7 @@ MonoBehaviour:
|
||||
gameIdField: {fileID: 8058295588038032232}
|
||||
leaderField: {fileID: 3311450659768657245}
|
||||
lastPlayedField: {fileID: 5482253986454480250}
|
||||
goButton: {fileID: 0}
|
||||
professionImage: {fileID: 6993077635068771622}
|
||||
dropButton: {fileID: 4081637582368108930}
|
||||
--- !u!114 &2208043217657811503
|
||||
MonoBehaviour:
|
||||
|
||||
+6
-1
@@ -10,7 +10,7 @@ public class RunningGameItem : MonoBehaviour {
|
||||
public TextMeshProUGUI gameIdField;
|
||||
public TextMeshProUGUI leaderField;
|
||||
public TextMeshProUGUI lastPlayedField;
|
||||
public Button goButton;
|
||||
public RawImage professionImage;
|
||||
public Button dropButton;
|
||||
|
||||
public Action<long> GoCallback;
|
||||
@@ -43,6 +43,11 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5ee82ebc029341c4858ef5b7e7cf201
|
||||
@@ -0,0 +1,220 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c15980e88e57845ce920e43f29d35fcc
|
||||
+5
-4
@@ -264,15 +264,16 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private int Cost(List<int> armaments) {
|
||||
double totalCost = 0.0;
|
||||
int totalCost = 0;
|
||||
|
||||
for (int i = 0; i < armaments.Count; i++) {
|
||||
var battalion = _battalions[i];
|
||||
|
||||
totalCost += _costs[i] * Math.Max(armaments[i] - battalion.Armament.Value, 0.0);
|
||||
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);
|
||||
}
|
||||
|
||||
return (int)Math.Ceiling(Math.Max(totalCost, 0.0));
|
||||
return totalCost;
|
||||
}
|
||||
|
||||
private int Cost() { return Cost(_newArmaments); }
|
||||
|
||||
+92
-13
@@ -15,7 +15,6 @@ 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;
|
||||
@@ -24,6 +23,12 @@ 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;
|
||||
@@ -120,6 +125,38 @@ 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;
|
||||
@@ -205,23 +242,43 @@ namespace eagle {
|
||||
ImprovementType.Devastation,
|
||||
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
|
||||
|
||||
// Determine which type to select
|
||||
// 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
|
||||
ImprovementType selectedType;
|
||||
if (improveCommand.LockedType != ImprovementType.None) {
|
||||
var lockedType = improveCommand.LockedType;
|
||||
if (improveCommand.AvailableTypes.Contains(lockedType)) {
|
||||
selectedType = lockedType;
|
||||
lockToggle.isOn = true;
|
||||
var lockToggle = GetLockToggleForType(lockedType);
|
||||
if (lockToggle != null) lockToggle.isOn = true;
|
||||
} else {
|
||||
selectedType = GetDefaultImprovementType();
|
||||
lockToggle.isOn = false;
|
||||
}
|
||||
} else {
|
||||
selectedType = GetDefaultImprovementType();
|
||||
lockToggle.isOn = false;
|
||||
}
|
||||
|
||||
SelectType(selectedType);
|
||||
UpdateLockToggleVisibility();
|
||||
}
|
||||
|
||||
public override AvailableCommand.SealedValueOneofCase CommandType =>
|
||||
@@ -240,13 +297,35 @@ namespace eagle {
|
||||
heroDropdownController.SelectedHeroId = heroId;
|
||||
}
|
||||
|
||||
public override SelectedCommand Command => new SelectedCommand {
|
||||
ImproveCommand =
|
||||
new ImproveSelectedCommand {
|
||||
ImprovementType = SelectedType,
|
||||
ActingHeroId = heroDropdownController.SelectedHeroId,
|
||||
LockType = lockToggle.isOn
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
-58
@@ -21,12 +21,7 @@ namespace eagle {
|
||||
public Toggle ExileToggle;
|
||||
public Toggle ExecuteToggle;
|
||||
public Toggle ReturnToggle;
|
||||
|
||||
public RawImage ReleaseIcon;
|
||||
public RawImage MoveIcon;
|
||||
public RawImage ExileIcon;
|
||||
public RawImage ExecuteIcon;
|
||||
public RawImage ReturnIcon;
|
||||
public ToggleGroup decisionToggleGroup;
|
||||
|
||||
public TMP_Dropdown moveToDropdown;
|
||||
|
||||
@@ -76,6 +71,33 @@ 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)
|
||||
@@ -143,28 +165,43 @@ namespace eagle {
|
||||
|
||||
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
|
||||
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
|
||||
@@ -182,7 +219,7 @@ namespace eagle {
|
||||
if (!matches) continue;
|
||||
|
||||
var heroName = GetHeroDisplayName(uh.HeroId);
|
||||
questMessages.Add($"{heroName} wants you to {actionVerb} this prisoner.");
|
||||
questMessages.Add($"{heroName} wants you to {actionVerb}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,24 +231,24 @@ namespace eagle {
|
||||
switch (details.SealedValueCase) {
|
||||
case QuestDetails.SealedValueOneofCase.ExecutePrisonerQuest:
|
||||
if (details.ExecutePrisonerQuest.PrisonerHeroId == prisonerId) {
|
||||
return (true, "execute");
|
||||
return (true, "execute this prisoner");
|
||||
}
|
||||
break;
|
||||
case QuestDetails.SealedValueOneofCase.ExilePrisonerQuest:
|
||||
if (details.ExilePrisonerQuest.PrisonerHeroId == prisonerId) {
|
||||
return (true, "exile");
|
||||
return (true, "exile this prisoner");
|
||||
}
|
||||
break;
|
||||
case QuestDetails.SealedValueOneofCase.FreePrisonerQuest:
|
||||
if (details.FreePrisonerQuest.PrisonerHeroId == prisonerId) {
|
||||
return (true, "release");
|
||||
return (true, "release this prisoner");
|
||||
}
|
||||
break;
|
||||
case QuestDetails.SealedValueOneofCase.ReturnPrisonerQuest:
|
||||
if (details.ReturnPrisonerQuest.PrisonerHeroId == prisonerId) {
|
||||
var factionName =
|
||||
_model.FactionName(details.ReturnPrisonerQuest.ToFactionId);
|
||||
return (true, $"return to {factionName}");
|
||||
return (true, $"return this prisoner to {factionName}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -225,36 +262,23 @@ namespace eagle {
|
||||
: "A free hero";
|
||||
}
|
||||
|
||||
private bool ConditionallyEnable(
|
||||
Toggle toggle,
|
||||
PrisonerManagementOption.SealedValueOneofCase option) {
|
||||
bool enable = SelectedPrisonerToManage.AvailableOptions.Any(
|
||||
opt => opt.SealedValueCase == option);
|
||||
toggle.gameObject.SetActive(enable);
|
||||
private const float DisabledAlpha = 0.35f;
|
||||
|
||||
if (enable) {
|
||||
ExecuteToggle.isOn = false;
|
||||
ExileToggle.isOn = false;
|
||||
MoveToggle.isOn = false;
|
||||
ReleaseToggle.isOn = false;
|
||||
ReturnToggle.isOn = false;
|
||||
private void ConfigureToggle(Toggle toggle, bool available) {
|
||||
if (toggle == null) return;
|
||||
|
||||
toggle.isOn = true;
|
||||
} else
|
||||
toggle.isOn = false;
|
||||
toggle.gameObject.SetActive(true);
|
||||
toggle.interactable = available;
|
||||
|
||||
return enable;
|
||||
}
|
||||
if (!available) { toggle.isOn = false; }
|
||||
|
||||
private void ConditionallyEnable(
|
||||
Toggle toggle,
|
||||
RawImage img,
|
||||
PrisonerManagementOption.SealedValueOneofCase option) {
|
||||
bool enable = ConditionallyEnable(toggle, option);
|
||||
toggle.group = available ? decisionToggleGroup : null;
|
||||
|
||||
Color color = img.color;
|
||||
color.a = enable ? 1.0f : 0.25f;
|
||||
img.color = color;
|
||||
var canvasGroup = toggle.GetComponent<CanvasGroup>();
|
||||
if (canvasGroup == null) {
|
||||
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
|
||||
}
|
||||
canvasGroup.alpha = available ? 1f : DisabledAlpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -40,9 +40,10 @@ namespace eagle {
|
||||
public TMP_Dropdown fromDropdown;
|
||||
public TMP_Dropdown toDropdown;
|
||||
|
||||
// Columns to hide in narrow layout mode (users select via side panels instead)
|
||||
// Columns to show/hide based on layout mode
|
||||
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;
|
||||
@@ -302,9 +303,11 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public override void OnLayoutModeChanged() {
|
||||
// Update hero/battalion column visibility when layout changes
|
||||
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
|
||||
// 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
|
||||
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
|
||||
if (battalionsColumnNarrow != null) battalionsColumnNarrow.SetActive(IsNarrowLayout);
|
||||
}
|
||||
|
||||
protected override void SetUpUI() {
|
||||
|
||||
+14
-7
@@ -16,6 +16,7 @@ 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;
|
||||
@@ -198,10 +199,20 @@ namespace eagle {
|
||||
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
|
||||
}
|
||||
|
||||
foreach (Transform row in availableBattalionsContent.transform) {
|
||||
Destroy(row.gameObject);
|
||||
PopulateBattalionsContent(availableBattalionsContent, oddColor);
|
||||
if (availableBattalionsContentNarrow != null) {
|
||||
PopulateBattalionsContent(availableBattalionsContentNarrow, oddColor);
|
||||
}
|
||||
parentTransform = availableBattalionsContent.transform;
|
||||
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
|
||||
selectionChanged.Invoke();
|
||||
}
|
||||
|
||||
private void PopulateBattalionsContent(GameObject content, Color oddColor) {
|
||||
foreach (Transform row in content.transform) { Destroy(row.gameObject); }
|
||||
|
||||
var parentTransform = content.transform;
|
||||
for (int i = 0; i < AvailableBattalions.Count; i++) {
|
||||
var battalion = AvailableBattalions[i];
|
||||
|
||||
@@ -217,10 +228,6 @@ namespace eagle {
|
||||
|
||||
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
|
||||
}
|
||||
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
|
||||
selectionChanged.Invoke();
|
||||
}
|
||||
|
||||
public void availableHeroButtonClicked(int index) {
|
||||
|
||||
+11
-12
@@ -23,7 +23,9 @@ namespace eagle {
|
||||
/// Attach to a TextMeshProUGUI component to display status.
|
||||
/// </summary>
|
||||
public class ConnectionStatusUI : MonoBehaviour {
|
||||
private TextMeshProUGUI _textComponent;
|
||||
[Header("UI References")]
|
||||
public TextMeshProUGUI statusText;
|
||||
|
||||
private PersistentClientConnection _connection;
|
||||
private IGameStateProvider _gameStateProvider;
|
||||
private string _environmentName;
|
||||
@@ -39,10 +41,8 @@ namespace eagle {
|
||||
private float _timeSinceLastUpdate = 0f;
|
||||
|
||||
void Start() {
|
||||
_textComponent = GetComponent<TextMeshProUGUI>();
|
||||
if (_textComponent == null) {
|
||||
Debug.LogError(
|
||||
"ConnectionStatusUI must be attached to a TextMeshProUGUI component");
|
||||
if (statusText == null) {
|
||||
Debug.LogError("ConnectionStatusUI: statusText reference not assigned");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ namespace eagle {
|
||||
}
|
||||
|
||||
void Update() {
|
||||
if (_connection == null || _textComponent == null) { return; }
|
||||
if (_connection == null || statusText == null) { return; }
|
||||
|
||||
_timeSinceLastUpdate += Time.deltaTime;
|
||||
if (_timeSinceLastUpdate < UpdateInterval) { return; }
|
||||
@@ -97,16 +97,15 @@ namespace eagle {
|
||||
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
|
||||
if (timeUntilTest.TotalSeconds > 0) {
|
||||
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
|
||||
_textComponent.text =
|
||||
$"<color=red>●</color> Server down. Retry in {seconds}s";
|
||||
statusText.text = $"<color=red>●</color> Server down. Retry in {seconds}s";
|
||||
return;
|
||||
}
|
||||
}
|
||||
_textComponent.text = "<color=red>●</color> Server unavailable";
|
||||
statusText.text = "<color=red>●</color> Server unavailable";
|
||||
return;
|
||||
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
|
||||
SetRetryButtonVisible(false);
|
||||
_textComponent.text = "<color=yellow>●</color> Testing connection...";
|
||||
statusText.text = "<color=yellow>●</color> Testing connection...";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,7 +118,7 @@ namespace eagle {
|
||||
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
|
||||
SetRetryButtonVisible(isCountingDown);
|
||||
|
||||
string statusText = state switch {
|
||||
string displayText = state switch {
|
||||
ConnectionState.Connected => GetConnectedStatusText(),
|
||||
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
|
||||
ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
|
||||
@@ -128,7 +127,7 @@ namespace eagle {
|
||||
_ => "<color=gray>●</color> Unknown"
|
||||
};
|
||||
|
||||
_textComponent.text = statusText;
|
||||
statusText.text = displayText;
|
||||
}
|
||||
|
||||
private string GetConnectedStatusText() {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 299f66fc896a34cdeb95e92e927a6f10
|
||||
@@ -15,6 +15,7 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public static readonly Dictionary<Profession, string> ProfessionNames = new() {
|
||||
{ Profession.UnknownProfession, "Unknown" },
|
||||
{ Profession.NoProfession, "None" },
|
||||
{ Profession.Engineer, "Engineer" },
|
||||
{ Profession.Mage, "Mage" },
|
||||
@@ -25,6 +26,7 @@ namespace eagle {
|
||||
};
|
||||
|
||||
public static readonly Dictionary<Profession, string> ShortProfessionNames = new() {
|
||||
{ Profession.UnknownProfession, "?" },
|
||||
{ Profession.NoProfession, "None" },
|
||||
{ Profession.Engineer, "Engr" },
|
||||
{ Profession.Mage, "Mage" },
|
||||
@@ -104,6 +106,9 @@ 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!";
|
||||
@@ -128,6 +133,8 @@ namespace eagle {
|
||||
case RecruitmentStatus.Traveler: return "Traveler";
|
||||
|
||||
case RecruitmentStatus.Sworn: return "Dedicated to another faction";
|
||||
|
||||
case RecruitmentStatus.WouldNeverJoin: return "Would never join";
|
||||
}
|
||||
|
||||
return $"Unknown!";
|
||||
@@ -175,6 +182,7 @@ 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";
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f64938ef171de43dba1b0a865fc86f60
|
||||
@@ -21,12 +21,15 @@ 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];
|
||||
}
|
||||
|
||||
|
||||
+12
-16
@@ -33,8 +33,6 @@ namespace eagle {
|
||||
|
||||
[Header("Status Labels")]
|
||||
public TextMeshProUGUI roundStatusLabel;
|
||||
public TextMeshProUGUI connectionStatusLabel;
|
||||
private bool _connectionStatusUIInitialized = false;
|
||||
|
||||
[Header("Main Panels")]
|
||||
public ProvinceInfoPanelController provinceInfoPanelController;
|
||||
@@ -145,16 +143,6 @@ 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;
|
||||
|
||||
@@ -225,12 +213,21 @@ 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
|
||||
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
|
||||
if (statusUI != null) { statusUI.SetGameStateProvider(null); }
|
||||
PersistentUIManager.Instance?.connectionStatusUI?.SetGameStateProvider(null);
|
||||
}
|
||||
|
||||
void MapControllerChangedTarget(List<ProvinceId> newTarget) {
|
||||
@@ -335,7 +332,7 @@ namespace eagle {
|
||||
ModelUpdater.ErrorHandler = errorHandler;
|
||||
|
||||
// Set up game state provider and environment for connection status UI
|
||||
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
|
||||
var statusUI = PersistentUIManager.Instance?.connectionStatusUI;
|
||||
if (statusUI != null) {
|
||||
statusUI.SetGameStateProvider(ModelUpdater);
|
||||
statusUI.SetEnvironment(environmentName);
|
||||
@@ -795,7 +792,6 @@ 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 "Back to Eagle" via
|
||||
// pending Shardok update - the UI already shows "Exit Battle" via
|
||||
// MarkBattleEnded(), so we just skip this stale update.
|
||||
return null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33c3d3ae0724f43b6990a941f06026cd
|
||||
guid: 0a32317c8e94148f1b87a7f66c498077
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
%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
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 81b03eaddf4de4a4aa385617e4881511
|
||||
guid: 1c093deb39df94fbca5faa9f015a2331
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
%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}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0cf4724a235af4fb8abab6456789936a
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
%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}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1be06ed8f9be949478a81914e2479d4d
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
%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}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5315bdd018d9e420dbbf55a0508a7f14
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,61 @@
|
||||
%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}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27861cc746ec947e88dac44d9a058e10
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
%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}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 384a5a10723d4475e8dba09c8ef66eca
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
%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
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+42
-17
@@ -2,20 +2,27 @@
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 6
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: province11_material
|
||||
m_Shader: {fileID: 4800000, guid: a5be49c245cce4a4e96cfbb74dbec1b6, type: 3}
|
||||
m_ShaderKeywords:
|
||||
m_LightmapFlags: 4
|
||||
m_Name: ParticleStandardUnlitMaterial
|
||||
m_Shader: {fileID: 211, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords:
|
||||
- _ALPHABLEND_ON
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 0
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
disabledShaderPasses: []
|
||||
m_CustomRenderQueue: 3000
|
||||
stringTagMap:
|
||||
RenderType: Transparent
|
||||
disabledShaderPasses:
|
||||
- GRABPASS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
@@ -43,10 +50,6 @@ Material:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _Mask:
|
||||
m_Texture: {fileID: 2800000, guid: 25ef829372cfa42c3aa9f659bda36aaf, type: 3}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
@@ -59,23 +62,45 @@ Material:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _BlendOp: 0
|
||||
- _BumpScale: 1
|
||||
- _Cutoff: 0.52
|
||||
- _CameraFadingEnabled: 0
|
||||
- _CameraFarFadeDistance: 2
|
||||
- _CameraNearFadeDistance: 1
|
||||
- _ColorMode: 0
|
||||
- _Cull: 2
|
||||
- _Cutoff: 0.5
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _DistortionBlend: 0.5
|
||||
- _DistortionEnabled: 0
|
||||
- _DistortionStrength: 1
|
||||
- _DistortionStrengthScaled: 0
|
||||
- _DstBlend: 10
|
||||
- _EmissionEnabled: 0
|
||||
- _FlipbookMode: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.5
|
||||
- _GlossyReflections: 1
|
||||
- _LightingEnabled: 0
|
||||
- _Metallic: 0
|
||||
- _Mode: 0
|
||||
- _Mode: 2
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SoftParticlesEnabled: 0
|
||||
- _SoftParticlesFarFadeDistance: 1
|
||||
- _SoftParticlesNearFadeDistance: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _SrcBlend: 5
|
||||
- _UVSec: 0
|
||||
- _ZWrite: 1
|
||||
- _ZWrite: 0
|
||||
m_Colors:
|
||||
- _CameraFadeParams: {r: 0, g: Infinity, b: 0, a: 0}
|
||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
- _ColorAddSubDiff: {r: 0, g: 0, b: 0, a: 0}
|
||||
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
|
||||
- _SoftParticleFadeParams: {r: 0, g: 0, b: 0, a: 0}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5523b3ce166c34ef98836d6891a63c0f
|
||||
guid: 67dcb765c9c324ff9a103624340bc9b5
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 0
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
%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
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,206 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 727093fab7ebc43dc94ee59675164706
|
||||
@@ -0,0 +1,217 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 01a76cb7235d04623be14e4b01f18754
|
||||
@@ -0,0 +1,316 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 336c92d7cae77487fa6915c9839d38f6
|
||||
+24
-1
@@ -9,6 +9,11 @@ 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;
|
||||
@@ -73,6 +78,21 @@ 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;
|
||||
|
||||
@@ -88,7 +108,10 @@ namespace eagle {
|
||||
});
|
||||
}
|
||||
|
||||
void Start() { panel.SetActive(Model != null); }
|
||||
void Start() {
|
||||
panel.SetActive(Model != null);
|
||||
if (popupPanel != null) { popupPanel.SetActive(false); }
|
||||
}
|
||||
|
||||
private bool HasProvinceInfo =>
|
||||
(CurrentProvince != null && CurrentProvince.FullInfo != null);
|
||||
|
||||
+3
-25
@@ -141,31 +141,9 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public void BattalionLongHoverRowChanged(int? newIndex) {
|
||||
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);
|
||||
}
|
||||
// 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); }
|
||||
}
|
||||
|
||||
private void SetUpHeroesTable() {
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.Linq;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using ProvinceId = System.Int32;
|
||||
using FactionId = System.Int32;
|
||||
|
||||
@@ -12,9 +11,15 @@ namespace eagle {
|
||||
public class MapController : MonoBehaviour {
|
||||
public EagleGameController eagleGameController;
|
||||
|
||||
public RawImage mapBWImage;
|
||||
public List<RawImage> provinceImagesList;
|
||||
public ProvinceColorManager provinceColorManager;
|
||||
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;
|
||||
@@ -34,16 +39,9 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void SetProvinceColor(ProvinceId provinceId, Color color) {
|
||||
var image = ImageForProvince(provinceId);
|
||||
if (!image.texture) {
|
||||
Texture2D texture = new Texture2D(1, 1);
|
||||
texture.wrapMode = TextureWrapMode.Repeat;
|
||||
image.texture = texture;
|
||||
if (provinceColorManager != null) {
|
||||
provinceColorManager.SetProvinceColor(provinceId, color);
|
||||
}
|
||||
|
||||
Texture2D t2d = (Texture2D)image.texture;
|
||||
t2d.SetPixel(0, 0, color);
|
||||
t2d.Apply();
|
||||
}
|
||||
|
||||
private ProvinceId? _selectedProvinceId;
|
||||
@@ -115,16 +113,18 @@ 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 TextAsset rawGray;
|
||||
|
||||
private int _imageWidth;
|
||||
private int _imageHeight;
|
||||
public ProvinceIDLoader provinceIDLoader;
|
||||
|
||||
private readonly Color _selectionColor = Color.grey;
|
||||
private readonly Color _targetColor = Color.red;
|
||||
@@ -147,9 +147,7 @@ namespace eagle {
|
||||
}
|
||||
|
||||
void Start() {
|
||||
_mapIdBytes = rawGray.bytes;
|
||||
_imageWidth = mapBWImage.texture.width;
|
||||
_imageHeight = mapBWImage.texture.height;
|
||||
_mapIdBytes = provinceIDLoader.DecompressedBytes;
|
||||
Enabled = true;
|
||||
_previouslyHoveredProvinceId = null;
|
||||
}
|
||||
@@ -158,17 +156,18 @@ namespace eagle {
|
||||
var mousePosition = new Vector2(point.x, Screen.height - point.y);
|
||||
var mapRect = gameObject.GetComponentInParent<PanelPositions>().MapRect;
|
||||
|
||||
var relativeX = (int)((mousePosition.x - mapRect.x) * _imageWidth / mapRect.width);
|
||||
var relativeY =
|
||||
(int)(_imageHeight -
|
||||
(mousePosition.y - mapRect.y) * _imageHeight / mapRect.height);
|
||||
int mapWidth = provinceIDLoader.mapWidth;
|
||||
int mapHeight = provinceIDLoader.mapHeight;
|
||||
|
||||
if (relativeX < 0 || relativeX >= _imageWidth || relativeY < 0 ||
|
||||
relativeY >= _imageHeight) {
|
||||
var relativeX = (int)((mousePosition.x - mapRect.x) * mapWidth / mapRect.width);
|
||||
var relativeY =
|
||||
(int)(mapHeight - (mousePosition.y - mapRect.y) * mapHeight / mapRect.height);
|
||||
|
||||
if (relativeX < 0 || relativeX >= mapWidth || relativeY < 0 || relativeY >= mapHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var provinceId = _mapIdBytes[relativeY * _imageWidth + relativeX];
|
||||
var provinceId = _mapIdBytes[relativeY * mapWidth + relativeX];
|
||||
if (provinceId == 0 || provinceId == 0xFF) { return null; }
|
||||
return provinceId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc258a4d89a7a41278242c1405ccebe5
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user